feat(aura-engine): walk-forward family — WindowRoller + walk_forward (C12 axis 3)

The third C12 orchestration axis: walk-forward varies the data window. A
WindowRoller is a pure iterator of WindowBounds { is, oos } — bounds only, never
tick data (#71 firewall). walk_forward runs a caller closure per split disjointly
over the shared run_indexed core (C1), and stitches the OOS pip-equity segments
into one continuous curve (each segment offset by the running sum of prior
segments' finals; an empty segment contributes 0.0, mirroring summarize). C2
no-look-ahead is structural: the roller only ever emits oos.0 == is.1 + 1, pinned
by a zero-tick bounds test. The in-sample optimize (axis 2) is closure-supplied,
not called by the engine: aura-engine has no aura-registry dependency (C9), and
C12 forbids baking search policy into the primitive — the CLI bridges both crates.

Param-stability is on-demand, not stored (the R2 decision, recorded with
provenance on #69): WalkForwardResult stores only the raw per-window outcomes +
the stitched curve, and param_stability(&result) -> Vec<MetricStats> is a public
reduction over the retained per-window chosen params. A stored summary would be
recomputable from the windows (redundant) and would force one statistic canonical
when "stability" admits several; this mirrors SweepFamily (raw points + on-demand
optimize), not McFamily (stored aggregate). MetricStats::from_values is extracted
from McAggregate::from_draws (behaviour-preserving — the 7 mc tests stay green)
and gains serde so the CLI summary renders it and #70 lineage can persist it.

`aura walkforward` runs a built-in rolling walk-forward over a synthetic windowed
source: per window it sweeps the built-in grid in-sample, optimizes by total_pips,
runs the chosen params out-of-sample, persists each OOS RunReport (C18), and prints
a stitched summary line.

Two plan deviations, both compiler-forced and consistent with siblings: added
#[derive(Debug)] to WindowRoller (the RED tests' unwrap_err needs Ok: Debug; the
sibling types already derive Debug); removed a now-redundant test-module
SyntheticSpec import after Step 2 hoisted it to the top-level use.

Verification: cargo test --workspace green (aura-engine 152 incl. walkforward 9 +
mc 9, aura-cli 14); clippy --workspace --all-targets -D warnings exit 0; cargo doc
--workspace --no-deps clean.

refs #69
This commit is contained in:
2026-06-15 15:22:12 +02:00
parent 7d52eff41f
commit 4764656062
6 changed files with 715 additions and 23 deletions
+180 -8
View File
@@ -15,10 +15,12 @@ mod render;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, Composite, Edge, FlatGraph, GraphBuilder, Harness,
RunManifest, RunReport, SourceSpec, SweepFamily, Target, VecSource,
f64_field, param_stability, summarize, walk_forward, Composite, Edge, FlatGraph,
GraphBuilder, Harness, RollMode, RunManifest, RunReport, SourceSpec, SweepFamily,
SyntheticSpec, Target, VecSource, WalkForwardResult, WindowBounds, WindowRoller,
WindowRun,
};
use aura_registry::{rank_by, Registry};
use aura_registry::{optimize, rank_by, Registry};
use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc::{self, Receiver};
@@ -428,6 +430,157 @@ fn run_sweep() {
}
}
/// `aura walkforward`: run a built-in rolling walk-forward over the sample
/// blueprint + a synthetic windowed source. Per window: sweep the built-in grid on
/// the in-sample slice, optimize by total_pips (axis 2 inside axis 3, where
/// aura-cli bridges engine + registry), run the chosen params out-of-sample,
/// persist the OOS RunReport (C18), and print it; finally print the stitched
/// summary line. Deterministic (C1).
fn run_walkforward() {
let reg = default_registry();
let result = walkforward_family();
for w in &result.windows {
if let Err(e) = reg.append(&w.run.oos_report) {
eprintln!("aura: {e}");
std::process::exit(2);
}
println!("{}", w.run.oos_report.to_json());
}
println!("{}", walkforward_summary_json(&result));
}
/// 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() -> WalkForwardResult {
let prices = walkforward_prices();
let span = (
prices.first().expect("non-empty synthetic stream").0,
prices.last().expect("non-empty synthetic stream").0,
);
let roller = WindowRoller::new(span, 24, 12, 12, RollMode::Rolling)
.expect("built-in walk-forward config fits the 60-bar synthetic span");
walk_forward(roller, |w: WindowBounds| {
let is_family = sweep_over(w.is.0, w.is.1);
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);
WindowRun { chosen_params: best.params, oos_equity, oos_report }
})
}
/// Sweep the built-in named grid over an in-sample window, sourcing the in-memory
/// windowed stream. Mirrors `sweep_family`, but windowed by `[from, to]`.
fn sweep_over(from: Timestamp, to: Timestamp) -> SweepFamily {
let bp = sample_blueprint_with_sinks().0;
let space = bp.param_space();
bp.axis("signals.trend.fast.length", [2, 3])
.axis("signals.trend.slow.length", [4, 5])
.axis("signals.momentum.fast.length", [2])
.axis("signals.momentum.slow.length", [4])
.axis("signals.momentum.signal.length", [3])
.axis("signals.blend.weights[0]", [1.0])
.axis("signals.blend.weights[1]", [1.0])
.axis("exposure.scale", [0.5])
.sweep(|point| {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp
.bootstrap_with_params(point.to_vec())
.expect("grid points are kind-checked against param_space");
h.run(vec![Box::new(walkforward_window_source(from, to))]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
let params = space
.iter()
.zip(point)
.map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
.collect();
RunReport {
manifest: sim_optimal_manifest(params, (from, to), 0),
metrics: summarize(&equity, &exposure),
}
})
.expect("the built-in named grid matches the sample param-space")
}
/// Run the chosen params over an out-of-sample window; return the recorded
/// pip-equity segment (for stitching) and the OOS RunReport (the C18 record).
fn run_oos(params: &[Scalar], from: Timestamp, to: Timestamp) -> (Vec<(Timestamp, f64)>, RunReport) {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let space = bp.param_space();
let mut h = bp
.bootstrap_with_params(params.to_vec())
.expect("chosen params are kind-checked against param_space");
h.run(vec![Box::new(walkforward_window_source(from, to))]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
let named = space
.iter()
.zip(params)
.map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
.collect();
let report = RunReport {
manifest: sim_optimal_manifest(named, (from, to), 0),
metrics: summarize(&equity, &exposure),
};
(equity, report)
}
/// The walk-forward summary line: window count, stitched OOS total pips (the last
/// stitched-curve value), and the on-demand per-param stability. Canonical JSON
/// (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()
}
/// A longer deterministic stream than `showcase_prices` — enough for several
/// IS/OOS windows with SMA warm-up. Seed-determined via `SyntheticSpec` (C1).
fn walkforward_prices() -> Vec<(Timestamp, Scalar)> {
let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 };
let mut src = spec.source(7);
let mut out = Vec::new();
while let Some(item) = aura_engine::Source::next(&mut src) {
out.push(item);
}
out
}
/// The in-memory windowed source the built-in demo uses (the firewall mapping to
/// `DataServer::stream_m1_windowed` is the real-data path; the demo stays in-memory,
/// mirroring `run_sweep`'s `showcase_prices`). Inclusive `[from, to]`.
fn walkforward_window_source(from: Timestamp, to: Timestamp) -> VecSource {
VecSource::new(
walkforward_prices()
.into_iter()
.filter(|&(t, _)| t >= from && t <= to)
.collect(),
)
}
/// Render the built-in walk-forward as the per-window OOS RunReport lines plus the
/// summary line — the `run_walkforward` shape minus registry persistence. Test
/// helper (mirrors `sweep_report`).
#[cfg(test)]
fn walkforward_report() -> String {
let result = walkforward_family();
let mut out = String::new();
for w in &result.windows {
out.push_str(&w.run.oos_report.to_json());
out.push('\n');
}
out.push_str(&walkforward_summary_json(&result));
out.push('\n');
out
}
/// `aura runs list`: print every stored run record, in store (over-time) order.
fn runs_list() {
for report in &load_runs_or_exit() {
@@ -582,7 +735,7 @@ fn run_macd() -> RunReport {
}
const USAGE: &str =
"usage: aura run [--macd] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] | aura graph | aura sweep | aura runs list | aura runs rank <metric>";
"usage: aura run [--macd] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] | aura graph | aura sweep | aura walkforward | aura runs list | aura runs rank <metric>";
fn main() {
// Collect argv and match the whole vector: every accepted form is exhaustive,
@@ -601,6 +754,7 @@ fn main() {
},
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
["sweep"] => run_sweep(),
["walkforward"] => run_walkforward(),
["runs", "list"] => runs_list(),
["runs", "rank", metric] => runs_rank(metric),
["--help"] | ["-h"] => println!("{USAGE}"),
@@ -614,10 +768,28 @@ fn main() {
#[cfg(test)]
mod tests {
use super::*;
// `SyntheticSpec` is used only by the seeded test vehicle below, so it is
// imported here rather than at module top (a top-level import would be
// `unused` in the non-test binary build under `clippy -D warnings`).
use aura_engine::SyntheticSpec;
#[test]
fn walkforward_report_is_deterministic() {
// spec §Testing 10: the built-in WFO render is byte-identical across two
// calls (C1).
assert_eq!(walkforward_report(), walkforward_report());
}
#[test]
fn walkforward_report_has_one_oos_line_per_window_plus_summary() {
// spec §Testing 11: N per-window OOS RunReport lines + one summary line.
let out = walkforward_report();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 4); // built-in roll = 3 windows + 1 summary
assert!(lines[3].contains(r#""walkforward""#), "summary line: {}", lines[3]);
for line in &lines[..3] {
assert!(
line.contains(r#""manifest""#) && line.contains(r#""metrics""#),
"expected an OOS RunReport line, got: {line}",
);
}
}
/// 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