diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 6638f9f..ff5903c 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -2365,11 +2365,15 @@ enum Command { Graph(GraphCmd), /// Sweep a parameter grid over a strategy or a loaded blueprint. Sweep(SweepCmd), - /// Walk-forward validation over a strategy or a loaded blueprint. + /// Walk-forward validation over a strategy or a loaded blueprint. Over --real, the + /// fixed 90/30-day roller fits to a --from/--to window shorter than it, preserving + /// the 3:1 IS:OOS ratio, instead of refusing the window outright. Walkforward(WalkforwardCmd), /// Grade one candidate across multiple instruments. Generalize(GeneralizeCmd), - /// Monte-Carlo over synthetic draws, an R-bootstrap, or a loaded blueprint. + /// Monte-Carlo over synthetic draws, an R-bootstrap, or a loaded blueprint. Over + /// --real, the fixed 90/30-day walk-forward roller fits to a --from/--to window + /// shorter than it, preserving the 3:1 IS:OOS ratio, instead of refusing outright. Mc(McCmd), /// List or inspect recorded run families. Runs(RunsCmd), @@ -2686,6 +2690,32 @@ fn wf_ms_sizes() -> (u64, u64, u64) { ) } +/// Fit the fixed real-archive walk-forward roller (`wf_ms_sizes`, 90/30/30 days) +/// to a resolved campaign window `(from_ms, to_ms)` (#239). When the fixed +/// IS+OOS span fits inside the window, the sizes come back byte-identical (every +/// existing anchor/e2e over a year-plus window pins this branch). When the window +/// is shorter than IS+OOS, the roller scales DOWN to the window, preserving the +/// fixed 3:1 IS:OOS ratio, with `step_ms == oos_ms` (a window of exactly IS+OOS +/// then yields exactly one roll — never zero). Pure and unit-testable; both +/// `dispatch_walkforward`/`dispatch_mc` consume it in place of the unconditional +/// `wf_ms_sizes()` stamp. The executor's own fit check (`campaign_run.rs`) stays +/// untouched — it still validates an AUTHORED document's declared window as-is. +fn fit_wf_ms_sizes(from_ms: i64, to_ms: i64) -> (u64, u64, u64) { + let (is_ms, oos_ms, step_ms) = wf_ms_sizes(); + let span_ms = to_ms.saturating_sub(from_ms).max(0) as u64; + if is_ms + oos_ms <= span_ms { + return (is_ms, oos_ms, step_ms); + } + // Preserve the fixed IS:OOS ratio derived from the constants themselves (not a + // bare literal): OOS = span/(ratio+1) (floor), IS = ratio*OOS, so + // IS+OOS = (ratio+1)*OOS <= span always holds, and step == OOS (one roll over + // the fit window, matching the fixed-size branch's `step_ms == oos_ms`). + let ratio = is_ms / oos_ms; + let oos_fit = span_ms / (ratio + 1); + let is_fit = oos_fit * ratio; + (is_fit, oos_fit, oos_fit) +} + /// Resolve a single optional stop knob (`--stop-length`/`--stop-k`): an absent flag /// defaults to `default`; a present one parses via [`parse_csv_list`] and refuses /// unless it holds exactly one value (the stop is a risk regime, not a swept axis). @@ -3421,7 +3451,7 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { DataChoice::Real { symbol: symbol.clone(), from_ms: from, to_ms: to }, env, ); - let (is_ms, oos_ms, step_ms) = wf_ms_sizes(); + let (is_ms, oos_ms, step_ms) = fit_wf_ms_sizes(from_ms, to_ms); let inv = verb_sugar::SugarInvocation { axes: &raw_axes, name, @@ -3536,7 +3566,7 @@ fn dispatch_mc(a: McCmd, env: &project::Env) { DataChoice::Real { symbol: symbol.clone(), from_ms: from, to_ms: to }, env, ); - let (is_ms, oos_ms, step_ms) = wf_ms_sizes(); + let (is_ms, oos_ms, step_ms) = fit_wf_ms_sizes(from_ms, to_ms); let inv = verb_sugar::SugarInvocation { axes: &raw_axes, name, @@ -4709,6 +4739,33 @@ mod tests { assert_eq!(WF_REAL_STEP_NS, 30 * day_ns); } + /// Property: a window that already fits the fixed 90/30/30-day roller passes + /// it through byte-identical (the year-plus anchor/e2e grade pins rely on this + /// branch never perturbing the fixed sizes). + #[test] + fn fit_wf_ms_sizes_passes_through_when_the_window_already_fits() { + let day_ms: i64 = 24 * 60 * 60 * 1_000; + let from_ms = 0; + let to_ms = 121 * day_ms; // > 90 + 30 days + assert_eq!(fit_wf_ms_sizes(from_ms, to_ms), wf_ms_sizes()); + } + + /// Property: a window shorter than IS+OOS scales the roller DOWN to the + /// window, preserving the fixed 3:1 IS:OOS ratio, with `step_ms == oos_ms` + /// (one roll over the fit window) and `is_ms + oos_ms <= span_ms` always. + #[test] + fn fit_wf_ms_sizes_scales_down_to_a_short_window_at_3_to_1() { + let day_ms: i64 = 24 * 60 * 60 * 1_000; + let from_ms = 0; + let to_ms = 30 * day_ms; // far shorter than the fixed 90+30-day roller + let span_ms = (to_ms - from_ms) as u64; + let (is_ms, oos_ms, step_ms) = fit_wf_ms_sizes(from_ms, to_ms); + assert_eq!(is_ms, oos_ms * 3, "the fit preserves the fixed 3:1 IS:OOS ratio"); + assert_eq!(step_ms, oos_ms, "one roll over the fit window: step == oos"); + assert!(is_ms + oos_ms <= span_ms, "the fit roller must fit inside the window"); + assert!(oos_ms > 0, "a 30-day window must yield a non-degenerate fit"); + } + #[test] fn sim_optimal_manifest_renders_per_instrument_pip() { let m = sim_optimal_manifest(vec![], (Timestamp(1), Timestamp(2)), 0, 1.0);