Files
Aura/crates/aura-engine/tests/ger40_breakout.rs
T
claude b39fd63396 refactor: split the aura-std roster into C28 layer crates
Phase 4 of the Stratification milestone. aura-std held four C28 ladder
layers in one roster; this cuts them into layer-aligned, aura-core-only
node crates so the import direction is enforced by the crate graph:

- aura-std        — engine nodes only (arithmetic/logic/rolling + sinks)
- aura-market     — session, resample
- aura-strategy   — bias, stops, sizer, cost-model machinery
- aura-backtest   — sim_broker, position_management
- aura-vocabulary — the relocated closed std_vocabulary roster

Node modules move verbatim (byte-identical renames); consumers are
rewired by import path only. A new structural test
(aura-vocabulary/tests/c28_layering.rs) asserts each node crate's
[dependencies] stay within its C28-permitted inner set, catching the
acyclic-but-outward violation the compiler misses.

Behaviour byte-identical: full workspace suite green (1448 tests), no
golden edited, clippy -D warnings clean. C28 Status block updated.

closes #288
2026-07-19 20:28:20 +02:00

316 lines
15 KiB
Rust

//! End-to-end verification of the GER40 / DAX 15-minute session-breakout
//! strategy (the milestone capstone), hand-wired
//! from the seven new `aura-std` nodes into a raw-index [`FlatGraph`] and run
//! over one synthetic Frankfurt session.
//!
//! This is the Phase-1 C9 deliverable: no composite / strategy-builder API is in
//! scope yet, so the DAG is wired by hand the way `harness.rs`'s own fixtures are
//! (`Edge`/`Target` raw indices, `Recorder` taps). It is a **verification** test —
//! the nodes already exist and are green in isolation, so once wired correctly it
//! must PASS; a failure would mean a real node/seam integration bug, not a missing
//! feature.
//!
//! The DAG (4 synthetic M1 OHLC sources → Resample[15m] → the breakout logic →
//! Latch → SimBroker → two Recorder taps):
//!
//! ```text
//! open,high,low,close (4 M1 sources, FIXED order)
//! -> Resample(15) ⇒ {open15,high15,low15,close15}
//! close15 -> Gt.a
//! high15 -> Delay(1) -> Gt.b (prevHigh15)
//! Gt -> breakout:bool (close15 > prevHigh15, STRICT)
//! close15 -> Session(9,0,Berlin,15) ⇒ bars_since_open:i64
//! bars_since_open -> EqConst(3) -> isBar3
//! bars_since_open -> EqConst(5) -> isBar5Close
//! And(breakout, isBar3) -> entry
//! Latch(set=entry, reset=isBar5Close) -> held:f64
//! held -> SimBroker(exposure=held, price=close15) ⇒ equity [pip_size=1.0]
//! equity -> Recorder (tap A); held -> Recorder (tap B)
//! ```
use std::sync::mpsc;
use aura_core::{NodeSchema, Scalar, ScalarKind, Timestamp};
use aura_engine::{Edge, FlatGraph, Harness, Source, SourceSpec, Target, VecSource};
use aura_backtest::SimBroker;
use aura_market::{Resample, Session};
use aura_std::{And, Delay, EqConst, Gt, Latch, Recorder};
use chrono::TimeZone;
use chrono_tz::Europe::Berlin;
// ---------------------------------------------------------------------------
// Node indices in the FlatGraph (one fixed layout, referenced by both wiring
// and assertions).
// ---------------------------------------------------------------------------
const RESAMPLE: usize = 0;
const DELAY: usize = 1;
const GT: usize = 2;
const SESSION: usize = 3;
const EQ3: usize = 4;
const EQ5: usize = 5;
const AND: usize = 6;
const LATCH: usize = 7;
const BROKER: usize = 8;
const REC_EQUITY: usize = 9; // tap A
const REC_HELD: usize = 10; // tap B
/// Build the epoch-ns UTC instant for a Berlin **local wall-clock** minute on
/// 2024-07-01 (CEST, summer, UTC+2) — exactly as `Session`'s own unit test does,
/// so the bucket boundaries and `bars_since_open` are self-consistent with the
/// node. Seconds are always 0 (M1 ticks land on whole minutes).
fn berlin(hour: u32, minute: u32) -> Timestamp {
let ns = Berlin
.with_ymd_and_hms(2024, 7, 1, hour, minute, 0)
.unwrap()
.timestamp_nanos_opt()
.unwrap();
Timestamp(ns)
}
/// One M1 tick: the same timestamp pushed into all four OHLC sources, with that
/// tick's (open, high, low, close). Resample folds open=first, high=max,
/// low=min, close=last over a 15m bucket.
struct M1 {
ts: Timestamp,
o: f64,
h: f64,
l: f64,
c: f64,
}
fn m1(hour: u32, minute: u32, o: f64, h: f64, l: f64, c: f64) -> M1 {
M1 { ts: berlin(hour, minute), o, h, l, c }
}
/// Split a list of M1 ticks into the four per-field [`VecSource`]s the four OHLC
/// roles consume, declared in the FIXED order open, high, low, close. The k-way
/// merge tie-breaks same-timestamp pushes by source index, so this order makes
/// Resample's `Barrier(0)` group complete deterministically each M1 instant.
fn ohlc_sources(ticks: &[M1]) -> Vec<Box<dyn Source>> {
let col = |pick: fn(&M1) -> f64| -> Box<dyn Source> {
Box::new(VecSource::new(
ticks.iter().map(|m| (m.ts, Scalar::f64(pick(m)))).collect(),
))
};
vec![col(|m| m.o), col(|m| m.h), col(|m| m.l), col(|m| m.c)] // open, high, low, close
}
/// Hand-wire the whole strategy DAG into a frozen [`Harness`], tapping equity
/// (`tap A`) and `held` (`tap B`) into the two supplied channels. A fresh build
/// per call (fresh nodes + channels) keeps two runs disjoint for the determinism
/// assertion (C1).
fn build_harness(
tx_equity: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_held: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
) -> Harness {
use aura_core::Firing;
// Each node's declared signature (kind/firing per port), parallel to `nodes`.
let signatures: Vec<NodeSchema> = vec![
Resample::builder().schema().clone(), // 0
Delay::builder().schema().clone(), // 1
Gt::builder().schema().clone(), // 2
Session::builder(9, 0, Berlin, 15).schema().clone(), // 3
EqConst::builder().schema().clone(), // 4
EqConst::builder().schema().clone(), // 5
And::builder().schema().clone(), // 6
Latch::builder().schema().clone(), // 7
SimBroker::builder(1.0).schema().clone(), // 8
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_equity.clone()).schema().clone(), // 9
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_held.clone()).schema().clone(), // 10
];
let nodes: Vec<Box<dyn aura_core::Node>> = vec![
Box::new(Resample::new(15)), // 0
Box::new(Delay::new(1)), // 1
Box::new(Gt::new()), // 2
Box::new(Session::new(9, 0, Berlin, 15)), // 3
Box::new(EqConst::new(3)), // 4
Box::new(EqConst::new(5)), // 5
Box::new(And::new()), // 6
Box::new(Latch::new()), // 7
Box::new(SimBroker::new(1.0)), // 8
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_equity)), // 9
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_held)), // 10
];
// Resample output fields: 0=open15, 1=high15, 2=low15, 3=close15.
const F_HIGH15: usize = 1;
const F_CLOSE15: usize = 3;
let edges = vec![
// close15 -> Gt.a (slot 0)
Edge { from: RESAMPLE, to: GT, slot: 0, from_field: F_CLOSE15 },
// high15 -> Delay.series (slot 0) -> Gt.b (slot 1)
Edge { from: RESAMPLE, to: DELAY, slot: 0, from_field: F_HIGH15 },
Edge { from: DELAY, to: GT, slot: 1, from_field: 0 },
// close15 -> Session.trigger (value ignored)
Edge { from: RESAMPLE, to: SESSION, slot: 0, from_field: F_CLOSE15 },
// bars_since_open -> EqConst(3) / EqConst(5)
Edge { from: SESSION, to: EQ3, slot: 0, from_field: 0 },
Edge { from: SESSION, to: EQ5, slot: 0, from_field: 0 },
// And(breakout, isBar3) -> entry
Edge { from: GT, to: AND, slot: 0, from_field: 0 },
Edge { from: EQ3, to: AND, slot: 1, from_field: 0 },
// Latch(set=entry, reset=isBar5Close)
Edge { from: AND, to: LATCH, slot: 0, from_field: 0 },
Edge { from: EQ5, to: LATCH, slot: 1, from_field: 0 },
// held -> SimBroker.exposure (slot 0); close15 -> SimBroker.price (slot 1)
Edge { from: LATCH, to: BROKER, slot: 0, from_field: 0 },
Edge { from: RESAMPLE, to: BROKER, slot: 1, from_field: F_CLOSE15 },
// equity -> tap A; held -> tap B
Edge { from: BROKER, to: REC_EQUITY, slot: 0, from_field: 0 },
Edge { from: LATCH, to: REC_HELD, slot: 0, from_field: 0 },
];
// Four f64 sources, one per OHLC field, each feeding one Resample slot. The
// declaration order open(0), high(1), low(2), close(3) is the merge tie-break
// order for the Barrier(0) group.
let sources = (0..4)
.map(|slot| SourceSpec::raw(ScalarKind::F64, vec![Target { node: RESAMPLE, slot }]))
.collect();
Harness::bootstrap(FlatGraph { nodes, signatures, sources, edges, taps: Vec::new() })
.expect("valid strategy DAG")
}
/// The synthetic M1 ticks realising one Frankfurt session whose 15m bars are:
/// bar1 high=100; bar2 high=110, close=108; bar3 close=112 (> bar2 high → the
/// strict breakout, and bars_since_open==3 → ENTRY); bar4 close=115; bar5
/// close=120; then a single 10:15 rollover tick (bar6) that CLOSES bar5 (else the
/// partial last bar is dropped and exposure leaks to EOF — trap (a)).
/// bars 1 and 2 precede the bar3 entry so `Delay[1]` is warm (trap (b)).
fn entry_session_ticks() -> Vec<M1> {
vec![
// bar1 09:00-09:15: high=100, close=100 (single tick).
m1(9, 0, 100.0, 100.0, 100.0, 100.0),
// bar2 09:15-09:30: high=110 (first tick), close=108 (second tick).
m1(9, 15, 100.0, 110.0, 100.0, 110.0),
m1(9, 20, 110.0, 110.0, 108.0, 108.0),
// bar3 09:30-09:45: close=112 (> bar2 high 110 → breakout; bar3 → entry).
m1(9, 30, 108.0, 112.0, 108.0, 112.0),
// bar4 09:45-10:00: close=115.
m1(9, 45, 112.0, 115.0, 112.0, 115.0),
// bar5 10:00-10:15: close=120.
m1(10, 0, 115.0, 120.0, 115.0, 120.0),
// bar6 rollover tick at 10:15: ANY value — closes bar5 (trap (a)).
m1(10, 15, 120.0, 120.0, 120.0, 120.0),
]
}
/// Drain a recorder channel into the single-column f64 values it captured, in
/// arrival order (the run is deterministic, so the order is stable).
fn drain_f64(rx: &mpsc::Receiver<(Timestamp, Vec<Scalar>)>) -> Vec<f64> {
rx.try_iter()
.map(|(_, row)| match row[0] {
Scalar::F64(v) => v,
other => panic!("expected an f64 column, got {other:?}"),
})
.collect()
}
/// Property: the GER40 session-breakout strategy, wired end-to-end, latches a
/// long position exactly from the bar3 close (the strict breakout that lands on
/// the third session bar) through the bar5 close, then exits — and the
/// sim-optimal broker integrates `held·Δclose15` into the equity curve over
/// exactly that window. This pins the *whole* node vocabulary composed: Resample
/// emit-on-rollover, Delay[1] = prevHigh15, strict-`>` breakout, Session
/// close-instant indexing, the EqConst i64→bool gates, And, the Latch SR
/// register (bool→f64), and SimBroker's lagged-exposure integration, against one
/// synthetic session.
#[test]
fn ger40_session_breakout_holds_exposure_bar3_to_bar5() {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_held, rx_held) = mpsc::channel();
let mut h = build_harness(tx_eq, tx_held);
h.run(ohlc_sources(&entry_session_ticks()));
// Tap B — the Latch f64, recorded once per completed bar (Resample emits 5
// bars: bar1..bar5 at 09:15/09:30/09:45/10:00/10:15). held is 0.0 before the
// bar3 close, 1.0 from the bar3 close through the bar5 close, then 0.0 at the
// bar5 close (reset-dominant exit on isBar5Close).
let held = drain_f64(&rx_held);
assert_eq!(
held,
vec![0.0, 0.0, 1.0, 1.0, 0.0],
"held: flat through bar2-close, latched 1.0 from bar3-close through bar4-close, \
0.0 at the bar5-close exit"
);
// Tap A — the SimBroker equity (pip_size = 1.0). The broker integrates the
// exposure HELD INTO each cycle (decided at t-1) times Δclose15, derived
// directly from sim_broker.rs (`cum += prev_exposure * (price - prev_price)`):
// close15 stream: 100, 108, 112, 115, 120
// prev_exposure : (0), 0, 0, 1, 1 (held set on the PRIOR bar)
// step PnL : - 0 0 1*(115-112)=3 1*(120-115)=5
// cum equity : 0.0 0.0 0.0 3.0 8.0
// Flat 0.0 through the bar3 close, accruing only while held=1.0 (bar3→bar5),
// final equity 8.0 pips.
let equity = drain_f64(&rx_eq);
assert_eq!(
equity,
vec![0.0, 0.0, 0.0, 3.0, 8.0],
"equity: flat 0.0 until the position is held, then +3 then +8 over the bar3→bar5 hold"
);
}
/// Property: the breakout is **strictly** greater-than, pinned end-to-end. When
/// bar3 closes *exactly equal* to bar2's high (110 == 110), `Gt` emits `false`,
/// so `entry` never fires, the Latch stays flat, and equity is 0.0 for the whole
/// session. A regression to `>=` would silently enter here; this is the
/// no-entry control that pins the strict-`>` semantic through the full graph.
#[test]
fn no_entry_when_bar3_close_equals_bar2_high_strict_greater() {
// Identical to the entry session, except bar3 closes at 110 == bar2.high(110),
// so the strict `>` is false and no position is ever taken.
let ticks = vec![
// bar1 09:00-09:15: high=100.
m1(9, 0, 100.0, 100.0, 100.0, 100.0),
// bar2 09:15-09:30: high=110.
m1(9, 15, 100.0, 110.0, 100.0, 110.0),
m1(9, 20, 110.0, 110.0, 108.0, 108.0),
// bar3 09:30-09:45: close=110 == bar2 high → NOT a strict breakout.
m1(9, 30, 108.0, 110.0, 108.0, 110.0),
// bar4 / bar5 continue upward (irrelevant — no entry ever latched).
m1(9, 45, 110.0, 115.0, 110.0, 115.0),
m1(10, 0, 115.0, 120.0, 115.0, 120.0),
// bar6 rollover tick: closes bar5.
m1(10, 15, 120.0, 120.0, 120.0, 120.0),
];
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_held, rx_held) = mpsc::channel();
let mut h = build_harness(tx_eq, tx_held);
h.run(ohlc_sources(&ticks));
let held = drain_f64(&rx_held);
assert_eq!(
held,
vec![0.0, 0.0, 0.0, 0.0, 0.0],
"strict `>`: close == prev high is no breakout, so held never latches"
);
let equity = drain_f64(&rx_eq);
assert_eq!(
equity,
vec![0.0, 0.0, 0.0, 0.0, 0.0],
"no position is ever taken, so equity is flat 0.0 across the session"
);
}
/// Property: the whole hand-wired harness is deterministic (C1) — two disjoint
/// runs over byte-identical input produce byte-identical recorded output. Mirrors
/// `harness.rs::fan_out_join_dag_runs_deterministically` on the full strategy DAG.
#[test]
fn ger40_breakout_run_is_deterministic() {
let run_once = || {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_held, rx_held) = mpsc::channel();
let mut h = build_harness(tx_eq, tx_held);
h.run(ohlc_sources(&entry_session_ticks()));
let eq: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let hd: Vec<(Timestamp, Vec<Scalar>)> = rx_held.try_iter().collect();
(eq, hd)
};
assert_eq!(run_once(), run_once(), "same input → byte-identical recorded output (C1)");
}