From b4e84335c44e10dccda69ca5fef7bc480d0ed147 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 24 Jun 2026 02:15:51 +0200 Subject: [PATCH] feat(stage1-r): Sizer + RiskExecutor + R-metric enrichment (iter 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration 2 of cycle 0065 (Stage-1 R signal quality) — the node + metric layer. Builds on iter-1's PositionManagement dense R-record + core summarize_r. What ships: - PositionManagement gains a 4th `size` input (slot 3 -> col 10), fed by the Sizer. R is computed size-INVARIANTLY (pure stop-distance ratio), so size never touches realized_r — pinned by a RED test (scaling size leaves every realized_r unchanged) at the node and again end-to-end through the executor. - Sizer (aura-std): `size = risk_budget / stop_distance` — flat-1R (risk_budget 1.0 => one risk unit per trade, size inversely proportional to the stop, not a constant). Reads bias (firing/presence) + stop_distance; the Stage-2 fixed-fractional sizer slots in unchanged (swap risk_budget for risk_fraction*equity, same node shape). - summarize_r enrichment: SQN (sqrt(n)*mean_R/sample-stdev_R; n<2 or zero-variance -> 0), conviction_terciles_r (E[R] by |bias_at_entry| tercile), net_expectancy_r (gross minus one round-trip cost per trade, charged in R via the latched_dist recovered from the entry_price/stop_price columns). summarize_r gains a `round_trip_cost` param (price units). The net-of-cost recovery is tested through the real producer->consumer seam, not just hand-built rows. - RunMetrics.r: Option with #[serde(default, skip_serializing_if = "Option::is_none")] — legacy runs.jsonl (no `r` key) deserialise to None and a pip-only run's on-disk shape stays byte-unchanged (C14/C18 back-compat). Every RunMetrics literal threaded (report.rs, aura-registry, aura-engine/mc). - RiskExecutor (aura-engine integration-test fixture, sibling of vol_stop_composite): FixedStop -> Sizer -> PositionManagement behind open bias+price input roles, price fanned to both the stop and PM, the Sizer's size into PM. Bias is produced in-graph from the single price source (a second bias *source* would k-way-merge into separate cycles and mark stale prices). The Veto is a DOCUMENTED SEAM, not a runtime node (a pass-through identity is what C19/C23 DCE deletes). Tests: the composite bootstraps + runs + folds to the documented hand value; R invariant under risk_budget while the size column scales; a live-folded RMetrics survives the RunMetrics serde round-trip. The dense-record size column (10) is brought under the cross-crate layout guard (stage1_r_e2e r_col_indices_match_producer_field_layout) so the executor fixture's size-invariance read is drift-protected like the others. Scope: the CLI/recording surface (#129) is sub-split into a separate iteration 3 and is NOT in this commit. Verified: cargo build --workspace clean; cargo test --workspace 500 passed, 0 failed; cargo clippy --workspace --all-targets -D warnings clean. refs #117 #127 #128 #129 --- crates/aura-engine/src/mc.rs | 1 + crates/aura-engine/src/report.rs | 261 +++++++++++++++++++-- crates/aura-engine/tests/risk_executor.rs | 170 ++++++++++++++ crates/aura-engine/tests/stage1_r_e2e.rs | 100 +++++++- crates/aura-registry/src/lib.rs | 2 +- crates/aura-std/src/lib.rs | 2 + crates/aura-std/src/position_management.rs | 37 ++- crates/aura-std/src/sizer.rs | 132 +++++++++++ 8 files changed, 665 insertions(+), 40 deletions(-) create mode 100644 crates/aura-engine/tests/risk_executor.rs create mode 100644 crates/aura-std/src/sizer.rs diff --git a/crates/aura-engine/src/mc.rs b/crates/aura-engine/src/mc.rs index ef9dd56..2e01eeb 100644 --- a/crates/aura-engine/src/mc.rs +++ b/crates/aura-engine/src/mc.rs @@ -209,6 +209,7 @@ mod tests { total_pips: v, max_drawdown: v, exposure_sign_flips: v as u64, + r: None, }, }, }) diff --git a/crates/aura-engine/src/report.rs b/crates/aura-engine/src/report.rs index 9e75113..b98800c 100644 --- a/crates/aura-engine/src/report.rs +++ b/crates/aura-engine/src/report.rs @@ -27,11 +27,17 @@ pub struct RunMetrics { /// A turnover proxy: it counts long<->short reversals *and* transitions /// into/out of flat — the plain sign-change count over the exposure series. pub exposure_sign_flips: u64, + /// Optional Stage-1 R metrics block. `None` for a pip-only run (and for legacy + /// `runs.jsonl` written before this field existed — `serde(default)`); omitted from + /// the JSON entirely when absent (`skip_serializing_if`), so the pip-only on-disk + /// shape stays byte-unchanged (C14/C18 back-compat). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub r: Option, } /// R-based signal-quality metrics (Stage-1), reduced from a `PositionManagement` dense -/// record stream by [`summarize_r`]. Account- and instrument-agnostic (pure R). The -/// iteration-2 fields (SQN, conviction terciles, net-of-cost) are added later. +/// record stream by [`summarize_r`]. Account- and instrument-agnostic (pure R). Carries +/// the enriched dispersion/churn fields (SQN, conviction terciles, net-of-cost). #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct RMetrics { pub expectancy_r: f64, // mean realised R over all trades (equal-weighted; headline) @@ -42,6 +48,9 @@ pub struct RMetrics { pub profit_factor: f64, // sum(win R) / |sum(loss R)|; 0.0 if no losses or no trades pub max_r_drawdown: f64, // worst peak-to-trough on the by-trade cumulative-R curve, >= 0 pub n_open_at_end: u64, // positions force-closed at window end (counted, not hidden) + pub sqn: f64, // √n · mean_R / sample-stdev_R; n<2 or zero-variance -> 0.0 + pub net_expectancy_r: f64, // mean(R - round_trip_cost / latched_dist) — churn-honest + pub conviction_terciles_r: [f64; 3], // E[R] by |bias_at_entry| tercile (asc); <3 trades -> [0,0,0] } // Dense `PositionManagement` record column indices — the lockstep contract with @@ -50,6 +59,9 @@ pub struct RMetrics { mod r_col { pub const CLOSED: usize = 0; pub const REALIZED_R: usize = 1; + pub const ENTRY_PRICE: usize = 6; + pub const STOP_PRICE: usize = 7; + pub const BIAS_AT_ENTRY_ABS: usize = 9; pub const OPEN: usize = 11; pub const UNREALIZED_R: usize = 12; } @@ -58,29 +70,56 @@ mod r_col { /// The trade ledger is the rows where `closed_this_cycle`; a position still open on the /// last row is force-closed at its `unrealized_r` (a window-end trade — never silently /// folded as unrealised MtM). Empty input -> a well-defined all-zero `RMetrics`. -pub fn summarize_r(record: &[(Timestamp, Vec)]) -> RMetrics { - // Rows are the producer's dense `PositionManagement` records, always `PM_WIDTH` - // (14) columns wide — pinned by `stage1_r_e2e.rs`. Trust that layout and index - // every column directly: a guard on one column while the next is a bare `[]` - // index would buy nothing (a short row panics either way). - let mut rs: Vec = Vec::new(); - let mut n_open_at_end = 0u64; +pub fn summarize_r(record: &[(Timestamp, Vec)], round_trip_cost: f64) -> RMetrics { + // Collect one entry per trade: its realised R, the entry-conviction |bias|, and the + // latched R-distance (|entry - stop|, the frozen R-denominator) recovered from the + // record. The ledger is the closed rows; a position still open on the last row is + // force-closed at its unrealized R (a window-end trade). + struct Trade { + r: f64, + bias_abs: f64, + latched: f64, + } + let mut trades: Vec = Vec::new(); for (_, row) in record { if row[r_col::CLOSED].as_bool() { - rs.push(row[r_col::REALIZED_R].as_f64()); + trades.push(Trade { + r: row[r_col::REALIZED_R].as_f64(), + bias_abs: row[r_col::BIAS_AT_ENTRY_ABS].as_f64(), + latched: (row[r_col::ENTRY_PRICE].as_f64() - row[r_col::STOP_PRICE].as_f64()).abs(), + }); } } + let mut n_open_at_end = 0u64; if let Some((_, last)) = record.last() && last[r_col::OPEN].as_bool() { - rs.push(last[r_col::UNREALIZED_R].as_f64()); + trades.push(Trade { + r: last[r_col::UNREALIZED_R].as_f64(), + bias_abs: last[r_col::BIAS_AT_ENTRY_ABS].as_f64(), + latched: (last[r_col::ENTRY_PRICE].as_f64() - last[r_col::STOP_PRICE].as_f64()).abs(), + }); n_open_at_end = 1; } - let n = rs.len() as u64; + let n = trades.len() as u64; if n == 0 { - return RMetrics { expectancy_r: 0.0, n_trades: 0, win_rate: 0.0, avg_win_r: 0.0, avg_loss_r: 0.0, profit_factor: 0.0, max_r_drawdown: 0.0, n_open_at_end: 0 }; + return RMetrics { + expectancy_r: 0.0, + n_trades: 0, + win_rate: 0.0, + avg_win_r: 0.0, + avg_loss_r: 0.0, + profit_factor: 0.0, + max_r_drawdown: 0.0, + n_open_at_end: 0, + sqn: 0.0, + net_expectancy_r: 0.0, + conviction_terciles_r: [0.0; 3], + }; } + let rs: Vec = trades.iter().map(|t| t.r).collect(); let sum: f64 = rs.iter().sum(); + let mean = sum / n as f64; let wins: Vec = rs.iter().copied().filter(|&r| r > 0.0).collect(); let losses: Vec = rs.iter().copied().filter(|&r| r <= 0.0).collect(); let sum_win: f64 = wins.iter().sum(); @@ -92,12 +131,52 @@ pub fn summarize_r(record: &[(Timestamp, Vec)]) -> RMetrics { let mut max_dd = 0.0_f64; for &r in &rs { cum += r; - if cum > peak { peak = cum; } + if cum > peak { + peak = cum; + } let dd = peak - cum; - if dd > max_dd { max_dd = dd; } + if dd > max_dd { + max_dd = dd; + } } + // SQN = √n · mean / sample-stdev. n < 2 or zero variance -> 0.0 (dispersion undefined). + let sqn = if n < 2 { + 0.0 + } else { + let var = rs.iter().map(|&r| (r - mean).powi(2)).sum::() / (n as f64 - 1.0); + let sd = var.sqrt(); + if sd > 0.0 { (n as f64).sqrt() * mean / sd } else { 0.0 } + }; + // net-of-cost: subtract one round-trip spread (price units) per trade, expressed in R + // by dividing by that trade's latched R-distance (a zero distance contributes no cost). + let net_sum: f64 = trades + .iter() + .map(|t| t.r - if t.latched > 0.0 { round_trip_cost / t.latched } else { 0.0 }) + .sum(); + let net_expectancy_r = net_sum / n as f64; + // conviction terciles: sort by |bias_at_entry| ascending, split into three contiguous + // near-equal-count buckets (floor boundaries i*n/3), E[R] per bucket. < 3 trades -> 0s. + let conviction_terciles_r = if n < 3 { + [0.0; 3] + } else { + let mut by_conv: Vec<&Trade> = trades.iter().collect(); + by_conv.sort_by(|a, b| a.bias_abs.total_cmp(&b.bias_abs)); + let nn = by_conv.len(); + let mut out = [0.0; 3]; + for (i, slot) in out.iter_mut().enumerate() { + let lo = i * nn / 3; + let hi = (i + 1) * nn / 3; + let bucket = &by_conv[lo..hi]; + *slot = if bucket.is_empty() { + 0.0 + } else { + bucket.iter().map(|t| t.r).sum::() / bucket.len() as f64 + }; + } + out + }; RMetrics { - expectancy_r: sum / n as f64, + expectancy_r: mean, n_trades: n, win_rate: wins.len() as f64 / n as f64, avg_win_r: avg(&wins), @@ -105,6 +184,9 @@ pub fn summarize_r(record: &[(Timestamp, Vec)]) -> RMetrics { profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 }, max_r_drawdown: max_dd, n_open_at_end, + sqn, + net_expectancy_r, + conviction_terciles_r, } } @@ -235,7 +317,7 @@ pub fn summarize( prev = Some(s); } - RunMetrics { total_pips, max_drawdown, exposure_sign_flips } + RunMetrics { total_pips, max_drawdown, exposure_sign_flips, r: None } } /// Three-way sign: `-1.0` / `0.0` / `+1.0`. Unlike `f64::signum` (which returns @@ -582,12 +664,29 @@ mod tests { v[r_col::UNREALIZED_R] = Scalar::f64(unreal); (Timestamp(0), v) } + + // A fuller closed-trade dense row: also sets entry_price (6), stop_price (7) and + // bias_at_entry_abs (9) — the geometry summarize_r recovers latched_dist and + // conviction from. Width up to UNREALIZED_R+1 (summarize_r never reads col 13). + fn r_row_full(realized: f64, entry: f64, stop: f64, bias_abs: f64) -> (Timestamp, Vec) { + let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1]; + v[r_col::CLOSED] = Scalar::bool(true); + v[r_col::REALIZED_R] = Scalar::f64(realized); + v[r_col::ENTRY_PRICE] = Scalar::f64(entry); + v[r_col::STOP_PRICE] = Scalar::f64(stop); + v[r_col::BIAS_AT_ENTRY_ABS] = Scalar::f64(bias_abs); + v[r_col::OPEN] = Scalar::bool(false); + (Timestamp(0), v) + } #[test] fn summarize_r_is_zero_on_empty() { - let m = summarize_r(&[]); + let m = summarize_r(&[], 0.0); assert_eq!(m.n_trades, 0); assert_eq!(m.expectancy_r, 0.0); assert_eq!(m.max_r_drawdown, 0.0); + assert_eq!(m.sqn, 0.0); + assert_eq!(m.net_expectancy_r, 0.0); + assert_eq!(m.conviction_terciles_r, [0.0; 3]); } #[test] fn summarize_r_expectancy_winrate_profit_factor() { @@ -598,7 +697,7 @@ mod tests { r_row(true, -1.0, true, 0.0), r_row(true, 1.0, false, 0.0), // last row not open ]; - let m = summarize_r(&rec); + let m = summarize_r(&rec, 0.0); assert_eq!(m.n_trades, 3); assert!((m.expectancy_r - (2.0 / 3.0)).abs() < 1e-9); assert!((m.win_rate - (2.0 / 3.0)).abs() < 1e-9); @@ -609,7 +708,7 @@ mod tests { fn summarize_r_force_closes_open_position_at_window_end() { // one closed +1, then last row open with unrealized -0.5 -> a window-end trade. let rec = vec![r_row(true, 1.0, true, 0.0), r_row(false, 0.0, true, -0.5)]; - let m = summarize_r(&rec); + let m = summarize_r(&rec, 0.0); assert_eq!(m.n_trades, 2); assert_eq!(m.n_open_at_end, 1); assert!((m.expectancy_r - 0.25).abs() < 1e-9); // (1 + -0.5)/2 @@ -618,7 +717,80 @@ mod tests { fn summarize_r_max_drawdown_on_by_trade_curve() { // cum: +3, +1 (dd 2), +4 -> max dd = 2. let rec = vec![r_row(true, 3.0, false, 0.0), r_row(true, -2.0, false, 0.0), r_row(true, 3.0, false, 0.0)]; - assert!((summarize_r(&rec).max_r_drawdown - 2.0).abs() < 1e-9); + assert!((summarize_r(&rec, 0.0).max_r_drawdown - 2.0).abs() < 1e-9); + } + + #[test] + fn summarize_r_sqn_is_sqrt_n_mean_over_stdev() { + // R = [1, 1, 1, -1]: mean 0.5; sample var = ((0.5)^2*3 + (1.5)^2)/3 = 1.0; sd = 1; + // sqn = sqrt(4)*0.5/1 = 1.0. + let rec = vec![ + r_row(true, 1.0, false, 0.0), + r_row(true, 1.0, false, 0.0), + r_row(true, 1.0, false, 0.0), + r_row(true, -1.0, false, 0.0), + ]; + let m = summarize_r(&rec, 0.0); + assert!((m.sqn - 1.0).abs() < 1e-9, "sqn = sqrt(n)*mean/sd; got {}", m.sqn); + } + + #[test] + fn summarize_r_sqn_zero_when_under_two_trades_or_zero_variance() { + // n < 2 -> 0 + assert_eq!(summarize_r(&[r_row(true, 2.0, false, 0.0)], 0.0).sqn, 0.0); + // identical R -> zero variance -> 0 + let flat = vec![r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0)]; + assert_eq!(summarize_r(&flat, 0.0).sqn, 0.0); + } + + #[test] + fn summarize_r_conviction_terciles_order_by_bias() { + // Calibrated: |bias| correlates with R. Sorted ascending by |bias|, the three + // terciles (2 trades each) are [-2,-1]->-1.5, [0,0]->0, [+1,+2]->+1.5. + let calibrated = vec![ + r_row_full(-2.0, 100.0, 99.0, 0.1), + r_row_full(-1.0, 100.0, 99.0, 0.2), + r_row_full(0.0, 100.0, 99.0, 0.5), + r_row_full(0.0, 100.0, 99.0, 0.6), + r_row_full(1.0, 100.0, 99.0, 0.9), + r_row_full(2.0, 100.0, 99.0, 1.0), + ]; + let m = summarize_r(&calibrated, 0.0); + assert!((m.conviction_terciles_r[0] - (-1.5)).abs() < 1e-9, "low tercile: {:?}", m.conviction_terciles_r); + assert!((m.conviction_terciles_r[2] - 1.5).abs() < 1e-9, "high tercile: {:?}", m.conviction_terciles_r); + assert!(m.conviction_terciles_r[2] > m.conviction_terciles_r[0], "high conviction must out-earn low when calibrated"); + + // Anti-calibrated: high |bias| earns LESS. The ordering must INVERT — proving the + // metric reads |bias|, not trade order. Same R multiset, |bias| reversed. + let anti = vec![ + r_row_full(2.0, 100.0, 99.0, 0.1), + r_row_full(1.0, 100.0, 99.0, 0.2), + r_row_full(0.0, 100.0, 99.0, 0.5), + r_row_full(0.0, 100.0, 99.0, 0.6), + r_row_full(-1.0, 100.0, 99.0, 0.9), + r_row_full(-2.0, 100.0, 99.0, 1.0), + ]; + let a = summarize_r(&anti, 0.0); + assert!(a.conviction_terciles_r[2] < a.conviction_terciles_r[0], "anti-calibrated must invert: {:?}", a.conviction_terciles_r); + } + + #[test] + fn summarize_r_conviction_terciles_zero_under_three_trades() { + let rec = vec![r_row_full(2.0, 100.0, 99.0, 0.5), r_row_full(-1.0, 100.0, 99.0, 0.5)]; + assert_eq!(summarize_r(&rec, 0.0).conviction_terciles_r, [0.0; 3]); + } + + #[test] + fn summarize_r_net_of_cost_subtracts_round_trip_per_trade() { + // one trade: R=+2, entry 100, stop 90 -> latched 10. round_trip_cost 1.0 (price + // units) -> cost in R = 1/10 = 0.1. gross E[R]=2.0, net = 1.9. + let rec = vec![r_row_full(2.0, 100.0, 90.0, 0.5)]; + let gross = summarize_r(&rec, 0.0); + let net = summarize_r(&rec, 1.0); + assert!((gross.expectancy_r - 2.0).abs() < 1e-9); + assert!((gross.net_expectancy_r - 2.0).abs() < 1e-9, "zero cost -> net == gross"); + assert!((net.expectancy_r - 2.0).abs() < 1e-9, "cost never changes gross expectancy"); + assert!((net.net_expectancy_r - 1.9).abs() < 1e-9, "net = 2 - 1/10; got {}", net.net_expectancy_r); } fn samples(values: &[f64]) -> Vec<(Timestamp, f64)> { @@ -712,6 +884,7 @@ mod tests { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1, + r: None, }, }; assert_eq!( @@ -735,7 +908,7 @@ mod tests { seed: 0, broker: "sim-optimal(pip_size=1.0)".to_string(), }, - metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1 }, + metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1, r: None }, }; // stdout (to_json) and disk (serde_json::to_string) are now the same bytes. assert_eq!(report.to_json(), serde_json::to_string(&report).unwrap()); @@ -755,7 +928,7 @@ mod tests { seed: 0, broker: "sim-optimal(pip_size=1.0)".to_string(), }, - metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1 }, + metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1, r: None }, }; let json = serde_json::to_string(&report).expect("serialize RunReport"); // window is a 2-element [from, to] array (Timestamp newtype is transparent) @@ -764,6 +937,48 @@ mod tests { assert_eq!(back, report); } + #[test] + fn runmetrics_with_r_block_round_trips() { + let m = RunMetrics { + total_pips: 3.0, + max_drawdown: 1.0, + exposure_sign_flips: 2, + r: Some(RMetrics { + expectancy_r: 0.5, + n_trades: 4, + win_rate: 0.5, + avg_win_r: 1.5, + avg_loss_r: -0.5, + profit_factor: 3.0, + max_r_drawdown: 0.5, + n_open_at_end: 1, + sqn: 1.0, + net_expectancy_r: 0.4, + conviction_terciles_r: [-0.5, 0.5, 1.5], + }), + }; + let json = serde_json::to_string(&m).expect("serialize"); + assert!(json.contains("\"r\":{"), "r block present when Some: {json}"); + let back: RunMetrics = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, m); + } + + #[test] + fn legacy_runmetrics_without_r_field_deserialises_to_none() { + // a pre-`r` runs.jsonl line: no `r` key at all. + let legacy = r#"{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":1}"#; + let m: RunMetrics = serde_json::from_str(legacy).expect("legacy line still deserialises"); + assert_eq!(m.r, None); + assert_eq!(m.total_pips, 12.0); + } + + #[test] + fn runmetrics_none_r_is_omitted_from_json() { + let m = RunMetrics { total_pips: 1.0, max_drawdown: 0.0, exposure_sign_flips: 0, r: None }; + let json = serde_json::to_string(&m).expect("serialize"); + assert!(!json.contains("\"r\""), "a None r must be omitted from the JSON: {json}"); + } + #[test] fn join_on_ts_aligns_streams_of_different_cardinality() { // spine fires every bar; side A is one row shorter (no ts 10, like cold diff --git a/crates/aura-engine/tests/risk_executor.rs b/crates/aura-engine/tests/risk_executor.rs new file mode 100644 index 0000000..a2b73d2 --- /dev/null +++ b/crates/aura-engine/tests/risk_executor.rs @@ -0,0 +1,170 @@ +//! The `RiskExecutor` composite (Stage-1, #128): a per-symbol risk-based executor over a +//! bias stream — `stop-rule -> Sizer -> position-management`, exposing the dense R-record. +//! Proves the composite bootstraps, runs end-to-end, and folds to the SAME R-outcomes as +//! the hand-wired iter-1 chain, and that R is invariant under the Sizer's `risk_budget` +//! (Stage-1 feed-forward). The Veto is a DOCUMENTED SEAM, not a runtime node (a +//! pass-through identity is exactly what C19/C23 DCE deletes), so it appears nowhere here. +use aura_core::{ + Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar, + ScalarKind, Timestamp, +}; +use aura_engine::{summarize_r, Composite, GraphBuilder, RunMetrics, VecSource}; +use aura_std::{FixedStop, PositionManagement, Recorder, Sizer, PM_FIELD_NAMES, PM_RECORD_KINDS}; +use std::sync::mpsc::channel; + +// The dense-record columns this fixture reads, named in lockstep with the sibling +// `stage1_r_e2e.rs` (which names `REALIZED_R = 1` the same way) so the cross-crate +// `PM_FIELD_NAMES` layout is never referenced by a bare literal. +const REALIZED_R: usize = 1; +const SIZE: usize = 10; + +/// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal +/// `FixedStop(stop_distance) -> Sizer(risk_budget) -> PositionManagement`, exposing every +/// field of PM's dense R-record. Price fans to BOTH the stop-rule and PM; bias fans to the +/// Sizer and PM; the Sizer's `size` feeds PM's size slot. (Stage-1 ships `FixedStop` here; +/// the volatility stop is a drop-in composite, see `vol_stop_composite.rs`.) +fn risk_executor(stop_distance: f64, risk_budget: f64) -> Composite { + let mut g = GraphBuilder::new("risk_executor"); + let bias = g.input_role("bias"); + let price = g.input_role("price"); + let stop = g.add(FixedStop::builder().bind("distance", Scalar::f64(stop_distance))); + let sizer = g.add(Sizer::builder().bind("risk_budget", Scalar::f64(risk_budget))); + let pm = g.add(PositionManagement::builder()); + g.feed(price, [stop.input("price"), pm.input("price")]); // price fans to stop + PM + g.feed(bias, [sizer.input("bias"), pm.input("bias")]); // bias fans to sizer + PM + g.connect(stop.output("stop_distance"), sizer.input("stop_distance")); + g.connect(stop.output("stop_distance"), pm.input("stop_distance")); + g.connect(sizer.output("size"), pm.input("size")); // the flat-1R size into PM + for field in PM_FIELD_NAMES { + g.expose(pm.output(field), field); + } + g.build().expect("risk_executor wires") +} + +/// An always-long strategy stand-in: emits a constant `+1` bias once price is present. The +/// strategy is upstream of the RiskExecutor; this is the minimal in-graph producer so the +/// whole chain runs off the single price source (a second bias *source* would k-way-merge +/// into separate cycles and mark stale prices — see harness.rs C4 tie-breaking). +struct ConstLongBias { + out: [Cell; 1], +} +impl ConstLongBias { + fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "ConstLongBias", + NodeSchema { + inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }], + output: vec![FieldSpec { name: "bias".into(), kind: ScalarKind::F64 }], + params: vec![], + }, + |_| Box::new(ConstLongBias { out: [Cell::from_f64(0.0)] }), + ) + } +} +impl Node for ConstLongBias { + fn lookbacks(&self) -> Vec { + vec![1] + } + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + if ctx.f64_in(0).is_empty() { + return None; + } + self.out[0] = Cell::from_f64(1.0); + Some(&self.out) + } + fn label(&self) -> String { + "ConstLongBias".into() + } +} + +/// Bootstrap a harness: one price source -> ConstLongBias (the strategy) + RiskExecutor; +/// the RiskExecutor's dense R-record into a Recorder. Returns the drained ledger. +fn run_executor(prices: &[f64], stop_distance: f64, risk_budget: f64) -> Vec<(Timestamp, Vec)> { + let (tx, rx) = channel(); + let mut g = GraphBuilder::new("risk_harness"); + let price = g.source_role("price", ScalarKind::F64); + let strat = g.add(ConstLongBias::builder()); + let exec = g.add(risk_executor(stop_distance, risk_budget)); + let rec = g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx)); + g.feed(price, [strat.input("price"), exec.input("price")]); + g.connect(strat.output("bias"), exec.input("bias")); + for (i, field) in PM_FIELD_NAMES.iter().enumerate() { + // `input` takes a `&'static str` (names resolve at the authoring boundary, C23); + // leak the per-column port name so the runtime-built `col[i]` satisfies that bound. + let col: &'static str = format!("col[{i}]").leak(); + g.connect(exec.output(field), rec.input(col)); + } + let mut h = g + .build() + .expect("risk_harness wires") + .bootstrap_with_params(vec![]) + .expect("bootstraps"); + let stream: Vec<(Timestamp, Scalar)> = prices + .iter() + .enumerate() + .map(|(i, &p)| (Timestamp(i as i64), Scalar::f64(p))) + .collect(); + h.run(vec![Box::new(VecSource::new(stream))]); + rx.try_iter().collect() +} + +/// Property: the composite bootstraps, runs, and folds to the documented hand value. A +/// constant long over a monotonically rising price never stops or flips, so the position +/// is open at window end: entry @100 latched on FixedStop(10), last mark @105 -> window-end +/// R = (105-100)/10 = +0.5, the only trade -> expectancy 0.5 (the bootstrapped-composite +/// twin of the iter-1 hand-wired `open_at_window_end_is_folded_into_expectancy_not_dropped`). +#[test] +fn risk_executor_bootstraps_and_folds_to_expected_rmetric() { + let ledger = run_executor(&[100.0, 102.0, 105.0], 10.0, 1.0); + let m = summarize_r(&ledger, 0.0); + assert_eq!(m.n_open_at_end, 1, "the open position must be counted"); + assert_eq!(m.n_trades, 1); + assert!((m.expectancy_r - 0.5).abs() < 1e-9, "window-end R = (105-100)/10; got {}", m.expectancy_r); +} + +/// Property: R is invariant under the Sizer's `risk_budget` (Stage-1 feed-forward). The +/// same price path at two budgets yields a bit-identical realised-R ledger, while the +/// `size` column scales with the budget — proving size flows through the Sizer into PM yet +/// never touches R. +#[test] +fn risk_executor_r_invariant_under_risk_budget() { + let path = [100.0, 104.0, 108.0, 110.0, 102.0, 96.0, 94.0]; + let a = run_executor(&path, 5.0, 1.0); + let b = run_executor(&path, 5.0, 8.0); + assert_eq!(a.len(), b.len()); + assert!(!a.is_empty()); + // index realized_r and size by the producer's dense-record layout (named consts above). + let realized = + |rows: &[(Timestamp, Vec)]| rows.iter().map(|(_, r)| r[REALIZED_R].as_f64()).collect::>(); + let size = + |rows: &[(Timestamp, Vec)]| rows.iter().map(|(_, r)| r[SIZE].as_f64()).collect::>(); + assert_eq!(realized(&a), realized(&b), "realized_r must be invariant under risk_budget"); + // size scaled 8x: at least one cycle has a nonzero size that is exactly 8x a's (same + // stop distance per cycle, budget 1 -> 8). + let (sa, sb) = (size(&a), size(&b)); + assert!( + sa.iter().zip(&sb).any(|(x, y)| *x > 0.0 && (*y - 8.0 * *x).abs() < 1e-9), + "risk_budget must scale size 8x: a={sa:?} b={sb:?}" + ); +} + +/// Property: **a real folded `RMetrics` survives the `RunMetrics.r` serde round-trip +/// byte-for-byte (the Stage-1 on-disk back-compat contract), driven from an actual run — +/// not a hand-built literal.** A bootstrapped RiskExecutor run is folded by `summarize_r` +/// and the result is attached as `RunMetrics.r = Some(..)`; serializing then +/// deserializing must reproduce an equal value, and the `r` key must be present. The +/// `report.rs` unit test asserts this on a hand-written `RMetrics`; here the value is +/// whatever the live fold produced, so a future `RMetrics` field that the fold sets but +/// serde forgets to thread would round-trip-diverge here (the literal test cannot see it). +/// The sibling pip-only-`None` path (omitted from JSON) is the inverse, covered in report.rs. +#[test] +fn folded_rmetrics_survives_runmetrics_serde_round_trip() { + let ledger = run_executor(&[100.0, 104.0, 108.0, 110.0, 102.0, 96.0, 94.0], 5.0, 1.0); + let folded = summarize_r(&ledger, 0.0); + assert!(folded.n_trades >= 1, "the run must produce at least one trade to fold"); + let m = RunMetrics { total_pips: 0.0, max_drawdown: 0.0, exposure_sign_flips: 0, r: Some(folded) }; + let json = serde_json::to_string(&m).expect("serialize a run's RunMetrics with an r block"); + assert!(json.contains("\"r\":{"), "the folded r block must be present in the JSON: {json}"); + let back: RunMetrics = serde_json::from_str(&json).expect("deserialize round-trips"); + assert_eq!(back, m, "a live-folded RMetrics must round-trip byte-for-byte"); +} diff --git a/crates/aura-engine/tests/stage1_r_e2e.rs b/crates/aura-engine/tests/stage1_r_e2e.rs index 84d826b..a4fcbe3 100644 --- a/crates/aura-engine/tests/stage1_r_e2e.rs +++ b/crates/aura-engine/tests/stage1_r_e2e.rs @@ -36,6 +36,10 @@ 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 BIAS_AT_ENTRY_ABS: usize = 9; +const SIZE: usize = 10; /// Property: the real producer->consumer seam composes. Driving `FixedStop` -> /// `PositionManagement` directly (node-by-node, not a bootstrapped graph) over a @@ -51,7 +55,7 @@ fn synthetic_long_then_stop_produces_a_sane_rmetric() { let mut pm = PositionManagement::new(); let mut sc = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; // stop input: price let mut pc: Vec = - (0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect(); + (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]; @@ -61,6 +65,7 @@ fn synthetic_long_then_stop_produces_a_sane_rmetric() { 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), @@ -71,23 +76,23 @@ fn synthetic_long_then_stop_produces_a_sane_rmetric() { )); } } - let m = summarize_r(&ledger); + let m = summarize_r(&ledger, 0.0); 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 -> summarize_r` -/// over a `(bias, price)` path, collecting the producer's dense records (decoded by -/// the producer's own `PM_RECORD_KINDS`, the wire contract) and folding them. This -/// is the actual producer->consumer seam, node-by-node; the resulting `RMetrics` is -/// what a recorded Stage-1 run would report. Per-cycle bias (not constant) so a -/// fixture can drop bias to 0 to forbid the immediate re-entry after a stop. -fn run_chain(stop: &mut dyn Node, path: &[(f64, f64)]) -> RMetrics { +/// 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 a Stage-1 +/// 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..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect(); + (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() { @@ -97,6 +102,7 @@ fn run_chain(stop: &mut dyn Node, path: &[(f64, f64)]) -> RMetrics { 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, @@ -104,7 +110,14 @@ fn run_chain(stop: &mut dyn Node, path: &[(f64, f64)]) -> RMetrics { )); } } - summarize_r(&ledger) + 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 Stage-1 run would report. +fn run_chain(stop: &mut dyn Node, path: &[(f64, f64)]) -> RMetrics { + summarize_r(&run_chain_ledger(stop, path), 0.0) } /// A constant-long-bias path (`bias = +1` every cycle) over `prices`. @@ -177,6 +190,55 @@ fn clean_stop_folds_to_exactly_minus_one_r() { 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, 0.0); + let net = summarize_r(&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, 0.0), summarize_r(&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, 0.0), summarize_r(&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"); +} + /// 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 @@ -201,4 +263,20 @@ fn r_col_indices_match_producer_field_layout() { 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), bias_at_entry_abs (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[BIAS_AT_ENTRY_ABS], "bias_at_entry_abs"); + assert_eq!(PM_RECORD_KINDS[ENTRY_PRICE], ScalarKind::F64); + assert_eq!(PM_RECORD_KINDS[STOP_PRICE], ScalarKind::F64); + assert_eq!(PM_RECORD_KINDS[BIAS_AT_ENTRY_ABS], 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); } diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index 7c158ea..ee98a6f 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -211,7 +211,7 @@ mod tests { seed: 0, broker: "b".to_string(), }, - metrics: RunMetrics { total_pips, max_drawdown, exposure_sign_flips: flips }, + metrics: RunMetrics { total_pips, max_drawdown, exposure_sign_flips: flips, r: None }, } } diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index d2cf34c..36af28c 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -31,6 +31,7 @@ mod recorder; mod resample; mod session; mod sim_broker; +mod sizer; mod sma; mod sqrt; mod stop_rule; @@ -54,6 +55,7 @@ pub use recorder::Recorder; pub use resample::Resample; pub use session::Session; pub use sim_broker::SimBroker; +pub use sizer::Sizer; pub use sma::Sma; pub use sqrt::Sqrt; pub use stop_rule::FixedStop; diff --git a/crates/aura-std/src/position_management.rs b/crates/aura-std/src/position_management.rs index c349546..e155077 100644 --- a/crates/aura-std/src/position_management.rs +++ b/crates/aura-std/src/position_management.rs @@ -78,6 +78,7 @@ impl PositionManagement { PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() }, PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }, PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "size".into() }, ]; let output = FIELD_NAMES .iter() @@ -109,7 +110,7 @@ fn sign0(v: f64) -> f64 { impl Node for PositionManagement { fn lookbacks(&self) -> Vec { - vec![1, 1, 1] + vec![1, 1, 1, 1] } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { @@ -120,6 +121,7 @@ impl Node for PositionManagement { let price = pw[0]; let bias = ctx.f64_in(0).get(0).unwrap_or(0.0); let dist = ctx.f64_in(2).get(0).unwrap_or(0.0); + let size = ctx.f64_in(3).get(0).unwrap_or(1.0); // the Sizer's size; defaults to flat-1R 1.0 if unwired let now = ctx.now(); let mut closed = false; @@ -219,7 +221,7 @@ impl Node for PositionManagement { Cell::from_f64(d_stop), Cell::from_f64(d_exit), Cell::from_f64(d_babs), - Cell::from_f64(1.0), // size: flat-1R placeholder (iter-2 Sizer) + Cell::from_f64(size), // size: from the Sizer (slot 3); R stays size-invariant Cell::from_bool(open), Cell::from_f64(unrealized), Cell::from_f64(self.cum_realized_r), @@ -241,14 +243,20 @@ impl Node for PositionManagement { mod tests { use super::*; use aura_core::{AnyColumn, Scalar}; - // Drive one cycle: push bias/price/stop into the three slots, eval, return the row. - fn step(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64) -> Vec { + // Drive one cycle: push bias/price/stop/size into the four slots, eval, return the row. + fn step_sized(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64, size: f64) -> Vec { cols[0].push(Scalar::f64(bias)).unwrap(); cols[1].push(Scalar::f64(price)).unwrap(); cols[2].push(Scalar::f64(dist)).unwrap(); + cols[3].push(Scalar::f64(size)).unwrap(); n.eval(Ctx::new(cols, Timestamp(1))).expect("dense record every cycle once price present").to_vec() } - fn cols() -> Vec { (0..3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect() } + // size defaults to 1.0 (the iter-1 placeholder value), so every existing case is + // behaviour-identical under the new 4-input shape. + fn step(n: &mut PositionManagement, cols: &mut [AnyColumn], bias: f64, price: f64, dist: f64) -> Vec { + step_sized(n, cols, bias, price, dist, 1.0) + } + fn cols() -> Vec { (0..4).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect() } #[test] fn emits_one_dense_record_per_cycle() { @@ -260,6 +268,25 @@ mod tests { assert!(!row[11].bool()); // open = false } + // (3) R-invariance under size: the Sizer's `size` flows into col 10 but never into R. + // Scaling size leaves every realized_r identical (the property that keeps Stage 1 + // feed-forward); col 10 reflects the size so we know it actually flowed end-to-end. + #[test] + fn realized_r_is_invariant_under_size() { + let run = |size: f64| { + let mut n = PositionManagement::new(); + let mut c = cols(); + let _ = step_sized(&mut n, &mut c, 1.0, 100.0, 10.0, size); // open long @100 + step_sized(&mut n, &mut c, 0.0, 110.0, 10.0, size) // bias->0 exit @110 + }; + let small = run(1.0); + let big = run(7.0); + assert_eq!(small[1].f64(), big[1].f64(), "realized_r must be size-invariant"); + assert_eq!(small[1].f64(), 1.0); // (110-100)/10 + assert_eq!(small[10].f64(), 1.0); // size column reflects the input + assert_eq!(big[10].f64(), 7.0); + } + // (1) No look-ahead, the SimBroker mirror: a long entered at 100 with stop_distance // 10, then bias->0 at price 110, realises R = (110-100)/10 = +1.0 (it earned the // move to 110; the flip closes it THERE, never retroactively flattening it). diff --git a/crates/aura-std/src/sizer.rs b/crates/aura-std/src/sizer.rs new file mode 100644 index 0000000..d36fd1b --- /dev/null +++ b/crates/aura-std/src/sizer.rs @@ -0,0 +1,132 @@ +//! `Sizer` — the flat-1R sizing seam (C10 Stage-1). Turns a protective-stop distance +//! into a position `size = risk_budget / stop_distance`: with `risk_budget = 1.0` this is +//! true flat-1R (one risk unit per trade) and `size` is inversely proportional to the +//! stop — NOT a constant. The `bias` input gates firing (a size is only meaningful when a +//! strategy is taking a position) and is the Stage-2 seam: fixed-fractional sizing swaps +//! `risk_budget` for `risk_fraction · equity` (an added equity input), same node shape. +//! R is computed SIZE-INVARIANTLY downstream, so the Sizer never contaminates signal +//! quality — it only scales the (Stage-2) currency exposure. +use aura_core::{ + Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, + ScalarKind, +}; + +/// Flat-1R sizer: `size = risk_budget / stop_distance` (`risk_budget` > 0). Emits `None` +/// until both inputs are present (warm-up filter, C8). +pub struct Sizer { + risk_budget: f64, + out: [Cell; 1], +} + +impl Sizer { + /// Build a sizer with risk budget `risk_budget` (must be > 0; `1.0` = flat-1R). + pub fn new(risk_budget: f64) -> Self { + assert!(risk_budget > 0.0, "Sizer risk_budget must be > 0"); + Self { risk_budget, out: [Cell::from_f64(0.0)] } + } + + /// The param-generic recipe: declares `risk_budget` and builds through `Sizer::new`. + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "Sizer", + NodeSchema { + inputs: vec![ + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() }, + ], + output: vec![FieldSpec { name: "size".into(), kind: ScalarKind::F64 }], + params: vec![ParamSpec { name: "risk_budget".into(), kind: ScalarKind::F64 }], + }, + |p| Box::new(Sizer::new(p[0].f64())), + ) + } +} + +impl Node for Sizer { + fn lookbacks(&self) -> Vec { + vec![1, 1] + } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let bias = ctx.f64_in(0); + let dist = ctx.f64_in(1); + if bias.is_empty() || dist.is_empty() { + return None; // a size needs a (present) bias and a stop distance + } + let d = dist[0]; + // a zero/degenerate stop distance yields zero size (no division blow-up); a real + // stop rule emits a strictly positive distance, so this guards only warm-up edges. + let size = if d > 0.0 { self.risk_budget / d } else { 0.0 }; + self.out[0] = Cell::from_f64(size); + Some(&self.out) + } + + fn label(&self) -> String { + format!("Sizer({})", self.risk_budget) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Scalar, Timestamp}; + + fn eval_once(s: &mut Sizer, bias: f64, dist: f64) -> Option { + let mut c = vec![ + AnyColumn::with_capacity(ScalarKind::F64, 1), + AnyColumn::with_capacity(ScalarKind::F64, 1), + ]; + c[0].push(Scalar::f64(bias)).unwrap(); + c[1].push(Scalar::f64(dist)).unwrap(); + s.eval(Ctx::new(&c, Timestamp(0))).map(|o| o[0].f64()) + } + + // (4) flat-1R invariant: size * stop_distance == risk_budget for every stop distance. + #[test] + fn sizer_is_flat_1r_invariant() { + for budget in [1.0_f64, 2.5] { + let mut s = Sizer::new(budget); + for dist in [0.5_f64, 2.0, 10.0, 37.5] { + let size = eval_once(&mut s, 1.0, dist).expect("both inputs present"); + assert!( + (size * dist - budget).abs() < 1e-9, + "size*dist must equal risk_budget; budget={budget} dist={dist} size={size}" + ); + } + } + } + + #[test] + #[should_panic(expected = "risk_budget must be > 0")] + fn sizer_panics_on_zero_risk_budget() { + let _ = Sizer::new(0.0); + } + + #[test] + #[should_panic(expected = "risk_budget must be > 0")] + fn sizer_panics_on_negative_risk_budget() { + let _ = Sizer::new(-1.0); + } + + #[test] + fn sizer_is_none_until_both_inputs_present() { + let mut s = Sizer::new(1.0); + let mut c = vec![ + AnyColumn::with_capacity(ScalarKind::F64, 1), + AnyColumn::with_capacity(ScalarKind::F64, 1), + ]; + // only bias present -> None + c[0].push(Scalar::f64(1.0)).unwrap(); + assert_eq!(s.eval(Ctx::new(&c, Timestamp(0))), None); + // both present -> Some(risk_budget / dist) = 1.0/10.0 = 0.1 + c[1].push(Scalar::f64(10.0)).unwrap(); + assert_eq!(s.eval(Ctx::new(&c, Timestamp(0))), Some([Cell::from_f64(0.1)].as_slice())); + } + + #[test] + fn input_slots_are_named_bias_and_stop_distance() { + let b = Sizer::builder(); + let names: Vec<&str> = b.schema().inputs.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["bias", "stop_distance"]); + } +}