Files
Aura/docs/specs/0071-stage1-breakout.md
T
Brummel 283bdd343d 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
2026-06-25 12:17:10 +02:00

18 KiB
Raw Blame History

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 F1F5 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 <ms>] [--to <ms>] \
    --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):

# 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 <id> 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.

// 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<usize> { 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<i64> replaces fast_len/slow_len; when Some(n) it is bound to BOTH rolling nodes.

// 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)

// 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 <csv> 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<i64> 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

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}. biasSimBroker.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 <fixture> --channel <n> 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 <csv> [--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 <id> rank <r-metric>.
  • 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.