From f286bb85f7897e99ea0ef93f127cd511779e9e72 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 26 Jun 2026 11:21:27 +0200 Subject: [PATCH] feat(0075): walk-forward strategy-selectable + R-reporting (iter 1) Make `aura walkforward --strategy stage1-r [--real ]` roll IS->OOS windows, sweep the stage1-r grid in-sample, pick the winner by an R metric (sqn_normalized), run it out-of-sample, and report per-window + pooled OOS R-metrics. The bare SMA walkforward path is byte-identical (its dispatch arm is verbatim today's body; the pooled `oos_r` block is emitted only when a window carries an `r` block). Engine: - RMetrics gains an in-memory `trade_rs: Vec` (realised R per closed trade), excluded from serde (`#[serde(skip)]`) and from a hand-written PartialEq, so the C18 wire shape and every existing equality assertion / round-trip stay unchanged. summarize_r now retains the per-trade R vector it used to drop. - New `r_metrics_from_rs(&[f64])` reduces a flat (pooled across-window) R series to RMetrics. Its R-distribution arithmetic is copied verbatim from summarize_r (the byte-pinned floats must not be algebraically refactored); the two copies are guarded in lockstep by a cross-reducer equality test. net_expectancy_r = expectancy_r (exact at the Stage-1 cost=0 invariant); conviction_terciles_r = [0,0,0] (per-trade conviction is not pooled). CLI: - walkforward gains --strategy + the four stage1-r grid flags (reusing parse_csv_list / Stage1RGrid); walkforward_family is strategy-dispatched. - Windowed stage1-r helpers: stage1_r_sweep_over (reduce-mode folded IS sweep, O(trades)/member) and run_oos_r (non-reduce OOS run, for the stitched pip-equity curve), plus stage1_r_space. Frictionless Stage-1 R (costs are Stage-2). Verified: cargo build clean, full `cargo test --workspace` green (SMA walkforward, synthetic mc, stage1_r_single_run_output_golden, and the C18 round-trips all preserved), clippy -D warnings clean. Monte-Carlo R-bootstrap is iter 2. refs #139 --- crates/aura-cli/src/main.rs | 268 ++++++++++++++++++++++++++----- crates/aura-cli/tests/cli_run.rs | 97 +++++++++++ crates/aura-engine/src/lib.rs | 5 +- crates/aura-engine/src/report.rs | 218 ++++++++++++++++++++++++- crates/aura-registry/src/lib.rs | 1 + 5 files changed, 542 insertions(+), 47 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index c816b44..2881822 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -14,14 +14,14 @@ mod render; use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series}; -use aura_core::{zip_params, Cell, Firing, Scalar, ScalarKind, Timestamp}; +use aura_core::{zip_params, Cell, Firing, ParamSpec, Scalar, ScalarKind, Timestamp}; use aura_composites::{risk_executor, risk_executor_vol_open, StopRule}; use aura_engine::{ - f64_field, join_on_ts, monte_carlo, param_stability, summarize, summarize_r, walk_forward, - window_of, ColumnarTrace, Composite, Edge, FlatGraph, GraphBuilder, Harness, JoinedRow, - McAggregate, McFamily, RollMode, RunManifest, RunMetrics, RunReport, SourceSpec, SweepFamily, - SweepPoint, SyntheticSpec, Target, VecSource, WalkForwardResult, WindowBounds, WindowRoller, - WindowRun, + f64_field, join_on_ts, monte_carlo, param_stability, r_metrics_from_rs, summarize, summarize_r, + walk_forward, window_of, ColumnarTrace, Composite, Edge, FlatGraph, GraphBuilder, Harness, + JoinedRow, McAggregate, McFamily, RollMode, RunManifest, RunMetrics, RunReport, SourceSpec, + SweepFamily, SweepPoint, SyntheticSpec, Target, VecSource, WalkForwardResult, WindowBounds, + WindowRoller, WindowRun, }; use aura_registry::{ group_families, mc_member_reports, optimize, rank_by, sweep_member_reports, @@ -1274,6 +1274,104 @@ fn stage1_r_sweep_family(trace: Option<&str>, data: &DataSource, grid: &Stage1RG .expect("the stage1-r named grid matches the stage1-r param-space") } +/// The param-space of the OPEN stage1-r blueprint (all four knobs free) — the kinds +/// the per-window `WindowRun::chosen_params` are read against (C7). Mirrors the +/// throwaway-build param_space resolution inside `stage1_r_sweep_family`. +fn stage1_r_space() -> Vec { + let (tx_eq, _) = mpsc::channel(); + let (tx_ex, _) = mpsc::channel(); + let (tx_r, _) = mpsc::channel(); + let (tx_req, _) = mpsc::channel(); + stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true).param_space() +} + +/// Windowed reduce-mode stage1-r sweep over `[from, to]` — the in-sample leg of the +/// stage1-r walk-forward. Identical grid/axis/fold logic to `stage1_r_sweep_family`, +/// but windowed (`windowed_sources`) and always folded (O(trades)/member): each +/// member's RunReport carries `metrics.r = Some(summarize_r(..))`, so the family is +/// rankable by an R metric. +fn stage1_r_sweep_over(from: Timestamp, to: Timestamp, data: &DataSource, grid: &Stage1RGrid) -> SweepFamily { + let pip = data.pip_size(); + let (tx_eq, _) = mpsc::channel(); + let (tx_ex, _) = mpsc::channel(); + let (tx_r, _) = mpsc::channel(); + let (tx_req, _) = mpsc::channel(); + let bp = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true); + let space = bp.param_space(); + let stop_length_axis = space.iter().map(|p| p.name.clone()) + .find(|n| n.ends_with(STOP_LENGTH_SUFFIX)).expect("open stage1-r vol-stop exposes a stop_length axis"); + let stop_k_axis = space.iter().map(|p| p.name.clone()) + .find(|n| n.ends_with(STOP_K_SUFFIX)).expect("open stage1-r vol-stop exposes a stop_k axis"); + bp + .axis("fast.length", grid.fast.clone()) + .axis("slow.length", grid.slow.clone()) + .axis(&stop_length_axis, grid.stop_length.clone()) + .axis(&stop_k_axis, grid.stop_k.clone()) + .sweep(|point| { + let (tx_eq, rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let (tx_r, rx_r) = mpsc::channel(); + let (tx_req, _rx_req) = mpsc::channel(); + let mut h = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true) + .bootstrap_with_cells(point) + .expect("stage1-r grid points are kind-checked against param_space"); + let sources = data.windowed_sources(from, to); + let window = window_of(&sources).expect("non-empty in-sample window"); + h.run(sources); + let mut named: Vec<(String, Scalar)> = zip_params(&space, point).into_iter() + .map(|(n, v)| (stage1_r_friendly_name(&n), v)).collect(); + named.push(("bias_scale".to_string(), Scalar::f64(0.5))); + let mut manifest = sim_optimal_manifest(named, window, 0, pip); + manifest.broker = stage1_r_broker_label(pip); + let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); + let (total_pips, max_drawdown) = rx_eq.try_iter().next() + .map(|(_, row)| (row[0].as_f64(), row[1].as_f64())).unwrap_or((0.0, 0.0)); + let bias_sign_flips = rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0); + let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None }; + m.r = Some(summarize_r(&r_rows, 0.0)); + RunReport { manifest, metrics: m } + }) + .expect("the stage1-r named grid matches the stage1-r param-space") +} + +/// Run the chosen stage1-r params over an OOS window; return the recorded pip-equity +/// segment (for stitching) and the OOS RunReport whose `metrics.r` carries both the +/// R metrics and the per-trade `trade_rs`. Non-reduce (raw recorders): one window's +/// curve is bounded, and `stitch` needs the full pip-equity series. +fn run_oos_r( + params: &[Cell], from: Timestamp, to: Timestamp, trace: Option<&str>, data: &DataSource, +) -> (Vec<(Timestamp, f64)>, RunReport) { + let pip = data.pip_size(); + let (tx_eq, rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let (tx_r, rx_r) = mpsc::channel(); + let (tx_req, rx_req) = mpsc::channel(); + let bp = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, false); + let space = bp.param_space(); + let mut h = bp.bootstrap_with_cells(params) + .expect("chosen params pre-validated by the in-sample GridSpace::new"); + let sources = data.windowed_sources(from, to); + let window = window_of(&sources).expect("non-empty out-of-sample window"); + h.run(sources); + let eq_rows = rx_eq.try_iter().collect::>(); + let ex_rows = rx_ex.try_iter().collect::>(); + let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); + let req_rows: Vec<(Timestamp, Vec)> = rx_req.try_iter().collect(); + let mut named: Vec<(String, Scalar)> = zip_params(&space, params).into_iter() + .map(|(n, v)| (stage1_r_friendly_name(&n), v)).collect(); + named.push(("bias_scale".to_string(), Scalar::f64(0.5))); + let mut manifest = sim_optimal_manifest(named, window, 0, pip); + manifest.broker = stage1_r_broker_label(pip); + if let Some(name) = trace { + persist_traces_r(&format!("{name}/oos{}", from.0), &manifest, &eq_rows, &ex_rows, &req_rows); + } + let equity = f64_field(&eq_rows, 0); + let exposure = f64_field(&ex_rows, 0); + let mut metrics = summarize(&equity, &exposure); + metrics.r = Some(summarize_r(&r_rows, 0.0)); + (equity, RunReport { manifest, metrics }) +} + /// `aura sweep --strategy stage1-breakout`: sweep the breakout harness over a channel × /// stop grid. One `channel` length drives BOTH rolling nodes (parameter-ganging, #61), /// so the family iterates the cartesian product MANUALLY with a fully-bound graph per @@ -1450,6 +1548,21 @@ enum Strategy { Stage1MeanRev, } +impl Strategy { + /// The CLI `--strategy` token this variant parses from — the inverse of the + /// `parse_*_args` match arms. Used to echo the offending strategy in error + /// messages so they name the actual input. + fn cli_token(self) -> &'static str { + match self { + Strategy::SmaCross => "sma", + Strategy::Momentum => "momentum", + Strategy::Stage1R => "stage1-r", + Strategy::Stage1Breakout => "stage1-breakout", + Strategy::Stage1MeanRev => "stage1-meanrev", + } + } +} + /// Parse a comma-separated list of `T` (each item parsed via `FromStr`), rejecting /// any item that fails to parse — the shared validator for the stage1-r grid flags /// (#137). The caller folds the `Err` into the subcommand `usage()` so a malformed @@ -1520,13 +1633,18 @@ fn parse_sweep_args( Ok((strategy, name, persist, real.finish(&usage)?, grid)) } -/// Parse the `walkforward` tail: `[--real [--from ] [--to ]] [--name | --trace ]`. -/// Defaults: synthetic, name "walkforward", no persist. `--name`/`--trace` are +/// Parse the `walkforward` tail: +/// `[--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ]`. +/// Defaults: SMA-cross, synthetic, name "walkforward", no persist. `--name`/`--trace` /// mutually exclusive; `--from`/`--to` require `--real`. Pure (no I/O / exit). -fn parse_walkforward_args(rest: &[&str]) -> Result<(String, bool, DataChoice), String> { - let usage = || "walkforward [--real [--from ] [--to ]] [--name | --trace ]".to_string(); - let mut name: Option<(String, bool)> = None; // (name, persist) +fn parse_walkforward_args( + rest: &[&str], +) -> Result<(Strategy, String, bool, DataChoice, Stage1RGrid), String> { + let usage = || "walkforward [--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ]".to_string(); + let mut strategy = Strategy::SmaCross; + let mut name: Option<(String, bool)> = None; let mut real = RealWindowGrammar::default(); + let mut grid = Stage1RGrid::default(); let mut tail = rest; while let Some((flag, t)) = tail.split_first() { let (value, t) = t.split_first().ok_or_else(usage)?; @@ -1535,14 +1653,28 @@ fn parse_walkforward_args(rest: &[&str]) -> Result<(String, bool, DataChoice), S continue; } match *flag { + "--strategy" => { + strategy = match *value { + "sma" => Strategy::SmaCross, + "momentum" => Strategy::Momentum, + "stage1-r" => Strategy::Stage1R, + "stage1-breakout" => Strategy::Stage1Breakout, + "stage1-meanrev" => Strategy::Stage1MeanRev, + _ => return Err(usage()), + }; + } "--name" if name.is_none() => name = Some(((*value).to_string(), false)), "--trace" if name.is_none() => name = Some(((*value).to_string(), true)), + "--fast" => grid.fast = parse_csv_list(value).map_err(|()| usage())?, + "--slow" => grid.slow = parse_csv_list(value).map_err(|()| usage())?, + "--stop-length" => grid.stop_length = parse_csv_list(value).map_err(|()| usage())?, + "--stop-k" => grid.stop_k = parse_csv_list(value).map_err(|()| usage())?, _ => return Err(usage()), } tail = t; } let (name, persist) = name.unwrap_or_else(|| ("walkforward".to_string(), false)); - Ok((name, persist, real.finish(&usage)?)) + Ok((strategy, name, persist, real.finish(&usage)?, grid)) } /// Render a family-member stdout line: the assigned `family_id` plus the embedded @@ -1612,7 +1744,7 @@ fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource, gr /// print each carrying the assigned id, then the stitched summary line. With /// `--trace`, also persist each OOS member's streams under /// `runs/traces//oos/` (opt-in). Deterministic (C1). -fn run_walkforward(name: &str, persist: bool, data: DataSource) { +fn run_walkforward(strategy: Strategy, name: &str, persist: bool, data: DataSource, grid: &Stage1RGrid) { if persist && let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family) { @@ -1620,7 +1752,7 @@ fn run_walkforward(name: &str, persist: bool, data: DataSource) { std::process::exit(2); } let reg = default_registry(); - let result = walkforward_family(persist.then_some(name), &data); + let result = walkforward_family(strategy, persist.then_some(name), &data, grid); let id = match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result)) { @@ -1638,9 +1770,14 @@ fn run_walkforward(name: &str, persist: bool, data: DataSource) { /// The built-in rolling walk-forward: 24-bar in-sample, 12-bar out-of-sample, /// stepping 12 (contiguous OOS tiling), over the 60-bar synthetic span -> 3 -/// windows. Each window sweeps the built-in grid in-sample, optimizes by -/// total_pips (axis 2), and runs the chosen params out-of-sample. -fn walkforward_family(trace: Option<&str>, data: &DataSource) -> WalkForwardResult { +/// windows. Each window sweeps a grid in-sample, optimizes by a metric, and runs +/// the chosen params out-of-sample — both strategy-dispatched: the `SmaCross` arm +/// sweeps the SMA sample grid and optimizes by `total_pips` (axis 2); the +/// `Stage1R` arm sweeps the stage1-r grid and optimizes by `sqn_normalized`. +/// Other strategies have no walk-forward form yet (exit 2). +fn walkforward_family( + strategy: Strategy, trace: Option<&str>, data: &DataSource, grid: &Stage1RGrid, +) -> WalkForwardResult { let span = data.wf_full_span(); let (is_len, oos_len, step) = data.wf_window_sizes(); let roller = match WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling) { @@ -1650,19 +1787,39 @@ fn walkforward_family(trace: Option<&str>, data: &DataSource) -> WalkForwardResu std::process::exit(2); } }; - let space = sample_blueprint_with_sinks(data.pip_size()).0.param_space(); - walk_forward(roller, space, |w: WindowBounds| { - let is_family = sweep_over(w.is.0, w.is.1, data); - let best = optimize(&is_family, "total_pips").expect("total_pips is a known metric"); - let (oos_equity, oos_report) = run_oos(&best.params, w.oos.0, w.oos.1, trace, data); - WindowRun { - // The tag-free sweep winner is the chosen point; its kinds live on - // WalkForwardResult.space (computed once above from the same blueprint). - chosen_params: best.params, - oos_equity, - oos_report, + match strategy { + Strategy::SmaCross => { + let space = sample_blueprint_with_sinks(data.pip_size()).0.param_space(); + walk_forward(roller, space, |w: WindowBounds| { + let is_family = sweep_over(w.is.0, w.is.1, data); + let best = optimize(&is_family, "total_pips").expect("total_pips is a known metric"); + let (oos_equity, oos_report) = run_oos(&best.params, w.oos.0, w.oos.1, trace, data); + WindowRun { + // The tag-free sweep winner is the chosen point; its kinds live on + // WalkForwardResult.space (computed once above from the same blueprint). + chosen_params: best.params, + oos_equity, + oos_report, + } + }) } - }) + Strategy::Stage1R => { + let space = stage1_r_space(); + walk_forward(roller, space, |w: WindowBounds| { + let is_family = stage1_r_sweep_over(w.is.0, w.is.1, data, grid); + let best = optimize(&is_family, "sqn_normalized").expect("sqn_normalized is a known metric"); + let (oos_equity, oos_report) = run_oos_r(&best.params, w.oos.0, w.oos.1, trace, data); + WindowRun { chosen_params: best.params, oos_equity, oos_report } + }) + } + other => { + eprintln!( + "aura: walkforward has no form for strategy '{}'", + other.cli_token() + ); + std::process::exit(2); + } + } } /// Sweep the built-in named grid over an in-sample window, sourcing the in-memory @@ -1733,14 +1890,22 @@ fn run_oos( /// (C14). fn walkforward_summary_json(result: &WalkForwardResult) -> String { let total = result.stitched_oos_equity.last().map(|&(_, v)| v).unwrap_or(0.0); - serde_json::json!({ - "walkforward": { - "windows": result.windows.len(), - "stitched_total_pips": total, - "param_stability": param_stability(result), - } - }) - .to_string() + // pool the per-window OOS per-trade R series in roll order, then reduce. + let pooled_rs: Vec = result.windows.iter() + .flat_map(|w| w.run.oos_report.metrics.r.as_ref().map(|r| r.trade_rs.clone()).unwrap_or_default()) + .collect(); + let mut obj = serde_json::json!({ + "windows": result.windows.len(), + "stitched_total_pips": total, + "param_stability": param_stability(result), + }); + if result.windows.iter().any(|w| w.run.oos_report.metrics.r.is_some()) { + // RMetrics serializes its scalar fields (trade_rs is serde-skipped, so the + // oos_r block is the clean R-metric summary of the pooled series). + obj["oos_r"] = serde_json::to_value(r_metrics_from_rs(&pooled_rs)) + .expect("RMetrics serializes"); + } + serde_json::json!({ "walkforward": obj }).to_string() } /// A longer deterministic stream than `showcase_prices` — enough for several @@ -1772,7 +1937,7 @@ fn walkforward_window_source(from: Timestamp, to: Timestamp) -> VecSource { /// helper (mirrors `sweep_report`). #[cfg(test)] fn walkforward_report() -> String { - let result = walkforward_family(None, &DataSource::Synthetic); + let result = walkforward_family(Strategy::SmaCross, None, &DataSource::Synthetic, &Stage1RGrid::default()); let mut out = String::new(); for w in &result.windows { out.push_str(&w.run.oos_report.to_json()); @@ -2589,7 +2754,7 @@ fn run_dispatch(args: RunArgs) -> Result { } const USAGE: &str = - "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] | aura chart [--tap ] [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura walkforward [--real [--from ] [--to ]] [--name |--trace ] | aura runs families | aura runs family [rank ]"; + "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] | aura chart [--tap ] [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura walkforward [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] | aura runs families | aura runs family [rank ]"; fn main() { // Collect argv and match the whole vector: every accepted form is exhaustive, @@ -2635,8 +2800,8 @@ fn main() { } }, ["walkforward", rest @ ..] => match parse_walkforward_args(rest) { - Ok((name, persist, choice)) => { - run_walkforward(&name, persist, DataSource::from_choice(choice)) + Ok((strategy, name, persist, choice, grid)) => { + run_walkforward(strategy, &name, persist, DataSource::from_choice(choice), &grid) } Err(msg) => { eprintln!("aura: {msg}"); @@ -3148,7 +3313,7 @@ mod tests { .append_family( "walkforward", FamilyKind::WalkForward, - &walkforward_member_reports(&walkforward_family(None, &DataSource::Synthetic)), + &walkforward_member_reports(&walkforward_family(Strategy::SmaCross, None, &DataSource::Synthetic, &Stage1RGrid::default())), ) .expect("walkforward family"); assert_eq!((sid.as_str(), mid.as_str(), wid.as_str()), ("sweep-0", "mc-0", "walkforward-0")); @@ -3650,15 +3815,30 @@ mod tests { /// and rejects two name flags or a `--real` missing its symbol. #[test] fn parse_walkforward_args_defaults_and_accepts_real() { - assert_eq!(parse_walkforward_args(&[]), Ok(("walkforward".to_string(), false, DataChoice::Synthetic))); + assert_eq!( + parse_walkforward_args(&[]), + Ok((Strategy::SmaCross, "walkforward".to_string(), false, DataChoice::Synthetic, Stage1RGrid::default())) + ); assert_eq!( parse_walkforward_args(&["--real", "EURUSD", "--trace", "w"]), - Ok(("w".to_string(), true, DataChoice::Real { symbol: "EURUSD".to_string(), from_ms: None, to_ms: None })) + Ok((Strategy::SmaCross, "w".to_string(), true, + DataChoice::Real { symbol: "EURUSD".to_string(), from_ms: None, to_ms: None }, + Stage1RGrid::default())) ); assert!(parse_walkforward_args(&["--name", "a", "--trace", "b"]).is_err()); assert!(parse_walkforward_args(&["--real"]).is_err()); } + #[test] + fn parse_walkforward_args_accepts_strategy_and_grid_flags() { + let r = parse_walkforward_args(&["--strategy", "stage1-r", "--fast", "5,10", "--stop-k", "2.0,3.0"]); + let (strategy, _, _, _, grid) = r.expect("valid stage1-r walkforward args"); + assert_eq!(strategy, Strategy::Stage1R); + assert_eq!(grid.fast, vec![5, 10]); + assert_eq!(grid.stop_k, vec![2.0, 3.0]); + assert!(parse_walkforward_args(&["--strategy", "bogus"]).is_err()); + } + /// `parse_walkforward_args` mirrors the shared `RealWindowGrammar` real/window strictness: /// an empty `--real` symbol and a repeated `--real`/`--from`/`--to` are usage /// errors — the same real-grammar rejection its sibling `parse_sweep_args` gives. diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index acabfe9..ab76d44 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -2310,3 +2310,100 @@ fn sweep_strategy_stage1_meanrev_grids_one_member_per_window() { ); let _ = std::fs::remove_dir_all(&cwd); } + +/// Property: `aura walkforward --strategy stage1-r` on synthetic data reports R +/// quality per window AND pooled across windows — every per-window member line +/// carries a `metrics.r` block (the windowed reduce-mode IS sweep folds the dense +/// R-record), and the summary line carries a pooled `oos_r` block (the across-window +/// reduction of the OOS per-trade R series). Deterministic: a second run is +/// byte-identical (C1). +#[test] +fn walkforward_strategy_stage1_r_reports_oos_r() { + let run = || { + let cwd = temp_cwd("wf-stage1r"); + let out = Command::new(BIN) + .args(["walkforward", "--strategy", "stage1-r"]) + .current_dir(&cwd) + .output() + .expect("spawn aura walkforward --strategy stage1-r"); + assert!( + out.status.success(), + "walkforward --strategy stage1-r exit: {:?}; stderr: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8(out.stdout).expect("utf-8"); + let _ = std::fs::remove_dir_all(&cwd); + stdout + }; + let out = run(); + let lines: Vec<&str> = out.trim().lines().collect(); + let summary: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap(); + assert!(summary["walkforward"]["oos_r"].is_object(), "summary carries oos_r"); + assert!(summary["walkforward"]["oos_r"]["n_trades"].is_number()); + // each per-window member line carries metrics.r + for l in &lines[..lines.len() - 1] { + let v: serde_json::Value = serde_json::from_str(l).unwrap(); + assert!(v["report"]["metrics"]["r"].is_object(), "per-window r block present"); + } + let out2 = run(); + assert_eq!(out, out2, "stage1-r walkforward is deterministic"); +} + +/// Property: the bare `aura walkforward` (default SMA-cross) summary is preserved +/// byte-for-byte by the new `--strategy`/grid plumbing — it still carries exactly +/// `windows`, `stitched_total_pips`, `param_stability` and NOT the stage1-r-only +/// `oos_r` block. The spec promised the bare path's golden is unchanged; the +/// signature widening defaults to SmaCross and the summary gates `oos_r` on +/// `metrics.r.is_some()`, so a regression that leaked R-reporting (or any other +/// key) into the SMA summary would be a silent contract break. This pins the +/// negative: the SMA arm produces no `metrics.r`, hence no `oos_r`. +#[test] +fn walkforward_bare_sma_summary_has_no_oos_r() { + let cwd = temp_cwd("wf-bare-sma"); + let out = Command::new(BIN) + .arg("walkforward") + .current_dir(&cwd) + .output() + .expect("spawn aura walkforward"); + assert!(out.status.success(), "bare walkforward exit: {:?}", out.status); + let stdout = String::from_utf8(out.stdout).expect("utf-8"); + let lines: Vec<&str> = stdout.trim().lines().collect(); + let summary: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap(); + let wf = &summary["walkforward"]; + assert!(wf["oos_r"].is_null(), "bare SMA summary must NOT carry oos_r: {wf}"); + assert!(wf["windows"].is_number(), "bare SMA summary keeps windows: {wf}"); + assert!(wf["stitched_total_pips"].is_number(), "bare SMA summary keeps stitched_total_pips: {wf}"); + assert!(wf["param_stability"].is_array(), "bare SMA summary keeps param_stability: {wf}"); + // no per-window member line carries a metrics.r block (R-reporting is stage1-r-only) + for l in &lines[..lines.len() - 1] { + let v: serde_json::Value = serde_json::from_str(l).unwrap(); + assert!(v["report"]["metrics"]["r"].is_null(), "bare SMA per-window line must NOT carry metrics.r: {l}"); + } + let _ = std::fs::remove_dir_all(&cwd); +} + +/// Property: a strategy that parses but has no walk-forward form yet (e.g. +/// `stage1-breakout`) is rejected with exit 2 and a stderr message that names the +/// OFFENDING strategy by its CLI token — not silently run as SMA, not crashed. +/// The dispatch's catch-all arm routes through `Strategy::cli_token()`, so the +/// diagnostic must echo the token the user typed (`stage1-breakout`), proving the +/// inverse map is wired. Distinct from a bad `--strategy bogus` (a usage parse +/// error): here the token is a valid strategy with no walk-forward arm. +#[test] +fn walkforward_unsupported_strategy_exits_2_naming_the_token() { + let cwd = temp_cwd("wf-unsupported"); + let out = Command::new(BIN) + .args(["walkforward", "--strategy", "stage1-breakout"]) + .current_dir(&cwd) + .output() + .expect("spawn aura walkforward --strategy stage1-breakout"); + assert_eq!(out.status.code(), Some(2), "unsupported walkforward strategy must exit 2"); + let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr"); + assert!( + stderr.contains("stage1-breakout"), + "stderr must name the offending strategy token: {stderr:?}" + ); + assert!(out.stdout.is_empty(), "no family summary on the rejected path: {:?}", out.stdout); + let _ = std::fs::remove_dir_all(&cwd); +} diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index bd0742e..b076933 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -62,8 +62,9 @@ pub use harness::{ VecSource, }; pub use report::{ - derive_position_events, f64_field, join_on_ts, summarize, summarize_r, ColumnarTrace, - JoinedRow, PositionAction, PositionEvent, RMetrics, RunManifest, RunMetrics, RunReport, + derive_position_events, f64_field, join_on_ts, r_metrics_from_rs, summarize, summarize_r, + ColumnarTrace, JoinedRow, PositionAction, PositionEvent, RMetrics, RunManifest, RunMetrics, + RunReport, }; pub use sweep::{ sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint, diff --git a/crates/aura-engine/src/report.rs b/crates/aura-engine/src/report.rs index 8421119..627dcd0 100644 --- a/crates/aura-engine/src/report.rs +++ b/crates/aura-engine/src/report.rs @@ -41,7 +41,7 @@ pub struct RunMetrics { /// R-based signal-quality metrics (Stage-1), reduced from a `PositionManagement` dense /// record stream by [`summarize_r`]. Account- and instrument-agnostic (pure R). Carries /// the enriched dispersion/churn fields (SQN, conviction terciles, net-of-cost). -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct RMetrics { pub expectancy_r: f64, // mean realised R over all trades (equal-weighted; headline) pub n_trades: u64, @@ -59,6 +59,30 @@ pub struct RMetrics { pub sqn_normalized: f64, pub net_expectancy_r: f64, // mean(R - round_trip_cost / latched_dist) — churn-honest pub conviction_terciles_r: [f64; 3], // E[R] by conviction_at_entry tercile (asc); <3 trades -> [0,0,0] + /// Realised R per closed trade, in trade order — an in-memory conduit for the + /// OOS R-series bootstrap. Excluded from serde (`skip`) so the C18 wire + /// shape is unchanged, and from `PartialEq` (below) so every existing + /// `RMetrics`/`RunReport` equality assertion and round-trip stays green. + #[serde(skip)] + pub trade_rs: Vec, +} + +impl PartialEq for RMetrics { + fn eq(&self, o: &Self) -> bool { + self.expectancy_r == o.expectancy_r + && self.n_trades == o.n_trades + && self.win_rate == o.win_rate + && self.avg_win_r == o.avg_win_r + && self.avg_loss_r == o.avg_loss_r + && self.profit_factor == o.profit_factor + && self.max_r_drawdown == o.max_r_drawdown + && self.n_open_at_end == o.n_open_at_end + && self.sqn == o.sqn + && self.sqn_normalized == o.sqn_normalized + && self.net_expectancy_r == o.net_expectancy_r + && self.conviction_terciles_r == o.conviction_terciles_r + // trade_rs deliberately excluded + } } // Dense `PositionManagement` record column indices — the lockstep contract with @@ -132,6 +156,7 @@ pub fn summarize_r(record: &[(Timestamp, Vec)], round_trip_cost: f64) -> sqn_normalized: 0.0, net_expectancy_r: 0.0, conviction_terciles_r: [0.0; 3], + trade_rs: Vec::new(), }; } let rs: Vec = trades.iter().map(|t| t.r).collect(); @@ -216,6 +241,73 @@ pub fn summarize_r(record: &[(Timestamp, Vec)], round_trip_cost: f64) -> sqn_normalized, net_expectancy_r, conviction_terciles_r, + trade_rs: rs, + } +} + +/// Reduce a flat per-trade R series (e.g. the pooled across-window OOS series of a +/// walk-forward) into `RMetrics`. The R-distribution fields (mean / win-rate / profit +/// factor / max-drawdown / SQN) duplicate [`summarize_r`]'s arithmetic byte-for-byte — +/// deliberately copied, not factored, so neither pinned-float expression shifts under +/// IEEE-754 non-associativity. MAINTENANCE COUPLING: the two copies must be edited in +/// lockstep; the only guard that they agree is the cross-reducer equality test +/// `summarize_r_includes_open_trade_and_matches_r_metrics_from_rs` — touch one copy +/// without the other and that test is the sole tripwire. The +/// fields a flat R series cannot carry are set honestly: `n_open_at_end = 0`, +/// `net_expectancy_r = expectancy_r` (exact under the Stage-1 cost = 0 invariant), +/// `conviction_terciles_r = [0,0,0]` (per-trade conviction is not pooled). Empty +/// input -> a well-defined all-zero `RMetrics`. +pub fn r_metrics_from_rs(rs: &[f64]) -> RMetrics { + let n = rs.len() as u64; + if n == 0 { + return RMetrics { + expectancy_r: 0.0, n_trades: 0, win_rate: 0.0, avg_win_r: 0.0, + avg_loss_r: 0.0, profit_factor: 0.0, max_r_drawdown: 0.0, + n_open_at_end: 0, sqn: 0.0, sqn_normalized: 0.0, net_expectancy_r: 0.0, + conviction_terciles_r: [0.0; 3], trade_rs: Vec::new(), + }; + } + let sum: f64 = rs.iter().sum(); + let mean = sum / n as f64; + let wins: Vec = rs.iter().copied().filter(|&r| r > 0.0).collect(); + let losses: Vec = rs.iter().copied().filter(|&r| r <= 0.0).collect(); + let sum_win: f64 = wins.iter().sum(); + let sum_loss: f64 = losses.iter().sum(); + let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::() / v.len() as f64 }; + let mut peak = f64::NEG_INFINITY; + let mut cum = 0.0; + let mut max_dd = 0.0_f64; + for &r in rs { + cum += r; + if cum > peak { peak = cum; } + let dd = peak - cum; + if dd > max_dd { max_dd = dd; } + } + let (sqn, sqn_normalized) = if n < 2 { + (0.0, 0.0) + } else { + let var = rs.iter().map(|&r| (r - mean).powi(2)).sum::() / (n as f64 - 1.0); + let sd = var.sqrt(); + if sd > 0.0 { + ((n as f64).sqrt() * mean / sd, (n.min(SQN_CAP) as f64).sqrt() * mean / sd) + } else { + (0.0, 0.0) + } + }; + RMetrics { + expectancy_r: mean, + n_trades: n, + win_rate: wins.len() as f64 / n as f64, + avg_win_r: avg(&wins), + avg_loss_r: avg(&losses), + profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 }, + max_r_drawdown: max_dd, + n_open_at_end: 0, + sqn, + sqn_normalized, + net_expectancy_r: mean, // cost = 0 -> net == gross (Stage-1 frictionless) + conviction_terciles_r: [0.0; 3], + trade_rs: Vec::new(), } } @@ -1145,6 +1237,7 @@ mod tests { sqn_normalized: 1.0, net_expectancy_r: 0.4, conviction_terciles_r: [-0.5, 0.5, 1.5], + trade_rs: Vec::new(), }), }; let json = serde_json::to_string(&m).expect("serialize"); @@ -1326,4 +1419,127 @@ mod tests { let rows = vec![(Timestamp(1), vec![Scalar::f64(1.0)])]; let _ = ColumnarTrace::from_rows("narrow", &[ScalarKind::F64, ScalarKind::F64], &rows); } + + #[test] + fn summarize_r_populates_trade_rs_in_trade_order() { + // two closed trades (R = +2.0, then -1.0) over a minimal PositionManagement + // record; trade_rs must carry [2.0, -1.0] in trade order. + let rec = pm_record_two_closed_trades(); // helper below + let m = summarize_r(&rec, 0.0); + assert_eq!(m.trade_rs, vec![2.0, -1.0]); + assert_eq!(m.n_trades, 2); + } + + #[test] + fn summarize_r_empty_record_has_empty_trade_rs() { + let m = summarize_r(&[], 0.0); + assert!(m.trade_rs.is_empty()); + assert_eq!(m.n_trades, 0); + } + + /// Property: a position still open on the last row is folded into the trade + /// ledger at its `unrealized_r` (a window-end trade), so `summarize_r`'s + /// `trade_rs` carries that synthetic open trade's R and `n_trades` counts it. + /// This is the one case where the two reducers' inputs differ in meaning — it + /// is exactly the per-trade R series the OOS conduit hands `r_metrics_from_rs`, + /// so the two must agree on the R-distribution arithmetic for an + /// open-at-end series. Closed +2.0 then open-at-end +0.5 -> rs [2.0, 0.5]. + #[test] + fn summarize_r_includes_open_trade_and_matches_r_metrics_from_rs() { + let rec = pm_record_closed_then_open_at_end(); + let m = summarize_r(&rec, 0.0); + assert_eq!(m.trade_rs, vec![2.0, 0.5]); + assert_eq!(m.n_trades, 2); + assert_eq!(m.n_open_at_end, 1); + // Feed the open-at-end pooled series through the flat reducer: the + // R-distribution fields (the verbatim-copied arithmetic) must agree. + let pooled = r_metrics_from_rs(&m.trade_rs); + assert_eq!(pooled.n_trades, m.n_trades); + assert_eq!(pooled.expectancy_r, m.expectancy_r); + assert_eq!(pooled.win_rate, m.win_rate); + assert_eq!(pooled.avg_win_r, m.avg_win_r); + assert_eq!(pooled.avg_loss_r, m.avg_loss_r); + assert_eq!(pooled.profit_factor, m.profit_factor); + assert_eq!(pooled.max_r_drawdown, m.max_r_drawdown); + assert_eq!(pooled.sqn, m.sqn); + assert_eq!(pooled.sqn_normalized, m.sqn_normalized); + } + + #[test] + fn rmetrics_partial_eq_ignores_trade_rs() { + // two RMetrics equal in every metric but differing in trade_rs compare EQUAL + // (trade_rs is an in-memory conduit, excluded from equality) — this is what + // keeps serialize->deserialize round-trips equal (trade_rs is serde-skipped, + // so it deserializes empty). + let a = summarize_r(&pm_record_two_closed_trades(), 0.0); + let mut b = a.clone(); + b.trade_rs = Vec::new(); + assert_eq!(a, b); + } + + /// A minimal dense PositionManagement record with two closed trades at R = +2, -1. + /// Columns per `r_col` (CLOSED=0, REALIZED_R=1, DIRECTION=4, ENTRY_PRICE=6, + /// STOP_PRICE=7, CONVICTION_AT_ENTRY=9, SIZE=10, OPEN=11, UNREALIZED_R=12); width 13. + fn pm_record_two_closed_trades() -> Vec<(Timestamp, Vec)> { + let row = |closed: bool, r: f64, open: bool| { + let mut c = vec![Scalar::f64(0.0); 13]; + c[0] = Scalar::bool(closed); + c[1] = Scalar::f64(r); + c[6] = Scalar::f64(1.0); // entry + c[7] = Scalar::f64(0.5); // stop -> latched 0.5 + c[9] = Scalar::f64(0.3); // conviction + c[11] = Scalar::bool(open); + c + }; + vec![ + (Timestamp(1), row(true, 2.0, false)), + (Timestamp(2), row(true, -1.0, false)), + ] + } + + /// A dense PositionManagement record whose last row is still open: one closed + /// trade at R = +2, then a position open at cycle end carrying UNREALIZED_R + /// = +0.5 (col 12, the field `summarize_r` reads for the window-end trade). + /// Same column map / width as `pm_record_two_closed_trades`. + fn pm_record_closed_then_open_at_end() -> Vec<(Timestamp, Vec)> { + let row = |closed: bool, realized_r: f64, open: bool, unrealized_r: f64| { + let mut c = vec![Scalar::f64(0.0); 13]; + c[0] = Scalar::bool(closed); + c[1] = Scalar::f64(realized_r); + c[6] = Scalar::f64(1.0); // entry + c[7] = Scalar::f64(0.5); // stop -> latched 0.5 + c[9] = Scalar::f64(0.3); // conviction + c[11] = Scalar::bool(open); + c[12] = Scalar::f64(unrealized_r); + c + }; + vec![ + (Timestamp(1), row(true, 2.0, false, 0.0)), + (Timestamp(2), row(false, 0.0, true, 0.5)), + ] + } + + #[test] + fn r_metrics_from_rs_folds_a_flat_series() { + // pooled across-window R series [2.0, -1.0, 1.0]: expectancy = 2/3, 2 wins of 3, + // profit_factor = (2+1)/1 = 3. At cost 0 (frictionless Stage-1) net == gross. + // conviction terciles are not pooled -> [0,0,0]; n_open_at_end is not a pooled + // concept -> 0. + let m = r_metrics_from_rs(&[2.0, -1.0, 1.0]); + assert_eq!(m.n_trades, 3); + assert!((m.expectancy_r - 2.0 / 3.0).abs() < 1e-12); + assert!((m.win_rate - 2.0 / 3.0).abs() < 1e-12); + assert!((m.profit_factor - 3.0).abs() < 1e-12); + assert_eq!(m.net_expectancy_r, m.expectancy_r); + assert_eq!(m.conviction_terciles_r, [0.0; 3]); + assert_eq!(m.n_open_at_end, 0); + } + + #[test] + fn r_metrics_from_rs_empty_is_all_zero() { + let m = r_metrics_from_rs(&[]); + assert_eq!(m.n_trades, 0); + assert_eq!(m.expectancy_r, 0.0); + assert_eq!(m.sqn, 0.0); + } } diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index 56ca930..dd367a8 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -265,6 +265,7 @@ mod tests { sqn_normalized: sqn, // mirror sqn: only the rank-key wiring is under test here net_expectancy_r, conviction_terciles_r: [0.0, 0.0, 0.0], + trade_rs: Vec::new(), }); rep }