b39fd63396
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
653 lines
36 KiB
Rust
653 lines
36 KiB
Rust
//! 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));
|
||
}
|