1ebb94c1b8
RunManifest gains defaults: Vec<(String, Scalar)> — the wrap-prefixed bound_param_space() of the signal, read after axis reopening, so a bound param an axis overrode has already left the space and flows through params instead (disjoint by construction; verified end to end: a sweep member's overridden fast.length sits in params while slow.length/bias.scale sit in defaults). params keeps its "what varied" semantics and stays the reproduce input. One-directional serde widening (#[serde(default)]) per the selection/instrument/topology_hash idiom — old records deserialize with an empty defaults; unlike the Option fields it always serializes, mirroring params. ~20 struct-literal sites across five crates gained the field (compile-mandated breadth, no behaviour change at those sites). The C14 ledger records the underlying decision (2026-07-13): generated outcome records spend redundancy on direct readability (single writer, cannot drift); authored intent artifacts admit none (every redundancy is a drift site) — so the fix lands in the manifest, never the blueprint. Verification: RED test run_manifest_stamps_untouched_bound_defaults green; cargo build --workspace; cargo test --workspace green; clippy -D warnings on the touched crates; binary-level sweep exclusivity check.
432 lines
21 KiB
Rust
432 lines
21 KiB
Rust
//! Shared wiring for the real-data GER40 15m session-breakout demonstration.
|
|
//!
|
|
//! This is the *single* definition of the breakout harness used by BOTH the
|
|
//! runnable example (`examples/ger40_breakout_real.rs`) and the gated
|
|
//! determinism test (`tests/ger40_breakout_real.rs`), so the 11-node FlatGraph
|
|
//! is wired exactly once and never drifts between the two. It is included into
|
|
//! each consumer via `#[path = "..."] mod`, not compiled as its own target.
|
|
//!
|
|
//! The DAG is the VERBATIM breakout graph proven synthetically in
|
|
//! `aura-engine/tests/ger40_breakout.rs` — only the sources change: real
|
|
//! `M1FieldSource`s over a data-server window replace the synthetic
|
|
//! `VecSource`s. Source declaration order (open, high, low, close) is the C4
|
|
//! merge tie-break order Resample's `Barrier(0)` group depends on, so it is
|
|
//! load-bearing and preserved.
|
|
//!
|
|
//! ```text
|
|
//! open,high,low,close (4 real 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(A); held -> Recorder(B)
|
|
//! bars_since_open -> Recorder(C); breakout -> Recorder(D)
|
|
//! ```
|
|
//!
|
|
//! The synthetic test taps two channels (equity, held). The real demonstration
|
|
//! taps two more — `bars_since_open` (i64) and `breakout` (bool) — so the
|
|
//! entry/exit trace can name *why* each session did or did not fire, without
|
|
//! reaching into engine internals (it reads only what sinks record, C8/C18).
|
|
|
|
#![allow(dead_code)] // each consumer (example / test) uses a subset of this API
|
|
|
|
use std::sync::mpsc;
|
|
|
|
use aura_core::{NodeSchema, Scalar, ScalarKind, Timestamp};
|
|
use aura_engine::{
|
|
join_on_ts, summarize, Composite, Edge, FlatGraph, GraphBuilder, Harness, RunManifest,
|
|
RunReport, SourceSpec, Target,
|
|
};
|
|
use aura_std::{And, Delay, EqConst, Gt, Latch, Recorder, Resample, Session, SimBroker};
|
|
use chrono::TimeZone;
|
|
use chrono_tz::Europe::Berlin;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Node indices in the FlatGraph (one fixed layout, the same nodes 0..=8 as
|
|
// `aura-engine/tests/ger40_breakout.rs`, plus two extra trace taps 11/12).
|
|
// ---------------------------------------------------------------------------
|
|
pub const RESAMPLE: usize = 0;
|
|
pub const DELAY: usize = 1;
|
|
pub const GT: usize = 2;
|
|
pub const SESSION: usize = 3;
|
|
pub const EQ3: usize = 4;
|
|
pub const EQ5: usize = 5;
|
|
pub const AND: usize = 6;
|
|
pub const LATCH: usize = 7;
|
|
pub const BROKER: usize = 8;
|
|
pub const REC_EQUITY: usize = 9; // tap A — SimBroker equity (f64, pips)
|
|
pub const REC_HELD: usize = 10; // tap B — Latch exposure (f64, 0.0/1.0)
|
|
pub const REC_BARS: usize = 11; // tap C — Session bars_since_open (i64)
|
|
pub const REC_BREAKOUT: usize = 12; // tap D — Gt breakout flag (bool)
|
|
|
|
/// The instrument. GER40 is an index, so pips are index points (`pip_size = 1.0`).
|
|
pub const SYMBOL: &str = "GER40";
|
|
|
|
/// The example's pip divisor, sourced from the recorded geometry sidecar — the same
|
|
/// provider truth the CLI `--real` path uses, now provider-sourced. Panics on a
|
|
/// symbol with no sidecar (the examples only run known instruments with local data).
|
|
pub fn pip_size_of(symbol: &str) -> f64 {
|
|
aura_ingest::instrument_geometry(&aura_ingest::default_data_server(), symbol)
|
|
.unwrap_or_else(|| panic!("no recorded geometry sidecar for {symbol}"))
|
|
.pip_size
|
|
}
|
|
|
|
/// Session config: Frankfurt cash open 09:00 Europe/Berlin, 15m bars. The same
|
|
/// `Session::new(9, 0, Europe::Berlin, 15)` the synthetic test pins.
|
|
pub const SESSION_HOUR: u32 = 9;
|
|
pub const SESSION_MINUTE: u32 = 0;
|
|
pub const BAR_MINUTES: i64 = 15;
|
|
|
|
/// The four recorder channels a built harness drains after a run.
|
|
pub struct Taps {
|
|
pub equity: mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
|
|
pub held: mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
|
|
pub bars: mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
|
|
pub breakout: mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
|
|
}
|
|
|
|
/// Hand-wire the breakout DAG into a frozen [`Harness`] with four recording
|
|
/// taps. A fresh build per call (fresh nodes + channels) keeps two runs
|
|
/// disjoint for the determinism assertion (C1). The wiring of nodes 0..=10 is
|
|
/// VERBATIM from `aura-engine/tests/ger40_breakout.rs`; nodes 11/12 add the two
|
|
/// trace taps.
|
|
pub fn build_harness() -> (Harness, Taps) {
|
|
use aura_core::Firing;
|
|
|
|
let pip_size = pip_size_of(SYMBOL);
|
|
let (tx_equity, rx_equity) = mpsc::channel();
|
|
let (tx_held, rx_held) = mpsc::channel();
|
|
let (tx_bars, rx_bars) = mpsc::channel();
|
|
let (tx_breakout, rx_breakout) = mpsc::channel();
|
|
|
|
// 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(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
|
|
.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(pip_size).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
|
|
Recorder::builder(vec![ScalarKind::I64], Firing::Any, tx_bars.clone())
|
|
.schema()
|
|
.clone(), // 11
|
|
Recorder::builder(vec![ScalarKind::Bool], Firing::Any, tx_breakout.clone())
|
|
.schema()
|
|
.clone(), // 12
|
|
];
|
|
|
|
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(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)), // 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(pip_size)), // 8
|
|
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_equity)), // 9
|
|
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_held)), // 10
|
|
Box::new(Recorder::new(&[ScalarKind::I64], Firing::Any, tx_bars)), // 11
|
|
Box::new(Recorder::new(&[ScalarKind::Bool], Firing::Any, tx_breakout)), // 12
|
|
];
|
|
|
|
// 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 },
|
|
// trace taps: bars_since_open -> tap C; breakout -> tap D
|
|
Edge { from: SESSION, to: REC_BARS, slot: 0, from_field: 0 },
|
|
Edge { from: GT, to: REC_BREAKOUT, 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 — identical to the synthetic capstone.
|
|
let sources = (0..4)
|
|
.map(|slot| SourceSpec {
|
|
kind: ScalarKind::F64,
|
|
targets: vec![Target { node: RESAMPLE, slot }],
|
|
})
|
|
.collect();
|
|
|
|
let h = Harness::bootstrap(FlatGraph { nodes, signatures, sources, edges })
|
|
.expect("valid breakout DAG");
|
|
(h, Taps { equity: rx_equity, held: rx_held, bars: rx_bars, breakout: rx_breakout })
|
|
}
|
|
|
|
/// Author the GER40 session-breakout as a World-consumable [`Composite`]
|
|
/// blueprint — the canonical shippable form of the strategy, of which
|
|
/// [`build_harness`]'s hand-wired `FlatGraph` is the compiled substrate (C11/C23).
|
|
///
|
|
/// This is the SAME 13-node breakout `build_harness` wires by raw index, authored
|
|
/// here fluently by name via [`GraphBuilder`] (canonical template
|
|
/// `aura-engine/src/test_fixtures.rs`). Being a `Composite` it exposes
|
|
/// `param_space()` / the by-name binder / `bootstrap_with_cells`, so `sweep` /
|
|
/// `walk_forward` / compare consume it with NO re-authoring (the #94 friction).
|
|
///
|
|
/// The structural decisions are baked here, not left to the caller:
|
|
/// - `bar_period_minutes` binds BOTH the period-bearing nodes at construction —
|
|
/// `Resample`'s `period_minutes` param AND `Session`'s baked period — so the two
|
|
/// are LOCKED EQUAL and a sweep cannot desync them to 0.0 pips (#96 / C34: a
|
|
/// 15m and a 30m breakout are *different strategies*, not one tuned).
|
|
/// - `delay.lag` is bound out to its structural constant `1` (the "previous bar's
|
|
/// high" register is not a tuning knob — the exact C34 deform-vs-tune case).
|
|
/// - The two `EqConst` nodes are `.named("entry_bar")` / `.named("exit_bar")` with
|
|
/// their `target` param LEFT UNBOUND — so `param_space()` is EXACTLY
|
|
/// `{ entry_bar.target, exit_bar.target }`, the two genuine tuning knobs.
|
|
///
|
|
/// The four OHLC fields are root **source roles** (open, high, low, close) in the
|
|
/// fixed C4 merge order, fanning into Resample's four `Barrier(0)` slots. The
|
|
/// SimBroker equity is exposed as an output field (`equity`, for the later World
|
|
/// demos) AND tapped into a recorder, alongside the held/bars/breakout taps — so
|
|
/// the standalone example/test drains the same trace `build_harness` does today.
|
|
///
|
|
/// Returns the blueprint plus the four recording [`Taps`]. A fresh build per call
|
|
/// (fresh nodes + channels) keeps two bootstraps disjoint for the C1 determinism
|
|
/// assertion, exactly as `build_harness` does.
|
|
pub fn ger40_breakout_blueprint(
|
|
pip_size: f64,
|
|
bar_period_minutes: i64,
|
|
open_hour: u32,
|
|
open_minute: u32,
|
|
tz: chrono_tz::Tz,
|
|
) -> (Composite, Taps) {
|
|
use aura_core::Firing;
|
|
|
|
let (tx_equity, rx_equity) = mpsc::channel();
|
|
let (tx_held, rx_held) = mpsc::channel();
|
|
let (tx_bars, rx_bars) = mpsc::channel();
|
|
let (tx_breakout, rx_breakout) = mpsc::channel();
|
|
|
|
let mut g = GraphBuilder::new("ger40_breakout");
|
|
|
|
// Strategy nodes. The two period-bearing nodes are bound EQUAL by construction:
|
|
// Resample's `period_minutes` param and Session's baked period both take
|
|
// `bar_period_minutes`, so no sweep can desync them. `delay.lag` is bound out.
|
|
let resample = g.add(
|
|
Resample::builder().bind("period_minutes", Scalar::i64(bar_period_minutes)),
|
|
);
|
|
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
|
|
let gt = g.add(Gt::builder());
|
|
let session = g.add(Session::builder(open_hour, open_minute, tz, bar_period_minutes));
|
|
// The ONLY two params: the EqConst targets, named so the space reads
|
|
// `entry_bar.target` / `exit_bar.target`. Their `target` is left UNBOUND.
|
|
let entry_bar = g.add(EqConst::builder().named("entry_bar"));
|
|
let exit_bar = g.add(EqConst::builder().named("exit_bar"));
|
|
let and = g.add(And::builder());
|
|
let latch = g.add(Latch::builder());
|
|
let broker = g.add(SimBroker::builder(pip_size));
|
|
|
|
// Recording sinks: equity (A), held (B), bars_since_open (C), breakout (D).
|
|
let rec_equity = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_equity));
|
|
let rec_held = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_held));
|
|
let rec_bars = g.add(Recorder::builder(vec![ScalarKind::I64], Firing::Any, tx_bars));
|
|
let rec_breakout =
|
|
g.add(Recorder::builder(vec![ScalarKind::Bool], Firing::Any, tx_breakout));
|
|
|
|
// Four OHLC source roles (open, high, low, close) in the fixed C4 merge order,
|
|
// each fanning into the matching Resample Barrier(0) slot (port names match
|
|
// the field names: "open"/"high"/"low"/"close").
|
|
let open = g.source_role("open", ScalarKind::F64);
|
|
let high = g.source_role("high", ScalarKind::F64);
|
|
let low = g.source_role("low", ScalarKind::F64);
|
|
let close = g.source_role("close", ScalarKind::F64);
|
|
g.feed(open, [resample.input("open")]);
|
|
g.feed(high, [resample.input("high")]);
|
|
g.feed(low, [resample.input("low")]);
|
|
g.feed(close, [resample.input("close")]);
|
|
|
|
// close15 -> Gt.a; high15 -> Delay -> Gt.b (prevHigh15, STRICT comparator).
|
|
g.connect(resample.output("close"), gt.input("a"));
|
|
g.connect(resample.output("high"), delay.input("series"));
|
|
g.connect(delay.output("value"), gt.input("b"));
|
|
// close15 -> Session.trigger (value ignored; fires once per completed bar).
|
|
g.connect(resample.output("close"), session.input("trigger"));
|
|
// bars_since_open -> EqConst(entry) / EqConst(exit).
|
|
g.connect(session.output("bars_since_open"), entry_bar.input("value"));
|
|
g.connect(session.output("bars_since_open"), exit_bar.input("value"));
|
|
// And(breakout, isEntryBar) -> entry.
|
|
g.connect(gt.output("value"), and.input("a"));
|
|
g.connect(entry_bar.output("value"), and.input("b"));
|
|
// Latch(set=entry, reset=isExitBar) -> held.
|
|
g.connect(and.output("value"), latch.input("set"));
|
|
g.connect(exit_bar.output("value"), latch.input("reset"));
|
|
// held -> SimBroker.exposure; close15 -> SimBroker.price.
|
|
g.connect(latch.output("value"), broker.input("exposure"));
|
|
g.connect(resample.output("close"), broker.input("price"));
|
|
// equity -> tap A; held -> tap B; bars -> tap C; breakout -> tap D.
|
|
g.connect(broker.output("equity"), rec_equity.input("col[0]"));
|
|
g.connect(latch.output("value"), rec_held.input("col[0]"));
|
|
g.connect(session.output("bars_since_open"), rec_bars.input("col[0]"));
|
|
g.connect(gt.output("value"), rec_breakout.input("col[0]"));
|
|
|
|
// Expose the SimBroker equity at the boundary (for the later World demos);
|
|
// the recorder taps still drain the standalone trace.
|
|
g.expose(broker.output("equity"), "equity");
|
|
|
|
let bp = g.build().expect("breakout blueprint resolves by name");
|
|
(bp, Taps { equity: rx_equity, held: rx_held, bars: rx_bars, breakout: rx_breakout })
|
|
}
|
|
|
|
/// Build the inclusive window for the whole calendar month `(year, month)` in
|
|
/// UTC: `[first instant of the 1st, last ms of the last day]`, in aura's native
|
|
/// epoch-ns [`Timestamp`] currency. UTC keeps the boundary trivial to reason
|
|
/// about; the Session node still indexes bars in Berlin wall-clock inside the
|
|
/// graph. The SAME UTC instants as before, typed as `Timestamp` (each bound
|
|
/// wrapped through the one `unix_ms_to_epoch_ns` seam), so the consumer layer is
|
|
/// Timestamp-native and no caller round-trips ms→Timestamp→ms.
|
|
pub fn utc_month_window(year: i32, month: u32) -> (Timestamp, Timestamp) {
|
|
let from = chrono::Utc
|
|
.with_ymd_and_hms(year, month, 1, 0, 0, 0)
|
|
.unwrap()
|
|
.timestamp_millis();
|
|
// first instant of the next month, minus one ms => last ms of this month.
|
|
let (ny, nm) = if month == 12 { (year + 1, 1) } else { (year, month + 1) };
|
|
let next = chrono::Utc
|
|
.with_ymd_and_hms(ny, nm, 1, 0, 0, 0)
|
|
.unwrap()
|
|
.timestamp_millis();
|
|
(
|
|
aura_ingest::unix_ms_to_epoch_ns(from),
|
|
aura_ingest::unix_ms_to_epoch_ns(next - 1),
|
|
)
|
|
}
|
|
|
|
/// One bar's recorded trace row, fused across the four taps by **timestamp**.
|
|
///
|
|
/// The four sinks do NOT all fire on the same cardinality: `equity` and `held`
|
|
/// fire once per completed Resample bar (downstream of Latch/SimBroker), but the
|
|
/// `breakout` (Gt) tap is one bar shorter — `Gt` depends on `Delay(1)` of
|
|
/// high15, which is cold on the very first bar and emits nothing — and the
|
|
/// `bars_since_open` (Session) tap filters bars that precede a session open. So
|
|
/// they cannot be fused by positional index; they are joined on the recorded
|
|
/// timestamp. `equity`/`held` are the spine (one row per bar); the other two are
|
|
/// looked up by ts, defaulting where they did not fire this bar.
|
|
pub struct BarTrace {
|
|
pub ts: Timestamp,
|
|
pub equity: f64,
|
|
pub held: f64,
|
|
/// Session bar index, or `-1` where Session did not emit this bar (outside a
|
|
/// tracked session).
|
|
pub bars_since_open: i64,
|
|
/// The strict-breakout flag, or `false` where Gt did not emit (the cold
|
|
/// first bar, before Delay(1) is warm).
|
|
pub breakout: bool,
|
|
}
|
|
|
|
/// Drain the four taps into a per-bar trace, joining on the recorded timestamp
|
|
/// (the sinks fire on different cardinalities — see [`BarTrace`]). Reads only
|
|
/// recorded sink output (C8/C18) — never engine internals.
|
|
pub fn drain_trace(taps: &Taps) -> Vec<BarTrace> {
|
|
let equity: Vec<(Timestamp, Vec<Scalar>)> = taps.equity.try_iter().collect();
|
|
let held: Vec<(Timestamp, Vec<Scalar>)> = taps.held.try_iter().collect();
|
|
let bars: Vec<(Timestamp, Vec<Scalar>)> = taps.bars.try_iter().collect();
|
|
let breakout: Vec<(Timestamp, Vec<Scalar>)> = taps.breakout.try_iter().collect();
|
|
|
|
join_on_ts(&equity, &[&held, &bars, &breakout])
|
|
.into_iter()
|
|
.map(|j| BarTrace {
|
|
ts: j.ts,
|
|
equity: as_f64(&j.spine[0]),
|
|
held: j.sides[0].as_ref().map_or(0.0, |r| as_f64(&r[0])),
|
|
bars_since_open: j.sides[1].as_ref().map_or(-1, |r| as_i64(&r[0])),
|
|
breakout: j.sides[2].as_ref().is_some_and(|r| as_bool(&r[0])),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Fold an already-drained per-bar trace into a [`RunReport`] over the real
|
|
/// window, exactly as `tests/real_bars.rs` does for the SMA sample. The trace is
|
|
/// the single drain of the recording channels (`drain_trace`); this reduction is
|
|
/// pure over it, so the example and the test share one report definition. The
|
|
/// window is the requested `[from, to]` epoch-ns window.
|
|
pub fn report_from_trace(trace: &[BarTrace], from: Timestamp, to: Timestamp) -> RunReport {
|
|
let equity: Vec<(Timestamp, f64)> = trace.iter().map(|b| (b.ts, b.equity)).collect();
|
|
let exposure: Vec<(Timestamp, f64)> = trace.iter().map(|b| (b.ts, b.held)).collect();
|
|
let metrics = summarize(&equity, &exposure);
|
|
|
|
RunReport {
|
|
manifest: RunManifest {
|
|
commit: "ger40-breakout-real".to_string(),
|
|
params: vec![
|
|
("session_hour".to_string(), Scalar::i64(SESSION_HOUR as i64)),
|
|
("session_minute".to_string(), Scalar::i64(SESSION_MINUTE as i64)),
|
|
("bar_minutes".to_string(), Scalar::i64(BAR_MINUTES)),
|
|
("entry_bar".to_string(), Scalar::i64(3)),
|
|
("exit_bar".to_string(), Scalar::i64(5)),
|
|
],
|
|
defaults: vec![],
|
|
window: (from, to),
|
|
seed: 0,
|
|
broker: "sim-optimal(pip_size=1)".to_string(),
|
|
selection: None,
|
|
instrument: None,
|
|
topology_hash: None,
|
|
project: None,
|
|
},
|
|
metrics,
|
|
}
|
|
}
|
|
|
|
fn as_f64(s: &Scalar) -> f64 {
|
|
match s {
|
|
Scalar::F64(v) => *v,
|
|
other => panic!("expected f64, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
fn as_i64(s: &Scalar) -> i64 {
|
|
match s {
|
|
Scalar::I64(v) => *v,
|
|
other => panic!("expected i64, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
fn as_bool(s: &Scalar) -> bool {
|
|
match s {
|
|
Scalar::Bool(v) => *v,
|
|
other => panic!("expected bool, got {other:?}"),
|
|
}
|
|
}
|