//! 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 = (0..4).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect(); let mut ledger: Vec<(Timestamp, Vec)> = 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)> { let mut sc = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; // stop input: price let mut pc: Vec = (0..4).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect(); let mut pm = PositionManagement::new(); let mut ledger: Vec<(Timestamp, Vec)> = 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)], c: f64) -> Vec<(Timestamp, Vec)> { 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)], c: f64, ) -> Vec<(Timestamp, Vec)> { 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)], k: f64, vol: f64, ) -> Vec<(Timestamp, Vec)> { 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)]]) -> Vec<(Timestamp, Vec)> { 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 = (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 = 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)); }