spec: 0072 stage1 mean-reversion candidate (boss-signed)
A third price-only Stage-1 R candidate: an EWMA Bollinger-band mean-reversion fade (price vs rolling mean +/- k*sigma, latched +-1, sign inverted vs breakout). Composed entirely from existing aura-std primitives (the vol_stop sigma pattern) -> zero new nodes. Screens the mean-reversion hypothesis under the same #137 lie-detector bar after the two trend candidates (momentum, breakout) were refuted. Grounding-check PASS (9/9 load-bearing assumptions ratified). refs #137
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
# 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