From 283bdd343df28d1c1012449813ec06acb7e7458c Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 25 Jun 2026 12:17:10 +0200 Subject: [PATCH] spec: 0071 stage1 breakout candidate (boss-signed) Donchian channel-breakout as the next Stage-1 R research candidate, after the SMA-momentum family was refuted (cross-index non-generalization + no within-symbol significance; #137/#141). Swaps only the signal leg of stage1_r_graph: close -> Delay(1) -> {RollingMax,RollingMin}(N) -> {Gt,Gt} -> {Latch,Latch} -> Sub = bias in {-1,0,+1}; the vol-stop defines R unchanged (clean A/B vs momentum). Two new aura-std nodes (RollingMax/RollingMin, monotonic-deque sliding extremum); the +/-1 direction latch composes from two existing Latch nodes + Sub. New --strategy stage1-breakout + --channel grid flag; the family iterates the grid with fully-bound graphs (compile_with_params(&[]) + Harness::bootstrap), sidestepping parameter-ganging. Delay(1) is the C2 causality guard (channel excludes current bar). Grounding-check PASS: all 10 load-bearing assumptions ratified by named green tests. Auto-signed under /boss (grounding-check PASS = the signature). refs #137 --- docs/specs/0071-stage1-breakout.md | 362 +++++++++++++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 docs/specs/0071-stage1-breakout.md diff --git a/docs/specs/0071-stage1-breakout.md b/docs/specs/0071-stage1-breakout.md new file mode 100644 index 0000000..4498bcb --- /dev/null +++ b/docs/specs/0071-stage1-breakout.md @@ -0,0 +1,362 @@ +# Stage-1 Donchian Breakout Candidate — Design Spec + +**Date:** 2026-06-25 +**Status:** Draft — awaiting user spec review +**Authors:** orchestrator + Claude + +> Reference issue: Brummel/Aura #137 (edge-research run log). The node-design +> forks F1–F5 are decided and recorded as a #137 comment; this spec produces them, +> it does not re-derive them. /boss autonomous within the active "Edge research" +> milestone. + +## Goal + +Add a **channel-breakout** signal candidate to the Stage-1 R research surface, so +the cross-symbol + temporal-OOS screen can be run on a *structurally different* +trend mechanic than the (now-refuted) SMA-cross momentum candidate. The +deliverable is a runnable + +``` +aura sweep --strategy stage1-breakout --real GER40 [--from ] [--to ] \ + --channel 480,960,1920,3840,7680 --stop-length 1920 --stop-k 2.0 +``` + +that emits the **same R-metrics block** (`expectancy_r`, `sqn`, `sqn_normalized`, +…) per grid member as `--strategy stage1-r`, so the breakout is screened under the +identical R yardstick and the only thing that differs from the momentum candidate +is the **signal leg**. + +Frictionless Stage-1 R (no costs). The breakout direction is the only new signal; +the vol-stop defines R exactly as in `stage1-r` (F3), giving a clean A/B. + +## Architecture + +The breakout is **the existing `stage1_r_graph` with its signal leg swapped**. In +`stage1_r_graph` the leg is `price → {fast SMA, slow SMA} → Sub(spread) → +Bias(0.5) → bias`. The breakout leg replaces that with a Donchian channel +direction: + +``` +price(close) ─┬───────────────────────────────────→ Gt_up.a , Gt_down.b (raw close[t]) + └→ Delay(1) ─┬→ RollingMax(N) → Gt_up.b (max close[t-N..t-1]) + └→ RollingMin(N) → Gt_down.a (min close[t-N..t-1]) + +Gt_up = (close[t] > max(close[t-N..t-1])) → up_break : bool (STRICT) +Gt_down = (min(close[t-N..t-1]) > close[t]) → down_break: bool (STRICT) + +up_latch = Latch(set = up_break, reset = down_break) → f64 {0,1} +down_latch = Latch(set = down_break, reset = up_break) → f64 {0,1} +bias = Sub(up_latch, down_latch) → f64 {-1, 0, +1} +``` + +`bias` then feeds `broker.exposure`, the exposure tap, and `exec.bias` — the +**identical seam** the SMA leg fed (`exposure.output("bias")`). Everything +downstream of the signal — `SimBroker`, the `RiskExecutor(vol_stop)`, the R-record, +the pip/eq/ex taps, the `reduce`-mode folding sinks — is **unchanged and shared** +with `stage1_r_graph`. + +**Why this composition (F2).** A breakout is a discrete regime flip held at the +extreme: `up_latch − down_latch` is `+1` after the last break was up, `−1` after +the last break was down, `0` before the first break. It is built from two existing +`Latch` nodes (set/reset, reset-dominant) plus a `Sub` — no new latch type is +needed. The two break legs cannot pulse on the same bar (a close cannot be both +strictly above the prior-N max and strictly below the prior-N min), so the +reset-dominant tie rule never fires. After a vol-stop exit while the latch still +holds `+1`, the executor re-enters in the same direction — desirable +trend-following behaviour, and the same executor composition the SMA path already +validated. + +**Why one `Delay(1)` on close, not two on the extrema (C2 — no look-ahead).** The +channel must exclude the current bar: `close[t]` is tested against +`max/min(close[t-N..t-1])`. Feeding one `Delay(1)` of `close` into both rolling +nodes makes each rolling output at cycle `t` cover `close[t-N..t-1]` (its newest +input is `close[t-1]`), so the comparison never sees `close[t]` on both sides. +Omitting the `Delay(1)` would let the rolling window include `close[t]`, making +`close[t] > max(window)` essentially never true — a degenerate (not look-ahead) +signal. The `Delay(1)` is the load-bearing causality guard and gets an explicit +test. + +**Why manual grid iteration, not the `.axis` param-space (F4 + ganging).** The +single `channel` length `N` drives **two** nodes (`RollingMax` and `RollingMin`). +Driving two slots from one swept value is parameter-ganging (#61), not yet +first-classed. So `stage1_breakout_sweep_family` iterates the grid **explicitly** +— for each `(channel, stop_length, stop_k)` point it builds a **fully-bound** +`stage1_breakout_graph` (both rolling nodes bound to the same `N`), +`compile_with_params(&[])` + `Harness::bootstrap`, runs, summarizes — exactly the +fully-bound path `run_stage1_r` already uses. This sidesteps the `.axis`/ganging +machinery entirely; the graph has no open slots. + +## Concrete code shapes + +### The user-facing program (the criterion's evidence) + +The researcher runs (and this is what the cycle delivers): + +```bash +# breakout screen, full history, GER40, channel grid, stop matched to slow signal +aura sweep --strategy stage1-breakout --real GER40 \ + --channel 480,960,1920,3840,7680 --stop-length 1920 --stop-k 2.0 \ + --name ger40-breakout + +# temporal OOS split (same as the momentum screen): IS then OOS +aura sweep --strategy stage1-breakout --real GER40 --to 1609459200000 \ + --channel 1920 --stop-length 1920 --stop-k 2.0 --name ger40-bo-is +aura sweep --strategy stage1-breakout --real GER40 --from 1609459200000 \ + --channel 1920 --stop-length 1920 --stop-k 2.0 --name ger40-bo-oos + +# rank by the R yardstick (same as stage1-r) +aura runs family rank sqn_normalized +``` + +Each member prints one JSON line whose `metrics.r` block is the same `RMetrics` +shape `stage1-r` emits (`expectancy_r`, `sqn`, `sqn_normalized`, `win_rate`, +`profit_factor`, `n_trades`, …), so the breakout is directly comparable and +rankable. + +### New node `RollingMax` (and its sibling `RollingMin`) — builder shape + +Mirrors `Sma`/`Delay` (node-owned window, `lookbacks()==[1]`, warm-up skip-emit, +`PrimitiveBuilder` with one `length` I64 param). The window statistic is the +**sliding maximum** maintained by a **monotonic (descending) deque** for O(1) +amortized updates — a per-cycle O(N) re-scan would be too slow at `N`≈7680 over +millions of bars. `RollingMin` is the mirror (ascending deque). Per the "operator +is topology" convention (see `gt.rs` docs) these are **two node types**, not one +node with a max/min param. + +```rust +// crates/aura-std/src/rolling_max.rs (rolling_min.rs is the mirror) +pub struct RollingMax { + length: usize, + // monotonic descending deque of (value, stream_index); front = current window max. + // node-owned, sized/bounded by `length` (C7, no per-cycle alloc on the hot path). + deque: VecDeque<(f64, u64)>, + seen: u64, // stream position, to evict the front when it exits the window + count: usize, // warm-up gate, silent until `length` (like Sma) + out: [Cell; 1], +} + +impl RollingMax { + pub fn new(length: usize) -> Self { /* assert length >= 1, mirror Sma::new */ } + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "RollingMax", + NodeSchema { + inputs: vec![PortSpec { kind: F64, firing: Any, name: "series".into() }], + output: vec![FieldSpec { name: "value".into(), kind: F64 }], + params: vec![ParamSpec { name: "length".into(), kind: I64 }], + }, + |p| Box::new(RollingMax::new(p[0].i64() as usize)), + ) + } +} + +impl Node for RollingMax { + fn lookbacks(&self) -> Vec { vec![1] } // window lives in node state + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let w = ctx.f64_in(0); + if w.is_empty() { return None; } + let x = w[0]; // newest (financial indexing) + // pop-back while back-value <= x (descending invariant); push (x, seen); + // pop-front while front index <= seen - length (evict out-of-window); + // bump seen/count; None until count >= length; else emit deque.front().value + // ... full body is the planner's + } +} +``` + +### `stage1_breakout_graph` — the swapped leg (vs `stage1_r_graph`) + +Same signature shape and the **same** broker / executor / taps / reduce branch as +`stage1_r_graph` (lines ~1944-2028), with the signal leg replaced. `channel: +Option` replaces `fast_len`/`slow_len`; when `Some(n)` it is bound to BOTH +rolling nodes. + +```rust +// before (stage1_r_graph signal leg) +let fast = g.add(Sma::builder().named("fast") /* +bind length */); +let slow = g.add(Sma::builder().named("slow") /* +bind length */); +let spread = g.add(Sub::builder()); +let exposure = g.add(Bias::builder().named("bias").bind("scale", Scalar::f64(0.5))); +g.feed(price, [fast.input("series"), slow.input("series"), broker.input("price"), exec.input("price")]); +g.connect(fast.output("value"), spread.input("lhs")); +g.connect(slow.output("value"), spread.input("rhs")); +g.connect(spread.output("value"), exposure.input("signal")); +let bias_out = exposure.output("bias"); + +// after (stage1_breakout_graph signal leg) — channel bound to BOTH rolling nodes +let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); +let mut mx_b = RollingMax::builder().named("channel_hi"); +let mut mn_b = RollingMin::builder().named("channel_lo"); +if let Some(n) = channel { mx_b = mx_b.bind("length", Scalar::i64(n)); + mn_b = mn_b.bind("length", Scalar::i64(n)); } +let mx = g.add(mx_b); +let mn = g.add(mn_b); +let gt_up = g.add(Gt::builder()); // close[t] > max(prior N) +let gt_down = g.add(Gt::builder()); // min(prior N) > close[t] +let up_latch = g.add(Latch::builder()); +let down_latch = g.add(Latch::builder()); +let bias = g.add(Sub::builder()); // up_latch - down_latch -> {-1,0,+1} +g.feed(price, [delay.input("series"), gt_up.input("a"), gt_down.input("b"), + broker.input("price"), exec.input("price")]); +g.connect(delay.output("value"), mx.input("series")); +g.connect(delay.output("value"), mn.input("series")); +g.connect(mx.output("value"), gt_up.input("b")); +g.connect(mn.output("value"), gt_down.input("a")); +g.connect(gt_up.output("value"), up_latch.input("set")); +g.connect(gt_down.output("value"), up_latch.input("reset")); +g.connect(gt_down.output("value"), down_latch.input("set")); +g.connect(gt_up.output("value"), down_latch.input("reset")); +g.connect(up_latch.output("value"), bias.input("lhs")); +g.connect(down_latch.output("value"), bias.input("rhs")); +let bias_out = bias.output("value"); +// ... bias_out feeds broker.exposure, ex tap, exec.bias — VERBATIM from stage1_r_graph +``` + +### `--strategy` / `Strategy` enum / dispatch (before → after) + +```rust +// enum (main.rs ~1286) +enum Strategy { SmaCross, Momentum, Stage1R, Stage1Breakout } // + variant + +// parse (main.rs ~1336) +"stage1-r" => Strategy::Stage1R, +"stage1-breakout" => Strategy::Stage1Breakout, // + arm + +// dispatch (main.rs ~1422) +Strategy::Stage1R => stage1_r_sweep_family(persist.then_some(name), &data, grid), +Strategy::Stage1Breakout => stage1_breakout_sweep_family(persist.then_some(name), &data, grid), +``` + +### `--channel` grid flag + +A new `--channel ` flag (parsed exactly like `--fast`/`--slow` via +`parse_csv_list`) supplies the breakout channel-length axis. The breakout reuses +`--stop-length` / `--stop-k` for the vol-stop (same as `stage1-r`); `--fast` / +`--slow` are not used by the breakout strategy. The grid representation carries a +`channel: Vec` defaulting to `vec![1920]` (a single sensible value, so a +no-flag `aura sweep --strategy stage1-breakout` runs one member; the screen always +passes `--channel` explicitly). Lowest-friction representation: extend the existing +`Stage1RGrid` with a `channel` field, keeping one parser + one grid struct; the +planner may instead introduce a dedicated `Stage1BreakoutGrid` — either resolves +the same behaviour. The breakout family reads `channel` + `stop_length` + +`stop_k`; the `stage1-r` family ignores `channel` (its default does not perturb +the existing stage1-r grid, so stage1-r goldens are untouched). + +### `stage1_breakout_sweep_family` — manual cartesian iteration + +```rust +fn stage1_breakout_sweep_family(trace: Option<&str>, data: &DataSource, grid: &Stage1RGrid) + -> SweepFamily +{ + let pip = data.pip_size(); + let window = data.full_window(); + let varying = /* which of {channel, stop_length, stop_k} has > 1 value */; + let mut points = Vec::new(); + for &c in &grid.channel { + for &sl in &grid.stop_length { + for &sk in &grid.stop_k { + let reduce = trace.is_none(); + let (tx_eq, rx_eq) = mpsc::channel(); /* ex, r, req similarly */ + // fully-bound graph: channel = Some(c), stop bound to Vol{ length: sl, k: sk } + let flat = stage1_breakout_graph(tx_eq, tx_ex, tx_r, tx_req, Some(c), sl, sk, reduce) + .compile_with_params(&[]).expect("bound breakout blueprint"); + let mut h = Harness::bootstrap(flat).expect("valid breakout harness"); + h.run(data.run_sources()); + // reduce vs trace branch: VERBATIM the stage1_r_sweep_family logic + // (folded GatedRecorder/SeriesReducer rows -> RunMetrics + summarize_r, + // or raw recorders + persist_traces_r + summarize on --trace) + let manifest = /* sim_optimal_manifest with channel/stop_length/stop_k, broker label */; + points.push(/* RunReport { manifest, metrics } as a SweepFamily point */); + } + } + } + SweepFamily { points } +} +``` + +(`stage1_breakout_graph` takes the stop knobs as bound values, so it uses +`stop_open = false` internally and binds `StopRule::Vol { length: sl, k: sk }` — +the `risk_executor(..)` path, not `risk_executor_vol_open`.) + +## Components + +| Component | File | Status | +|---|---|---| +| `RollingMax` (sliding-window max, monotonic deque) | `crates/aura-std/src/rolling_max.rs` | NEW | +| `RollingMin` (sliding-window min, mirror) | `crates/aura-std/src/rolling_min.rs` | NEW | +| re-export both | `crates/aura-std/src/lib.rs` | MODIFY | +| `stage1_breakout_graph` | `crates/aura-cli/src/main.rs` | NEW | +| `stage1_breakout_sweep_family` | `crates/aura-cli/src/main.rs` | NEW | +| `Strategy::Stage1Breakout` + parse + dispatch | `crates/aura-cli/src/main.rs` | MODIFY | +| `--channel` flag + grid `channel` field | `crates/aura-cli/src/main.rs` | MODIFY | +| reused verbatim: `Delay`, `Gt`, `Latch`, `Sub`, `SimBroker`, `risk_executor`, the taps, the reduce-mode folding sinks | (existing) | REUSE | + +## Data flow + +Per cycle (after warm-up `N+1` bars): `close → Delay(1) → {RollingMax, RollingMin} +→ {Gt_up, Gt_down} → {up_latch, down_latch} → Sub = bias ∈ {−1,0,+1}`. `bias` → +`SimBroker.exposure` (pip equity tap), exposure tap, `RiskExecutor.bias`. The +`RiskExecutor` applies the vol-stop (defines R), emits the dense R-record → folded +by `GatedRecorder` (reduce) or raw `Recorder` (trace) → `summarize_r` → the `r` +block. Pip eq/ex taps fold via `SeriesReducer`/`summarize` exactly as today. +Identical to the `stage1-r` data flow from `bias` onward. + +## Error handling + +- `RollingMax::new` / `RollingMin::new` assert `length >= 1` (mirror `Sma`/`Delay`). +- A malformed `--channel 480,x` rejects to the subcommand `usage()` (via + `parse_csv_list`, exactly like `--fast`). +- Warm-up: each new node emits `None` until its window is full; `Gt` emits `None` + until both legs present; `Latch` emits `None` while both legs cold; so `bias` is + `None` (flat) until the first break after warm-up — no fabricated signal. +- An unknown `--strategy` value still rejects to `usage()` (existing path). + +## Testing strategy + +RED-first. New tests, all additive; **every existing golden** (stage1-r / sma / +momentum; single run + sweep + `--trace`) stays byte-identical (the breakout adds +a new strategy path and two new nodes; it touches no existing graph). + +1. **`RollingMax` / `RollingMin` correctness + warm-up** (unit, per node): + warms up silent for `length-1` samples, then emits the sliding max/min; mirror + `sma_warms_up_then_tracks_the_window_mean`. Include a length-1 identity case. +2. **Monotonic-deque == naive reference** (unit): drive a long deterministic noisy + series through the node and a reference O(N) per-window `max`/`min`; assert + equality at every warmed cycle (the deque must not drift from the true window + extremum). Mirror `incremental_matches_full_resum_within_tolerance`. +3. **C2 causality — the channel excludes the current bar** (integration, the + load-bearing test): on a crafted close series with a single new all-time high + at bar `t`, assert the up-break fires **at `t`** (close[t] > max(close[t-N..t-1])) + and that a graph wired WITHOUT the `Delay(1)` would NOT fire on that bar — i.e. + pin that the comparison is against `max(close[t-N..t-1])`, never a window that + includes `close[t]`. +4. **Breakout direction latch ±1 hold** (integration): a series with an up-break + then quiet bars then a down-break yields `bias` = `+1` held across the quiet + bars, flipping to `−1` on the down-break, `0` before the first break. +5. **CLI seam** (cli_run-style): `aura sweep --strategy stage1-breakout --real + --channel ` produces a member whose `metrics.r` block is present + and has the `RMetrics` fields (parity with the stage1-r sweep seam test); and + that folded-no-trace metrics equal raw-`--trace` metrics byte-for-byte (mirror + the 0070 equivalence test). +6. **Existing goldens unchanged**: `cargo test --workspace` green; the stage1-r / + sma / momentum report goldens byte-identical. + +Final gates: `cargo test --workspace` and `cargo clippy --workspace +--all-targets -- -D warnings`. + +## Acceptance criteria + +- `aura sweep --strategy stage1-breakout --real GER40 --channel + [--from][--to] [--stop-length][--stop-k]` runs and prints one R-bearing + `RunReport` JSON line per channel (× stop) grid point, rankable via `aura runs + family rank `. +- The breakout signal is causal (C2): `close[t]` is compared only against + `max/min(close[t-N..t-1])` — proven by test 3. +- Invariants preserved: C1 (deterministic synchronous run; manual iteration builds + disjoint fully-bound harnesses), C2 (Delay(1) channel exclusion), C7 (node-owned + deque/ring state, no `Rc`/`RefCell`), C8 (each new node one output, emits nothing + when cold). +- All pre-existing goldens byte-identical; workspace tests + clippy green. +- The deliverable enables the next research step: a cross-symbol (GER40 + FRA40) + and temporal-OOS breakout screen under the identical R yardstick used for the + refuted momentum candidate.