diff --git a/docs/plans/0072-stage1-meanrev.md b/docs/plans/0072-stage1-meanrev.md new file mode 100644 index 0000000..444cda3 --- /dev/null +++ b/docs/plans/0072-stage1-meanrev.md @@ -0,0 +1,616 @@ +# Stage-1 mean-reversion candidate (EWMA Bollinger-band fade) — Implementation Plan + +> **Parent spec:** `docs/specs/0072-stage1-meanrev.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Add a third price-only Stage-1 R strategy candidate — an EWMA +Bollinger-band mean-reversion fade (`aura sweep --strategy stage1-meanrev`) — +composed entirely from existing `aura-std` primitives (zero new nodes), so the +edge hunt can screen the mean-reversion hypothesis under the same R yardstick. + +**Architecture:** A new `stage1_meanrev_graph` clones `stage1_breakout_graph` +and swaps ONLY the signal leg (Donchian channel → Bollinger band `mean ± k·σ`, +direction inverted to fade), reusing the identical downstream seam (SimBroker, +RiskExecutor, dense R-record, reduce-vs-trace recorders). A +`stage1_meanrev_sweep_family` clones `stage1_breakout_sweep_family` with a +cartesian `window × band_k × stop_length × stop_k`. The CLI gets a +`Strategy::Stage1MeanRev` variant, a `stage1-meanrev` parse arm, `--window` / +`--band-k` grid flags, two new `Stage1RGrid` fields, and updated usage strings. + +**Tech Stack:** `crates/aura-cli/src/main.rs` (graph + sweep family + CLI +plumbing), `crates/aura-engine/tests/` (signal-composition e2e), +`crates/aura-cli/tests/cli_run.rs` (CLI seam + grid). No `aura-std` / +`aura-engine` / `aura-composites` source change. + +--- + +**Files this plan creates or modifies:** + +- Create: `crates/aura-engine/tests/stage1_meanrev_e2e.rs` — signal-composition + test (hand-wired Bollinger fade, no CLI dep). +- Modify: `crates/aura-cli/src/main.rs` — imports (`:31-34`); `Stage1RGrid` + struct + Default (`:1125-1143`); `stage1_meanrev_sweep_family` (new, beside + `:1274-1338`); `enum Strategy` (`:1362`); `parse_sweep_args` arm + flags + + usage (`:1399, :1413-1427`); dispatch (`:1501-1506`); `run_sweep` doc + (`:1486`); `stage1_meanrev_graph` (new, beside `:2117-2205`); `USAGE` const + (`:2388-2389`); in-file parse test (`#[cfg(test)] mod`, beside `:3344-3355`). +- Test: `crates/aura-cli/tests/cli_run.rs` — folded==raw seam test + grid + one-member test (beside `:2057-2150`). + +--- + +### Task 1: Signal-composition e2e test (hand-wired Bollinger fade) + +The mean-reversion signal leg composes only existing `aura-std` nodes, so this +test characterises the composed fade direction + latch hold + causality. It is +made RED-first by stubbing the wiring helper before filling it in. + +**Files:** +- Create: `crates/aura-engine/tests/stage1_meanrev_e2e.rs` + +- [ ] **Step 1: Write the test file with a STUBBED wiring helper (for a clean RED).** + +Create `crates/aura-engine/tests/stage1_meanrev_e2e.rs` with the two `#[test]` +functions and a STUB helper that returns an empty vec (so both tests fail on the +non-empty assertion — an honest RED before the wiring exists): + +```rust +//! Mean-reversion signal composition: price -> {Ema mean, Sub dev} -> Mul sq -> +//! Ema var -> Sqrt sigma -> LinComb(k) band -> {Add upper, Sub lower} -> +//! {Gt, Gt} -> {Latch, Latch} -> Sub = bias in {-1,0,+1}. Tests the FADE +//! direction (price above mean+k*sigma -> short -1; below mean-k*sigma -> long +//! +1), the latched hold, and that no fade fires on a flat series. Built +//! straight from aura-std nodes (no CLI dependency); mirrors stage1_breakout_e2e. + +use aura_core::{Scalar, Timestamp}; +use aura_engine::{GraphBuilder, Harness, Source, VecSource}; +use aura_std::{Add, Ema, Gt, Latch, LinComb, Mul, Recorder, Sqrt, Sub}; +use std::sync::mpsc; + +// Feed `closes` into the Bollinger-fade signal subgraph (window n, band width k) +// and tap the exposure (bias) through a Recorder. Returns the emitted bias values +// in cycle order. STUB for the RED step — replaced with the real wiring in Step 3. +fn run_meanrev_bias(closes: &[f64], n: i64, k: f64) -> Vec { + let _ = (closes, n, k); + Vec::new() +} + +#[test] +fn meanrev_fades_against_the_move_and_holds_the_latch() { + // k = 0 makes the band collapse to the mean (upper = lower = mean), so the + // fade fires on ANY deviation from the lagging EWMA mean — isolating the + // direction + latch from the sigma threshold (the k>0 band is exercised by + // the real-data CLI screen). n = 3 -> alpha = 0.5, so the mean lags the level + // clearly. A long calm, then a sustained jump UP, then a sustained drop DOWN. + // calm (price == mean -> no break) | up (price > mean -> SHORT) | down (price < mean -> LONG) + let closes = [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 130.0, 130.0, 130.0, 70.0, 70.0, 70.0]; + let bias = run_meanrev_bias(&closes, 3, 0.0); + assert!(!bias.is_empty(), "the signal must emit once warmed up"); + assert_eq!(*bias.first().unwrap(), 0.0, "calm bars (price == mean) must not fade; got {bias:?}"); + let first_short = bias.iter().position(|&b| b == -1.0).expect("an up-move must fade SHORT (-1)"); + let first_long = bias.iter().position(|&b| b == 1.0).expect("a down-move must fade LONG (+1)"); + assert!(first_short < first_long, "short (up-fade) must precede long (down-fade); got {bias:?}"); + assert_eq!(*bias.last().unwrap(), 1.0, "the down-fade long must hold to the end; got {bias:?}"); +} + +#[test] +fn meanrev_flat_series_never_fades() { + // A perfectly flat series: dev == 0, sigma == 0, band == mean, so price is + // never strictly beyond the band -> no break ever -> bias pinned at 0. + let bias = run_meanrev_bias(&[100.0; 10], 3, 2.0); + assert!(!bias.is_empty(), "the signal must emit once warmed up"); + assert!(bias.iter().all(|&b| b == 0.0), "a flat series must never fade; got {bias:?}"); +} +``` + +- [ ] **Step 2: Run to verify it FAILS.** + +Run: `cargo test -p aura-engine --test stage1_meanrev_e2e` +Expected: FAIL — both tests panic on `the signal must emit once warmed up` (the +stub returns an empty vec). + +- [ ] **Step 3: Replace the stub helper with the real wiring.** + +Replace the `run_meanrev_bias` body (the `let _ = (closes, n, k); Vec::new()` +stub) with the full `GraphBuilder` wiring (keep the signature and the two +`#[test]` fns unchanged): + +```rust +fn run_meanrev_bias(closes: &[f64], n: i64, k: f64) -> Vec { + let (tx, rx) = mpsc::channel(); + let mut g = GraphBuilder::new("meanrev_sig"); + let mean = g.add(Ema::builder().bind("length", Scalar::i64(n))); + let dev = g.add(Sub::builder()); // price - mean + let sq = g.add(Mul::builder()); // dev * dev + let var = g.add(Ema::builder().bind("length", Scalar::i64(n))); // EWMA variance + let sigma = g.add(Sqrt::builder()); + let band = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); // k*sigma + let upper = g.add(Add::builder()); // mean + k*sigma + let lower = g.add(Sub::builder()); // mean - k*sigma + let gt_hi = g.add(Gt::builder()); // price > upper + let gt_lo = g.add(Gt::builder()); // lower > price + let short_latch = g.add(Latch::builder()); + let long_latch = g.add(Latch::builder()); + let bias = g.add(Sub::builder()); // long_latch - short_latch + let rec = g.add(Recorder::builder(vec![aura_core::ScalarKind::F64], aura_core::Firing::Any, tx)); + let price = g.source_role("price", aura_core::ScalarKind::F64); + g.feed(price, [mean.input("series"), dev.input("lhs"), gt_hi.input("a"), gt_lo.input("b")]); + g.connect(mean.output("value"), dev.input("rhs")); + g.connect(dev.output("value"), sq.input("lhs")); + g.connect(dev.output("value"), sq.input("rhs")); + g.connect(sq.output("value"), var.input("series")); + g.connect(var.output("value"), sigma.input("value")); + g.connect(sigma.output("value"), band.input("term[0]")); + g.connect(mean.output("value"), upper.input("lhs")); + g.connect(band.output("value"), upper.input("rhs")); + g.connect(mean.output("value"), lower.input("lhs")); + g.connect(band.output("value"), lower.input("rhs")); + g.connect(upper.output("value"), gt_hi.input("b")); + g.connect(lower.output("value"), gt_lo.input("a")); + g.connect(gt_hi.output("value"), short_latch.input("set")); + g.connect(gt_lo.output("value"), short_latch.input("reset")); + g.connect(gt_lo.output("value"), long_latch.input("set")); + g.connect(gt_hi.output("value"), long_latch.input("reset")); + g.connect(long_latch.output("value"), bias.input("lhs")); + g.connect(short_latch.output("value"), bias.input("rhs")); + g.connect(bias.output("value"), rec.input("col[0]")); + let flat = g.build().expect("meanrev signal wiring resolves").compile_with_params(&[]).expect("compiles"); + let mut h = Harness::bootstrap(flat).expect("bootstraps"); + let prices: Vec<(Timestamp, Scalar)> = + closes.iter().enumerate().map(|(i, &c)| (Timestamp(i as i64), Scalar::f64(c))).collect(); + let src: Vec> = vec![Box::new(VecSource::new(prices))]; + h.run(src); + rx.try_iter().map(|(_, row): (Timestamp, Vec)| row[0].as_f64()).collect() +} +``` + +- [ ] **Step 4: Run to verify it PASSES.** + +Run: `cargo test -p aura-engine --test stage1_meanrev_e2e` +Expected: PASS (both tests green). + +--- + +### Task 2: CLI mean-reversion strategy (one compile unit) + +The enum variant, dispatch arm, sweep family, graph builder, grid fields, +parse arm, and flags are one compile unit (adding `Stage1MeanRev` makes the +dispatch `match` non-exhaustive until its arm + the family + the graph exist). +They land together. The RED gate is the CLI seam test. + +**Files:** +- Modify: `crates/aura-cli/src/main.rs` +- Test: `crates/aura-cli/tests/cli_run.rs` + +- [ ] **Step 1: Write the RED CLI seam test.** + +In `crates/aura-cli/tests/cli_run.rs`, after the breakout tests (`:2150`), add: + +```rust +/// The mean-reversion strategy reaches the CLI seam: `aura sweep --strategy +/// stage1-meanrev` emits an R-bearing member, and the folded no-trace path equals +/// the raw --trace path byte-for-byte (parity with the stage1-r / breakout +/// fold-vs-raw guard). window 3 warms up on the synthetic stream; one window × +/// one band_k × the single default stop = one member. +#[test] +fn sweep_strategy_stage1_meanrev_folded_no_trace_metrics_equal_raw_trace_metrics() { + let cwd = temp_cwd("sweep-stage1-meanrev-fold-vs-raw"); + + let folded = Command::new(BIN) + .args(["sweep", "--strategy", "stage1-meanrev", "--window", "3"]) + .current_dir(&cwd) + .output() + .expect("spawn folded (no-trace) stage1-meanrev sweep"); + assert!( + folded.status.success(), + "folded no-trace meanrev sweep exit: {:?}; stderr: {}", + folded.status, + String::from_utf8_lossy(&folded.stderr) + ); + let folded_out = String::from_utf8(folded.stdout).expect("utf-8"); + + let raw = Command::new(BIN) + .args(["sweep", "--strategy", "stage1-meanrev", "--window", "3", "--trace", "m1"]) + .current_dir(&cwd) + .output() + .expect("spawn raw (--trace) stage1-meanrev sweep"); + assert!( + raw.status.success(), + "raw --trace meanrev sweep exit: {:?}; stderr: {}", + raw.status, + String::from_utf8_lossy(&raw.stderr) + ); + let raw_out = String::from_utf8(raw.stdout).expect("utf-8"); + + let folded_lines: Vec<&str> = folded_out.lines().collect(); + let raw_lines: Vec<&str> = raw_out.lines().collect(); + assert_eq!(folded_lines.len(), 1, "folded meanrev sweep must print 1 member: {folded_out:?}"); + assert_eq!( + raw_lines.len(), + folded_lines.len(), + "member count must match: folded {} vs raw {}", + folded_lines.len(), + raw_lines.len() + ); + + for (i, (f, r)) in folded_lines.iter().zip(raw_lines.iter()).enumerate() { + let fm = metrics_object(f); + let rm = metrics_object(r); + assert!(fm.contains("\"r\":{"), "folded meanrev member {i} must carry an r block: {fm}"); + assert_eq!( + fm, rm, + "folded vs raw meanrev metrics diverge at member {i}\n folded: {fm}\n raw: {rm}" + ); + } + let _ = std::fs::remove_dir_all(&cwd); +} + +/// Property: the meanrev family is the cartesian product of its grid axes — a +/// multi-value `--window` list yields exactly one member per window value, each +/// carrying its own bound window length in the manifest params. Guards the manual +/// cartesian loop in `stage1_meanrev_sweep_family`. windows 3 and 4 both warm up +/// on the 18-bar synthetic stream, so two members must appear, in grid order. +#[test] +fn sweep_strategy_stage1_meanrev_grids_one_member_per_window() { + let cwd = temp_cwd("sweep-stage1-meanrev-window-grid"); + let out = Command::new(BIN) + .args(["sweep", "--strategy", "stage1-meanrev", "--window", "3,4"]) + .current_dir(&cwd) + .output() + .expect("spawn 2-window stage1-meanrev sweep"); + assert!( + out.status.success(), + "window-grid meanrev sweep exit: {:?}; stderr: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8(out.stdout).expect("utf-8"); + let lines: Vec<&str> = stdout.lines().collect(); + assert_eq!(lines.len(), 2, "2-window grid must print 2 members: {stdout:?}"); + assert!( + lines[0].contains("[\"window\",{\"I64\":3}]"), + "first member must bind window=3: {}", + lines[0] + ); + assert!( + lines[1].contains("[\"window\",{\"I64\":4}]"), + "second member must bind window=4: {}", + lines[1] + ); + let _ = std::fs::remove_dir_all(&cwd); +} +``` + +- [ ] **Step 2: Run the seam test to verify it FAILS.** + +Run: `cargo test -p aura-cli --test cli_run sweep_strategy_stage1_meanrev_folded` +Expected: FAIL — the binary rejects `--strategy stage1-meanrev` (unknown +strategy → usage error → non-zero exit), so `folded.status.success()` is false. + +- [ ] **Step 3: Add the `Add`, `Mul`, `Sqrt` imports.** + +In `crates/aura-cli/src/main.rs` the `use aura_std::{...}` block (`:31-34`) +already imports `Ema, Sub, LinComb, Gt, Latch`. Add `Add`, `Mul`, `Sqrt` to that +import list (alphabetical placement; they are public `aura_std` symbols). + +- [ ] **Step 4: Add the `stage1_meanrev_graph` builder.** + +In `crates/aura-cli/src/main.rs`, immediately after `stage1_breakout_graph` +(after `:2205`), add: + +```rust +/// The Stage-1 EWMA Bollinger-band mean-reversion candidate, mirroring +/// `stage1_breakout_graph` but swapping the signal leg: fade deviation from a +/// rolling mean (price above `mean + k*sigma` -> short; below `mean - k*sigma` -> +/// long), latched +-1. sigma = `Sqrt(Ema((price-mean)^2))` (deviation squared +/// then smoothed, the vol_stop shape — no catastrophic cancellation, no NaN). +/// No Delay: the current bar legitimately belongs to its own band (causal, C2). +/// `window` gangs the mean Ema and the variance Ema (one Bollinger window); +/// `band_k` is the band half-width in sigma. Everything below the signal leg is +/// byte-identical to `stage1_breakout_graph`. +#[allow(clippy::type_complexity, clippy::too_many_arguments)] +fn stage1_meanrev_graph( + tx_eq: mpsc::Sender<(Timestamp, Vec)>, + tx_ex: mpsc::Sender<(Timestamp, Vec)>, + tx_r: mpsc::Sender<(Timestamp, Vec)>, + tx_req: mpsc::Sender<(Timestamp, Vec)>, + window: Option, + band_k: f64, + stop_length: i64, + stop_k: f64, + reduce: bool, +) -> Composite { + let mut g = GraphBuilder::new("stage1_meanrev"); + // EWMA Bollinger-band mean-reversion signal leg (the ONLY change vs breakout). + let (mut mean_b, mut var_b) = + (Ema::builder().named("mean_window"), Ema::builder().named("var_window")); + if let Some(n) = window { + mean_b = mean_b.bind("length", Scalar::i64(n)); + var_b = var_b.bind("length", Scalar::i64(n)); + } + let mean = g.add(mean_b); + let dev = g.add(Sub::builder()); // price - mean + let sq = g.add(Mul::builder()); // dev * dev + let var = g.add(var_b); // EWMA variance + let sigma = g.add(Sqrt::builder()); // sigma (price units) + let band = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(band_k))); // k*sigma + let upper = g.add(Add::builder()); // mean + k*sigma + let lower = g.add(Sub::builder()); // mean - k*sigma + let gt_hi = g.add(Gt::builder()); // price > upper -> overextended up -> fade short + let gt_lo = g.add(Gt::builder()); // lower > price -> overextended down -> fade long + let short_latch = g.add(Latch::builder()); + let long_latch = g.add(Latch::builder()); + let exposure = g.add(Sub::builder()); // long_latch - short_latch -> bias in {-1,0,+1} + // pip branch (VERBATIM from stage1_breakout_graph). + let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE)); + let gate_col = PM_FIELD_NAMES + .iter() + .position(|&n| n == "closed_this_cycle") + .expect("PM record has a closed_this_cycle column"); + let eq = if reduce { + g.add(SeriesReducer::builder(Firing::Any, tx_eq)) + } else { + g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq)) + }; + let ex = if reduce { + g.add(SeriesReducer::builder(Firing::Any, tx_ex)) + } else { + g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex)) + }; + let exec = g.add(risk_executor(StopRule::Vol { length: stop_length, k: stop_k }, 1.0)); + let rrec = if reduce { + g.add(GatedRecorder::builder(PM_RECORD_KINDS.to_vec(), gate_col, Firing::Any, tx_r)) + } else { + g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r)) + }; + let price = g.source_role("price", ScalarKind::F64); + g.feed( + price, + [ + mean.input("series"), + dev.input("lhs"), + gt_hi.input("a"), + gt_lo.input("b"), + broker.input("price"), + exec.input("price"), + ], + ); + g.connect(mean.output("value"), dev.input("rhs")); + g.connect(dev.output("value"), sq.input("lhs")); + g.connect(dev.output("value"), sq.input("rhs")); // square: feed dev to both legs + g.connect(sq.output("value"), var.input("series")); + g.connect(var.output("value"), sigma.input("value")); + g.connect(sigma.output("value"), band.input("term[0]")); + g.connect(mean.output("value"), upper.input("lhs")); + g.connect(band.output("value"), upper.input("rhs")); // upper = mean + k*sigma + g.connect(mean.output("value"), lower.input("lhs")); + g.connect(band.output("value"), lower.input("rhs")); // lower = mean - k*sigma + g.connect(upper.output("value"), gt_hi.input("b")); + g.connect(lower.output("value"), gt_lo.input("a")); + g.connect(gt_hi.output("value"), short_latch.input("set")); + g.connect(gt_lo.output("value"), short_latch.input("reset")); + g.connect(gt_lo.output("value"), long_latch.input("set")); + g.connect(gt_hi.output("value"), long_latch.input("reset")); + g.connect(long_latch.output("value"), exposure.input("lhs")); + g.connect(short_latch.output("value"), exposure.input("rhs")); + g.connect(exposure.output("value"), broker.input("exposure")); + g.connect(exposure.output("value"), ex.input("col[0]")); + g.connect(exposure.output("value"), exec.input("bias")); + g.connect(broker.output("equity"), eq.input("col[0]")); + for (i, field) in PM_FIELD_NAMES.iter().enumerate() { + g.connect(exec.output(field), rrec.input(COL_PORTS[i].as_str())); + } + if !reduce { + let r_equity = g.add( + LinComb::builder(2) + .bind("weights[0]", Scalar::f64(1.0)) + .bind("weights[1]", Scalar::f64(1.0)), + ); + let req = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_req)); + g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]")); + g.connect(exec.output("unrealized_r"), r_equity.input("term[1]")); + g.connect(r_equity.output("value"), req.input("col[0]")); + } + g.build().expect("stage1_meanrev wiring resolves") +} +``` + +- [ ] **Step 5: Extend `Stage1RGrid` with `window` and `band_k`.** + +In `crates/aura-cli/src/main.rs`, the `Stage1RGrid` struct (`:1125-1131`): add +two fields after `channel`: + +```rust + channel: Vec, + window: Vec, + band_k: Vec, +``` + +and in its `Default` impl (`:1133-1143`), after `channel: vec![1920],`: + +```rust + channel: vec![1920], + window: vec![1920], + band_k: vec![2.0], +``` + +- [ ] **Step 6: Add the `stage1_meanrev_sweep_family`.** + +In `crates/aura-cli/src/main.rs`, immediately after +`stage1_breakout_sweep_family` (after `:1338`), add: + +```rust +fn stage1_meanrev_sweep_family(trace: Option<&str>, data: &DataSource, grid: &Stage1RGrid) -> SweepFamily { + let pip = data.pip_size(); + let window = data.full_window(); + let mut varying: HashSet = HashSet::new(); + if grid.window.len() > 1 { + varying.insert("window".to_string()); + } + if grid.band_k.len() > 1 { + varying.insert("band_k".to_string()); + } + if grid.stop_length.len() > 1 { + varying.insert("stop_length".to_string()); + } + if grid.stop_k.len() > 1 { + varying.insert("stop_k".to_string()); + } + let mut points = Vec::new(); + for &n in &grid.window { + for &bk in &grid.band_k { + for &sl in &grid.stop_length { + for &sk in &grid.stop_k { + 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 reduce = trace.is_none(); + let flat = + stage1_meanrev_graph(tx_eq, tx_ex, tx_r, tx_req, Some(n), bk, sl, sk, reduce) + .compile_with_params(&[]) + .expect("valid stage1-meanrev blueprint"); + let mut h = Harness::bootstrap(flat).expect("valid stage1-meanrev harness"); + h.run(data.run_sources()); + let named: Vec<(String, Scalar)> = vec![ + ("window".to_string(), Scalar::i64(n)), + ("band_k".to_string(), Scalar::f64(bk)), + ("stop_length".to_string(), Scalar::i64(sl)), + ("stop_k".to_string(), Scalar::f64(sk)), + ]; + let key = member_key(&named, &varying); + let mut manifest = sim_optimal_manifest(named, window, 0, pip); + manifest.broker = stage1_r_broker_label(pip); + let metrics = if reduce { + 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)); + m + } else { + let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); + let ex_rows: Vec<(Timestamp, Vec)> = 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(); + if let Some(name) = trace { + persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows); + } + let mut m = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); + m.r = Some(summarize_r(&r_rows, 0.0)); + m + }; + points.push(SweepPoint { params: vec![], report: RunReport { manifest, metrics } }); + } + } + } + } + SweepFamily { space: vec![], points } +} +``` + +> If clippy flags the 4-deep loop nest (`clippy::too_many_lines` or complexity), +> the breakout 3-deep precedent compiled clean; add `#[allow(...)]` only if the +> final clippy gate (Step 11) actually reports it — do not pre-emptively add. + +- [ ] **Step 7: Add the `Stage1MeanRev` variant AND its dispatch arm (lockstep).** + +In `enum Strategy` (`:1362-1367`), after `Stage1Breakout`: + +```rust + Stage1Breakout, + Stage1MeanRev, +``` + +In the dispatch `match strategy` in `run_sweep` (`:1501-1506`), after the +`Stage1Breakout` arm (`:1505`): + +```rust + Strategy::Stage1Breakout => stage1_breakout_sweep_family(persist.then_some(name), &data, grid), + Strategy::Stage1MeanRev => stage1_meanrev_sweep_family(persist.then_some(name), &data, grid), +``` + +- [ ] **Step 8: Add the parse arm and the two grid flags.** + +In `parse_sweep_args`, the `--strategy` value match (`:1413-1419`), after the +`"stage1-breakout"` arm (`:1417`): + +```rust + "stage1-breakout" => Strategy::Stage1Breakout, + "stage1-meanrev" => Strategy::Stage1MeanRev, +``` + +In the flag match (`:1423-1428`), after the `"--channel"` arm (`:1427`): + +```rust + "--channel" => grid.channel = parse_csv_list(value).map_err(|()| usage())?, + "--window" => grid.window = parse_csv_list(value).map_err(|()| usage())?, + "--band-k" => grid.band_k = parse_csv_list(value).map_err(|()| usage())?, +``` + +- [ ] **Step 9: Update the four usage-string literals.** + +Four literals list the strategy set; two also list grid flags. Apply each exact +substring replacement: + +1. `:1399` (the `usage` closure in `parse_sweep_args`): replace + `` with + ``, AND replace + `[--channel ]` with `[--channel ] [--window ] [--band-k ]`. +2. `:1386` (the doc-comment on `parse_sweep_args`): same two replacements as + `:1399`. +3. `:1486` (the doc-comment on `run_sweep`): replace + `` with + `` (no flag list here). +4. `:2389` (the `USAGE` const, the `aura sweep` clause only — leave the + `aura run [--harness ]` clause untouched): replace the + sweep clause's `[--strategy ]` with + `[--strategy ]`. + +- [ ] **Step 10: Add the in-file parse unit test.** + +In the `#[cfg(test)] mod` of `crates/aura-cli/src/main.rs`, after +`parse_sweep_args_parses_the_channel_grid_flag` (`:3344-3355`), add: + +```rust + /// Property: the meanrev `--window` / `--band-k` flags parse comma-separated + /// lists onto `Stage1RGrid.{window,band_k}` (the meanrev family's signal axes), + /// with the same strictness as the other grid flags — absent flags keep the + /// historical defaults, a malformed list is the usage error. + #[test] + fn parse_sweep_args_parses_the_meanrev_grid_flags() { + let parsed = parse_sweep_args(&[ + "--strategy", "stage1-meanrev", "--window", "120,240,480", "--band-k", "1.5,2.5", + ]) + .expect("the meanrev grid flags parse"); + assert_eq!(parsed.0, Strategy::Stage1MeanRev); + assert_eq!(parsed.4.window, vec![120, 240, 480]); + assert_eq!(parsed.4.band_k, vec![1.5, 2.5]); + // absent flags keep the historical defaults. + assert_eq!(parse_sweep_args(&[]).unwrap().4.window, vec![1920]); + assert_eq!(parse_sweep_args(&[]).unwrap().4.band_k, vec![2.0]); + // a malformed list is the strict usage error. + assert!(parse_sweep_args(&["--window", "120,x"]).is_err()); + assert!(parse_sweep_args(&["--band-k", ""]).is_err()); + } +``` + +- [ ] **Step 11: Run the full workspace gates.** + +Run: `cargo test -p aura-cli --test cli_run sweep_strategy_stage1_meanrev` +Expected: PASS (both meanrev CLI tests green). + +Run: `cargo test --workspace` +Expected: PASS — all tests green, including ALL pre-existing goldens +(stage1-r / sma / momentum / breakout, single run + sweep + --trace) BYTE-IDENTICAL +(the new variant is additive; `Stage1RGrid`'s existing defaults are untouched). + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: PASS — no warnings.