feat(0075): walk-forward strategy-selectable + R-reporting (iter 1)

Make `aura walkforward --strategy stage1-r [--real <SYM>]` 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<f64>` (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
This commit is contained in:
2026-06-26 11:21:27 +02:00
parent 2593af86f8
commit f286bb85f7
5 changed files with 542 additions and 47 deletions
+224 -44
View File
@@ -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<ParamSpec> {
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<Scalar>)> = 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::<Vec<_>>();
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = 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 <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]`.
/// Defaults: synthetic, name "walkforward", no persist. `--name`/`--trace` are
/// Parse the `walkforward` tail:
/// `[--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>]`.
/// 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 <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>]".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 <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>]".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/<n>/oos<ns>/` (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<f64> = 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<RunReport, String> {
}
const USAGE: &str =
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] | aura runs families | aura runs family <id> [rank <metric>]";
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.
+97
View File
@@ -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);
}