Files
Aura/crates/aura-engine/tests/r_sma_e2e.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

653 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! R E2E: a synthetic bias+price chain through stop-rule + position-management,
//! recorded and folded by `summarize_r`. Also guards the dense-record layout contract
//! (aura-std's `PM_FIELD_NAMES`/`PM_RECORD_KINDS` vs the column indices `summarize_r`
//! reads).
//!
//! Two distinct jobs live here:
//!
//! 1. **The producer -> consumer seam (E2E).** The whole point of the dense
//! `PositionManagement` record is that one node emits it and a *different*
//! crate (`summarize_r` in `aura-engine`) folds it. The `summarize_r` unit
//! tests in `report.rs` feed hand-built rows whose indices are hardcoded
//! independently of the producer, so they never exercise that seam. Here the
//! real chain runs node-by-node: a synthetic price drives `FixedStop` and
//! `PositionManagement` directly (their `eval` is the contract), the dense
//! records are collected, and `summarize_r` folds them. If the producer's
//! column layout and the consumer's reads disagreed, the folded R-metrics
//! would be wrong.
//!
//! 2. **The column-index contract guard.** `report.rs::summarize_r` reads the
//! dense record by raw index (`CLOSED=0`, `REALIZED_R=1`, `OPEN=11`,
//! `UNREALIZED_R=12`). `aura-std` declares the authoritative layout in
//! `PM_FIELD_NAMES` / `PM_RECORD_KINDS` with a fixed `PM_WIDTH`. `aura-std` is
//! only a dev-dependency of `aura-engine`, so the two sit across a crate
//! boundary with no compiler link — a reorder of `PM_FIELD_NAMES` (or a
//! `PM_WIDTH` change) would silently misalign `summarize_r`. The
//! `r_col_indices_match_producer_field_layout` test pins that contract.
use aura_core::{AnyColumn, Ctx, Node, Scalar, ScalarKind, Timestamp};
use aura_engine::{PositionAction, RMetrics, derive_position_events, summarize_r};
use aura_backtest::{PM_FIELD_NAMES, PM_RECORD_KINDS, PM_WIDTH, PositionManagement};
use aura_strategy::{ConstantCost, CostSum, FixedStop, VolSlippageCost};
// The indices `report.rs::summarize_r` reads the dense record by. Re-declared here
// (the consumer's `r_col` module is private to `aura-engine`) so the guard test can
// assert they line up with the producer's `PM_FIELD_NAMES` / `PM_RECORD_KINDS`.
const CLOSED: usize = 0;
const REALIZED_R: usize = 1;
const OPEN: usize = 11;
const UNREALIZED_R: usize = 12;
const ENTRY_PRICE: usize = 6;
const STOP_PRICE: usize = 7;
const CONVICTION_AT_ENTRY: usize = 9;
const SIZE: usize = 10;
const DIRECTION: usize = 4;
const CUM_REALIZED_R: usize = 13;
// `ConstantCost`'s 3-wide output schema [cost_in_r, cum_cost_in_r, open_cost_in_r] — the
// run-path's in-graph `net_r_equity` tap reads cum (1) + open (2); summarize_r reads 0/2.
const CUM_COST_IN_R: usize = 1;
const OPEN_COST_IN_R: usize = 2;
/// Property: the real producer->consumer seam composes. Driving `FixedStop` ->
/// `PositionManagement` directly (node-by-node, not a bootstrapped graph) over a
/// synthetic price that rises then falls through the stop, with a constant long
/// bias, must produce at least one trade and a finite `RMetrics` — proving
/// `stop -> position-management -> summarize_r` folds the *recorded* dense records
/// into well-defined R-outcomes. The entry sits near 100 and the price gaps down
/// through the 95 stop, so the closed trade is a loss; a fresh long reopens on the
/// last cycle and is force-closed at window end.
#[test]
fn synthetic_long_then_stop_produces_a_sane_rmetric() {
let mut stop = FixedStop::new(5.0);
let mut pm = PositionManagement::new();
let mut sc = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; // stop input: price
let mut pc: Vec<AnyColumn> =
(0..4).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect();
let mut ledger: Vec<(Timestamp, Vec<Scalar>)> = vec![];
// price path: up to 110 then down through the 95 stop.
let prices = [100.0, 104.0, 108.0, 110.0, 102.0, 96.0, 94.0];
for (i, &p) in prices.iter().enumerate() {
sc[0].push(Scalar::f64(p)).unwrap();
let dist = stop.eval(Ctx::new(&sc, Timestamp(i as i64))).map(|c| c[0].f64()).unwrap_or(0.0);
pc[0].push(Scalar::f64(1.0)).unwrap(); // constant long bias
pc[1].push(Scalar::f64(p)).unwrap();
pc[2].push(Scalar::f64(dist)).unwrap();
pc[3].push(Scalar::f64(1.0)).unwrap(); // flat-1R size; R is size-invariant
if let Some(row) = pm.eval(Ctx::new(&pc, Timestamp(i as i64))) {
ledger.push((
Timestamp(i as i64),
row.iter()
.enumerate()
.map(|(j, c)| Scalar::from_cell(PM_RECORD_KINDS[j], *c))
.collect(),
));
}
}
let m = summarize_r(&ledger, &[]);
assert!(m.n_trades >= 1, "expected at least one trade (entry then stop), got {m:?}");
assert!(m.expectancy_r.is_finite());
// entered ~100, stopped at the 95 level (price gapped to 94 through it) -> a loss.
assert!(m.expectancy_r < 0.0 || m.n_open_at_end == 1);
}
/// Drive the real cross-crate chain `stop -> PositionManagement` over a `(bias, price)`
/// path, collecting the producer's dense records (decoded by the producer's own
/// `PM_RECORD_KINDS`, the wire contract) into a ledger — the recorded stream an R-yardstick
/// run would persist. Per-cycle bias (not constant) so a fixture can drop bias to 0 to
/// forbid the immediate re-entry after a stop. The fold is left to the caller so the
/// same recorded ledger can be summarized at several `round_trip_cost` values.
fn run_chain_ledger(stop: &mut dyn Node, path: &[(f64, f64)]) -> Vec<(Timestamp, Vec<Scalar>)> {
let mut sc = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; // stop input: price
let mut pc: Vec<AnyColumn> =
(0..4).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect();
let mut pm = PositionManagement::new();
let mut ledger: Vec<(Timestamp, Vec<Scalar>)> = vec![];
for (i, &(bias, p)) in path.iter().enumerate() {
let ts = Timestamp(i as i64);
sc[0].push(Scalar::f64(p)).unwrap();
let dist = stop.eval(Ctx::new(&sc, ts)).map(|c| c[0].f64()).unwrap_or(0.0);
pc[0].push(Scalar::f64(bias)).unwrap();
pc[1].push(Scalar::f64(p)).unwrap();
pc[2].push(Scalar::f64(dist)).unwrap();
pc[3].push(Scalar::f64(1.0)).unwrap(); // flat-1R size; R is size-invariant
if let Some(row) = pm.eval(Ctx::new(&pc, ts)) {
ledger.push((
ts,
row.iter().enumerate().map(|(j, c)| Scalar::from_cell(PM_RECORD_KINDS[j], *c)).collect(),
));
}
}
ledger
}
/// Drive the real cross-crate chain `stop -> PositionManagement -> summarize_r`
/// over a `(bias, price)` path. This is the actual producer->consumer seam,
/// node-by-node; the resulting `RMetrics` is what a recorded R-yardstick run would report.
fn run_chain(stop: &mut dyn Node, path: &[(f64, f64)]) -> RMetrics {
summarize_r(&run_chain_ledger(stop, path), &[])
}
/// The co-temporal cost stream a `ConstantCost(c)` node would emit over `ledger`: per row,
/// the round-trip cost `c` (price units) expressed in R via that row's latched distance
/// (`|entry_price - stop_price|`), placed in BOTH the closed-cost (col 0) and the open-cost
/// (col 2) slots of the producer's 3-wide `[cost_in_r, cum_cost_in_r, open_cost_in_r]` row.
/// A zero-latched (flat) row contributes no cost. `summarize_r` reads col 0 for a closed
/// trade and col 2 for the window-end open trade, so this reproduces the retired scalar
/// `round_trip_cost` argument exactly — the cost recovered from the producer's own
/// `entry_price`/`stop_price` columns, end-to-end. Col 1 (cum) is unread by `summarize_r`.
fn const_cost_stream(ledger: &[(Timestamp, Vec<Scalar>)], c: f64) -> Vec<(Timestamp, Vec<Scalar>)> {
ledger
.iter()
.map(|(ts, row)| {
let latched = (row[ENTRY_PRICE].as_f64() - row[STOP_PRICE].as_f64()).abs();
let cir = if latched > 0.0 { c / latched } else { 0.0 };
(*ts, vec![Scalar::f64(cir), Scalar::f64(0.0), Scalar::f64(cir)])
})
.collect()
}
/// A constant-long-bias path (`bias = +1` every cycle) over `prices`.
fn long_path(prices: &[f64]) -> Vec<(f64, f64)> {
prices.iter().map(|&p| (1.0, p)).collect()
}
/// Property: **R is stop-defined, and the stop choice flows through the whole
/// producer->consumer seam.** The same constant-long-bias price path, folded
/// through two different stop rules, must yield different expectancy_r — because R
/// is `(exit - entry) / latched_distance` and the two rules latch different
/// distances. With a no-gap stop hit at exactly the stop level the fold returns
/// exactly -1R *whatever the distance*, so to observe the stop-defines-R property
/// end-to-end the price must overshoot the stop: then a wider FixedStop (distance
/// 10) realises a shallower R-loss than a tight FixedStop (distance 1). A run that
/// ignored the stop distance (e.g. measured R in raw pips) would collapse these.
#[test]
fn r_is_stop_defined_two_stops_fold_to_different_expectancy() {
// Open long ~100, then a sharp drop that overshoots both stops.
// FixedStop(10): stop @90, fill @85 -> R = (85-100)/10 = -1.5.
let mut fixed = FixedStop::new(10.0);
let wide = run_chain(&mut fixed, &long_path(&[100.0, 100.0, 85.0]));
// A TIGHT FixedStop (distance 1.0): entry @100, stop @99; drop to 96 overshoots,
// fill @96 -> R = (96-100)/1 = -4. A unit (1.0) far tighter than the wide unit (10.0),
// so for a comparable drop it folds to a ~10x deeper R-loss — R is stop-defined.
let mut tight = FixedStop::new(1.0);
let tight_m = run_chain(&mut tight, &long_path(&[100.0, 100.0, 96.0]));
assert!(wide.n_trades >= 1 && tight_m.n_trades >= 1);
// R is the (exit-entry)/distance ratio: the tight unit (1.0) yields a far deeper
// R-loss than the wide unit (10.0) for a comparable price drop.
assert!(
tight_m.expectancy_r < wide.expectancy_r,
"stop choice must change folded R: tight={:?} wide={:?}",
tight_m.expectancy_r,
wide.expectancy_r,
);
}
/// Property: **a position open at window end is force-closed into expectancy at its
/// unrealized R, not silently dropped — through the real fold.** A constant long
/// bias with a monotonically rising price never triggers a stop or bias-exit, so the
/// position is still open on the last record. `summarize_r` must count it
/// (`n_open_at_end == 1`) and fold its unrealized R into expectancy. Entry latches at
/// 100 with FixedStop distance 10; the last mark is 105, so the forced window-end
/// trade is exactly +0.5R and, being the only trade, that is the expectancy.
#[test]
fn open_at_window_end_is_folded_into_expectancy_not_dropped() {
let mut stop = FixedStop::new(10.0);
let m = run_chain(&mut stop, &long_path(&[100.0, 102.0, 105.0])); // long @100, never exits, last @105
assert_eq!(m.n_open_at_end, 1, "the open position must be counted, not hidden");
assert_eq!(m.n_trades, 1, "exactly the one window-end trade");
assert!((m.expectancy_r - 0.5).abs() < 1e-9, "window-end R = (105-100)/10; got {}", m.expectancy_r);
}
/// Property: **a clean no-gap stop folds to exactly -1R through the chain (the
/// lagged-fill keystone), and that exact value survives the cross-crate fold.** A
/// long opened at 100 with FixedStop distance 10 (stop @90), price falling to touch
/// 90 exactly, realises -1.0R at the producer and `summarize_r` reports expectancy
/// exactly -1.0 — the R-unit's defining identity (1R = the loss if stopped),
/// asserted end-to-end rather than only on hand-built rows. Bias drops to 0 on the
/// stop cycle so the producer does not immediately re-enter a fresh long (which
/// would add a window-end trade) — isolating the single stopped trade.
#[test]
fn clean_stop_folds_to_exactly_minus_one_r() {
let mut stop = FixedStop::new(10.0);
// (bias, price): open long @100, hold, then bias->0 as price touches the 90 stop.
let m = run_chain(&mut stop, &[(1.0, 100.0), (1.0, 100.0), (0.0, 90.0)]);
assert_eq!(m.n_trades, 1);
assert_eq!(m.n_open_at_end, 0, "the stop closed it before window end");
assert!((m.expectancy_r + 1.0).abs() < 1e-9, "1R = the loss if stopped; got {}", m.expectancy_r);
}
/// Property: **net-of-cost expectancy is gross minus one round-trip cost per trade,
/// charged in R via the `latched_dist` the consumer recovers from the producer's
/// `entry_price`/`stop_price` columns — through the real seam.** A constant long opened
/// at 100 on FixedStop distance 10 (so `latched_dist = |100 - 90| = 10`), price rising to
/// 110, folds to a gross window-end R of +1.0; charging a 2.0 price-unit round-trip cost
/// must lower *net* expectancy to `1.0 - 2.0/10 = 0.8` while leaving gross untouched. The
/// cost is recovered end-to-end (not from a hand-built row): if the producer reordered
/// `entry_price`/`stop_price` or `summarize_r` recovered the distance wrong, net would
/// drift here while gross stayed correct — the failure mode this seam test exists to catch.
#[test]
fn net_of_cost_charges_one_round_trip_per_trade_through_the_recovered_latched_dist() {
let mut stop = FixedStop::new(10.0);
let ledger = run_chain_ledger(&mut stop, &long_path(&[100.0, 105.0, 110.0]));
let gross = summarize_r(&ledger, &[]);
let net = summarize_r(&ledger, &const_cost_stream(&ledger, 2.0));
assert_eq!(gross.n_trades, 1);
assert!((gross.expectancy_r - 1.0).abs() < 1e-9, "gross window-end R = (110-100)/10; got {}", gross.expectancy_r);
// gross is untouched by the cost; only net_expectancy_r absorbs it.
assert!((net.expectancy_r - 1.0).abs() < 1e-9, "round_trip_cost must never change gross expectancy");
assert!(
(net.net_expectancy_r - 0.8).abs() < 1e-9,
"net = gross - cost/latched_dist = 1.0 - 2.0/10; got {}",
net.net_expectancy_r,
);
}
/// Property: **a wider stop dilutes the same price-unit round-trip cost in R — the cost
/// is per-R, not per-pip — proven through the producer-recovered `latched_dist`.** The same
/// constant cost (2.0 price units) charged against a tight FixedStop (distance 5, latched 5)
/// costs `2.0/5 = 0.4R`, but against a wide FixedStop (distance 20, latched 20) only
/// `2.0/20 = 0.1R`. Both paths open at 100 and rise so the gross window-end R differs by
/// construction, but the *cost component* (gross - net) must be strictly larger for the
/// tighter stop — a run that charged cost in raw pips (ignoring the latched distance) would
/// collapse these to equal, the regression this guards.
#[test]
fn net_of_cost_is_charged_per_r_so_a_wider_stop_dilutes_it() {
let mut tight = FixedStop::new(5.0);
let tight_l = run_chain_ledger(&mut tight, &long_path(&[100.0, 102.0, 105.0]));
let (tg, tn) = (summarize_r(&tight_l, &[]), summarize_r(&tight_l, &const_cost_stream(&tight_l, 2.0)));
let mut wide = FixedStop::new(20.0);
let wide_l = run_chain_ledger(&mut wide, &long_path(&[100.0, 102.0, 105.0]));
let (wg, wn) = (summarize_r(&wide_l, &[]), summarize_r(&wide_l, &const_cost_stream(&wide_l, 2.0)));
let tight_cost = tg.expectancy_r - tn.net_expectancy_r; // 2.0/5 = 0.4
let wide_cost = wg.expectancy_r - wn.net_expectancy_r; // 2.0/20 = 0.1
assert!((tight_cost - 0.4).abs() < 1e-9, "tight cost = 2/5; got {tight_cost}");
assert!((wide_cost - 0.1).abs() < 1e-9, "wide cost = 2/20; got {wide_cost}");
assert!(tight_cost > wide_cost, "a tighter stop must pay more R per fixed price-unit cost");
}
/// Drive the REAL `aura_strategy::ConstantCost(c)` node over a recorded PM `ledger`,
/// node-by-node off the producer's own `closed`/`open`/`entry_price`/`stop_price`
/// columns — the actual cost producer the run-path graph taps (not an algebraic stand-in
/// like `const_cost_stream`). Returns the node's 3-wide `[cost_in_r, cum_cost_in_r,
/// open_cost_in_r]` rows, co-temporal 1:1 with `ledger`. Fresh single-element columns per
/// cycle (the node's lookback is 1), matching the node's own unit-test driving pattern.
fn const_cost_node_stream(
ledger: &[(Timestamp, Vec<Scalar>)],
c: f64,
) -> Vec<(Timestamp, Vec<Scalar>)> {
let mut node = ConstantCost::new(c);
ledger
.iter()
.map(|(ts, row)| {
let mut cols = vec![
AnyColumn::with_capacity(ScalarKind::Bool, 1), // closed
AnyColumn::with_capacity(ScalarKind::Bool, 1), // open
AnyColumn::with_capacity(ScalarKind::F64, 1), // entry
AnyColumn::with_capacity(ScalarKind::F64, 1), // stop
];
cols[0].push(Scalar::bool(row[CLOSED].as_bool())).unwrap();
cols[1].push(Scalar::bool(row[OPEN].as_bool())).unwrap();
cols[2].push(Scalar::f64(row[ENTRY_PRICE].as_f64())).unwrap();
cols[3].push(Scalar::f64(row[STOP_PRICE].as_f64())).unwrap();
let out = node.eval(Ctx::new(&cols, *ts)).expect("cost row co-temporal with PM record");
(*ts, out.iter().map(|cell| Scalar::f64(cell.f64())).collect())
})
.collect()
}
/// Property (Task 3, the run-path net seam): **the in-graph `net_r_equity` tap and the
/// post-run `summarize_r` net fold agree on the same recorded streams.** The run-path
/// graph taps a per-cycle `net_r_equity = cum_realized_r + unrealized_r cum_cost_in_r
/// open_cost_in_r` (a `LinComb` over the executor + `ConstantCost` outputs), while
/// `summarize_r` independently folds the same PM records + cost stream into
/// `net_expectancy_r`. The FINAL `net_r_equity` sample must equal the post-run net total
/// (`net_expectancy_r × n_trades` = Σ over trades of `r cost`) — a divergence would mean
/// the charted net-R-equity series and the reported net metric disagree. The path carries
/// one clean stopped loser AND a window-end open trade, so both the cumulative-close-cost
/// (`cum_cost_in_r`) and the window-end open-cost (`open_cost_in_r`) terms bite. The cost is
/// produced by the real `ConstantCost` node, the same producer the run-path wires.
#[test]
fn net_r_equity_final_sample_agrees_with_summarize_r_net_total() {
let mut stop = FixedStop::new(10.0);
// open long @100, stop out at the 90 level (bias->0): a clean -1R closed loser; then a
// fresh long @100 held up to 105, open at window end (+0.5R). Closed-cost charges
// cum_cost_in_r; the open trade charges open_cost_in_r — both net terms exercised.
let ledger = run_chain_ledger(
&mut stop,
&[(1.0, 100.0), (1.0, 100.0), (0.0, 90.0), (1.0, 100.0), (1.0, 102.0), (1.0, 105.0)],
);
let cost = const_cost_node_stream(&ledger, 2.0);
let m = summarize_r(&ledger, &cost);
assert_eq!(m.n_trades, 2, "one closed loser + one window-end open trade");
assert_eq!(m.n_open_at_end, 1, "the second long is still open at window end");
// post-run net total = mean net R × trade count = Σ (r cost) over all trades.
let post_run_net_total = m.net_expectancy_r * m.n_trades as f64;
// in-graph final net_r_equity sample = the LinComb(1,1,1,1) at the last cycle.
let (_, last_pm) = ledger.last().unwrap();
let (_, last_cost) = cost.last().unwrap();
let net_eq_final = last_pm[CUM_REALIZED_R].as_f64() + last_pm[UNREALIZED_R].as_f64()
- last_cost[CUM_COST_IN_R].as_f64()
- last_cost[OPEN_COST_IN_R].as_f64();
assert!(
(net_eq_final - post_run_net_total).abs() < 1e-9,
"in-graph net_r_equity {net_eq_final} must equal post-run net total {post_run_net_total}",
);
}
/// Drive the REAL `aura_strategy::VolSlippageCost(k)` node over a recorded PM `ledger`
/// with a constant `vol` per cycle — the run-path's vol-slippage producer. Returns
/// the node's 3-wide `[cost_in_r, cum_cost_in_r, open_cost_in_r]` rows, co-temporal
/// 1:1 with `ledger`.
fn vol_slippage_node_stream(
ledger: &[(Timestamp, Vec<Scalar>)],
k: f64,
vol: f64,
) -> Vec<(Timestamp, Vec<Scalar>)> {
let mut node = VolSlippageCost::new(k);
ledger
.iter()
.map(|(ts, row)| {
let mut cols = vec![
AnyColumn::with_capacity(ScalarKind::Bool, 1), // closed
AnyColumn::with_capacity(ScalarKind::Bool, 1), // open
AnyColumn::with_capacity(ScalarKind::F64, 1), // entry
AnyColumn::with_capacity(ScalarKind::F64, 1), // stop
AnyColumn::with_capacity(ScalarKind::F64, 1), // volatility
];
cols[0].push(Scalar::bool(row[CLOSED].as_bool())).unwrap();
cols[1].push(Scalar::bool(row[OPEN].as_bool())).unwrap();
cols[2].push(Scalar::f64(row[ENTRY_PRICE].as_f64())).unwrap();
cols[3].push(Scalar::f64(row[STOP_PRICE].as_f64())).unwrap();
cols[4].push(Scalar::f64(vol)).unwrap();
let out = node.eval(Ctx::new(&cols, *ts)).expect("cost row co-temporal with PM record");
(*ts, out.iter().map(|cell| Scalar::f64(cell.f64())).collect())
})
.collect()
}
/// Drive the REAL `aura_strategy::CostSum(n)` aggregator over `n` co-temporal cost
/// streams, summing them per-field — the run-path's cost-graph output node.
fn cost_sum_node_stream(streams: &[&[(Timestamp, Vec<Scalar>)]]) -> Vec<(Timestamp, Vec<Scalar>)> {
let n = streams.len();
let len = streams[0].len();
let mut node = CostSum::new(n);
(0..len)
.map(|i| {
let ts = streams[0][i].0;
let mut cols: Vec<AnyColumn> =
(0..n * 3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect();
for (k, s) in streams.iter().enumerate() {
let (_, row) = &s[i];
for f in 0..3 {
cols[k * 3 + f].push(Scalar::f64(row[f].as_f64())).unwrap();
}
}
let out = node.eval(Ctx::new(&cols, ts)).expect("aggregate co-temporal with cost streams");
(ts, out.iter().map(|cell| Scalar::f64(cell.f64())).collect())
})
.collect()
}
/// Property (spec 0082, exact composition): the `CostSum` of the real
/// `ConstantCost` + `VolSlippageCost` node streams folds through `summarize_r` to
/// the exact additive net — `net_both == net_flat + net_vol gross` (since each
/// single net is `gross its_mean_cost`, the composed net subtracts BOTH mean
/// costs). The path carries a clean stopped loser AND a window-end open trade, so
/// both the cumulative-close-cost and the window-end open-cost terms bite.
#[test]
fn cost_sum_composes_constant_and_vol_slippage_exactly() {
let mut stop = FixedStop::new(10.0);
let ledger = run_chain_ledger(
&mut stop,
&[(1.0, 100.0), (1.0, 100.0), (0.0, 90.0), (1.0, 100.0), (1.0, 102.0), (1.0, 105.0)],
);
let cc = const_cost_node_stream(&ledger, 2.0);
let vs = vol_slippage_node_stream(&ledger, 0.5, 3.0);
let summed = cost_sum_node_stream(&[&cc, &vs]);
let gross = summarize_r(&ledger, &[]).expectancy_r;
let net_flat = summarize_r(&ledger, &cc).net_expectancy_r;
let net_vol = summarize_r(&ledger, &vs).net_expectancy_r;
let net_both = summarize_r(&ledger, &summed).net_expectancy_r;
// additive identity: mean(r cc vs) == mean(r cc) + mean(r vs) mean(r)
assert!(
(net_both - (net_flat + net_vol - gross)).abs() < 1e-9,
"composition is exact + additive: net_both {net_both} == net_flat {net_flat} + net_vol {net_vol} gross {gross}",
);
// both costs bite: the composed net is strictly below either single-cost net.
assert!(net_both < net_flat && net_both < net_vol, "both costs bite");
}
/// Property (spec 0082): the aggregate `CostSum` stream agrees with the in-graph
/// net seam — the in-graph final `net_r_equity` sample (a LinComb over the executor
/// outputs and the AGGREGATE cost) equals the post-run `summarize_r` net total. The
/// aggregate is the one cost stream the net tap and `summarize_r` both read.
#[test]
fn aggregate_net_r_equity_final_sample_agrees_with_summarize_r_net_total() {
let mut stop = FixedStop::new(10.0);
let ledger = run_chain_ledger(
&mut stop,
&[(1.0, 100.0), (1.0, 100.0), (0.0, 90.0), (1.0, 100.0), (1.0, 102.0), (1.0, 105.0)],
);
let cc = const_cost_node_stream(&ledger, 2.0);
let vs = vol_slippage_node_stream(&ledger, 0.5, 3.0);
let summed = cost_sum_node_stream(&[&cc, &vs]);
let m = summarize_r(&ledger, &summed);
let post_run_net_total = m.net_expectancy_r * m.n_trades as f64;
let (_, last_pm) = ledger.last().unwrap();
let (_, last_cost) = summed.last().unwrap();
let net_eq_final = last_pm[CUM_REALIZED_R].as_f64() + last_pm[UNREALIZED_R].as_f64()
- last_cost[CUM_COST_IN_R].as_f64()
- last_cost[OPEN_COST_IN_R].as_f64();
assert!(
(net_eq_final - post_run_net_total).abs() < 1e-9,
"in-graph aggregate net_r_equity {net_eq_final} must equal post-run net total {post_run_net_total}",
);
}
/// Property (spec 0082): `CostSum(1)` is the identity — a lone vol-slippage stream
/// folds through the aggregator to exactly the un-aggregated net (the run path is
/// uniform whether one or several cost nodes are wired).
#[test]
fn cost_sum_of_one_is_identity_for_vol_slippage() {
let mut stop = FixedStop::new(10.0);
let ledger = run_chain_ledger(&mut stop, &long_path(&[100.0, 102.0, 105.0]));
let vs = vol_slippage_node_stream(&ledger, 0.5, 3.0);
let single = cost_sum_node_stream(&[&vs]);
assert_eq!(
summarize_r(&ledger, &single).net_expectancy_r,
summarize_r(&ledger, &vs).net_expectancy_r,
"CostSum(1) does not move the net",
);
}
/// Property (#130): **`sqn` and `sqn_normalized` (SQN100) are computed from the
/// producer's *recorded* dense records, not hand-built rows, and below the
/// 100-trade cap the two are identical.** Every `summarize_r` test that touches
/// `sqn_normalized` lives in `report.rs` and feeds rows whose column indices are
/// hardcoded independently of the producer — the exact seam this file's header
/// warns about. Here a real `FixedStop -> PositionManagement` chain produces four
/// closed trades (three +1R winners, one -1R loser) whose recorded `realized_r`
/// the consumer reads back: mean_R = 0.5, sample-sd = 1.0, so q = mean/sd = 0.5
/// and `sqn = √4·q = 1.0`. n = 4 ≤ SQN_CAP (100), so the cap is inactive and
/// `sqn_normalized = √(min(4,100))·q = sqn` *exactly*. A producer-layout drift
/// that misaligned `realized_r` would break both values here even though the
/// hand-built `report.rs` units stayed green.
#[test]
fn sqn_and_sqn_normalized_fold_from_recorded_records_and_agree_below_cap() {
let mut stop = FixedStop::new(10.0);
let mut path: Vec<(f64, f64)> = vec![];
// three winners: open long @100 (FixedStop dist 10 -> latched 10), exit @110
// via bias->0 -> realized_r = (110-100)/10 = +1R each.
for _ in 0..3 {
path.push((1.0, 100.0));
path.push((1.0, 105.0));
path.push((0.0, 110.0));
}
// one loser: open @100, then bias->0 as price touches the 90 stop -> -1R.
path.push((1.0, 100.0));
path.push((1.0, 100.0));
path.push((0.0, 90.0));
let m = run_chain(&mut stop, &path);
assert_eq!(m.n_trades, 4, "three winners + one loser, all closed before window end");
assert_eq!(m.n_open_at_end, 0, "every trade exits via bias->0; nothing open at end");
assert!((m.expectancy_r - 0.5).abs() < 1e-9, "mean R = (1+1+1-1)/4; got {}", m.expectancy_r);
// sqn = √n · mean/sd = √4 · (0.5/1.0) = 1.0, folded from the recorded realized_r.
assert!((m.sqn - 1.0).abs() < 1e-9, "raw sqn = √4·(0.5/1.0); got {}", m.sqn);
// n = 4 <= SQN_CAP(100): the cap is inactive, so SQN100 == raw sqn exactly.
assert!(
(m.sqn_normalized - m.sqn).abs() < 1e-12,
"below the cap, sqn_normalized must equal sqn (got {} vs {})",
m.sqn_normalized,
m.sqn,
);
}
/// Contract guard (the lockstep cross-crate column-index agreement `report.rs`
/// names): `PM_WIDTH` is the fixed dense-record arity, the indices `summarize_r`
/// reads the dense record by must equal the indices the producer's authoritative
/// `PM_FIELD_NAMES` assigns those columns, and the producer's `PM_RECORD_KINDS` at
/// those indices must be the kinds `summarize_r` assumes when it reads them as
/// bool / f64. `aura-std` is only a dev-dependency, so nothing but this test stops
/// a `PM_WIDTH` change or a `PM_FIELD_NAMES` reorder from silently breaking the fold.
#[test]
fn r_col_indices_match_producer_field_layout() {
assert_eq!(PM_WIDTH, 14);
assert_eq!(PM_FIELD_NAMES.len(), PM_WIDTH);
assert_eq!(PM_RECORD_KINDS.len(), PM_WIDTH);
// names: the consumer's read indices land on the columns it intends.
assert_eq!(PM_FIELD_NAMES[CLOSED], "closed_this_cycle");
assert_eq!(PM_FIELD_NAMES[REALIZED_R], "realized_r");
assert_eq!(PM_FIELD_NAMES[OPEN], "open");
assert_eq!(PM_FIELD_NAMES[UNREALIZED_R], "unrealized_r");
// kinds: summarize_r reads CLOSED/OPEN as bool and REALIZED_R/UNREALIZED_R as f64.
assert_eq!(PM_RECORD_KINDS[CLOSED], ScalarKind::Bool);
assert_eq!(PM_RECORD_KINDS[OPEN], ScalarKind::Bool);
assert_eq!(PM_RECORD_KINDS[REALIZED_R], ScalarKind::F64);
assert_eq!(PM_RECORD_KINDS[UNREALIZED_R], ScalarKind::F64);
// iter-2 reads: entry_price (6), stop_price (7), conviction_at_entry (9) — the geometry
// summarize_r recovers latched_dist (net-of-cost) and conviction (terciles) from.
assert_eq!(PM_FIELD_NAMES[ENTRY_PRICE], "entry_price");
assert_eq!(PM_FIELD_NAMES[STOP_PRICE], "stop_price");
assert_eq!(PM_FIELD_NAMES[CONVICTION_AT_ENTRY], "conviction_at_entry");
assert_eq!(PM_RECORD_KINDS[ENTRY_PRICE], ScalarKind::F64);
assert_eq!(PM_RECORD_KINDS[STOP_PRICE], ScalarKind::F64);
assert_eq!(PM_RECORD_KINDS[CONVICTION_AT_ENTRY], ScalarKind::F64);
// iter-2 size column (10): the Sizer's `size` flows here, and the sibling
// `risk_executor.rs` fixture asserts its R-invariance by reading this index — so the
// size column's position is pinned against the producer layout too, not just the reads
// `summarize_r` makes (the lockstep claim in that fixture's header relies on this).
assert_eq!(PM_FIELD_NAMES[SIZE], "size");
assert_eq!(PM_RECORD_KINDS[SIZE], ScalarKind::F64);
// iter-2 direction column (4): the signed bias sign `derive_position_events` reads to
// decide Buy vs Sell. Pinned against the producer layout so a reorder can't silently
// flip the derived event-table's action — the lockstep claim now covers the derive too.
assert_eq!(PM_FIELD_NAMES[DIRECTION], "direction");
assert_eq!(PM_RECORD_KINDS[DIRECTION], ScalarKind::I64);
}
/// Property (#114, derived not hand-built): **a bias reversal in the REAL producer
/// derives Close-then-opposite-open at one `event_ts`, close first.** The reversal
/// contract is the central acceptance criterion of the derive, but the `report.rs`
/// unit sets the `closed`/`direction`/`open`/`size` columns by hand, independently
/// of `PositionManagement` — so it can stay green while the producer's actual
/// reversal row (one record carrying `closed_this_cycle=true` AND `open=true` AND a
/// flipped `direction`) disagrees with the column indices the derive reads. Here the
/// real `FixedStop -> PositionManagement` chain emits that one reversal record (long
/// opened @100, bias flips to short @110 with no stop hit -> ReversalLeg + reopen),
/// and the derive must turn it into exactly Buy@t0, then at the SAME flip instant
/// Close(the long) before Sell(the short). A producer-layout drift that misplaced
/// `direction`, or a derive that opened before it closed, breaks this even though the
/// hand-built `report.rs` reversal unit stays green — the cross-crate seam this guards.
#[test]
fn reversal_in_real_producer_derives_close_then_opposite_open_at_one_ts() {
let mut stop = FixedStop::new(10.0);
// (bias, price): open long @100, hold @105, then bias flips short @110 (above the
// 90 stop -> no stop hit, a clean reversal leg).
let ledger = run_chain_ledger(&mut stop, &[(1.0, 100.0), (1.0, 105.0), (-1.0, 110.0)]);
let events = derive_position_events(&ledger, 3);
// open long; reversal at the flip cycle = Close(long) then Sell(short), same ts;
// the short is open at window end, so no synthetic Close after it.
assert_eq!(events.len(), 3, "Buy, Close, Sell; got {events:?}");
assert_eq!(events[0].action, PositionAction::Buy);
assert_eq!(events[0].position_id, 0);
// close before open, at one and the same instant (the #114 ordering contract).
assert_eq!(events[1].action, PositionAction::Close);
assert_eq!(events[1].position_id, 0, "the Close references the long it closes");
assert_eq!(events[2].action, PositionAction::Sell, "opposite-direction reopen");
assert_eq!(events[2].position_id, 1, "a fresh position id for the reopened leg");
assert_eq!(
events[1].event_ts, events[2].event_ts,
"Close and the opposite open share the reversal instant",
);
// close strictly before open in emission order (the table is ordered, close first).
let (close_idx, sell_idx) = (1usize, 2usize);
assert!(close_idx < sell_idx, "Close must be emitted before the opposite open");
assert!(events.iter().all(|e| e.instrument_id == 3));
}
/// Property: **a short entry in the REAL producer derives a `Sell`, reading the
/// producer's signed `direction` column across the crate seam.** Every other E2E
/// path opens longs only, so the derive's `dir < 0 => Sell` branch — and its read of
/// the real `PositionManagement` `direction` column (i64, index 4) — is exercised
/// nowhere else end-to-end. A constant `bias = -1` path opens a short directly; the
/// derive must emit `Sell` (not `Buy`). A producer that wrote `direction` to a
/// different index, or a derive that defaulted to `Buy`, breaks this.
#[test]
fn short_entry_in_real_producer_derives_sell() {
let mut stop = FixedStop::new(10.0);
// constant short bias: open short @100, hold while price falls (a winning short).
let ledger = run_chain_ledger(&mut stop, &[(-1.0, 100.0), (-1.0, 98.0), (-1.0, 96.0)]);
let events = derive_position_events(&ledger, 1);
assert!(!events.is_empty(), "a short must open");
assert_eq!(events[0].action, PositionAction::Sell, "negative direction derives Sell");
assert_eq!(events[0].position_id, 0);
}
/// Property: the derived position-event table agrees with the R-metrics fold over
/// the SAME recorded ledger — one Close per closed round-trip, every open either
/// closed in-window or still open at window end, every Close referencing an earlier
/// open, and the caller's instrument_id threaded onto every event.
#[test]
fn derived_event_table_agrees_with_r_metrics_over_one_ledger() {
let mut stop = FixedStop::new(5.0);
let ledger =
run_chain_ledger(&mut stop, &long_path(&[100.0, 104.0, 108.0, 110.0, 102.0, 96.0, 94.0]));
let m: RMetrics = summarize_r(&ledger, &[]);
let events = derive_position_events(&ledger, 7);
let closes = events.iter().filter(|e| e.action == PositionAction::Close).count() as u64;
let opens = events
.iter()
.filter(|e| matches!(e.action, PositionAction::Buy | PositionAction::Sell))
.count() as u64;
// one Close per closed round-trip; a window-end open position has no Close.
assert_eq!(closes, m.n_trades - m.n_open_at_end);
// every open is either closed in-window or still open at window end.
assert_eq!(opens, closes + m.n_open_at_end);
assert!(opens >= 1, "scenario must open at least one position");
// referential integrity: every Close references a position_id opened earlier.
let mut opened: std::collections::HashSet<i64> = std::collections::HashSet::new();
for e in &events {
match e.action {
PositionAction::Buy | PositionAction::Sell => {
opened.insert(e.position_id);
}
PositionAction::Close => assert!(opened.contains(&e.position_id)),
}
}
// the caller-supplied instrument_id is threaded onto every event.
assert!(events.iter().all(|e| e.instrument_id == 7));
}