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
This commit is contained in:
2026-07-19 20:28:20 +02:00
parent 34ff539143
commit b39fd63396
66 changed files with 398 additions and 152 deletions
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "aura-backtest"
edition.workspace = true
version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
aura-core = { path = "../aura-core" }
+12
View File
@@ -0,0 +1,12 @@
//! `aura-backtest` — the backtest-execution node layer (C28): simulated execution
//! without money (the `SimBroker` pip yardstick, position management), measured in
//! R. Depends only on `aura-core`.
mod position_management;
mod sim_broker;
pub use position_management::{
ExitReason, FIELD_NAMES as PM_FIELD_NAMES, PositionManagement,
RECORD_KINDS as PM_RECORD_KINDS, WIDTH as PM_WIDTH,
};
pub use sim_broker::SimBroker;
@@ -0,0 +1,410 @@
//! `PositionManagement` — the stateful heart of feed-forward risk-based execution. Turns a
//! bias + a protective-stop distance into a stream of realised per-trade R-outcomes.
//! Emits ONE dense record per cycle (C8, like `SimBroker`; multi-field output on the
//! `Resample` precedent): the trade ledger is the rows where `closed_this_cycle`, the
//! R-equity is `cum_realized_r + unrealized_r`, and a position still open at window end
//! is the last row with `open = true`. R is computed SIZE-INVARIANTLY:
//! `realized_r = direction * (exit - entry) / latched_distance`.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind,
Timestamp,
};
/// Why a trade closed. i64-encoded into the dense record (column 2). `summarize_r`
/// matches the raw i64 across the crate boundary (the record streams as type-erased
/// `Scalar`s, C4 SoA, not as this enum), so the encoding is the load-bearing contract.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(i64)]
pub enum ExitReason {
Stop = 0,
BiasFlip = 1,
ReversalLeg = 2,
WindowEnd = 3,
}
/// Dense record column layout — the lockstep contract shared (by convention) with the
/// `Recorder` tap kinds and `summarize_r`'s column reads.
pub const WIDTH: usize = 14;
pub const FIELD_NAMES: [&str; WIDTH] = [
"closed_this_cycle",
"realized_r",
"exit_reason",
"was_stopped",
"direction",
"entry_ts",
"entry_price",
"stop_price",
"exit_price",
"conviction_at_entry",
"size",
"open",
"unrealized_r",
"cum_realized_r",
];
pub const RECORD_KINDS: [ScalarKind; WIDTH] = {
use ScalarKind::*;
[
Bool, F64, I64, Bool, I64, Timestamp, F64, F64, F64, F64, F64, Bool, F64, F64,
]
};
// The frozen-R latch: an open position records its entry, its latched (frozen at
// entry) R-distance, the protective stop level, and entry metadata. R is computed
// against `latched_dist`, never a re-read distance — the R-denominator is frozen.
struct Open {
dir: i64,
entry: f64,
latched_dist: f64,
stop_level: f64,
entry_ts: Timestamp,
bias_abs: f64,
}
pub struct PositionManagement {
pos: Option<Open>,
cum_realized_r: f64,
out: [Cell; WIDTH],
}
impl PositionManagement {
pub fn new() -> Self {
Self {
pos: None,
cum_realized_r: 0.0,
out: [Cell::from_f64(0.0); WIDTH],
}
}
pub fn builder() -> PrimitiveBuilder {
let inputs = vec![
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "size".into() },
];
let output = FIELD_NAMES
.iter()
.zip(RECORD_KINDS)
.map(|(n, k)| FieldSpec { name: (*n).into(), kind: k })
.collect();
PrimitiveBuilder::new(
"PositionManagement",
NodeSchema { inputs, output, params: vec![] },
|_| Box::new(PositionManagement::new()),
)
}
}
impl Default for PositionManagement {
fn default() -> Self {
Self::new()
}
}
fn sign0(v: f64) -> f64 {
if v > 0.0 {
1.0
} else if v < 0.0 {
-1.0
} else {
0.0
}
}
impl Node for PositionManagement {
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1, 1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let pw = ctx.f64_in(1);
if pw.is_empty() {
return None; // no price yet — nothing to do
}
let price = pw[0];
let bias = ctx.f64_in(0).get(0).unwrap_or(0.0);
let dist = ctx.f64_in(2).get(0).unwrap_or(0.0);
let size = ctx.f64_in(3).get(0).unwrap_or(1.0); // the Sizer's size; defaults to flat-1R 1.0 if unwired
let now = ctx.now();
let mut closed = false;
let mut realized = 0.0;
let mut reason = ExitReason::Stop as i64;
let mut was_stopped = false;
let mut ex_dir = 0i64;
let mut ex_entry_ts = Timestamp(0);
let mut ex_entry = 0.0;
let mut ex_stop = 0.0;
let mut ex_exit = 0.0;
let mut ex_bias_abs = 0.0;
// (A) Maintain/exit the position held into this cycle, marked to `price`.
if let Some(p) = &self.pos {
let stop_hit = (p.dir > 0 && price <= p.stop_level)
|| (p.dir < 0 && price >= p.stop_level);
let bias_exit = sign0(bias) != p.dir as f64; // flip or ->0
if stop_hit {
let fill = if p.dir > 0 {
price.min(p.stop_level)
} else {
price.max(p.stop_level)
};
// size-invariant: R is a pure stop-distance ratio (feed-forward).
realized = p.dir as f64 * (fill - p.entry) / p.latched_dist;
was_stopped = true;
reason = ExitReason::Stop as i64;
ex_exit = fill;
(ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_bias_abs) =
(p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs);
self.cum_realized_r += realized;
closed = true;
self.pos = None;
} else if bias_exit {
// size-invariant: R is a pure stop-distance ratio (feed-forward).
realized = p.dir as f64 * (price - p.entry) / p.latched_dist;
reason = if sign0(bias) != 0.0 {
ExitReason::ReversalLeg as i64
} else {
ExitReason::BiasFlip as i64
};
ex_exit = price;
(ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_bias_abs) =
(p.dir, p.entry_ts, p.entry, p.stop_level, p.bias_abs);
self.cum_realized_r += realized;
closed = true;
self.pos = None;
}
}
// (B) Open if flat, bias nonzero, and a valid (>0) frozen R-distance is available.
if self.pos.is_none() && sign0(bias) != 0.0 && dist > 0.0 {
let dir = sign0(bias) as i64;
let stop_level = if dir > 0 { price - dist } else { price + dist };
self.pos = Some(Open {
dir,
entry: price,
latched_dist: dist,
stop_level,
entry_ts: now,
bias_abs: bias.abs(),
});
}
// (C) Open-position fields (carry for window-end) + unrealized mark.
let (open, unrealized, o_dir, o_ets, o_entry, o_stop, o_babs) = match &self.pos {
Some(p) => (
true,
p.dir as f64 * (price - p.entry) / p.latched_dist,
p.dir,
p.entry_ts,
p.entry,
p.stop_level,
p.bias_abs,
),
None => (false, 0.0, 0, Timestamp(0), 0.0, 0.0, 0.0),
};
// Trade-detail columns describe the CLOSED trade if closed, else the OPEN one.
let (d_dir, d_ets, d_entry, d_stop, d_exit, d_babs) = if closed {
(ex_dir, ex_entry_ts, ex_entry, ex_stop, ex_exit, ex_bias_abs)
} else {
(o_dir, o_ets, o_entry, o_stop, 0.0, o_babs)
};
// `direction` (col 4) tracks the position OPEN at cycle end — it is carried for
// window-end synthesis and names the reopened leg on a reversal. It falls back
// to the closed trade's direction only when flat at cycle end. The other
// trade-detail columns (and the debug_assert below) stay keyed to the CLOSED
// trade's geometry via `d_*`.
let out_dir = if open { o_dir } else { d_dir };
self.out = [
Cell::from_bool(closed),
Cell::from_f64(realized),
Cell::from_i64(reason),
Cell::from_bool(was_stopped),
Cell::from_i64(out_dir),
Cell::from_ts(d_ets),
Cell::from_f64(d_entry),
Cell::from_f64(d_stop),
Cell::from_f64(d_exit),
Cell::from_f64(d_babs),
Cell::from_f64(size), // size: from the Sizer (slot 3); R stays size-invariant
Cell::from_bool(open),
Cell::from_f64(unrealized),
Cell::from_f64(self.cum_realized_r),
];
// Scale-robust tolerance: the column re-derivation reconstructs the R-denominator
// via `d_entry - d_stop`, a catastrophic-cancellation subtraction at tiny-pip price
// scale (entry ~1.1, dist ~1e-6) that drifts from the cancellation-free frozen
// `latched_dist` `realized` was computed with. A relative tolerance (floored at the
// historical absolute 1e-9 for |R| <= 1) keeps the guard meaningful while letting the
// two build profiles agree on tiny-pip instruments — `realized` itself is exact.
debug_assert!(
!closed
|| (realized - d_dir as f64 * (d_exit - d_entry) / (d_entry - d_stop).abs()).abs()
< 1e-9 * realized.abs().max(1.0)
);
Some(&self.out)
}
fn label(&self) -> String {
"PositionManagement".into()
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar};
// Drive one cycle: push bias/price/stop/size into the four slots, eval, return the row.
fn step_sized(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64, size: f64) -> Vec<Cell> {
cols[0].push(Scalar::f64(bias)).unwrap();
cols[1].push(Scalar::f64(price)).unwrap();
cols[2].push(Scalar::f64(dist)).unwrap();
cols[3].push(Scalar::f64(size)).unwrap();
n.eval(Ctx::new(cols, Timestamp(1))).expect("dense record every cycle once price present").to_vec()
}
// size defaults to 1.0 (the iter-1 placeholder value), so every existing case is
// behaviour-identical under the new 4-input shape.
fn step(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64) -> Vec<Cell> {
step_sized(n, cols, bias, price, dist, 1.0)
}
fn cols() -> Vec<AnyColumn> { (0..4).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect() }
#[test]
fn emits_one_dense_record_per_cycle() {
let mut n = PositionManagement::new();
let mut c = cols();
let row = step(&mut n, &mut c, 0.0, 100.0, 10.0); // flat
assert_eq!(row.len(), WIDTH);
assert!(!row[0].bool()); // closed_this_cycle = false
assert!(!row[11].bool()); // open = false
}
// (3) R-invariance under size: the Sizer's `size` flows into col 10 but never into R.
// Scaling size leaves every realized_r identical (the property that keeps the research loop
// feed-forward); col 10 reflects the size so we know it actually flowed end-to-end.
#[test]
fn realized_r_is_invariant_under_size() {
let run = |size: f64| {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step_sized(&mut n, &mut c, 1.0, 100.0, 10.0, size); // open long @100
step_sized(&mut n, &mut c, 0.0, 110.0, 10.0, size) // bias->0 exit @110
};
let small = run(1.0);
let big = run(7.0);
assert_eq!(small[1].f64(), big[1].f64(), "realized_r must be size-invariant");
assert_eq!(small[1].f64(), 1.0); // (110-100)/10
assert_eq!(small[10].f64(), 1.0); // size column reflects the input
assert_eq!(big[10].f64(), 7.0);
}
// (1) No look-ahead, the SimBroker mirror: a long entered at 100 with stop_distance
// 10, then bias->0 at price 110, realises R = (110-100)/10 = +1.0 (it earned the
// move to 110; the flip closes it THERE, never retroactively flattening it).
#[test]
fn no_lookahead_bias_exit_realises_the_held_move() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90
let row = step(&mut n, &mut c, 0.0, 110.0, 10.0); // bias->0 @110: exit
assert!(row[0].bool()); // closed_this_cycle
assert_eq!(row[1].f64(), 1.0); // realized_r = +1.0
assert_eq!(row[2].i64(), ExitReason::BiasFlip as i64);
assert!(!row[3].bool()); // not stopped
}
// A position opened this cycle never earns the pre-entry move: entered @110, its
// own-cycle unrealized R is 0.
#[test]
fn entry_does_not_earn_pre_entry_move() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 0.0, 100.0, 10.0); // flat
let row = step(&mut n, &mut c, 1.0, 110.0, 10.0); // open long @110
assert!(row[11].bool()); // open
assert_eq!(row[12].f64(), 0.0); // unrealized_r = 0 at entry cycle
}
// (2) Stop tail is NOT capped at -1R: a gap THROUGH the stop realises R < -1.
#[test]
fn stop_out_is_not_capped_at_minus_one_r() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90
let row = step(&mut n, &mut c, 0.0, 80.0, 10.0); // gaps to 80 (< stop 90)
assert!(row[0].bool());
assert!(row[3].bool()); // was_stopped
assert_eq!(row[2].i64(), ExitReason::Stop as i64);
assert_eq!(row[1].f64(), -2.0); // (80-100)/10 = -2R, the honest tail
}
// A no-gap stop fills exactly at the stop -> exactly -1R.
#[test]
fn no_gap_stop_is_exactly_minus_one_r() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100, stop 90
let row = step(&mut n, &mut c, 1.0, 90.0, 10.0); // touches stop exactly
assert_eq!(row[1].f64(), -1.0);
}
// (5) One close per cycle on a reversal: long -> bias flips short closes ONE leg
// (one record, ReversalLeg) and reopens short as state (open=true, dir=-1).
#[test]
fn reversal_closes_one_leg_and_reopens() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100
let row = step(&mut n, &mut c, -1.0, 110.0, 10.0); // flip short @110
assert!(row[0].bool()); // one close
assert_eq!(row[1].f64(), 1.0); // closed long: (110-100)/10
assert_eq!(row[2].i64(), ExitReason::ReversalLeg as i64);
assert!(row[11].bool()); // reopened: open
assert_eq!(row[4].i64(), -1); // new direction short
}
// Property: the realized_r <-> dense-record-columns redundancy guard holds for a
// tiny-pip instrument (EURUSD-scale entry, a sub-pip stop distance). `eval` stores
// `realized` from the cancellation-free frozen `latched_dist`; the in-`eval`
// redundancy guard RE-derives R from the record columns as
// `direction * (exit - entry) / |entry - stop|`, reconstructing the R-denominator
// by the subtraction `entry - stop`. At a price scale ~1.1 with a stop distance
// ~1e-6, that subtraction loses ~6 significant digits relative to the magnitude of
// `entry` (~2e-16 ULP / 1e-6 dist), so the recomputed denominator drifts from the
// frozen `latched_dist`; with a deep gap-through-stop realising a large |R|, the
// recomputed R diverges from the (correct) stored `realized` by more than the
// guard's ABSOLUTE 1e-9 tolerance, panicking a debug build while a release build
// (assert compiled out) returns the correct R. The stored `realized` is correct;
// the guard must use a scale-robust (relative) tolerance so the two build profiles
// agree. A no-op for large-pip instruments (GER40), where the cancellation is
// negligible.
#[test]
fn tiny_pip_gap_stop_does_not_trip_the_redundancy_guard() {
let entry = 1.10000_f64; // EURUSD-scale price
let dist = 0.000001_f64; // sub-pip vol-stop distance (a quiet M1 bar)
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, entry, dist); // open long @1.10000, stop @1.099999
// A fast gap straight through the stop: price 1.09990, ~100e-6 below entry ->
// a ~-100R loss (stop-outs are NOT capped at -1R; the honest tail). In a debug
// build the current absolute-tolerance guard PANICS here.
let row = step(&mut n, &mut c, 1.0, 1.09990_f64, dist);
assert!(row[0].bool(), "closed_this_cycle");
assert!(row[3].bool(), "was_stopped");
// The stored realized_r and the record-column re-derivation must agree within a
// tolerance robust to the tiny price scale (relative, not absolute 1e-9).
let realized = row[1].f64();
let (dir, d_entry, d_stop, d_exit) =
(row[4].i64() as f64, row[6].f64(), row[7].f64(), row[8].f64());
let recomputed = dir * (d_exit - d_entry) / (d_entry - d_stop).abs();
assert!(
(realized - recomputed).abs() <= 1e-9 * realized.abs().max(1.0),
"realized_r ({realized}) must match the column re-derivation ({recomputed}) \
within a scale-robust tolerance",
);
}
// (4) Open at window end is carried on the last row: open=true, the unrealized R the
// post-run fold will force-close, and the direction for window-end synthesis.
#[test]
fn open_at_window_end_is_carried_on_the_last_row() {
let mut n = PositionManagement::new();
let mut c = cols();
let _ = step(&mut n, &mut c, 1.0, 100.0, 10.0); // open long @100
let row = step(&mut n, &mut c, 1.0, 105.0, 10.0); // still long @105, no close
assert!(!row[0].bool()); // not closed
assert!(row[11].bool()); // open
assert_eq!(row[12].f64(), 0.5); // unrealized (105-100)/10
assert_eq!(row[4].i64(), 1); // direction carried for window-end synthesis
}
}
+172
View File
@@ -0,0 +1,172 @@
//! `SimBroker` — the sim-optimal broker (class (a) of C10): deterministic,
//! frictionless, perfect-fill. Consumes an exposure stream (slot 0) + a price
//! stream (slot 1) and integrates the return earned by the exposure held INTO
//! each cycle (decided at t-1) into a cumulative synthetic pip-equity output.
//! Measures signal quality, not execution-modelled P&L.
use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind};
/// Integrates `exposure * price-return` into cumulative pips. `pip_size` is
/// per-instrument reference metadata (beside the hot path, C7/C15), held here,
/// never streamed.
///
/// # Input slots
///
/// Two `f64` inputs, **order is load-bearing** (both are `f64`, so a swapped
/// wiring is *not* caught at bootstrap — it would silently produce a wrong
/// equity curve):
///
/// - **slot 0 — exposure** ∈ [-1, +1] (the strategy's intent, e.g. from
/// [`Bias`](crate::Bias)).
/// - **slot 1 — price** (the instrument price the exposure is marked against).
///
/// # Firing and warm-up
///
/// Both inputs are [`Firing::Any`](aura_core::Firing::Any), so the broker fires
/// on **every price-fresh cycle** (price is typically the source tick). A
/// not-yet-warmed exposure leg is read as flat `0.0`, so the broker emits an
/// equity row from the **first** price tick, reading `0.0` until the exposure
/// leg produces its first value — the recorded curve therefore has one row per
/// price cycle, with leading `0.0`s during warm-up, not one row per exposure.
///
/// # Output
///
/// One `f64` column, the cumulative pip equity (a producer; tap it with a
/// recording sink to persist the curve).
pub struct SimBroker {
pip_size: f64,
prev_price: Option<f64>,
prev_exposure: f64, // exposure held into this cycle (decided at t-1); 0.0 = flat
cum: f64, // cumulative pips
out: [Cell; 1],
}
impl SimBroker {
/// Build a sim-optimal broker for an instrument whose pip is `pip_size`
/// (price units per pip; must be > 0).
pub fn new(pip_size: f64) -> Self {
assert!(pip_size > 0.0, "SimBroker pip_size must be > 0");
Self {
pip_size,
prev_price: None,
prev_exposure: 0.0,
cum: 0.0,
out: [Cell::from_f64(0.0)],
}
}
/// The param-generic recipe for a blueprint primitive. `pip_size` is per-instrument
/// metadata (C10/C15), not a tunable param — it is captured by the closure, not
/// injected; the node declares no params.
pub fn builder(pip_size: f64) -> PrimitiveBuilder {
PrimitiveBuilder::new(
"SimBroker",
NodeSchema {
inputs: vec![
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "exposure".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() },
],
output: vec![FieldSpec { name: "equity".into(), kind: ScalarKind::F64 }],
params: vec![],
},
move |_| Box::new(SimBroker::new(pip_size)),
)
}
}
impl Node for SimBroker {
fn lookbacks(&self) -> Vec<usize> {
vec![1, 1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let price = ctx.f64_in(1);
if price.is_empty() {
return None; // no price yet — nothing to mark
}
let price = price[0];
let expo = ctx.f64_in(0).get(0).unwrap_or(0.0); // flat until exposure warms up
if let Some(pp) = self.prev_price {
self.cum += self.prev_exposure * (price - pp) / self.pip_size;
}
self.prev_price = Some(price);
self.prev_exposure = expo; // update AFTER taking PnL — no look-ahead (C2)
self.out[0] = Cell::from_f64(self.cum);
Some(&self.out)
}
fn label(&self) -> String {
format!("SimBroker({})", self.pip_size)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
fn two_f64_inputs() -> Vec<AnyColumn> {
vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
]
}
// Drive one broker cycle by hand: optionally push an exposure into slot 0
// (None models a cycle where the exposure chain has not warmed up — slot 0
// stays empty) and a price into slot 1, then eval and return the equity.
fn step(b: &mut SimBroker, inputs: &mut [AnyColumn], expo: Option<f64>, price: f64) -> f64 {
if let Some(e) = expo {
inputs[0].push(Scalar::f64(e)).unwrap();
}
inputs[1].push(Scalar::f64(price)).unwrap();
match b.eval(Ctx::new(inputs, Timestamp(0))) {
Some([c]) => c.f64(),
other => panic!("expected Some([F64]), got {other:?}"),
}
}
#[test]
fn sim_broker_integrates_lagged_exposure_times_return() {
let mut b = SimBroker::new(1.0);
let mut inputs = two_f64_inputs();
assert_eq!(step(&mut b, &mut inputs, Some(0.5), 100.0), 0.0); // no prev price
assert_eq!(step(&mut b, &mut inputs, Some(0.5), 110.0), 5.0); // 0.5*(110-100)
assert_eq!(step(&mut b, &mut inputs, Some(-1.0), 108.0), 4.0); // +0.5*(108-110) = -1
assert_eq!(step(&mut b, &mut inputs, Some(-1.0), 100.0), 12.0); // +(-1)*(100-108) = +8
}
#[test]
fn sim_broker_no_lookahead() {
let mut b = SimBroker::new(1.0);
let mut inputs = two_f64_inputs();
step(&mut b, &mut inputs, Some(1.0), 100.0);
// exposure flips to 0.0 THIS cycle, but the PnL must use the 1.0 held
// into it: 1.0*(110-100) = 10. Using the fresh 0.0 would give 0.
assert_eq!(step(&mut b, &mut inputs, Some(0.0), 110.0), 10.0);
}
#[test]
fn sim_broker_is_flat_during_warmup() {
let mut b = SimBroker::new(1.0);
let mut inputs = two_f64_inputs();
// exposure never pushed (slot 0 empty) -> treated as flat; equity stays 0
assert_eq!(step(&mut b, &mut inputs, None, 100.0), 0.0);
assert_eq!(step(&mut b, &mut inputs, None, 110.0), 0.0);
assert_eq!(step(&mut b, &mut inputs, None, 90.0), 0.0);
}
#[test]
fn sim_broker_first_cycle_has_no_pnl() {
let mut b = SimBroker::new(1.0);
let mut inputs = two_f64_inputs();
assert_eq!(step(&mut b, &mut inputs, Some(1.0), 100.0), 0.0); // no prev price to mark
}
#[test]
fn input_slots_are_named_exposure_price() {
let b = SimBroker::builder(1.0);
let names: Vec<&str> = b.schema().inputs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["exposure", "price"]);
}
}