audit(0072): cycle close — mean-reversion candidate refuted; drop ephemera
The EWMA Bollinger-band mean-reversion fade is refuted: no cross-index generalization (FRA40 leans positive, GER40 negative — two correlated indices disagreeing in sign), no OOS-significant cell on either index, and its lone IS-significant cell is consistent with multiple-testing noise (full verdict on #137). The machinery (--strategy stage1-meanrev, the composed EWMA Bollinger band, zero new nodes) is permanent and kept; only the per-cycle spec/plan are dropped per the ephemeral-artifact lifecycle convention. refs #137
This commit is contained in:
@@ -1,616 +0,0 @@
|
||||
# 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<f64> {
|
||||
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<f64> {
|
||||
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<Box<dyn Source>> = vec![Box::new(VecSource::new(prices))];
|
||||
h.run(src);
|
||||
rx.try_iter().map(|(_, row): (Timestamp, Vec<Scalar>)| 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<Scalar>)>,
|
||||
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
tx_r: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
tx_req: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
window: Option<i64>,
|
||||
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<i64>,
|
||||
window: Vec<i64>,
|
||||
band_k: Vec<f64>,
|
||||
```
|
||||
|
||||
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<String> = 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<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));
|
||||
m
|
||||
} else {
|
||||
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
|
||||
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
|
||||
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
|
||||
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = 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
|
||||
`<sma|momentum|stage1-r|stage1-breakout>` with
|
||||
`<sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>`, AND replace
|
||||
`[--channel <csv>]` with `[--channel <csv>] [--window <csv>] [--band-k <csv>]`.
|
||||
2. `:1386` (the doc-comment on `parse_sweep_args`): same two replacements as
|
||||
`:1399`.
|
||||
3. `:1486` (the doc-comment on `run_sweep`): replace
|
||||
`<sma|momentum|stage1-r|stage1-breakout>` with
|
||||
`<sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>` (no flag list here).
|
||||
4. `:2389` (the `USAGE` const, the `aura sweep` clause only — leave the
|
||||
`aura run [--harness <sma|macd|stage1-r>]` clause untouched): replace the
|
||||
sweep clause's `[--strategy <sma|momentum|stage1-r|stage1-breakout>]` with
|
||||
`[--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>]`.
|
||||
|
||||
- [ ] **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.
|
||||
@@ -1,249 +0,0 @@
|
||||
# Stage-1 mean-reversion candidate (EWMA Bollinger-band fade) — Design Spec
|
||||
|
||||
**Date:** 2026-06-25
|
||||
**Status:** Draft — awaiting user spec review
|
||||
**Authors:** orchestrator + Claude
|
||||
|
||||
## Goal
|
||||
|
||||
Add a third price-only Stage-1 R strategy candidate — an **EWMA Bollinger-band
|
||||
mean-reversion fade** — so the edge hunt (#137) can screen the mean-reversion
|
||||
hypothesis with the *same* yardstick already used for the two refuted
|
||||
trend-following candidates (MA-cross momentum #141, channel breakout 0071). The
|
||||
signal fades deviation from a rolling mean: when price runs above its mean by
|
||||
more than `k·σ` it goes **short** (expecting reversion down); when it runs below
|
||||
by `k·σ` it goes **long**. Direction (sign) only, latched ±1, unsized — feeding
|
||||
the unchanged bias → RiskExecutor (vol-stop defines R) → flat-1R seam.
|
||||
|
||||
Crucially this candidate needs **zero new nodes**: the rolling mean and the
|
||||
rolling σ are composed from existing `aura-std` primitives exactly as
|
||||
`aura-composites::vol_stop` already composes its EWMA σ.
|
||||
|
||||
## Architecture
|
||||
|
||||
`aura sweep --strategy stage1-meanrev --real <SYM>` builds, per grid point, a
|
||||
harness whose **only** difference from `stage1_breakout_graph` is the signal
|
||||
leg. Everything downstream of the `exposure` (bias) node — SimBroker pip leg,
|
||||
exposure tap, RiskExecutor, the dense R-record, the reduce-vs-trace metrics
|
||||
branch — is byte-identical to the breakout/stage1-r graphs.
|
||||
|
||||
The signal leg is a **Bollinger-band fade** built from `Ema`, `Sub`, `Mul`,
|
||||
`Sqrt`, `LinComb`, `Add`, `Gt`, `Latch` — all already in `aura-std`:
|
||||
|
||||
```
|
||||
mean = Ema(price, n) // rolling mean
|
||||
dev = price − mean
|
||||
σ = Sqrt(Ema(dev·dev, n)) // EWMA std-dev, deviation-squared-then-smoothed
|
||||
kσ = LinComb([k])(σ) // band half-width
|
||||
upper = mean + kσ ; lower = mean − kσ
|
||||
hi_break = price > upper // overextended UP → fade short
|
||||
lo_break = lower > price // overextended DOWN → fade long
|
||||
short_latch: set=hi_break, reset=lo_break
|
||||
long_latch : set=lo_break, reset=hi_break
|
||||
bias = long_latch − short_latch // +1 long / −1 short / 0 before first break
|
||||
```
|
||||
|
||||
Two structural points distinguish it from breakout, both **derived** and
|
||||
recorded on the reference issue #137:
|
||||
|
||||
1. **σ via deviation-squared-then-smoothed** (`Sqrt(Ema((price−Ema(price))²))`),
|
||||
the exact `vol_stop` shape. Squaring the *small* deviation (~tens of points),
|
||||
not the raw price (~10⁴), avoids catastrophic cancellation — so there is **no
|
||||
NaN risk and no clamp / new `RollingStdDev` node**, unlike `Sma(p²) − Sma(p)²`
|
||||
which subtracts two ~3×10⁸ numbers.
|
||||
2. **No `Delay(1)` on the band path** (C2). The current bar legitimately belongs
|
||||
to its own Bollinger band: computing mean/σ over a window ending at `t` and
|
||||
comparing `price[t]` against it uses only information available at `t`
|
||||
(standard Bollinger, no look-ahead). This differs from breakout, whose "break
|
||||
of the *prior* channel" semantics *required* excluding the current bar.
|
||||
|
||||
The band window `n` is **ganged** across the mean `Ema` and the variance `Ema`
|
||||
by binding both to the same value at build time (canonical Bollinger uses one
|
||||
window for both) — no parameter-ganging feature (#61) is needed, since the sweep
|
||||
builds a fully-bound graph per point.
|
||||
|
||||
## Concrete code shapes
|
||||
|
||||
### User-facing invocation (the acceptance evidence)
|
||||
|
||||
The mean-reversion screen the edge hunt will run — identical surface to the
|
||||
breakout screen, two new grid flags:
|
||||
|
||||
```sh
|
||||
# cross-index + temporal-OOS screen, band window × band width × R stop
|
||||
aura sweep --strategy stage1-meanrev --real GER40 \
|
||||
--window 120,240,480,960,1920 --band-k 1.5,2.0,2.5 \
|
||||
--stop-length 1920 --stop-k 2.0 --from 1609459200000 # OOS half (>= 2021-01-01)
|
||||
```
|
||||
|
||||
Each emitted `RunReport` JSON line carries an `r` block
|
||||
(`expectancy_r`, `n_trades`, `sqn`, `sqn_normalized`, …) under
|
||||
`metrics.r`, and a manifest recording the varying axes (`window`, `band_k`,
|
||||
`stop_length`, `stop_k`). Folded (no-trace) and raw (`--trace`) metrics are
|
||||
identical (the 0070 finalize-equivalence invariant).
|
||||
|
||||
### The new graph builder (mirrors `stage1_breakout_graph`, signal leg swapped)
|
||||
|
||||
`crates/aura-cli/src/main.rs`, a new `stage1_meanrev_graph`. Signature mirrors
|
||||
`stage1_breakout_graph` but swaps the channel knob for the band window + width:
|
||||
|
||||
```rust
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
fn stage1_meanrev_graph(
|
||||
tx_eq: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
tx_r: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
tx_req: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
window: Option<i64>, // band Ema length (ganged across mean + variance)
|
||||
band_k: f64, // band half-width in σ
|
||||
stop_length: i64,
|
||||
stop_k: f64,
|
||||
reduce: bool,
|
||||
) -> Composite {
|
||||
let mut g = GraphBuilder::new("stage1_meanrev");
|
||||
// --- 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()); // σ (price units)
|
||||
let band = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(band_k))); // k·σ
|
||||
let upper = g.add(Add::builder()); // mean + k·σ
|
||||
let lower = g.add(Sub::builder()); // mean − k·σ
|
||||
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 exposure = g.add(Sub::builder()); // long_latch − short_latch -> bias in {-1,0,+1}
|
||||
// --- downstream: VERBATIM from stage1_breakout_graph (broker pip leg, taps,
|
||||
// risk_executor, dense R-record, reduce-vs-trace recorders) ---
|
||||
// ... (broker, eq, ex, exec, rrec, r_equity exactly as breakout) ...
|
||||
|
||||
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·σ
|
||||
g.connect(mean.output("value"), lower.input("lhs"));
|
||||
g.connect(band.output("value"), lower.input("rhs")); // lower = mean − k·σ
|
||||
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"));
|
||||
// exposure.output("value") fans to broker.exposure, ex.col[0], exec.bias — as breakout.
|
||||
g.build().expect("stage1_meanrev wiring resolves")
|
||||
}
|
||||
```
|
||||
|
||||
### Sweep family, grid, CLI plumbing (mirrors the breakout deltas)
|
||||
|
||||
```rust
|
||||
// Stage1RGrid: two new fields (stage1-r / breakout ignore them; their goldens untouched)
|
||||
struct Stage1RGrid { fast, slow, stop_length, stop_k, channel,
|
||||
window: Vec<i64>, band_k: Vec<f64> }
|
||||
// Default: window: vec![1920], band_k: vec![2.0]
|
||||
|
||||
// stage1_meanrev_sweep_family: cartesian window × band_k × stop_length × stop_k,
|
||||
// manual per-point build (compile_with_params(&[]) -> Harness::bootstrap),
|
||||
// reduce-vs-trace branch VERBATIM from stage1_breakout_sweep_family;
|
||||
// varying-axis set over {window, band_k, stop_length, stop_k};
|
||||
// manifest records ("window", i64), ("band_k", f64), ("stop_length", i64), ("stop_k", f64).
|
||||
|
||||
enum Strategy { SmaCross, Momentum, Stage1R, Stage1Breakout, Stage1MeanRev } // new variant
|
||||
// parse arm: "stage1-meanrev" => Strategy::Stage1MeanRev,
|
||||
// new flags: "--window" => grid.window = parse_csv_list(value)?,
|
||||
// "--band-k" => grid.band_k = parse_csv_list(value)?,
|
||||
// dispatch arm: Strategy::Stage1MeanRev => stage1_meanrev_sweep_family(persist.then_some(name), &data, grid),
|
||||
// usage strings: add stage1-meanrev to the <...> list and [--window <csv>] [--band-k <csv>]
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
- **`stage1_meanrev_graph`** (new, `aura-cli/src/main.rs`) — the harness builder.
|
||||
- **`stage1_meanrev_sweep_family`** (new, `aura-cli/src/main.rs`) — the cartesian
|
||||
grid runner.
|
||||
- **`Stage1RGrid`** (extended) — `window: Vec<i64>`, `band_k: Vec<f64>`.
|
||||
- **`Strategy::Stage1MeanRev`** + parse/dispatch/flag arms + usage strings.
|
||||
- No `aura-std` / `aura-engine` / `aura-composites` change. No new node.
|
||||
|
||||
## Data flow
|
||||
|
||||
`price (M1 close) → {mean Ema, dev} → σ branch → band → {hi,lo} Gt → {short,long}
|
||||
latch → exposure (bias ∈ {−1,0,+1})`, then the unchanged
|
||||
`bias → SimBroker(pip) + RiskExecutor(vol-stop → Sizer → PositionManagement)`,
|
||||
folded by `summarize` / `summarize_r` into a `RunReport` with an `r` block. C1
|
||||
(deterministic), C2 (causal — no future bar read), C7 (node-owned Ema/Latch
|
||||
state), C8 (one output per node) all hold; the engine stays domain-free
|
||||
(type-erased Scalar records).
|
||||
|
||||
## Error handling
|
||||
|
||||
- `parse_csv_list` rejects empty / non-numeric `--window` / `--band-k` items
|
||||
(mirrors `--channel`): the parse returns the usage string.
|
||||
- `Ema::new` already asserts `length >= 1`; an empty window grid is impossible
|
||||
(default is non-empty, parse rejects empty lists).
|
||||
- No division anywhere in the signal leg → no divide-by-zero; the
|
||||
deviation-squared σ form is NaN-free by construction (variance ≥ 0).
|
||||
|
||||
## Testing strategy
|
||||
|
||||
RED-first per task.
|
||||
|
||||
1. **Signal composition (aura-engine test, no CLI dep)** — hand-wire the
|
||||
mean-reversion signal subgraph, tap the `exposure` bias through a Recorder,
|
||||
feed a hand-built close series, assert the latched fade:
|
||||
- a series that spikes far above its mean → **−1** (short), held across quiet
|
||||
bars, then dips far below → flips to **+1** (long); **0** before the first
|
||||
band break. (Contrastive C2 form: the band uses the current bar, so a
|
||||
single large outlier at `t` still breaks its own band — assert a break
|
||||
fires on the outlier bar, proving causality without look-ahead.)
|
||||
- a flat/quiet series (no `k·σ` break) → bias stays **0** throughout (σ small,
|
||||
no break — the fade never fires).
|
||||
2. **CLI seam (aura-cli test)** — `stage1_meanrev_sweep_family` on synthetic data
|
||||
emits an `r` block, and **folded (no-trace) metrics == raw (`--trace`)
|
||||
metrics** (the 0070 finalize-equivalence invariant), mirroring the breakout
|
||||
CLI test.
|
||||
3. **Grid plumbing (aura-cli test)** — `--window a,b` / `--band-k x,y` parse onto
|
||||
the grid (one member per cartesian point); absent flags keep the defaults;
|
||||
empty / non-numeric items are rejected.
|
||||
4. **Golden invariance** — all existing metric goldens (stage1-r / sma /
|
||||
momentum / breakout; single run + sweep + `--trace`) stay **byte-identical**
|
||||
(the new variant is additive; the shared grid's defaults are unchanged).
|
||||
|
||||
Final gates: `cargo test --workspace` and
|
||||
`cargo clippy --workspace --all-targets -- -D warnings`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `aura sweep --strategy stage1-meanrev --real <SYM>` runs and emits per-point
|
||||
`RunReport` lines with an `r` block — the screen's intended user reaches for it
|
||||
exactly as for `stage1-breakout`.
|
||||
- The signal is a causal (C2) Bollinger-band fade: latched ±1, 0 before first
|
||||
break, sign inverted vs breakout (above-band → short).
|
||||
- Zero new nodes; no `aura-std`/`aura-engine`/`aura-composites` edit.
|
||||
- All pre-existing goldens byte-identical.
|
||||
- Workspace tests + clippy green.
|
||||
|
||||
The candidate is then **screened** (cross-index GER40+FRA40, temporal IS/OOS
|
||||
split 2021-01-01, window × band_k grid) under the #137 lie-detector bar — a cell
|
||||
counts as a real edge only if it generalizes across indices, survives IS→OOS,
|
||||
and clears |t| ≳ 2. Building the candidate is this cycle; the verdict is the
|
||||
research finding recorded on #137.
|
||||
Reference in New Issue
Block a user