diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 1de0431..00949ef 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -67,6 +67,12 @@ const WF_REAL_IS_NS: i64 = 90 * WF_DAY_NS; const WF_REAL_OOS_NS: i64 = 30 * WF_DAY_NS; const WF_REAL_STEP_NS: i64 = 30 * WF_DAY_NS; +/// The winner-selection objective for walk-forward's per-window IS refit — the +/// deflation-aware SQN variant (#144 default). Named once so the three call sites +/// (the inline r-sma roller, the inline blueprint roller, and the dissolved +/// campaign sugar's bridge) cannot drift apart on the token. +const WINNER_SELECTION_METRIC: &str = "sqn_normalized"; + /// Fixed RNG seed for the trials-deflation reality-check bootstrap in walk-forward /// winner selection. Recorded on each winner's manifest (so `overfit_probability` /// is reproducible by re-run); a CLI flag for it is a deferred refinement. The @@ -1758,7 +1764,7 @@ fn walkforward_family( let space = r_sma_space(); walk_forward(roller, space, |w: WindowBounds| { let (is_family, lattice) = r_sma_sweep_over(w.is.0, w.is.1, data, grid, env); - let (best, selection) = match select_winner(&is_family, "sqn_normalized", select, lattice.as_deref()) { + let (best, selection) = match select_winner(&is_family, WINNER_SELECTION_METRIC, select, lattice.as_deref()) { Ok(v) => v, Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } }; @@ -1882,16 +1888,18 @@ fn walkforward_summary_json(result: &WalkForwardResult) -> String { /// `stitched_total_pips` = the per-window `total_pips` summed left-to-right in roll /// order (the engine `stitch` folds each segment's final cumulative value, and a /// window's OOS segment ends at its `total_pips`); `param_stability` reduces each -/// r-sma IS-refit axis over the per-window chosen params (read by exact name from -/// each report's prefix-stripped `manifest.params`) through the same -/// `MetricStats::from_values`; the `oos_r` block pools the per-window `trade_rs` +/// r-sma IS-refit axis over the per-window chosen params (read from each report's +/// `manifest.params` via [`campaign_run::raw_matches_wrapped`] — the blueprint axes +/// `fast.length`/`slow.length` are recorded wrapped as `sma_signal.fast.length` etc. +/// by the strategy's own param space, while `stop_length`/`stop_k` ride the risk +/// regime unwrapped, so an exact-name match would miss the wrapped pair) through the +/// same `MetricStats::from_values`; the `oos_r` block pools the per-window `trade_rs` /// through `r_metrics_from_rs`. Canonical JSON (C14). /// /// The axis list + order mirror what `r_sma_space` yields (fast, slow, stop_length, /// stop_k) — the same order + values the inline `param_stability` reduces. A wrong /// order or axis would fail the committed exact-grade anchor loudly (it pins /// `param_stability[0].mean` = the fast-MA refit mean), never silently. -#[allow(dead_code)] fn walkforward_summary_json_from_reports(reports: &[RunReport]) -> String { let total: f64 = reports.iter().map(|r| r.metrics.total_pips).sum(); // The four r-sma axis names, in `r_sma_space` order. Coercion to `f64` is @@ -1910,7 +1918,7 @@ fn walkforward_summary_json_from_reports(reports: &[RunReport]) -> String { .manifest .params .iter() - .find(|(name, _)| name == axis) + .find(|(name, _)| campaign_run::raw_matches_wrapped(axis, name)) .expect("each r-sma walk-forward window records its chosen axis"); match v { Scalar::I64(i) => *i as f64, @@ -3141,7 +3149,7 @@ fn blueprint_walkforward_family( walk_forward(roller, space.clone(), |w: WindowBounds| { let (is_family, lattice) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env) .expect("axes validated in the dispatch-boundary pre-flight"); - let (best, selection) = match select_winner(&is_family, "sqn_normalized", select, Some(&lattice)) { + let (best, selection) = match select_winner(&is_family, WINNER_SELECTION_METRIC, select, Some(&lattice)) { Ok(v) => v, Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } }; @@ -4201,6 +4209,61 @@ fn run_args_from(a: &RunCmd) -> Result { Ok(RunArgs { harness, data, trace: a.trace.clone(), cost, slip_vol_mult, carry_per_cycle }) } +/// The real walk-forward roller sizes in ms (the campaign wf stage works in ms: +/// span `cell.window_ms`, sizes the `StageBlock` `_ms` fields). `WF_REAL_*_NS` are +/// nanoseconds; divide by 1e6. The ms sizes over the doc window reproduce the same +/// calendar windows the inline ns roller produces (anchor-gated). +fn wf_ms_sizes() -> (u64, u64, u64) { + ( + (WF_REAL_IS_NS / 1_000_000) as u64, + (WF_REAL_OOS_NS / 1_000_000) as u64, + (WF_REAL_STEP_NS / 1_000_000) as u64, + ) +} + +/// True iff `--select` names a plateau mode (refused on the dissolved path, Fork B, +/// until #215 ships the wf-stage plateau relaxation). Reuses `parse_select` so the +/// vocabulary stays single-sourced. +fn select_is_plateau(select: Option<&str>) -> bool { + matches!(select.map(parse_select), Some(Ok(Selection::Plateau(_)))) +} + +/// Convert `WalkforwardCmd` into the resolved argument shape the walkforward sugar +/// consumes (the dissolved `--strategy r-sma --real` branch). Single instrument, +/// multi-value fast/slow (the IS-refit grid), single-value stop (Fork A: the stop is +/// a risk regime, not a swept axis); all four knobs required. `parse_csv_list` +/// (`:1586`) is generic over `T: FromStr`, so the same helper parses the i64 grids +/// and the f64 stop; the local `knobs` closure (captureless → `Copy`, mirroring +/// `generalize_args_from`'s idiom) is reused across the four `ok_or_else` calls. +/// `--name`/`--trace` are mutually exclusive here too, matching the flag's own +/// documented contract and the inline path's `name_persist` refusal. +#[allow(clippy::type_complexity)] +fn walkforward_args_from( + a: &WalkforwardCmd, +) -> Result<(String, String, Vec, Vec, i64, f64, Option, Option), String> { + let symbol = match a.real.as_deref() { + None | Some("") => return Err("walkforward --strategy r-sma dissolves only over --real ".to_string()), + Some(s) => s.to_string(), + }; + let knobs = || "walkforward requires --fast --slow --stop-length --stop-k (fast/slow may be CSV grids; the stop is single-value)".to_string(); + let fast: Vec = parse_csv_list(a.fast.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; + let slow: Vec = parse_csv_list(a.slow.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; + let stop_length: Vec = parse_csv_list(a.stop_length.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; + let stop_k: Vec = parse_csv_list(a.stop_k.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; + if stop_length.len() != 1 || stop_k.len() != 1 { + return Err("walkforward: the stop is a single risk regime; --stop-length and --stop-k take one value each".to_string()); + } + let name = match (a.name.as_deref(), a.trace.as_deref()) { + (Some(_), Some(_)) => { + return Err("walkforward: --name and --trace are mutually exclusive".to_string()); + } + (Some(n), None) => n.to_string(), + (None, Some(t)) => t.to_string(), + (None, None) => "walkforward".to_string(), + }; + Ok((name, symbol, fast, slow, stop_length[0], stop_k[0], a.from, a.to)) +} + /// Convert `GeneralizeCmd` into the resolved argument shape the generalize sugar /// consumes. The candidate is a single cell (clap already types `--fast`/etc. as /// one `i64`/`f64`), all four knobs required, `--real` a `>=2`-distinct comma @@ -4749,6 +4812,62 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { eprintln!("aura: {}", usage()); std::process::exit(2); } + // Dissolution split (#210, Reading A): only the real-archive r-sma + // execution routes through the campaign path. Everything else — synthetic + // (any strategy) and non-r-sma real — stays on the inline handler below, + // fenced until #159. + if a.strategy.as_deref() == Some("r-sma") && a.real.is_some() { + if let Some(s) = a.select.as_deref() + && parse_select(s).is_err() + { + eprintln!("aura: {}", usage()); + std::process::exit(2); + } + if select_is_plateau(a.select.as_deref()) { + eprintln!("aura: --select plateau is not yet available on the campaign path; see #215"); + std::process::exit(2); + } + let (name, symbol, fast, slow, stop_length, stop_k, from, to) = + walkforward_args_from(&a).unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + let signal = sma_signal(None, None); + let canonical = blueprint_to_json(&signal).expect("a bare sma_signal serializes"); + let reg = env.registry(); + let topo = topology_hash(&signal); + reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { + eprintln!("aura: {e}"); + std::process::exit(1); + }); + // Unlike `dispatch_generalize` (a single run per instrument, insensitive + // to a day's edge shift), the inline `walkforward_family` sources its span + // from `DataSource::wf_full_span`, which — for Real — ALWAYS clips + // `--from`/`--to` to the archive's actual first/last bar in range (never the + // literal ms request): a holiday/weekend edge shifts every IS/OOS window's + // calendar placement, so the roller must clip identically here too, even + // when both flags are given, or the per-window winners and OOS pips diverge + // from the committed exact-grade anchor. + let source = DataSource::from_choice( + DataChoice::Real { symbol: symbol.clone(), from_ms: from, to_ms: to }, + env, + ); + let (from_ts, to_ts) = source.full_window(env); + let (from_ms, to_ms) = ( + aura_ingest::epoch_ns_to_unix_ms(from_ts), + aura_ingest::epoch_ns_to_unix_ms(to_ts), + ); + let (is_ms, oos_ms, step_ms) = wf_ms_sizes(); + verb_sugar::run_walkforward_sugar( + &fast, &slow, stop_length, stop_k, WINNER_SELECTION_METRIC, + is_ms, oos_ms, step_ms, &name, &symbol, from_ms, to_ms, &canonical, env, + ) + .unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(1); + }); + return; + } let strategy = strategy_from(a.strategy.as_deref(), &usage).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index da93dde..e67b6f2 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -352,7 +352,6 @@ pub(crate) fn run_generalize_sugar( } /// The two generated documents of one dissolved `walkforward` invocation. -#[allow(dead_code)] #[derive(Debug)] pub(crate) struct GeneratedWalkforward { pub process: ProcessDoc, @@ -368,7 +367,6 @@ pub(crate) struct GeneratedWalkforward { /// (ms). `blueprint_canonical` is the bare `sma_signal` blueprint already stored by /// topology hash; its content id is the strategy ref. #[allow(clippy::too_many_arguments)] -#[allow(dead_code)] pub(crate) fn translate_walkforward( fast: &[i64], slow: &[i64], @@ -442,7 +440,6 @@ pub(crate) fn translate_walkforward( /// Auto-register both generated walkforward documents (content-addressed, /// idempotent). Returns `(process_id, campaign_id)`. -#[allow(dead_code)] pub(crate) fn register_generated_wf( reg: &aura_registry::Registry, generated: &GeneratedWalkforward, @@ -454,6 +451,57 @@ pub(crate) fn register_generated_wf( Ok((process_id, campaign_id)) } +/// Run one dissolved `walkforward` invocation end-to-end: register the generated +/// documents, run through the one campaign path, then reprint the per-window member +/// lines + the summary line from the recorded `WalkForward` family. The stdout +/// summary line is byte-identical to the inline path (the committed exact-grade +/// anchor is the gate). +#[allow(clippy::too_many_arguments)] +pub(crate) fn run_walkforward_sugar( + fast: &[i64], + slow: &[i64], + stop_length: i64, + stop_k: f64, + metric: &str, + in_sample_ms: u64, + out_of_sample_ms: u64, + step_ms: u64, + name: &str, + symbol: &str, + from_ms: i64, + to_ms: i64, + blueprint_canonical: &str, + env: &crate::project::Env, +) -> Result<(), String> { + let generated = translate_walkforward( + fast, slow, stop_length, stop_k, metric, in_sample_ms, out_of_sample_ms, step_ms, + name, symbol, from_ms, to_ms, blueprint_canonical, + )?; + let probe_params: Vec<(String, Scalar)> = vec![ + ("fast.length".to_string(), Scalar::i64(fast[0])), + ("slow.length".to_string(), Scalar::i64(slow[0])), + ]; + validate_before_register(&generated.process, &generated.campaign, blueprint_canonical, &probe_params, env)?; + let reg = env.registry(); + let (_process_id, campaign_id) = register_generated_wf(®, &generated)?; + let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?; + + // The single cell's walk_forward StageFamily. + let wf = run + .outcome + .cells + .iter() + .flat_map(|c| c.families.iter()) + .find(|f| f.block == "std::walk_forward") + .ok_or("walkforward produced no walk_forward family")?; + + for report in &wf.reports { + println!("{}", crate::family_member_line(&wf.family_id, report)); + } + println!("{}", crate::walkforward_summary_json_from_reports(&wf.reports)); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 6879ea4..e344ca5 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -3695,6 +3695,202 @@ fn walkforward_real_e2e_pins_the_exact_current_grade() { assert_eq!(ps[1]["mean"].as_f64(), Some(17.333333333333332), "slow-MA refit mean: {grade_line}"); } +/// Fork A: the dissolved walkforward binds the stop as a single risk regime, so a +/// multi-value --stop-length/--stop-k is refused (exit 2) before any run. +#[test] +fn walkforward_dissolved_refuses_a_multi_value_stop() { + let cwd = temp_cwd("walkforward-multi-stop"); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "walkforward", "--strategy", "r-sma", "--real", "GER40", + "--fast", "3", "--slow", "12", "--stop-length", "14,20", "--stop-k", "2.0", + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + assert_eq!(out.status.code(), Some(2), "multi-value stop is a usage refusal"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("single risk regime"), "stop-regime refusal: {stderr}"); +} + +/// Fork B: --select plateau is refused on the dissolved path (exit 2) with a +/// pointer to #215 until the wf-stage plateau relaxation ships. +#[test] +fn walkforward_dissolved_refuses_select_plateau() { + let cwd = temp_cwd("walkforward-plateau"); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "walkforward", "--strategy", "r-sma", "--real", "GER40", + "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "--select", "plateau:worst", + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + assert_eq!(out.status.code(), Some(2), "plateau is refused on the dissolved path"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("#215"), "forward pointer to the plateau follow-up: {stderr}"); +} + +/// `walkforward_args_from` requires all four grid knobs on the dissolved r-sma-real +/// path (unlike the inline path, which defaults an absent flag via `RGrid::default()`) +/// — a missing knob is a usage refusal (exit 2), not a silent default. +#[test] +fn walkforward_dissolved_refuses_missing_knobs() { + let cwd = temp_cwd("walkforward-missing-knobs"); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["walkforward", "--strategy", "r-sma", "--real", "GER40", "--fast", "3", "--slow", "12"]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + assert_eq!(out.status.code(), Some(2), "missing --stop-length/--stop-k is a usage refusal"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("requires --fast --slow --stop-length --stop-k"), "missing-knob refusal: {stderr}"); +} + +/// An empty `--real ""` still satisfies clap's `Option::is_some()` dispatch guard, so +/// `walkforward_args_from`'s own empty-string check is what refuses it (exit 2) — +/// distinct from omitting `--real` entirely, which never enters the dissolved branch. +#[test] +fn walkforward_dissolved_refuses_an_empty_real_symbol() { + let cwd = temp_cwd("walkforward-empty-real"); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "walkforward", "--strategy", "r-sma", "--real", "", + "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + assert_eq!(out.status.code(), Some(2), "empty --real is a usage refusal"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("dissolves only over --real"), "empty-real refusal: {stderr}"); +} + +/// Front-end parity: an unknown `--select` token is refused (exit 2) on the +/// dissolved path exactly as it is on the inline path — it must not be silently +/// swallowed by `select_is_plateau`'s plateau-only check and fall through to argmax. +#[test] +fn walkforward_dissolved_refuses_an_unknown_select_token() { + let cwd = temp_cwd("walkforward-bad-select"); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "walkforward", "--strategy", "r-sma", "--real", "GER40", + "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "--select", "bogus", + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + assert_eq!(out.status.code(), Some(2), "unknown --select token is a usage refusal"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("Usage: aura walkforward"), "unknown-select refusal: {stderr}"); +} + +/// `--trace`'s own help text ("mutually exclusive with --name") is a contract the +/// inline path already enforces via `name_persist`; the dissolved r-sma-real path +/// must refuse the same combination (exit 2) rather than silently letting --name +/// win over --trace. +#[test] +fn walkforward_dissolved_refuses_name_and_trace_together() { + let cwd = temp_cwd("walkforward-name-and-trace"); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "walkforward", "--strategy", "r-sma", "--real", "GER40", + "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "--name", "a", "--trace", "b", + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + assert_eq!(out.status.code(), Some(2), "--name and --trace together is a usage refusal"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("mutually exclusive"), "name/trace refusal: {stderr}"); +} + +/// Property (#210 T3, dispatch split): a successful `aura walkforward --strategy +/// r-sma --real` durably auto-registers exactly one generated process document, one +/// generated campaign document (carrying the `--name` handle and the stop as a +/// non-empty single risk regime), and one campaign-run record — mirroring the +/// generalize dissolution's audit trail. The persisted family set is exactly one +/// `Sweep` family (the pipeline's leading `std::sweep(argmax)` stage) plus one +/// `WalkForward` family (the per-window OOS reports) — the observable proof that +/// `dispatch_walkforward`'s r-sma-real branch now runs through the one campaign +/// executor rather than the deleted inline roller. Gated on the shared GER40 +/// archive; skips cleanly on a data refusal. +#[test] +fn walkforward_dissolves_through_the_campaign_path() { + const FROM_MS: &str = "1735689600000"; + const TO_MS: &str = "1767225599000"; + let cwd = temp_cwd("walkforward-dissolves"); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "walkforward", "--strategy", "r-sma", "--real", "GER40", + "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + if out.status.code() == Some(1) { + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no local data") || stderr.contains("no recorded geometry"), + "exit 1 must be a data refusal, got: {stderr}" + ); + eprintln!("skip: no local GER40 data"); + return; + } + assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); + + let count = |sub: &str| { + std::fs::read_dir(cwd.join("runs").join(sub)) + .map(|d| d.count()) + .unwrap_or(0) + }; + assert_eq!(count("processes"), 1, "one generated process document registered"); + assert_eq!(count("campaigns"), 1, "one generated campaign document registered"); + let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl")) + .expect("campaign_runs.jsonl exists after a sugar run"); + assert_eq!(runs_log.lines().count(), 1, "one campaign run recorded"); + + let campaigns_dir = cwd.join("runs").join("campaigns"); + let campaign_doc_path = std::fs::read_dir(&campaigns_dir) + .expect("campaigns dir exists") + .next() + .expect("exactly one campaign document") + .expect("readable dir entry") + .path(); + let campaign_doc_json = std::fs::read_to_string(&campaign_doc_path).expect("read campaign doc"); + assert!( + campaign_doc_json.contains("\"name\":\"walkforward\""), + "the generated campaign document carries the --name handle: {campaign_doc_json}" + ); + assert!( + campaign_doc_json.contains("\"risk\":[") + && campaign_doc_json.contains("\"length\":14") + && campaign_doc_json.contains("\"k\":2.0"), + "the stop rides a non-empty risk regime: {campaign_doc_json}" + ); + + let fams = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["runs", "families"]) + .current_dir(&cwd) + .output() + .expect("spawn families"); + let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned(); + assert_eq!( + fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(), + 1, + "one Sweep family from the pipeline's leading argmax stage: {fams_out}" + ); + assert_eq!( + fams_out.lines().filter(|l| l.contains("\"kind\":\"WalkForward\"")).count(), + 1, + "one WalkForward family holding the per-window OOS reports: {fams_out}" + ); +} + /// Property: `aura generalize` is now thin sugar over the one campaign path — a /// successful run durably auto-registers exactly one generated process document, /// one generated campaign document (carrying the `--name` handle and the stop as