feat(cli): walkforward campaign translator + summary reducer (#210 T1-T2)

The additive machinery for the walkforward dissolution, unwired this slice
(dispatch_walkforward untouched — `aura walkforward` behaviour byte-for-byte
unchanged). Two pieces, both mirroring the generalize seam:

- `translate_walkforward` + `register_generated_wf` + `GeneratedWalkforward`
  (verb_sugar.rs): argv -> a `[std::sweep(argmax), std::walk_forward]` process +
  a campaign carrying the multi-value fast/slow IS-refit grid over one instrument
  under a single Vol risk regime (the stop). The preceding sweep enumerates the
  survivor grid; the wf stage refits it per window.
- `walkforward_summary_json_from_reports` (main.rs): the pure-sugar reducer that
  reconstructs the verb's summary line from the campaign's recorded per-window OOS
  reports — `stitched_total_pips` = the per-window total_pips summed in roll order,
  `param_stability` over the four r-sma axes via the same `MetricStats::from_values`,
  pooled `oos_r` via `r_metrics_from_rs`. Byte-identical to the inline
  `walkforward_summary_json` (the committed anchor gates it once Task 3 wires it).

Both carry `#[allow(dead_code)]` until Task 3's dispatch split calls them.

The third `Generated*`/`register_generated_*` trio duplicates the sweep/generalize
shape by design (per-verb mirror); the bundle consolidation is deferred to #214.
Unit test note: `r_metrics_from_rs` clears `trade_rs` (it is the terminal pooled
reducer), so the reducer's characterization fixture rebuilds each window's
`RMetrics` with `trade_rs` restored, modelling what a real OOS window's report
carries (from `summarize_r`).

refs #210
This commit is contained in:
2026-07-06 22:12:47 +02:00
parent f7835353de
commit a72cd6b115
2 changed files with 336 additions and 1 deletions
+117
View File
@@ -1876,6 +1876,71 @@ fn walkforward_summary_json(result: &WalkForwardResult) -> String {
serde_json::json!({ "walkforward": obj }).to_string()
}
/// The walk-forward summary line reconstructed from the recorded per-window OOS
/// reports (the campaign path's `WalkForward` `StageFamily.reports`) rather than a
/// live `WalkForwardResult`. Byte-identical to `walkforward_summary_json`:
/// `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`
/// 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
// decided per-value at runtime by the `Scalar` variant match below (i64 axes
// cast value-as-f64, the f64 axis passes through), exactly as
// `param_stability`'s per-`ScalarKind` coerce does — this list carries no
// per-axis type flag.
const AXES: [&str; 4] = ["fast.length", "slow.length", "stop_length", "stop_k"];
let param_stability: Vec<aura_engine::MetricStats> = AXES
.iter()
.map(|axis| {
let vals: Vec<f64> = reports
.iter()
.map(|r| {
let (_, v) = r
.manifest
.params
.iter()
.find(|(name, _)| name == axis)
.expect("each r-sma walk-forward window records its chosen axis");
match v {
Scalar::I64(i) => *i as f64,
Scalar::F64(f) => *f,
Scalar::Bool(b) => *b as i64 as f64,
Scalar::Timestamp(_) => {
unreachable!("a timestamp is a structural axis, never a param knob")
}
}
})
.collect();
aura_engine::MetricStats::from_values(&vals)
})
.collect();
let pooled_rs: Vec<f64> = reports
.iter()
.flat_map(|r| r.metrics.r.as_ref().map(|m| m.trade_rs.clone()).unwrap_or_default())
.collect();
let mut obj = serde_json::json!({
"windows": reports.len(),
"stitched_total_pips": total,
"param_stability": param_stability,
});
if reports.iter().any(|r| r.metrics.r.is_some()) {
obj["oos_r"] = serde_json::to_value(r_metrics_from_rs(&pooled_rs))
.expect("RMetrics serializes");
}
serde_json::json!({ "walkforward": obj }).to_string()
}
/// The cross-instrument generalization line: the chosen metric, instrument count,
/// worst-case floor, sign-agreement count, and the per-instrument breakdown. Canonical
/// JSON (C14), mirroring `walkforward_summary_json`'s `{"generalize": obj}` shape.
@@ -5224,6 +5289,58 @@ mod tests {
}
}
/// `walkforward_summary_json_from_reports` reduces a hand-built two-window
/// report set to the same `{"windows", "stitched_total_pips",
/// "param_stability", "oos_r"}` shape as the live-`WalkForwardResult` path:
/// pips sum in roll order, each r-sma axis reduces across windows, and the
/// pooled `trade_rs` yields the R-metric block.
#[test]
fn walkforward_summary_from_reports_reconstructs_the_summary() {
// Two windows, chosen params (3,12,14,2.0) then (5,20,14,2.0).
let mk = |fast: i64, slow: i64, pips: f64, rs: Vec<f64>| -> RunReport {
let mut rep = RunReport {
manifest: RunManifest {
commit: "t".into(),
params: vec![
("fast.length".into(), Scalar::I64(fast)),
("slow.length".into(), Scalar::I64(slow)),
("stop_length".into(), Scalar::I64(14)),
("stop_k".into(), Scalar::F64(2.0)),
("bias_scale".into(), Scalar::F64(0.5)),
],
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "t".into(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: summarize(&[], &[]),
};
rep.metrics.total_pips = pips;
// `r_metrics_from_rs` deliberately clears `trade_rs` (it is the terminal
// pooled reducer, not a per-window record); a real OOS window's report
// gets `trade_rs` from `summarize_r` over the raw trade record instead,
// so restore it here to model that shape.
rep.metrics.r =
Some(aura_engine::RMetrics { trade_rs: rs.clone(), ..r_metrics_from_rs(&rs) });
rep
};
let reports = vec![mk(3, 12, 10.0, vec![1.0, -0.5]), mk(5, 20, -4.0, vec![0.25])];
let line = walkforward_summary_json_from_reports(&reports);
let v: serde_json::Value = serde_json::from_str(&line).unwrap();
let wf = &v["walkforward"];
assert_eq!(wf["windows"].as_u64(), Some(2));
assert_eq!(wf["stitched_total_pips"].as_f64(), Some(6.0)); // 10.0 + -4.0
let ps = wf["param_stability"].as_array().unwrap();
assert_eq!(ps.len(), 4, "four r-sma slots");
assert_eq!(ps[0]["mean"].as_f64(), Some(4.0)); // fast: (3+5)/2
assert_eq!(ps[1]["mean"].as_f64(), Some(16.0)); // slow: (12+20)/2
assert!(wf["oos_r"].is_object(), "pooled oos_r present when any window has r");
assert_eq!(wf["oos_r"]["n_trades"].as_u64(), Some(3)); // 2 + 1
}
/// The drained sink trace of a seeded run — the recorded rows of the equity
/// and exposure sinks. Compared row-for-row so the C1 seed-determinism
/// property is tested at the trace level (strictly stronger than the folded