diff --git a/Cargo.lock b/Cargo.lock index d6012e8..89ab70e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,9 +100,7 @@ dependencies = [ name = "aura-analysis" version = "0.1.0" dependencies = [ - "aura-core", "serde", - "serde_json", ] [[package]] @@ -110,6 +108,8 @@ name = "aura-backtest" version = "0.1.0" dependencies = [ "aura-core", + "serde", + "serde_json", ] [[package]] @@ -133,6 +133,7 @@ name = "aura-campaign" version = "0.1.0" dependencies = [ "aura-analysis", + "aura-backtest", "aura-core", "aura-engine", "aura-registry", diff --git a/crates/aura-analysis/Cargo.toml b/crates/aura-analysis/Cargo.toml index 6556e32..ba70dd6 100644 --- a/crates/aura-analysis/Cargo.toml +++ b/crates/aura-analysis/Cargo.toml @@ -6,15 +6,8 @@ license.workspace = true publish.workspace = true [dependencies] -aura-core = { path = "../aura-core" } -# serde is admitted under the amended C16 per-case dependency policy (INDEX.md): -# it gives the run-report types a typed (de)serialization path for the run -# registry (cycle 0029). serde's output is deterministic (C1-safe). +# serde gives the selection-provenance types (`FamilySelection`/`SelectionMode`, +# embedded by the engine's `RunManifest.selection`) their typed +# (de)serialization path for the run registry (C18). Deterministic output +# (C1-safe). The backtest metric types moved to aura-backtest (#291). serde = { workspace = true } - -[dev-dependencies] -# serde_json is used only by this crate's round-trip tests (the production types -# derive serde, but JSON rendering lives in aura-engine's RunReport::to_json). -# Test-only -> dev-dependencies, so it never enters the frozen artifact's library -# link (C16 frozen-artifact scrutiny). -serde_json = { workspace = true } diff --git a/crates/aura-analysis/src/lib.rs b/crates/aura-analysis/src/lib.rs index c3af246..ec9cc84 100644 --- a/crates/aura-analysis/src/lib.rs +++ b/crates/aura-analysis/src/lib.rs @@ -1,13 +1,13 @@ -//! Pure trading-domain analysis leaf (C1/C12): the post-run reductions over a -//! run's recorded streams that are functions of the recorded data alone — -//! R-based signal-quality metrics, the broker-independent position-event table, -//! and the multiple-comparison hurdle math. Split out of `aura-engine`'s -//! `report` module (issue #136) so the trading-domain arithmetic depends only on -//! `aura-core` + serde, not on the engine's trace-plumbing. Every type's serde -//! shape is byte-pinned by C18 goldens; the bodies are moved verbatim (no float -//! expression re-associated). - -use aura_core::{Scalar, Timestamp}; +//! Domain-free post-run statistics and selection provenance (C1/C12): the +//! multiple-comparison hurdle math (`inv_norm_cdf`, `expected_max_of_normals`) +//! and the selection-provenance record a sweep winner carries +//! (`FamilySelection` / `SelectionMode`, embedded by the engine's +//! `RunManifest.selection`). No trading vocabulary lives here: the backtest +//! reductions (R-metrics, the position-event table) moved to +//! `aura-backtest::metrics` (issue #291, C28 phase 5). Foundation-grade — any +//! ladder rung may depend on this crate without violating the C28 import +//! direction. Every type's serde shape is byte-pinned by C18 goldens; bodies +//! moved verbatim. /// Which selection objective produced the record (additive provenance, C23). /// `Argmax` is the bare-best pick (cycle 0076), deflated for the number of trials. @@ -52,445 +52,6 @@ pub struct FamilySelection { pub n_neighbours: Option, } -/// Summary metrics reduced from a run's recorded streams — the `-> metrics` -/// half of C12's atomic sim unit. Pure function of the recorded streams. -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct RunMetrics { - /// Final cumulative pip equity — the last value of the (cumulative) - /// pip-equity curve. `0.0` if the curve is empty. - pub total_pips: f64, - /// Largest peak-to-trough drop on the cumulative pip curve: - /// `max_t (running_peak(t) - equity(t))`, always `>= 0.0` (`0.0` if the - /// curve is monotonic non-decreasing or empty). - pub max_drawdown: f64, - /// Count of adjacent recorded bias samples whose sign differs (a zero - /// bias normalizes to sign `0`, so flat is distinct from long/short). - /// A turnover proxy: it counts long<->short reversals *and* transitions - /// into/out of flat — the plain sign-change count over the bias series. - /// The serde alias accepts the pre-rename `exposure_sign_flips` key so legacy - /// `runs.jsonl` lines still deserialise (C14/C18 back-compat). - #[serde(alias = "exposure_sign_flips")] - pub bias_sign_flips: u64, - /// Optional 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, reduced from a `PositionManagement` dense -/// 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, serde::Serialize, serde::Deserialize)] -pub struct RMetrics { - pub expectancy_r: f64, // mean realised R over all trades (equal-weighted; headline) - pub n_trades: u64, - pub win_rate: f64, // fraction with R > 0 - pub avg_win_r: f64, - pub avg_loss_r: f64, - 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 - /// (mean_R / sample-stdev_R) · √(min(n, SQN_CAP)): the n-normalized SQN - /// ("SQN score", Van Tharp), turnover-robust vs. the raw `sqn`. n<2 or - /// zero-variance -> 0.0. - #[serde(default)] - pub sqn_normalized: f64, - pub net_expectancy_r: f64, // mean(R - cost_in_r) folded from the cost-model stream; == gross when empty - pub conviction_terciles_r: [f64; 3], // E[R] by conviction_at_entry tercile (asc); <3 trades -> [0,0,0] - /// Cost-netted realised R per closed trade (`r − cost_in_r`), in trade - /// order — an in-memory conduit for the OOS R-series bootstrap. Equals - /// the gross series bit-for-bit when no cost model is bound (an empty - /// cost stream is the documented gross-R baseline: cost `0.0` per - /// trade). Excluded from serde (`skip`) so the C18 wire shape is - /// unchanged, and from `PartialEq` (below) so every existing - /// `RMetrics`/`RunReport` equality assertion and round-trip stays green. - #[serde(skip)] - pub net_trade_rs: Vec, -} - -impl PartialEq for RMetrics { - fn eq(&self, o: &Self) -> bool { - self.expectancy_r == o.expectancy_r - && self.n_trades == o.n_trades - && self.win_rate == o.win_rate - && self.avg_win_r == o.avg_win_r - && self.avg_loss_r == o.avg_loss_r - && self.profit_factor == o.profit_factor - && self.max_r_drawdown == o.max_r_drawdown - && self.n_open_at_end == o.n_open_at_end - && self.sqn == o.sqn - && self.sqn_normalized == o.sqn_normalized - && self.net_expectancy_r == o.net_expectancy_r - && self.conviction_terciles_r == o.conviction_terciles_r - // net_trade_rs deliberately excluded - } -} - -// Dense `PositionManagement` record column indices — the lockstep contract with -// aura-std's FIELD_NAMES/RECORD_KINDS. The record crosses the crate boundary as a -// type-erased `Scalar` vector (C4 SoA), so it is read positionally, not by importing -// the producer's types; the layout is shared by convention and `r_sma_e2e.rs` -// guards that it matches. -mod r_col { - pub const CLOSED: usize = 0; - pub const REALIZED_R: usize = 1; - pub const DIRECTION: usize = 4; - // entry/stop define the latched distance (1R). Since the net-of-cost fold reads the - // cost-in-R straight from the cost stream (the aura-std `ConstantCost` producer recovers - // the distance), this crate's reductions no longer touch these two columns; they stay in - // the documented layout contract and are exercised by the test fixtures. - #[allow(dead_code)] - pub const ENTRY_PRICE: usize = 6; - #[allow(dead_code)] - pub const STOP_PRICE: usize = 7; - pub const CONVICTION_AT_ENTRY: usize = 9; - pub const SIZE: usize = 10; - pub const OPEN: usize = 11; - pub const UNREALIZED_R: usize = 12; -} - -// Cost-model record column indices — the lockstep contract with aura-std's -// `ConstantCost` output schema (`cost_in_r` / `cum_cost_in_r` / `open_cost_in_r`). -// Read positionally across the crate boundary as a type-erased `Scalar` vector -// (C4 SoA), like `r_col`. `summarize_r` folds the per-close cost (col 0) and the -// window-end open trade's would-be cost (col 2); the running sum (col 1) is not -// folded, so it is intentionally not named here. -mod cost_col { - pub const COST_IN_R: usize = 0; - pub const OPEN_COST_IN_R: usize = 2; -} - -/// Van Tharp's conventional trade-count cap for the n-normalized SQN ("SQN -/// score"): capping n stops the single-number objective rewarding sheer turnover. -const SQN_CAP: u64 = 100; - -/// Reduce a `PositionManagement` dense record stream into R-metrics. Pure (C1). -/// 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)], - cost: &[(Timestamp, Vec)], -) -> RMetrics { - // Collect one entry per trade: its realised R, the entry-conviction |bias|, and the - // per-trade cost-in-R the cost-model graph supplied (read positionally from the - // co-temporal `cost` stream). 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, - cost: f64, - } - // Positional join: the cost stream is co-temporal 1:1 with the record (both recorded - // per cycle off the same executor cadence — a cost node emits exactly when PM emits), - // so `record[i]` and `cost[i]` are the same cycle. A closed row `i` reads `cost[i]`'s - // `cost_in_r` (col 0); the window-end open last row reads its `open_cost_in_r` (col 2). - // An empty `cost` slice ⇒ every `.get(i)` is `None` ⇒ cost `0.0` (the gross-R baseline). - // Positional avoids the shared-timestamp collision a `ts`-keyed map would have (C4: - // same `ts` can be two cycles). - let mut trades: Vec = Vec::new(); - for (i, (_, row)) in record.iter().enumerate() { - if row[r_col::CLOSED].as_bool() { - let c = cost.get(i).map(|(_, cr)| cr[cost_col::COST_IN_R].as_f64()).unwrap_or(0.0); - trades.push(Trade { - r: row[r_col::REALIZED_R].as_f64(), - bias_abs: row[r_col::CONVICTION_AT_ENTRY].as_f64(), - cost: c, - }); - } - } - let mut n_open_at_end = 0u64; - if let Some((_, last)) = record.last() - && last[r_col::OPEN].as_bool() - { - let c = cost - .get(record.len() - 1) - .map(|(_, cr)| cr[cost_col::OPEN_COST_IN_R].as_f64()) - .unwrap_or(0.0); - trades.push(Trade { - r: last[r_col::UNREALIZED_R].as_f64(), - bias_abs: last[r_col::CONVICTION_AT_ENTRY].as_f64(), - cost: c, - }); - n_open_at_end = 1; - } - 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, - sqn: 0.0, - sqn_normalized: 0.0, - net_expectancy_r: 0.0, - conviction_terciles_r: [0.0; 3], - net_trade_rs: Vec::new(), - }; - } - 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(); - let sum_loss: f64 = losses.iter().sum(); // <= 0 - let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::() / v.len() as f64 }; - // by-trade cumulative-R drawdown - let mut peak = f64::NEG_INFINITY; - let mut cum = 0.0; - let mut max_dd = 0.0_f64; - for &r in &rs { - cum += r; - if cum > peak { - peak = cum; - } - let dd = peak - cum; - if dd > max_dd { - max_dd = dd; - } - } - // SQN = √n · mean / sample-stdev (raw, Van Tharp). SQN100 = √(min(n,SQN_CAP)) · - // mean / sd — the same dispersion ratio with the trade count capped - // (turnover-robust). n < 2 or zero variance -> 0.0 for both (dispersion undefined). - let (sqn, sqn_normalized) = if n < 2 { - (0.0, 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 { - // raw sqn kept bit-identical to the pre-0067 expression (sqrt(n)*mean/sd, - // not factored via a shared ratio) so the existing metric is byte-unchanged. - ( - (n as f64).sqrt() * mean / sd, - (n.min(SQN_CAP) as f64).sqrt() * mean / sd, - ) - } else { - (0.0, 0.0) - } - }; - // net-of-cost: subtract the cost-model's per-trade cost-in-R (from the cost stream; - // an empty stream is the gross-R baseline — every trade's cost is 0.0). - let net_sum: f64 = trades.iter().map(|t| t.r - t.cost).sum(); - let net_expectancy_r = net_sum / n as f64; - // The per-trade net series the OOS bootstrap conduit carries (#259). A - // separate materialization, deliberately NOT factored through `net_sum`: - // that expression's tokens are byte-pinned (see the SQN note above). - let net_rs: Vec = trades.iter().map(|t| t.r - t.cost).collect(); - // conviction terciles: sort by conviction_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: mean, - n_trades: n, - win_rate: wins.len() as f64 / n as f64, - avg_win_r: avg(&wins), - avg_loss_r: avg(&losses), - profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 }, - max_r_drawdown: max_dd, - n_open_at_end, - sqn, - sqn_normalized, - net_expectancy_r, - conviction_terciles_r, - net_trade_rs: net_rs, - } -} - -/// Reduce a flat per-trade R series (e.g. the pooled across-window OOS series of a -/// walk-forward) into `RMetrics`. The R-distribution fields (mean / win-rate / profit -/// factor / max-drawdown / SQN) duplicate [`summarize_r`]'s arithmetic byte-for-byte — -/// deliberately copied, not factored, so neither pinned-float expression shifts under -/// IEEE-754 non-associativity. MAINTENANCE COUPLING: the two copies must be edited in -/// lockstep; the only guard that they agree is the cross-reducer equality test -/// `summarize_r_includes_open_trade_and_matches_r_metrics_from_rs` — touch one copy -/// without the other and that test is the sole tripwire. The -/// fields a flat R series cannot carry are set honestly: `n_open_at_end = 0`, -/// `net_expectancy_r = expectancy_r` (exact: the input series is already -/// cost-netted — the conduit carries `r − cost_in_r`), -/// `conviction_terciles_r = [0,0,0]` (per-trade conviction is not pooled). Empty -/// input -> a well-defined all-zero `RMetrics`. -pub fn r_metrics_from_rs(rs: &[f64]) -> RMetrics { - let n = rs.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, sqn: 0.0, sqn_normalized: 0.0, net_expectancy_r: 0.0, - conviction_terciles_r: [0.0; 3], net_trade_rs: Vec::new(), - }; - } - 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(); - let sum_loss: f64 = losses.iter().sum(); - let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::() / v.len() as f64 }; - let mut peak = f64::NEG_INFINITY; - let mut cum = 0.0; - let mut max_dd = 0.0_f64; - for &r in rs { - cum += r; - if cum > peak { peak = cum; } - let dd = peak - cum; - if dd > max_dd { max_dd = dd; } - } - let (sqn, sqn_normalized) = if n < 2 { - (0.0, 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, (n.min(SQN_CAP) as f64).sqrt() * mean / sd) - } else { - (0.0, 0.0) - } - }; - RMetrics { - expectancy_r: mean, - n_trades: n, - win_rate: wins.len() as f64 / n as f64, - avg_win_r: avg(&wins), - avg_loss_r: avg(&losses), - profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 }, - max_r_drawdown: max_dd, - n_open_at_end: 0, - sqn, - sqn_normalized, - net_expectancy_r: mean, // input series is already net -> net == mean - conviction_terciles_r: [0.0; 3], - net_trade_rs: Vec::new(), - } -} - -/// Derive the broker-independent position-event table from a `PositionManagement` -/// dense record (read positionally, C7 SoA), as the first difference of the -/// executed book (`deal = target - book - in_flight`; `in_flight = 0` for the -/// instant-fill backtest). Pure (C1); no look-ahead — each event's `event_ts` is -/// its own cycle (C2). `instrument_id` is supplied by the caller (the engine never -/// imports an instrument spec from `aura-ingest`). A reversal emits `Close` then the -/// opposite open at one `event_ts` (close first); a position still open on the last -/// row emits its open with no synthetic `Close` (the table records actual executed events — -/// unlike `summarize_r`, which force-closes for the R metric). -pub fn derive_position_events( - record: &[(Timestamp, Vec)], - instrument_id: i64, -) -> Vec { - // The derive's single-position book (in_flight is structurally 0). - struct Book { - position_id: i64, - volume: f64, - } - let mut out: Vec = Vec::new(); - let mut book: Option = None; - let mut next_id: i64 = 0; - for (ts, row) in record { - // 1) close first: the book held into this cycle exited this cycle. - if row[r_col::CLOSED].as_bool() - && let Some(b) = book.take() - { - out.push(PositionEvent { - event_ts: *ts, - action: PositionAction::Close, - position_id: b.position_id, - instrument_id, - volume: b.volume, - }); - } - // 2) then open: a position is open at cycle end the book is not tracking. - if row[r_col::OPEN].as_bool() && book.is_none() { - let dir = row[r_col::DIRECTION].as_i64(); - let volume = row[r_col::SIZE].as_f64(); - let action = if dir >= 0 { PositionAction::Buy } else { PositionAction::Sell }; - out.push(PositionEvent { - event_ts: *ts, - action, - position_id: next_id, - instrument_id, - volume, - }); - book = Some(Book { position_id: next_id, volume }); - next_id += 1; - } - } - out -} - -/// The three position-event actions (C10). Direction IS the action; volume is -/// unsigned. Serde-encoded as its i64 mapping (`Buy=0, Sell=1, Close=2`) so the -/// persisted/columnar form stays C7-scalar and ledger-faithful (`action: i64`). -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(into = "i64", try_from = "i64")] -pub enum PositionAction { - Buy, - Sell, - Close, -} - -impl From for i64 { - fn from(a: PositionAction) -> i64 { - match a { - PositionAction::Buy => 0, - PositionAction::Sell => 1, - PositionAction::Close => 2, - } - } -} - -impl TryFrom for PositionAction { - type Error = String; - fn try_from(v: i64) -> Result { - match v { - 0 => Ok(PositionAction::Buy), - 1 => Ok(PositionAction::Sell), - 2 => Ok(PositionAction::Close), - other => Err(format!("invalid PositionAction i64: {other}")), - } - } -} - -/// One row of C10's derived position-event table — the broker-independent audit -/// view (the first difference of the exposure state). A post-run value type -/// (sibling of [`RunMetrics`]), NOT a per-`eval` node output (C8). Multiple events -/// may share one `event_ts` (a reversal: Close then open, close-before-open). No -/// `open_ts` — a position's open time is its opening event's `event_ts`. -#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct PositionEvent { - pub event_ts: Timestamp, - pub action: PositionAction, - /// Monotonic, assigned at open. A Close references an existing `position_id`. - pub position_id: i64, - pub instrument_id: i64, - /// Lots, unsigned. A partial close carries its own (smaller) volume. - pub volume: f64, -} - /// Inverse standard-normal CDF (quantile function), Acklam's rational /// approximation — absolute error < ~1.2e-9 over `p ∈ (0,1)`. Pure (C1). /// Callers pass strictly-interior `p` (`k >= 2` keeps the argument off the @@ -564,600 +125,4 @@ mod tests { assert!((1.0..1.1).contains(&e4)); // ~1.05 (not the √(2 ln 4)=1.66 asymptote) assert!((2.4..2.7).contains(&e100)); // ~2.53 } - - #[test] - fn position_action_round_trips_through_i64() { - for a in [PositionAction::Buy, PositionAction::Sell, PositionAction::Close] { - let n: i64 = a.into(); - assert_eq!(PositionAction::try_from(n), Ok(a)); - } - assert_eq!(i64::from(PositionAction::Buy), 0); - assert_eq!(i64::from(PositionAction::Sell), 1); - assert_eq!(i64::from(PositionAction::Close), 2); - } - - #[test] - fn position_action_rejects_out_of_range_i64() { - assert!(PositionAction::try_from(3).is_err()); - assert!(PositionAction::try_from(-1).is_err()); - } - - #[test] - fn position_event_serde_round_trips_with_bare_int_action() { - let ev = PositionEvent { - event_ts: Timestamp(42), - action: PositionAction::Sell, - position_id: 7, - instrument_id: 3, - volume: 0.5, - }; - let json = serde_json::to_string(&ev).expect("serialize"); - // action encodes as a bare integer (C7 scalar shape), not a tagged enum - assert!(json.contains("\"action\":1"), "action not bare-int encoded: {json}"); - let back: PositionEvent = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(back, ev); - } - - #[test] - fn position_events_may_share_one_event_ts_on_reversal() { - // a stop-and-reverse: Close then open at the SAME event_ts (close-before-open). - let ts = Timestamp(100); - let close = PositionEvent { - event_ts: ts, action: PositionAction::Close, - position_id: 1, instrument_id: 3, volume: 0.5, - }; - let open = PositionEvent { - event_ts: ts, action: PositionAction::Sell, - position_id: 2, instrument_id: 3, volume: 0.5, - }; - let table = [close, open]; - assert_eq!(table[0].event_ts, table[1].event_ts); - assert_eq!(table[0].action, PositionAction::Close); - assert_eq!(table[1].action, PositionAction::Sell); - } - - // Build a minimal dense record row: only the columns summarize_r reads matter. - // The four written slots reference `r_col::*` (not bare literals) so the helper - // tracks the same constants it exists to test — a literal would mask drift in them. - fn r_row(closed: bool, realized: f64, open: bool, unreal: f64) -> (Timestamp, Vec) { - let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1]; - v[r_col::CLOSED] = Scalar::bool(closed); - v[r_col::REALIZED_R] = Scalar::f64(realized); - v[r_col::OPEN] = Scalar::bool(open); - 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 - // conviction_at_entry (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::CONVICTION_AT_ENTRY] = Scalar::f64(bias_abs); - v[r_col::OPEN] = Scalar::bool(false); - (Timestamp(0), v) - } - - // A 14-wide PositionManagement dense row carrying BOTH the closed-trade geometry - // (entry/stop/conviction) AND the open-at-end fields (open/unrealized_r) — the one - // fixture the cost-stream subsumption test needs (`r_row_full` forces closed=true, so - // it cannot build a window-end open row). Only the columns summarize_r reads are set; - // the rest default to f64(0.0) (never read), mirroring the existing row helpers. - fn row14(closed: bool, r: f64, entry: f64, stop: f64, conv: f64, open: bool, unreal: f64) -> Vec { - let mut v = vec![Scalar::f64(0.0); 14]; - v[r_col::CLOSED] = Scalar::bool(closed); - v[r_col::REALIZED_R] = Scalar::f64(r); - v[r_col::ENTRY_PRICE] = Scalar::f64(entry); - v[r_col::STOP_PRICE] = Scalar::f64(stop); - v[r_col::CONVICTION_AT_ENTRY] = Scalar::f64(conv); - v[r_col::OPEN] = Scalar::bool(open); - v[r_col::UNREALIZED_R] = Scalar::f64(unreal); - v - } - #[test] - fn summarize_r_is_zero_on_empty() { - let m = summarize_r(&[], &[]); - 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.sqn_normalized, 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() { - // three closed trades: +2, -1, +1 (no open at end). E[R] = 2/3. - let rec = vec![ - r_row(true, 2.0, true, 0.0), - r_row(false, 0.0, true, 0.0), - 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, &[]); - 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); - assert!((m.profit_factor - 3.0).abs() < 1e-9); // (2+1)/1 - assert_eq!(m.n_open_at_end, 0); - } - #[test] - 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, &[]); - 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 - } - #[test] - 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); - } - - #[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, &[]); - 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)], &[]).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, &[]).sqn, 0.0); - } - - #[test] - fn summarize_r_sqn_normalized_caps_trade_count_at_100() { - // 144 closed trades alternating R = 1.0 / 2.0 (nonzero variance, sd > 0). - // raw sqn = √144·q, SQN100 = √(min(144,100))·q = √100·q for the SAME ratio - // q = mean/sd, so sqn / sqn_normalized = √(144/100) = 1.2, independent of q. - let rec: Vec<_> = (0..144) - .map(|i| r_row(true, if i % 2 == 0 { 1.0 } else { 2.0 }, false, 0.0)) - .collect(); - let m = summarize_r(&rec, &[]); - assert_eq!(m.n_trades, 144); - assert!(m.sqn_normalized > 0.0, "sqn_normalized nonzero; got {}", m.sqn_normalized); - assert!(m.sqn_normalized < m.sqn, "capped SQN100 < raw sqn for n > 100"); - assert!( - (m.sqn / m.sqn_normalized - 1.2).abs() < 1e-9, - "sqn / sqn_normalized = √(144/100) = 1.2; got {}", - m.sqn / m.sqn_normalized - ); - } - - #[test] - fn summarize_r_sqn_normalized_equals_raw_sqn_below_cap() { - // n = 4 <= 100 -> cap inactive -> SQN100 == raw sqn (same ledger as - // summarize_r_sqn_is_sqrt_n_mean_over_stdev, where sqn = 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, &[]); - assert!((m.sqn_normalized - m.sqn).abs() < 1e-12, "below cap, SQN100 == sqn"); - assert!((m.sqn_normalized - 1.0).abs() < 1e-9, "sqn_normalized = 1.0 here"); - } - - #[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, &[]); - 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, &[]); - 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, &[]).conviction_terciles_r, [0.0; 3]); - } - - #[test] - fn summarize_r_net_of_cost_subtracts_round_trip_per_trade() { - // one closed trade: R=+2, entry 100, stop 90 -> latched 10. A ConstantCost(1.0) - // node (1.0 price units) emits cost_in_r = 1.0/10 = 0.1 on the closed row (col 0). - // gross E[R]=2.0, net = 1.9. The empty stream is the gross baseline (net == gross). - let rec = vec![r_row_full(2.0, 100.0, 90.0, 0.5)]; - let gross = summarize_r(&rec, &[]); - // producer 3-wide row [cost_in_r, cum_cost_in_r, open_cost_in_r]: closed -> 0.1 / 0.1 / 0. - let cost = vec![(Timestamp(0), vec![Scalar::f64(0.1), Scalar::f64(0.1), Scalar::f64(0.0)])]; - let net = summarize_r(&rec, &cost); - 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); - } - - /// Property: `summarize_r` folding the cost stream reproduces the retired scalar - /// `round_trip_cost` argument EXACTLY — `net_expectancy_r == mean(rᵢ − costᵢ)` over - /// BOTH the closed trade (cost from the stream's col 0) and the window-end open trade - /// (cost from col 2), with the cost-in-R taken straight from the co-temporal stream a - /// `ConstantCost` node emits (not recomputed from latched). Exercises a nonzero cost, - /// so it proves exact subsumption — not merely the cost=0 baseline. - #[test] - fn summarize_r_folds_cost_stream_exactly_like_the_scalar_over_all_trades() { - // Trade 1: closed, entry 100 stop 96 (latched 4), realized R = 1.0. - // Trade 2: still open at window end, entry 100 stop 98 (latched 2), unrealized R = 0.5. - let c = 2.0_f64; - let record = vec![ - (Timestamp(0), row14(true, 1.0, 100.0, 96.0, 1.0, false, 0.0)), - (Timestamp(1), row14(false, 0.0, 100.0, 98.0, 1.0, true, 0.5)), - ]; - // Cost stream the ConstantCost(2.0) node emits, co-temporal — the producer's - // 3-wide row [cost_in_r, cum_cost_in_r, open_cost_in_r]. - let cost = vec![ - // closed row: 0.5 charged on close (col 0), cum 0.5, no open cost. - (Timestamp(0), vec![Scalar::f64(c / 4.0), Scalar::f64(c / 4.0), Scalar::f64(0.0)]), - // open row: no close cost, cum unchanged, would-be open cost 1.0 (col 2). - (Timestamp(1), vec![Scalar::f64(0.0), Scalar::f64(c / 4.0), Scalar::f64(c / 2.0)]), - ]; - let m = summarize_r(&record, &cost); - // gross E[R] = mean(1.0, 0.5) = 0.75; net = mean(1.0-0.5, 0.5-1.0) = mean(0.5,-0.5) = 0.0 - assert!((m.expectancy_r - 0.75).abs() < 1e-12); - assert!((m.net_expectancy_r - 0.0).abs() < 1e-12); - } - - /// Property (#259): the bootstrap conduit `net_trade_rs` carries the - /// COST-NETTED per-trade R (`r − cost_in_r`) in trade order — the same - /// per-trade values `net_expectancy_r` averages — while every gross - /// scalar stays cost-free. With an empty cost stream the conduit equals - /// the gross series bit-for-bit (cost 0.0 per trade), which is what - /// keeps uncosted campaigns byte-identical. - #[test] - fn summarize_r_net_trade_rs_is_the_cost_netted_series_in_trade_order() { - let c = 2.0_f64; - let record = vec![ - (Timestamp(0), row14(true, 1.0, 100.0, 96.0, 1.0, false, 0.0)), - (Timestamp(1), row14(false, 0.0, 100.0, 98.0, 1.0, true, 0.5)), - ]; - let cost = vec![ - (Timestamp(0), vec![Scalar::f64(c / 4.0), Scalar::f64(c / 4.0), Scalar::f64(0.0)]), - (Timestamp(1), vec![Scalar::f64(0.0), Scalar::f64(c / 4.0), Scalar::f64(c / 2.0)]), - ]; - let m = summarize_r(&record, &cost); - assert_eq!(m.net_trade_rs, vec![1.0 - 0.5, 0.5 - 1.0], "net conduit = r − cost, trade order"); - assert!((m.expectancy_r - 0.75).abs() < 1e-12, "gross scalars stay cost-free"); - let gross = summarize_r(&record, &[]); - assert_eq!(gross.net_trade_rs, vec![1.0, 0.5], "empty cost stream ⇒ conduit == gross series"); - } - - // One PositionManagement dense record row for the derive tests: only the columns - // derive_position_events reads (closed, direction, size, open) are set; the rest - // default to 0. Distinct per-row timestamps (the derive keys events on the row's - // own cycle, not the entry_ts column). - fn pm_row(ts: i64, closed: bool, dir: i64, size: f64, open: bool) -> (Timestamp, Vec) { - let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1]; - v[r_col::CLOSED] = Scalar::bool(closed); - v[r_col::DIRECTION] = Scalar::i64(dir); - v[r_col::SIZE] = Scalar::f64(size); - v[r_col::OPEN] = Scalar::bool(open); - (Timestamp(ts), v) - } - - #[test] - fn derive_reversal_emits_close_then_opposite_open_at_one_ts() { - let rec = vec![ - pm_row(10, false, 1, 2.0, true), // open long - pm_row(20, true, -1, 3.0, true), // reversal: close long, reopen short - pm_row(30, true, -1, 3.0, false), // close short, flat - ]; - let ev = derive_position_events(&rec, 42); - assert_eq!(ev.len(), 4); - assert_eq!(ev[0].action, PositionAction::Buy); - assert_eq!(ev[0].event_ts, Timestamp(10)); - assert_eq!(ev[0].position_id, 0); - assert_eq!(ev[0].volume, 2.0); - assert_eq!(ev[0].instrument_id, 42); - assert_eq!(ev[1].action, PositionAction::Close); - assert_eq!(ev[1].position_id, 0); - assert_eq!(ev[1].event_ts, Timestamp(20)); - assert_eq!(ev[1].volume, 2.0); // close sizes the actual (old) book, not the new leg - assert_eq!(ev[2].action, PositionAction::Sell); - assert_eq!(ev[2].position_id, 1); - assert_eq!(ev[2].event_ts, Timestamp(20)); // same instant as the close - assert_eq!(ev[2].volume, 3.0); - assert_eq!(ev[3].action, PositionAction::Close); - assert_eq!(ev[3].position_id, 1); - assert_eq!(ev[3].event_ts, Timestamp(30)); - } - - #[test] - fn derive_normal_open_hold_close_lifecycle() { - let rec = vec![ - pm_row(1, false, 1, 1.0, true), // open long - pm_row(2, false, 1, 1.0, true), // hold (no event) - pm_row(3, true, 1, 1.0, false), // close to flat - ]; - let ev = derive_position_events(&rec, 9); - assert_eq!(ev.len(), 2); - assert_eq!(ev[0].action, PositionAction::Buy); - assert_eq!(ev[0].event_ts, Timestamp(1)); - assert_eq!(ev[1].action, PositionAction::Close); - assert_eq!(ev[1].event_ts, Timestamp(3)); - assert_eq!(ev[1].position_id, 0); - } - - #[test] - fn derive_short_entry_emits_sell() { - let ev = derive_position_events(&[pm_row(5, false, -1, 1.0, true)], 0); - assert_eq!(ev.len(), 1); - assert_eq!(ev[0].action, PositionAction::Sell); - assert_eq!(ev[0].position_id, 0); - } - - #[test] - fn derive_window_end_open_has_no_synthetic_close() { - // opened and never closed in-window -> open event only, no Close. - let rec = vec![ - pm_row(1, false, 1, 1.0, true), - pm_row(2, false, 1, 1.0, true), // still open on the last row - ]; - let ev = derive_position_events(&rec, 0); - assert_eq!(ev.len(), 1); - assert_eq!(ev[0].action, PositionAction::Buy); - } - - #[test] - fn derive_empty_record_is_empty_table() { - let rec: Vec<(Timestamp, Vec)> = vec![]; - assert!(derive_position_events(&rec, 0).is_empty()); - } - - #[test] - fn derive_position_ids_are_monotonic_across_trades() { - let rec = vec![ - pm_row(1, false, 1, 1.0, true), // open id0 - pm_row(2, true, 1, 1.0, false), // close id0 - pm_row(3, false, -1, 1.0, true), // open id1 (short) - pm_row(4, true, -1, 1.0, false), // close id1 - ]; - let ev = derive_position_events(&rec, 0); - assert_eq!(ev.len(), 4); - assert_eq!(ev[0].position_id, 0); - assert_eq!(ev[1].position_id, 0); - assert_eq!(ev[2].position_id, 1); - assert_eq!(ev[3].position_id, 1); - } - - #[test] - fn runmetrics_with_r_block_round_trips() { - let m = RunMetrics { - total_pips: 3.0, - max_drawdown: 1.0, - bias_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, - sqn_normalized: 1.0, - net_expectancy_r: 0.4, - conviction_terciles_r: [-0.5, 0.5, 1.5], - net_trade_rs: Vec::new(), - }), - }; - 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 rmetrics_deserializes_without_sqn_normalized_field_to_zero() { - // C18 back-compat: a pre-0067 `r:` block (serialized before sqn_normalized - // existed) lacks the field; serde(default) must fill it with 0.0. - let legacy = r#"{"expectancy_r":1.0,"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":0,"sqn":1.0,"net_expectancy_r":0.4,"conviction_terciles_r":[0.0,0.0,0.0]}"#; - let m: RMetrics = serde_json::from_str(legacy).expect("legacy RMetrics deserializes"); - assert_eq!(m.sqn_normalized, 0.0); - assert_eq!(m.sqn, 1.0); - } - - #[test] - fn legacy_exposure_sign_flips_key_reads_via_alias_and_new_output_uses_bias() { - // a legacy runs.jsonl metrics line carries the OLD key — the serde alias must accept it. - let legacy = r#"{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":3}"#; - let m: RunMetrics = serde_json::from_str(legacy).expect("legacy exposure_sign_flips deserialises"); - assert_eq!(m.bias_sign_flips, 3); - // new output serialises the NEW key, and the old key is gone from output. - let json = serde_json::to_string(&m).unwrap(); - assert!(json.contains("\"bias_sign_flips\":3"), "new output uses bias_sign_flips: {json}"); - assert!(!json.contains("exposure_sign_flips"), "old key absent from new output: {json}"); - } - - #[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, bias_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 summarize_r_populates_net_trade_rs_in_trade_order() { - // two closed trades (R = +2.0, then -1.0) over a minimal PositionManagement - // record; net_trade_rs must carry [2.0, -1.0] in trade order. - let rec = pm_record_two_closed_trades(); // helper below - let m = summarize_r(&rec, &[]); - assert_eq!(m.net_trade_rs, vec![2.0, -1.0]); - assert_eq!(m.n_trades, 2); - } - - #[test] - fn summarize_r_empty_record_has_empty_net_trade_rs() { - let m = summarize_r(&[], &[]); - assert!(m.net_trade_rs.is_empty()); - assert_eq!(m.n_trades, 0); - } - - /// Property: a position still open on the last row is folded into the trade - /// ledger at its `unrealized_r` (a window-end trade), so `summarize_r`'s - /// `net_trade_rs` carries that synthetic open trade's R and `n_trades` counts it. - /// This is the one case where the two reducers' inputs differ in meaning — it - /// is exactly the per-trade R series the OOS conduit hands `r_metrics_from_rs`, - /// so the two must agree on the R-distribution arithmetic for an - /// open-at-end series. Closed +2.0 then open-at-end +0.5 -> rs [2.0, 0.5]. - #[test] - fn summarize_r_includes_open_trade_and_matches_r_metrics_from_rs() { - let rec = pm_record_closed_then_open_at_end(); - let m = summarize_r(&rec, &[]); - assert_eq!(m.net_trade_rs, vec![2.0, 0.5]); - assert_eq!(m.n_trades, 2); - assert_eq!(m.n_open_at_end, 1); - // Feed the open-at-end pooled series through the flat reducer: the - // R-distribution fields (the verbatim-copied arithmetic) must agree. - let pooled = r_metrics_from_rs(&m.net_trade_rs); - assert_eq!(pooled.n_trades, m.n_trades); - assert_eq!(pooled.expectancy_r, m.expectancy_r); - assert_eq!(pooled.win_rate, m.win_rate); - assert_eq!(pooled.avg_win_r, m.avg_win_r); - assert_eq!(pooled.avg_loss_r, m.avg_loss_r); - assert_eq!(pooled.profit_factor, m.profit_factor); - assert_eq!(pooled.max_r_drawdown, m.max_r_drawdown); - assert_eq!(pooled.sqn, m.sqn); - assert_eq!(pooled.sqn_normalized, m.sqn_normalized); - } - - #[test] - fn rmetrics_partial_eq_ignores_net_trade_rs() { - // two RMetrics equal in every metric but differing in net_trade_rs compare EQUAL - // (net_trade_rs is an in-memory conduit, excluded from equality) — this is what - // keeps serialize->deserialize round-trips equal (net_trade_rs is serde-skipped, - // so it deserializes empty). - let a = summarize_r(&pm_record_two_closed_trades(), &[]); - let mut b = a.clone(); - b.net_trade_rs = Vec::new(); - assert_eq!(a, b); - } - - #[test] - fn populated_net_trade_rs_is_absent_from_serialized_json() { - // the in-memory conduit must never reach the wire: a populated net_trade_rs is - // dropped by #[serde(skip)], so the C18 on-disk shape is byte-unperturbed (#139). - let m = summarize_r(&pm_record_two_closed_trades(), &[]); - assert_eq!(m.net_trade_rs, vec![2.0, -1.0], "precondition: net_trade_rs is populated"); - let json = serde_json::to_string(&m).expect("RMetrics serializes"); - assert!(!json.contains("net_trade_rs"), "net_trade_rs must not reach the wire: {json}"); - } - - /// A minimal dense PositionManagement record with two closed trades at R = +2, -1. - /// Columns per `r_col` (CLOSED=0, REALIZED_R=1, DIRECTION=4, ENTRY_PRICE=6, - /// STOP_PRICE=7, CONVICTION_AT_ENTRY=9, SIZE=10, OPEN=11, UNREALIZED_R=12); width 13. - fn pm_record_two_closed_trades() -> Vec<(Timestamp, Vec)> { - let row = |closed: bool, r: f64, open: bool| { - let mut c = vec![Scalar::f64(0.0); 13]; - c[0] = Scalar::bool(closed); - c[1] = Scalar::f64(r); - c[6] = Scalar::f64(1.0); // entry - c[7] = Scalar::f64(0.5); // stop -> latched 0.5 - c[9] = Scalar::f64(0.3); // conviction - c[11] = Scalar::bool(open); - c - }; - vec![ - (Timestamp(1), row(true, 2.0, false)), - (Timestamp(2), row(true, -1.0, false)), - ] - } - - /// A dense PositionManagement record whose last row is still open: one closed - /// trade at R = +2, then a position open at cycle end carrying UNREALIZED_R - /// = +0.5 (col 12, the field `summarize_r` reads for the window-end trade). - /// Same column map / width as `pm_record_two_closed_trades`. - fn pm_record_closed_then_open_at_end() -> Vec<(Timestamp, Vec)> { - let row = |closed: bool, realized_r: f64, open: bool, unrealized_r: f64| { - let mut c = vec![Scalar::f64(0.0); 13]; - c[0] = Scalar::bool(closed); - c[1] = Scalar::f64(realized_r); - c[6] = Scalar::f64(1.0); // entry - c[7] = Scalar::f64(0.5); // stop -> latched 0.5 - c[9] = Scalar::f64(0.3); // conviction - c[11] = Scalar::bool(open); - c[12] = Scalar::f64(unrealized_r); - c - }; - vec![ - (Timestamp(1), row(true, 2.0, false, 0.0)), - (Timestamp(2), row(false, 0.0, true, 0.5)), - ] - } - - #[test] - fn r_metrics_from_rs_folds_a_flat_series() { - // pooled across-window R series [2.0, -1.0, 1.0]: expectancy = 2/3, 2 wins of 3, - // profit_factor = (2+1)/1 = 3. At cost 0 (frictionless) net == gross. - // conviction terciles are not pooled -> [0,0,0]; n_open_at_end is not a pooled - // concept -> 0. - let m = r_metrics_from_rs(&[2.0, -1.0, 1.0]); - assert_eq!(m.n_trades, 3); - assert!((m.expectancy_r - 2.0 / 3.0).abs() < 1e-12); - assert!((m.win_rate - 2.0 / 3.0).abs() < 1e-12); - assert!((m.profit_factor - 3.0).abs() < 1e-12); - assert_eq!(m.net_expectancy_r, m.expectancy_r); - assert_eq!(m.conviction_terciles_r, [0.0; 3]); - assert_eq!(m.n_open_at_end, 0); - } - - #[test] - fn r_metrics_from_rs_empty_is_all_zero() { - let m = r_metrics_from_rs(&[]); - assert_eq!(m.n_trades, 0); - assert_eq!(m.expectancy_r, 0.0); - assert_eq!(m.sqn, 0.0); - } } diff --git a/crates/aura-backtest/Cargo.toml b/crates/aura-backtest/Cargo.toml index fbd2f03..2e86ab4 100644 --- a/crates/aura-backtest/Cargo.toml +++ b/crates/aura-backtest/Cargo.toml @@ -7,3 +7,13 @@ publish.workspace = true [dependencies] aura-core = { path = "../aura-core" } +# serde gives the backtest metric types (`RunMetrics`/`RMetrics`/`PositionEvent`) +# their typed (de)serialization path for the run registry (C18; moved with the +# types from aura-analysis, #291). Deterministic output (C1-safe). +serde = { workspace = true } + +[dev-dependencies] +# serde_json is used only by the moved metric round-trip tests (JSON rendering +# for production lives in aura-engine's RunReport::to_json). Test-only, so it +# never enters the frozen artifact's library link (C16). +serde_json = { workspace = true } diff --git a/crates/aura-backtest/src/lib.rs b/crates/aura-backtest/src/lib.rs index 9845a90..358ab2f 100644 --- a/crates/aura-backtest/src/lib.rs +++ b/crates/aura-backtest/src/lib.rs @@ -1,10 +1,17 @@ -//! `aura-backtest` — the backtest-execution node layer (C28): simulated execution -//! without money (the `SimBroker` pip yardstick, position management), measured in -//! R. Depends only on `aura-core`. +//! `aura-backtest` — the backtest layer (C28): simulated execution without +//! money (the `SimBroker` pip yardstick, position management), measured in R, +//! plus the backtest metric reductions over the recorded streams +//! (`metrics`, moved from `aura-analysis` by #291). Depends only on +//! `aura-core` (+ serde for the metric types' C18 wire shapes). +mod metrics; mod position_management; mod sim_broker; +pub use metrics::{ + derive_position_events, r_metrics_from_rs, summarize_r, PositionAction, + PositionEvent, RMetrics, RunMetrics, +}; pub use position_management::{ ExitReason, FIELD_NAMES as PM_FIELD_NAMES, PositionManagement, RECORD_KINDS as PM_RECORD_KINDS, WIDTH as PM_WIDTH, diff --git a/crates/aura-backtest/src/metrics.rs b/crates/aura-backtest/src/metrics.rs new file mode 100644 index 0000000..2f753ae --- /dev/null +++ b/crates/aura-backtest/src/metrics.rs @@ -0,0 +1,1051 @@ +//! Backtest metrics (C28 backtest layer): the post-run reductions over a run's +//! recorded streams that are functions of the recorded data alone — R-based +//! signal-quality metrics (`summarize_r` / `r_metrics_from_rs`) and the +//! broker-independent position-event table (`derive_position_events`). Moved +//! verbatim from `aura-analysis` (issue #291, C28 phase 5; originally split out +//! of `aura-engine`'s `report` module, issue #136) — beside the +//! `position_management` producer whose record layout these reductions read. +//! Every type's serde shape is byte-pinned by C18 goldens; no float expression +//! re-associated. + +use aura_core::{Scalar, Timestamp}; + +/// Summary metrics reduced from a run's recorded streams — the `-> metrics` +/// half of C12's atomic sim unit. Pure function of the recorded streams. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct RunMetrics { + /// Final cumulative pip equity — the last value of the (cumulative) + /// pip-equity curve. `0.0` if the curve is empty. + pub total_pips: f64, + /// Largest peak-to-trough drop on the cumulative pip curve: + /// `max_t (running_peak(t) - equity(t))`, always `>= 0.0` (`0.0` if the + /// curve is monotonic non-decreasing or empty). + pub max_drawdown: f64, + /// Count of adjacent recorded bias samples whose sign differs (a zero + /// bias normalizes to sign `0`, so flat is distinct from long/short). + /// A turnover proxy: it counts long<->short reversals *and* transitions + /// into/out of flat — the plain sign-change count over the bias series. + /// The serde alias accepts the pre-rename `exposure_sign_flips` key so legacy + /// `runs.jsonl` lines still deserialise (C14/C18 back-compat). + #[serde(alias = "exposure_sign_flips")] + pub bias_sign_flips: u64, + /// Optional 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, reduced from a `PositionManagement` dense +/// 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, serde::Serialize, serde::Deserialize)] +pub struct RMetrics { + pub expectancy_r: f64, // mean realised R over all trades (equal-weighted; headline) + pub n_trades: u64, + pub win_rate: f64, // fraction with R > 0 + pub avg_win_r: f64, + pub avg_loss_r: f64, + 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 + /// (mean_R / sample-stdev_R) · √(min(n, SQN_CAP)): the n-normalized SQN + /// ("SQN score", Van Tharp), turnover-robust vs. the raw `sqn`. n<2 or + /// zero-variance -> 0.0. + #[serde(default)] + pub sqn_normalized: f64, + pub net_expectancy_r: f64, // mean(R - cost_in_r) folded from the cost-model stream; == gross when empty + pub conviction_terciles_r: [f64; 3], // E[R] by conviction_at_entry tercile (asc); <3 trades -> [0,0,0] + /// Cost-netted realised R per closed trade (`r − cost_in_r`), in trade + /// order — an in-memory conduit for the OOS R-series bootstrap. Equals + /// the gross series bit-for-bit when no cost model is bound (an empty + /// cost stream is the documented gross-R baseline: cost `0.0` per + /// trade). Excluded from serde (`skip`) so the C18 wire shape is + /// unchanged, and from `PartialEq` (below) so every existing + /// `RMetrics`/`RunReport` equality assertion and round-trip stays green. + #[serde(skip)] + pub net_trade_rs: Vec, +} + +impl PartialEq for RMetrics { + fn eq(&self, o: &Self) -> bool { + self.expectancy_r == o.expectancy_r + && self.n_trades == o.n_trades + && self.win_rate == o.win_rate + && self.avg_win_r == o.avg_win_r + && self.avg_loss_r == o.avg_loss_r + && self.profit_factor == o.profit_factor + && self.max_r_drawdown == o.max_r_drawdown + && self.n_open_at_end == o.n_open_at_end + && self.sqn == o.sqn + && self.sqn_normalized == o.sqn_normalized + && self.net_expectancy_r == o.net_expectancy_r + && self.conviction_terciles_r == o.conviction_terciles_r + // net_trade_rs deliberately excluded + } +} + +// Dense `PositionManagement` record column indices — the lockstep contract with +// `crate::position_management::{FIELD_NAMES, RECORD_KINDS}` (intra-crate since +// #291). The record crosses the node/reduction seam as a type-erased `Scalar` +// vector (C4 SoA), so it is read positionally by convention, not by importing +// the producer's consts; `r_sma_e2e.rs` guards that the layout matches. +mod r_col { + pub const CLOSED: usize = 0; + pub const REALIZED_R: usize = 1; + pub const DIRECTION: usize = 4; + // entry/stop define the latched distance (1R). Since the net-of-cost fold reads the + // cost-in-R straight from the cost stream (the aura-std `ConstantCost` producer recovers + // the distance), this crate's reductions no longer touch these two columns; they stay in + // the documented layout contract and are exercised by the test fixtures. + #[allow(dead_code)] + pub const ENTRY_PRICE: usize = 6; + #[allow(dead_code)] + pub const STOP_PRICE: usize = 7; + pub const CONVICTION_AT_ENTRY: usize = 9; + pub const SIZE: usize = 10; + pub const OPEN: usize = 11; + pub const UNREALIZED_R: usize = 12; +} + +// Cost-model record column indices — the lockstep contract with aura-std's +// `ConstantCost` output schema (`cost_in_r` / `cum_cost_in_r` / `open_cost_in_r`). +// Read positionally across the crate boundary as a type-erased `Scalar` vector +// (C4 SoA), like `r_col`. `summarize_r` folds the per-close cost (col 0) and the +// window-end open trade's would-be cost (col 2); the running sum (col 1) is not +// folded, so it is intentionally not named here. +mod cost_col { + pub const COST_IN_R: usize = 0; + pub const OPEN_COST_IN_R: usize = 2; +} + +/// Van Tharp's conventional trade-count cap for the n-normalized SQN ("SQN +/// score"): capping n stops the single-number objective rewarding sheer turnover. +const SQN_CAP: u64 = 100; + +/// Reduce a `PositionManagement` dense record stream into R-metrics. Pure (C1). +/// 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)], + cost: &[(Timestamp, Vec)], +) -> RMetrics { + // Collect one entry per trade: its realised R, the entry-conviction |bias|, and the + // per-trade cost-in-R the cost-model graph supplied (read positionally from the + // co-temporal `cost` stream). 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, + cost: f64, + } + // Positional join: the cost stream is co-temporal 1:1 with the record (both recorded + // per cycle off the same executor cadence — a cost node emits exactly when PM emits), + // so `record[i]` and `cost[i]` are the same cycle. A closed row `i` reads `cost[i]`'s + // `cost_in_r` (col 0); the window-end open last row reads its `open_cost_in_r` (col 2). + // An empty `cost` slice ⇒ every `.get(i)` is `None` ⇒ cost `0.0` (the gross-R baseline). + // Positional avoids the shared-timestamp collision a `ts`-keyed map would have (C4: + // same `ts` can be two cycles). + let mut trades: Vec = Vec::new(); + for (i, (_, row)) in record.iter().enumerate() { + if row[r_col::CLOSED].as_bool() { + let c = cost.get(i).map(|(_, cr)| cr[cost_col::COST_IN_R].as_f64()).unwrap_or(0.0); + trades.push(Trade { + r: row[r_col::REALIZED_R].as_f64(), + bias_abs: row[r_col::CONVICTION_AT_ENTRY].as_f64(), + cost: c, + }); + } + } + let mut n_open_at_end = 0u64; + if let Some((_, last)) = record.last() + && last[r_col::OPEN].as_bool() + { + let c = cost + .get(record.len() - 1) + .map(|(_, cr)| cr[cost_col::OPEN_COST_IN_R].as_f64()) + .unwrap_or(0.0); + trades.push(Trade { + r: last[r_col::UNREALIZED_R].as_f64(), + bias_abs: last[r_col::CONVICTION_AT_ENTRY].as_f64(), + cost: c, + }); + n_open_at_end = 1; + } + 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, + sqn: 0.0, + sqn_normalized: 0.0, + net_expectancy_r: 0.0, + conviction_terciles_r: [0.0; 3], + net_trade_rs: Vec::new(), + }; + } + 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(); + let sum_loss: f64 = losses.iter().sum(); // <= 0 + let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::() / v.len() as f64 }; + // by-trade cumulative-R drawdown + let mut peak = f64::NEG_INFINITY; + let mut cum = 0.0; + let mut max_dd = 0.0_f64; + for &r in &rs { + cum += r; + if cum > peak { + peak = cum; + } + let dd = peak - cum; + if dd > max_dd { + max_dd = dd; + } + } + // SQN = √n · mean / sample-stdev (raw, Van Tharp). SQN100 = √(min(n,SQN_CAP)) · + // mean / sd — the same dispersion ratio with the trade count capped + // (turnover-robust). n < 2 or zero variance -> 0.0 for both (dispersion undefined). + let (sqn, sqn_normalized) = if n < 2 { + (0.0, 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 { + // raw sqn kept bit-identical to the pre-0067 expression (sqrt(n)*mean/sd, + // not factored via a shared ratio) so the existing metric is byte-unchanged. + ( + (n as f64).sqrt() * mean / sd, + (n.min(SQN_CAP) as f64).sqrt() * mean / sd, + ) + } else { + (0.0, 0.0) + } + }; + // net-of-cost: subtract the cost-model's per-trade cost-in-R (from the cost stream; + // an empty stream is the gross-R baseline — every trade's cost is 0.0). + let net_sum: f64 = trades.iter().map(|t| t.r - t.cost).sum(); + let net_expectancy_r = net_sum / n as f64; + // The per-trade net series the OOS bootstrap conduit carries (#259). A + // separate materialization, deliberately NOT factored through `net_sum`: + // that expression's tokens are byte-pinned (see the SQN note above). + let net_rs: Vec = trades.iter().map(|t| t.r - t.cost).collect(); + // conviction terciles: sort by conviction_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: mean, + n_trades: n, + win_rate: wins.len() as f64 / n as f64, + avg_win_r: avg(&wins), + avg_loss_r: avg(&losses), + profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 }, + max_r_drawdown: max_dd, + n_open_at_end, + sqn, + sqn_normalized, + net_expectancy_r, + conviction_terciles_r, + net_trade_rs: net_rs, + } +} + +/// Reduce a flat per-trade R series (e.g. the pooled across-window OOS series of a +/// walk-forward) into `RMetrics`. The R-distribution fields (mean / win-rate / profit +/// factor / max-drawdown / SQN) duplicate [`summarize_r`]'s arithmetic byte-for-byte — +/// deliberately copied, not factored, so neither pinned-float expression shifts under +/// IEEE-754 non-associativity. MAINTENANCE COUPLING: the two copies must be edited in +/// lockstep; the only guard that they agree is the cross-reducer equality test +/// `summarize_r_includes_open_trade_and_matches_r_metrics_from_rs` — touch one copy +/// without the other and that test is the sole tripwire. The +/// fields a flat R series cannot carry are set honestly: `n_open_at_end = 0`, +/// `net_expectancy_r = expectancy_r` (exact: the input series is already +/// cost-netted — the conduit carries `r − cost_in_r`), +/// `conviction_terciles_r = [0,0,0]` (per-trade conviction is not pooled). Empty +/// input -> a well-defined all-zero `RMetrics`. +pub fn r_metrics_from_rs(rs: &[f64]) -> RMetrics { + let n = rs.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, sqn: 0.0, sqn_normalized: 0.0, net_expectancy_r: 0.0, + conviction_terciles_r: [0.0; 3], net_trade_rs: Vec::new(), + }; + } + 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(); + let sum_loss: f64 = losses.iter().sum(); + let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::() / v.len() as f64 }; + let mut peak = f64::NEG_INFINITY; + let mut cum = 0.0; + let mut max_dd = 0.0_f64; + for &r in rs { + cum += r; + if cum > peak { peak = cum; } + let dd = peak - cum; + if dd > max_dd { max_dd = dd; } + } + let (sqn, sqn_normalized) = if n < 2 { + (0.0, 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, (n.min(SQN_CAP) as f64).sqrt() * mean / sd) + } else { + (0.0, 0.0) + } + }; + RMetrics { + expectancy_r: mean, + n_trades: n, + win_rate: wins.len() as f64 / n as f64, + avg_win_r: avg(&wins), + avg_loss_r: avg(&losses), + profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 }, + max_r_drawdown: max_dd, + n_open_at_end: 0, + sqn, + sqn_normalized, + net_expectancy_r: mean, // input series is already net -> net == mean + conviction_terciles_r: [0.0; 3], + net_trade_rs: Vec::new(), + } +} + +/// Derive the broker-independent position-event table from a `PositionManagement` +/// dense record (read positionally, C7 SoA), as the first difference of the +/// executed book (`deal = target - book - in_flight`; `in_flight = 0` for the +/// instant-fill backtest). Pure (C1); no look-ahead — each event's `event_ts` is +/// its own cycle (C2). `instrument_id` is supplied by the caller (the engine never +/// imports an instrument spec from `aura-ingest`). A reversal emits `Close` then the +/// opposite open at one `event_ts` (close first); a position still open on the last +/// row emits its open with no synthetic `Close` (the table records actual executed events — +/// unlike `summarize_r`, which force-closes for the R metric). +pub fn derive_position_events( + record: &[(Timestamp, Vec)], + instrument_id: i64, +) -> Vec { + // The derive's single-position book (in_flight is structurally 0). + struct Book { + position_id: i64, + volume: f64, + } + let mut out: Vec = Vec::new(); + let mut book: Option = None; + let mut next_id: i64 = 0; + for (ts, row) in record { + // 1) close first: the book held into this cycle exited this cycle. + if row[r_col::CLOSED].as_bool() + && let Some(b) = book.take() + { + out.push(PositionEvent { + event_ts: *ts, + action: PositionAction::Close, + position_id: b.position_id, + instrument_id, + volume: b.volume, + }); + } + // 2) then open: a position is open at cycle end the book is not tracking. + if row[r_col::OPEN].as_bool() && book.is_none() { + let dir = row[r_col::DIRECTION].as_i64(); + let volume = row[r_col::SIZE].as_f64(); + let action = if dir >= 0 { PositionAction::Buy } else { PositionAction::Sell }; + out.push(PositionEvent { + event_ts: *ts, + action, + position_id: next_id, + instrument_id, + volume, + }); + book = Some(Book { position_id: next_id, volume }); + next_id += 1; + } + } + out +} + +/// The three position-event actions (C10). Direction IS the action; volume is +/// unsigned. Serde-encoded as its i64 mapping (`Buy=0, Sell=1, Close=2`) so the +/// persisted/columnar form stays C7-scalar and ledger-faithful (`action: i64`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(into = "i64", try_from = "i64")] +pub enum PositionAction { + Buy, + Sell, + Close, +} + +impl From for i64 { + fn from(a: PositionAction) -> i64 { + match a { + PositionAction::Buy => 0, + PositionAction::Sell => 1, + PositionAction::Close => 2, + } + } +} + +impl TryFrom for PositionAction { + type Error = String; + fn try_from(v: i64) -> Result { + match v { + 0 => Ok(PositionAction::Buy), + 1 => Ok(PositionAction::Sell), + 2 => Ok(PositionAction::Close), + other => Err(format!("invalid PositionAction i64: {other}")), + } + } +} + +/// One row of C10's derived position-event table — the broker-independent audit +/// view (the first difference of the exposure state). A post-run value type +/// (sibling of [`RunMetrics`]), NOT a per-`eval` node output (C8). Multiple events +/// may share one `event_ts` (a reversal: Close then open, close-before-open). No +/// `open_ts` — a position's open time is its opening event's `event_ts`. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct PositionEvent { + pub event_ts: Timestamp, + pub action: PositionAction, + /// Monotonic, assigned at open. A Close references an existing `position_id`. + pub position_id: i64, + pub instrument_id: i64, + /// Lots, unsigned. A partial close carries its own (smaller) volume. + pub volume: f64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn position_action_round_trips_through_i64() { + for a in [PositionAction::Buy, PositionAction::Sell, PositionAction::Close] { + let n: i64 = a.into(); + assert_eq!(PositionAction::try_from(n), Ok(a)); + } + assert_eq!(i64::from(PositionAction::Buy), 0); + assert_eq!(i64::from(PositionAction::Sell), 1); + assert_eq!(i64::from(PositionAction::Close), 2); + } + + #[test] + fn position_action_rejects_out_of_range_i64() { + assert!(PositionAction::try_from(3).is_err()); + assert!(PositionAction::try_from(-1).is_err()); + } + + #[test] + fn position_event_serde_round_trips_with_bare_int_action() { + let ev = PositionEvent { + event_ts: Timestamp(42), + action: PositionAction::Sell, + position_id: 7, + instrument_id: 3, + volume: 0.5, + }; + let json = serde_json::to_string(&ev).expect("serialize"); + // action encodes as a bare integer (C7 scalar shape), not a tagged enum + assert!(json.contains("\"action\":1"), "action not bare-int encoded: {json}"); + let back: PositionEvent = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, ev); + } + + #[test] + fn position_events_may_share_one_event_ts_on_reversal() { + // a stop-and-reverse: Close then open at the SAME event_ts (close-before-open). + let ts = Timestamp(100); + let close = PositionEvent { + event_ts: ts, action: PositionAction::Close, + position_id: 1, instrument_id: 3, volume: 0.5, + }; + let open = PositionEvent { + event_ts: ts, action: PositionAction::Sell, + position_id: 2, instrument_id: 3, volume: 0.5, + }; + let table = [close, open]; + assert_eq!(table[0].event_ts, table[1].event_ts); + assert_eq!(table[0].action, PositionAction::Close); + assert_eq!(table[1].action, PositionAction::Sell); + } + + // Build a minimal dense record row: only the columns summarize_r reads matter. + // The four written slots reference `r_col::*` (not bare literals) so the helper + // tracks the same constants it exists to test — a literal would mask drift in them. + fn r_row(closed: bool, realized: f64, open: bool, unreal: f64) -> (Timestamp, Vec) { + let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1]; + v[r_col::CLOSED] = Scalar::bool(closed); + v[r_col::REALIZED_R] = Scalar::f64(realized); + v[r_col::OPEN] = Scalar::bool(open); + 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 + // conviction_at_entry (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::CONVICTION_AT_ENTRY] = Scalar::f64(bias_abs); + v[r_col::OPEN] = Scalar::bool(false); + (Timestamp(0), v) + } + + // A 14-wide PositionManagement dense row carrying BOTH the closed-trade geometry + // (entry/stop/conviction) AND the open-at-end fields (open/unrealized_r) — the one + // fixture the cost-stream subsumption test needs (`r_row_full` forces closed=true, so + // it cannot build a window-end open row). Only the columns summarize_r reads are set; + // the rest default to f64(0.0) (never read), mirroring the existing row helpers. + fn row14(closed: bool, r: f64, entry: f64, stop: f64, conv: f64, open: bool, unreal: f64) -> Vec { + let mut v = vec![Scalar::f64(0.0); 14]; + v[r_col::CLOSED] = Scalar::bool(closed); + v[r_col::REALIZED_R] = Scalar::f64(r); + v[r_col::ENTRY_PRICE] = Scalar::f64(entry); + v[r_col::STOP_PRICE] = Scalar::f64(stop); + v[r_col::CONVICTION_AT_ENTRY] = Scalar::f64(conv); + v[r_col::OPEN] = Scalar::bool(open); + v[r_col::UNREALIZED_R] = Scalar::f64(unreal); + v + } + #[test] + fn summarize_r_is_zero_on_empty() { + let m = summarize_r(&[], &[]); + 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.sqn_normalized, 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() { + // three closed trades: +2, -1, +1 (no open at end). E[R] = 2/3. + let rec = vec![ + r_row(true, 2.0, true, 0.0), + r_row(false, 0.0, true, 0.0), + 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, &[]); + 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); + assert!((m.profit_factor - 3.0).abs() < 1e-9); // (2+1)/1 + assert_eq!(m.n_open_at_end, 0); + } + #[test] + 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, &[]); + 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 + } + #[test] + 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); + } + + #[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, &[]); + 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)], &[]).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, &[]).sqn, 0.0); + } + + #[test] + fn summarize_r_sqn_normalized_caps_trade_count_at_100() { + // 144 closed trades alternating R = 1.0 / 2.0 (nonzero variance, sd > 0). + // raw sqn = √144·q, SQN100 = √(min(144,100))·q = √100·q for the SAME ratio + // q = mean/sd, so sqn / sqn_normalized = √(144/100) = 1.2, independent of q. + let rec: Vec<_> = (0..144) + .map(|i| r_row(true, if i % 2 == 0 { 1.0 } else { 2.0 }, false, 0.0)) + .collect(); + let m = summarize_r(&rec, &[]); + assert_eq!(m.n_trades, 144); + assert!(m.sqn_normalized > 0.0, "sqn_normalized nonzero; got {}", m.sqn_normalized); + assert!(m.sqn_normalized < m.sqn, "capped SQN100 < raw sqn for n > 100"); + assert!( + (m.sqn / m.sqn_normalized - 1.2).abs() < 1e-9, + "sqn / sqn_normalized = √(144/100) = 1.2; got {}", + m.sqn / m.sqn_normalized + ); + } + + #[test] + fn summarize_r_sqn_normalized_equals_raw_sqn_below_cap() { + // n = 4 <= 100 -> cap inactive -> SQN100 == raw sqn (same ledger as + // summarize_r_sqn_is_sqrt_n_mean_over_stdev, where sqn = 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, &[]); + assert!((m.sqn_normalized - m.sqn).abs() < 1e-12, "below cap, SQN100 == sqn"); + assert!((m.sqn_normalized - 1.0).abs() < 1e-9, "sqn_normalized = 1.0 here"); + } + + #[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, &[]); + 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, &[]); + 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, &[]).conviction_terciles_r, [0.0; 3]); + } + + #[test] + fn summarize_r_net_of_cost_subtracts_round_trip_per_trade() { + // one closed trade: R=+2, entry 100, stop 90 -> latched 10. A ConstantCost(1.0) + // node (1.0 price units) emits cost_in_r = 1.0/10 = 0.1 on the closed row (col 0). + // gross E[R]=2.0, net = 1.9. The empty stream is the gross baseline (net == gross). + let rec = vec![r_row_full(2.0, 100.0, 90.0, 0.5)]; + let gross = summarize_r(&rec, &[]); + // producer 3-wide row [cost_in_r, cum_cost_in_r, open_cost_in_r]: closed -> 0.1 / 0.1 / 0. + let cost = vec![(Timestamp(0), vec![Scalar::f64(0.1), Scalar::f64(0.1), Scalar::f64(0.0)])]; + let net = summarize_r(&rec, &cost); + 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); + } + + /// Property: `summarize_r` folding the cost stream reproduces the retired scalar + /// `round_trip_cost` argument EXACTLY — `net_expectancy_r == mean(rᵢ − costᵢ)` over + /// BOTH the closed trade (cost from the stream's col 0) and the window-end open trade + /// (cost from col 2), with the cost-in-R taken straight from the co-temporal stream a + /// `ConstantCost` node emits (not recomputed from latched). Exercises a nonzero cost, + /// so it proves exact subsumption — not merely the cost=0 baseline. + #[test] + fn summarize_r_folds_cost_stream_exactly_like_the_scalar_over_all_trades() { + // Trade 1: closed, entry 100 stop 96 (latched 4), realized R = 1.0. + // Trade 2: still open at window end, entry 100 stop 98 (latched 2), unrealized R = 0.5. + let c = 2.0_f64; + let record = vec![ + (Timestamp(0), row14(true, 1.0, 100.0, 96.0, 1.0, false, 0.0)), + (Timestamp(1), row14(false, 0.0, 100.0, 98.0, 1.0, true, 0.5)), + ]; + // Cost stream the ConstantCost(2.0) node emits, co-temporal — the producer's + // 3-wide row [cost_in_r, cum_cost_in_r, open_cost_in_r]. + let cost = vec![ + // closed row: 0.5 charged on close (col 0), cum 0.5, no open cost. + (Timestamp(0), vec![Scalar::f64(c / 4.0), Scalar::f64(c / 4.0), Scalar::f64(0.0)]), + // open row: no close cost, cum unchanged, would-be open cost 1.0 (col 2). + (Timestamp(1), vec![Scalar::f64(0.0), Scalar::f64(c / 4.0), Scalar::f64(c / 2.0)]), + ]; + let m = summarize_r(&record, &cost); + // gross E[R] = mean(1.0, 0.5) = 0.75; net = mean(1.0-0.5, 0.5-1.0) = mean(0.5,-0.5) = 0.0 + assert!((m.expectancy_r - 0.75).abs() < 1e-12); + assert!((m.net_expectancy_r - 0.0).abs() < 1e-12); + } + + /// Property (#259): the bootstrap conduit `net_trade_rs` carries the + /// COST-NETTED per-trade R (`r − cost_in_r`) in trade order — the same + /// per-trade values `net_expectancy_r` averages — while every gross + /// scalar stays cost-free. With an empty cost stream the conduit equals + /// the gross series bit-for-bit (cost 0.0 per trade), which is what + /// keeps uncosted campaigns byte-identical. + #[test] + fn summarize_r_net_trade_rs_is_the_cost_netted_series_in_trade_order() { + let c = 2.0_f64; + let record = vec![ + (Timestamp(0), row14(true, 1.0, 100.0, 96.0, 1.0, false, 0.0)), + (Timestamp(1), row14(false, 0.0, 100.0, 98.0, 1.0, true, 0.5)), + ]; + let cost = vec![ + (Timestamp(0), vec![Scalar::f64(c / 4.0), Scalar::f64(c / 4.0), Scalar::f64(0.0)]), + (Timestamp(1), vec![Scalar::f64(0.0), Scalar::f64(c / 4.0), Scalar::f64(c / 2.0)]), + ]; + let m = summarize_r(&record, &cost); + assert_eq!(m.net_trade_rs, vec![1.0 - 0.5, 0.5 - 1.0], "net conduit = r − cost, trade order"); + assert!((m.expectancy_r - 0.75).abs() < 1e-12, "gross scalars stay cost-free"); + let gross = summarize_r(&record, &[]); + assert_eq!(gross.net_trade_rs, vec![1.0, 0.5], "empty cost stream ⇒ conduit == gross series"); + } + + // One PositionManagement dense record row for the derive tests: only the columns + // derive_position_events reads (closed, direction, size, open) are set; the rest + // default to 0. Distinct per-row timestamps (the derive keys events on the row's + // own cycle, not the entry_ts column). + fn pm_row(ts: i64, closed: bool, dir: i64, size: f64, open: bool) -> (Timestamp, Vec) { + let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1]; + v[r_col::CLOSED] = Scalar::bool(closed); + v[r_col::DIRECTION] = Scalar::i64(dir); + v[r_col::SIZE] = Scalar::f64(size); + v[r_col::OPEN] = Scalar::bool(open); + (Timestamp(ts), v) + } + + #[test] + fn derive_reversal_emits_close_then_opposite_open_at_one_ts() { + let rec = vec![ + pm_row(10, false, 1, 2.0, true), // open long + pm_row(20, true, -1, 3.0, true), // reversal: close long, reopen short + pm_row(30, true, -1, 3.0, false), // close short, flat + ]; + let ev = derive_position_events(&rec, 42); + assert_eq!(ev.len(), 4); + assert_eq!(ev[0].action, PositionAction::Buy); + assert_eq!(ev[0].event_ts, Timestamp(10)); + assert_eq!(ev[0].position_id, 0); + assert_eq!(ev[0].volume, 2.0); + assert_eq!(ev[0].instrument_id, 42); + assert_eq!(ev[1].action, PositionAction::Close); + assert_eq!(ev[1].position_id, 0); + assert_eq!(ev[1].event_ts, Timestamp(20)); + assert_eq!(ev[1].volume, 2.0); // close sizes the actual (old) book, not the new leg + assert_eq!(ev[2].action, PositionAction::Sell); + assert_eq!(ev[2].position_id, 1); + assert_eq!(ev[2].event_ts, Timestamp(20)); // same instant as the close + assert_eq!(ev[2].volume, 3.0); + assert_eq!(ev[3].action, PositionAction::Close); + assert_eq!(ev[3].position_id, 1); + assert_eq!(ev[3].event_ts, Timestamp(30)); + } + + #[test] + fn derive_normal_open_hold_close_lifecycle() { + let rec = vec![ + pm_row(1, false, 1, 1.0, true), // open long + pm_row(2, false, 1, 1.0, true), // hold (no event) + pm_row(3, true, 1, 1.0, false), // close to flat + ]; + let ev = derive_position_events(&rec, 9); + assert_eq!(ev.len(), 2); + assert_eq!(ev[0].action, PositionAction::Buy); + assert_eq!(ev[0].event_ts, Timestamp(1)); + assert_eq!(ev[1].action, PositionAction::Close); + assert_eq!(ev[1].event_ts, Timestamp(3)); + assert_eq!(ev[1].position_id, 0); + } + + #[test] + fn derive_short_entry_emits_sell() { + let ev = derive_position_events(&[pm_row(5, false, -1, 1.0, true)], 0); + assert_eq!(ev.len(), 1); + assert_eq!(ev[0].action, PositionAction::Sell); + assert_eq!(ev[0].position_id, 0); + } + + #[test] + fn derive_window_end_open_has_no_synthetic_close() { + // opened and never closed in-window -> open event only, no Close. + let rec = vec![ + pm_row(1, false, 1, 1.0, true), + pm_row(2, false, 1, 1.0, true), // still open on the last row + ]; + let ev = derive_position_events(&rec, 0); + assert_eq!(ev.len(), 1); + assert_eq!(ev[0].action, PositionAction::Buy); + } + + #[test] + fn derive_empty_record_is_empty_table() { + let rec: Vec<(Timestamp, Vec)> = vec![]; + assert!(derive_position_events(&rec, 0).is_empty()); + } + + #[test] + fn derive_position_ids_are_monotonic_across_trades() { + let rec = vec![ + pm_row(1, false, 1, 1.0, true), // open id0 + pm_row(2, true, 1, 1.0, false), // close id0 + pm_row(3, false, -1, 1.0, true), // open id1 (short) + pm_row(4, true, -1, 1.0, false), // close id1 + ]; + let ev = derive_position_events(&rec, 0); + assert_eq!(ev.len(), 4); + assert_eq!(ev[0].position_id, 0); + assert_eq!(ev[1].position_id, 0); + assert_eq!(ev[2].position_id, 1); + assert_eq!(ev[3].position_id, 1); + } + + #[test] + fn runmetrics_with_r_block_round_trips() { + let m = RunMetrics { + total_pips: 3.0, + max_drawdown: 1.0, + bias_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, + sqn_normalized: 1.0, + net_expectancy_r: 0.4, + conviction_terciles_r: [-0.5, 0.5, 1.5], + net_trade_rs: Vec::new(), + }), + }; + 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 rmetrics_deserializes_without_sqn_normalized_field_to_zero() { + // C18 back-compat: a pre-0067 `r:` block (serialized before sqn_normalized + // existed) lacks the field; serde(default) must fill it with 0.0. + let legacy = r#"{"expectancy_r":1.0,"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":0,"sqn":1.0,"net_expectancy_r":0.4,"conviction_terciles_r":[0.0,0.0,0.0]}"#; + let m: RMetrics = serde_json::from_str(legacy).expect("legacy RMetrics deserializes"); + assert_eq!(m.sqn_normalized, 0.0); + assert_eq!(m.sqn, 1.0); + } + + #[test] + fn legacy_exposure_sign_flips_key_reads_via_alias_and_new_output_uses_bias() { + // a legacy runs.jsonl metrics line carries the OLD key — the serde alias must accept it. + let legacy = r#"{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":3}"#; + let m: RunMetrics = serde_json::from_str(legacy).expect("legacy exposure_sign_flips deserialises"); + assert_eq!(m.bias_sign_flips, 3); + // new output serialises the NEW key, and the old key is gone from output. + let json = serde_json::to_string(&m).unwrap(); + assert!(json.contains("\"bias_sign_flips\":3"), "new output uses bias_sign_flips: {json}"); + assert!(!json.contains("exposure_sign_flips"), "old key absent from new output: {json}"); + } + + #[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, bias_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 summarize_r_populates_net_trade_rs_in_trade_order() { + // two closed trades (R = +2.0, then -1.0) over a minimal PositionManagement + // record; net_trade_rs must carry [2.0, -1.0] in trade order. + let rec = pm_record_two_closed_trades(); // helper below + let m = summarize_r(&rec, &[]); + assert_eq!(m.net_trade_rs, vec![2.0, -1.0]); + assert_eq!(m.n_trades, 2); + } + + #[test] + fn summarize_r_empty_record_has_empty_net_trade_rs() { + let m = summarize_r(&[], &[]); + assert!(m.net_trade_rs.is_empty()); + assert_eq!(m.n_trades, 0); + } + + /// Property: a position still open on the last row is folded into the trade + /// ledger at its `unrealized_r` (a window-end trade), so `summarize_r`'s + /// `net_trade_rs` carries that synthetic open trade's R and `n_trades` counts it. + /// This is the one case where the two reducers' inputs differ in meaning — it + /// is exactly the per-trade R series the OOS conduit hands `r_metrics_from_rs`, + /// so the two must agree on the R-distribution arithmetic for an + /// open-at-end series. Closed +2.0 then open-at-end +0.5 -> rs [2.0, 0.5]. + #[test] + fn summarize_r_includes_open_trade_and_matches_r_metrics_from_rs() { + let rec = pm_record_closed_then_open_at_end(); + let m = summarize_r(&rec, &[]); + assert_eq!(m.net_trade_rs, vec![2.0, 0.5]); + assert_eq!(m.n_trades, 2); + assert_eq!(m.n_open_at_end, 1); + // Feed the open-at-end pooled series through the flat reducer: the + // R-distribution fields (the verbatim-copied arithmetic) must agree. + let pooled = r_metrics_from_rs(&m.net_trade_rs); + assert_eq!(pooled.n_trades, m.n_trades); + assert_eq!(pooled.expectancy_r, m.expectancy_r); + assert_eq!(pooled.win_rate, m.win_rate); + assert_eq!(pooled.avg_win_r, m.avg_win_r); + assert_eq!(pooled.avg_loss_r, m.avg_loss_r); + assert_eq!(pooled.profit_factor, m.profit_factor); + assert_eq!(pooled.max_r_drawdown, m.max_r_drawdown); + assert_eq!(pooled.sqn, m.sqn); + assert_eq!(pooled.sqn_normalized, m.sqn_normalized); + } + + #[test] + fn rmetrics_partial_eq_ignores_net_trade_rs() { + // two RMetrics equal in every metric but differing in net_trade_rs compare EQUAL + // (net_trade_rs is an in-memory conduit, excluded from equality) — this is what + // keeps serialize->deserialize round-trips equal (net_trade_rs is serde-skipped, + // so it deserializes empty). + let a = summarize_r(&pm_record_two_closed_trades(), &[]); + let mut b = a.clone(); + b.net_trade_rs = Vec::new(); + assert_eq!(a, b); + } + + #[test] + fn populated_net_trade_rs_is_absent_from_serialized_json() { + // the in-memory conduit must never reach the wire: a populated net_trade_rs is + // dropped by #[serde(skip)], so the C18 on-disk shape is byte-unperturbed (#139). + let m = summarize_r(&pm_record_two_closed_trades(), &[]); + assert_eq!(m.net_trade_rs, vec![2.0, -1.0], "precondition: net_trade_rs is populated"); + let json = serde_json::to_string(&m).expect("RMetrics serializes"); + assert!(!json.contains("net_trade_rs"), "net_trade_rs must not reach the wire: {json}"); + } + + /// A minimal dense PositionManagement record with two closed trades at R = +2, -1. + /// Columns per `r_col` (CLOSED=0, REALIZED_R=1, DIRECTION=4, ENTRY_PRICE=6, + /// STOP_PRICE=7, CONVICTION_AT_ENTRY=9, SIZE=10, OPEN=11, UNREALIZED_R=12); width 13. + fn pm_record_two_closed_trades() -> Vec<(Timestamp, Vec)> { + let row = |closed: bool, r: f64, open: bool| { + let mut c = vec![Scalar::f64(0.0); 13]; + c[0] = Scalar::bool(closed); + c[1] = Scalar::f64(r); + c[6] = Scalar::f64(1.0); // entry + c[7] = Scalar::f64(0.5); // stop -> latched 0.5 + c[9] = Scalar::f64(0.3); // conviction + c[11] = Scalar::bool(open); + c + }; + vec![ + (Timestamp(1), row(true, 2.0, false)), + (Timestamp(2), row(true, -1.0, false)), + ] + } + + /// A dense PositionManagement record whose last row is still open: one closed + /// trade at R = +2, then a position open at cycle end carrying UNREALIZED_R + /// = +0.5 (col 12, the field `summarize_r` reads for the window-end trade). + /// Same column map / width as `pm_record_two_closed_trades`. + fn pm_record_closed_then_open_at_end() -> Vec<(Timestamp, Vec)> { + let row = |closed: bool, realized_r: f64, open: bool, unrealized_r: f64| { + let mut c = vec![Scalar::f64(0.0); 13]; + c[0] = Scalar::bool(closed); + c[1] = Scalar::f64(realized_r); + c[6] = Scalar::f64(1.0); // entry + c[7] = Scalar::f64(0.5); // stop -> latched 0.5 + c[9] = Scalar::f64(0.3); // conviction + c[11] = Scalar::bool(open); + c[12] = Scalar::f64(unrealized_r); + c + }; + vec![ + (Timestamp(1), row(true, 2.0, false, 0.0)), + (Timestamp(2), row(false, 0.0, true, 0.5)), + ] + } + + #[test] + fn r_metrics_from_rs_folds_a_flat_series() { + // pooled across-window R series [2.0, -1.0, 1.0]: expectancy = 2/3, 2 wins of 3, + // profit_factor = (2+1)/1 = 3. At cost 0 (frictionless) net == gross. + // conviction terciles are not pooled -> [0,0,0]; n_open_at_end is not a pooled + // concept -> 0. + let m = r_metrics_from_rs(&[2.0, -1.0, 1.0]); + assert_eq!(m.n_trades, 3); + assert!((m.expectancy_r - 2.0 / 3.0).abs() < 1e-12); + assert!((m.win_rate - 2.0 / 3.0).abs() < 1e-12); + assert!((m.profit_factor - 3.0).abs() < 1e-12); + assert_eq!(m.net_expectancy_r, m.expectancy_r); + assert_eq!(m.conviction_terciles_r, [0.0; 3]); + assert_eq!(m.n_open_at_end, 0); + } + + #[test] + fn r_metrics_from_rs_empty_is_all_zero() { + let m = r_metrics_from_rs(&[]); + assert_eq!(m.n_trades, 0); + assert_eq!(m.expectancy_r, 0.0); + assert_eq!(m.sqn, 0.0); + } +} diff --git a/crates/aura-campaign/Cargo.toml b/crates/aura-campaign/Cargo.toml index 8614f85..6fde10e 100644 --- a/crates/aura-campaign/Cargo.toml +++ b/crates/aura-campaign/Cargo.toml @@ -28,3 +28,9 @@ serde_json = { workspace = true } # work-stealing pool the engine's run_indexed already uses — parallelism across # sims, never within one. Direct versioned dep, mirroring aura-engine. rayon = "1" + +[dev-dependencies] +# aura-backtest supplies RunMetrics/summarize_r for the metric-vocabulary drift +# guard's direct-source import (tests/metric_vocabulary_e2e.rs). Production +# code reaches these types via the aura-engine re-export instead. +aura-backtest = { path = "../aura-backtest" } diff --git a/crates/aura-campaign/tests/metric_vocabulary_e2e.rs b/crates/aura-campaign/tests/metric_vocabulary_e2e.rs index 5adcaba..c21f29c 100644 --- a/crates/aura-campaign/tests/metric_vocabulary_e2e.rs +++ b/crates/aura-campaign/tests/metric_vocabulary_e2e.rs @@ -1,15 +1,17 @@ //! Cross-crate guard for #190: pins `aura_research::metric_vocabulary()` (the //! hand-written 17-name declared metric roster) against the ACTUAL scalar -//! fields the three metric-bearing `aura-analysis` types serialize — not a -//! second hand-list that could independently drift. `aura-campaign` is the -//! one crate that already depends on both `aura-analysis` (the types) and -//! `aura-research` (the vocabulary), so it is the only place this pin can be -//! written today (`aura-research` deliberately has no `aura-analysis` dep — +//! fields the three metric-bearing types serialize — `RunMetrics`/`RMetrics` +//! from `aura-backtest` (via `summarize_r`), `FamilySelection` from +//! `aura-analysis` — not a second hand-list that could independently drift. +//! `aura-campaign` is the one crate that already depends on the type sources +//! and `aura-research` (the vocabulary), so this pin lives here +//! (`aura-research` deliberately has no dep on either type crate — //! `metric_vocabulary`'s own doc comment names this as an accepted residual). use std::collections::BTreeSet; -use aura_analysis::{summarize_r, FamilySelection, RunMetrics, SelectionMode}; +use aura_analysis::{FamilySelection, SelectionMode}; +use aura_backtest::{summarize_r, RunMetrics}; /// The names of every field of a serialized sample whose JSON value is a /// `Number` — i.e. a scalar metric readout, not a string/enum tag, a nested diff --git a/crates/aura-engine/Cargo.toml b/crates/aura-engine/Cargo.toml index 9d1f967..d7102cd 100644 --- a/crates/aura-engine/Cargo.toml +++ b/crates/aura-engine/Cargo.toml @@ -7,11 +7,16 @@ publish.workspace = true [dependencies] aura-core = { path = "../aura-core" } -# aura-analysis holds the pure trading-domain reductions (R-metrics, the -# position-event table, the multiple-comparison hurdle math) lifted out of -# `report` (issue #136). The engine re-exports them via `report::` so callers -# resolve the moved types through aura-engine unchanged (C18 byte-identity). +# aura-analysis holds the domain-free post-run statistics and the +# selection-provenance types the engine's `RunManifest.selection` embeds +# (#136, reduced to the domain-free half by #291). Re-exported via `report::` +# so callers resolve the types through aura-engine unchanged (C18). aura-analysis = { path = "../aura-analysis" } +# aura-backtest holds the backtest metric reductions re-exported through +# `report::` (#291). TRANSIENT widening of the known C28 layer violation: the +# phase-2 edge cut (#147) removes this production edge again on the same +# branch. +aura-backtest = { path = "../aura-backtest" } # serde is admitted under the amended C16 per-case dependency policy (INDEX.md): # it gives the run-report types a typed (de)serialization path for the run # registry (cycle 0029). serde's output is deterministic (C1-safe). @@ -38,7 +43,6 @@ rayon = "1" aura-std = { path = "../aura-std" } aura-market = { path = "../aura-market" } aura-strategy = { path = "../aura-strategy" } -aura-backtest = { path = "../aura-backtest" } aura-vocabulary = { path = "../aura-vocabulary" } # chrono / chrono-tz build the Berlin-local-wall-clock epoch-ns timestamps the # GER40 session-breakout E2E fixture (tests/ger40_breakout.rs) feeds the engine, diff --git a/crates/aura-engine/src/report.rs b/crates/aura-engine/src/report.rs index 58677bd..95a4c63 100644 --- a/crates/aura-engine/src/report.rs +++ b/crates/aura-engine/src/report.rs @@ -11,15 +11,17 @@ use aura_core::{Scalar, ScalarKind, SeriesFold, Timestamp}; use std::collections::HashMap; -// The pure trading-domain reductions (R-metrics, the position-event table, the -// multiple-comparison hurdle math) live in `aura-analysis` (issue #136); they are -// re-imported here so `report::`'s namespace — and therefore `aura-engine`'s -// `lib.rs` re-export and every `crate::report::X` reference — resolves unchanged -// (C18 byte-identity). `summarize` (below) bridges trace columns into these types -// at the engine boundary, so it stays here. -pub use aura_analysis::{ - derive_position_events, expected_max_of_normals, inv_norm_cdf, r_metrics_from_rs, summarize_r, - FamilySelection, PositionAction, PositionEvent, RMetrics, RunMetrics, SelectionMode, +// The backtest reductions (R-metrics, the position-event table) live in +// `aura-backtest::metrics`, the domain-free hurdle math + selection provenance +// in `aura-analysis` (issues #136, #291); both are re-imported here so +// `report::`'s namespace — and therefore `aura-engine`'s `lib.rs` re-export and +// every `crate::report::X` reference — resolves unchanged (C18 byte-identity). +// `summarize` (below) bridges trace columns into these types at the engine +// boundary, so it stays here. +pub use aura_analysis::{expected_max_of_normals, inv_norm_cdf, FamilySelection, SelectionMode}; +pub use aura_backtest::{ + derive_position_events, r_metrics_from_rs, summarize_r, PositionAction, + PositionEvent, RMetrics, RunMetrics, }; /// The reproducible run descriptor (C18). **Caller-supplied**: the engine diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 0ac9974..eeec39a 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -2667,14 +2667,16 @@ shell", #286).** This contract states the *target*. At HEAD the crates cut by mechanical role (vocabulary / reductions / documents / orchestration / CLI), not cleanly by layer, so the layering is realized only partially: - The dependency *direction* already nearly obeys the rule. The one hard - production violation is `aura-engine → aura-analysis`: `report.rs:20` - re-exports the R-metrics, the position-event table, the hurdle math, and the - selection-provenance types (`RMetrics`/`RunMetrics`/`summarize_r`/ - `r_metrics_from_rs`, `PositionEvent`/`PositionAction`/`derive_position_events`, - `inv_norm_cdf`/`expected_max_of_normals`, `FamilySelection`/`SelectionMode`), - and the generic-statistics kernel in `mc.rs` carries R/bias-typed fields - (`McAggregate.bias_sign_flips`, `RBootstrap.e_r`) inside otherwise domain-free - structs. + production violation is the engine's re-export of the backtest metrics — + since the #291 split, `aura-engine → aura-backtest`: `report.rs` re-exports + the R-metrics and the position-event table (`RMetrics`/`RunMetrics`/ + `summarize_r`/`r_metrics_from_rs`, `PositionEvent`/`PositionAction`/ + `derive_position_events`), and the generic-statistics kernel in `mc.rs` + carries R/bias-typed fields (`McAggregate.bias_sign_flips`, `RBootstrap.e_r`) + inside otherwise domain-free structs. The `aura-engine → aura-analysis` edge + is no longer a violation: post-#291 that crate holds only domain-free + statistics + selection provenance (`FamilySelection` for + `RunManifest.selection`), foundation-grade under this contract. - **That violation is the exact surface of the deliberately-deferred #147.** Cutting it cleanly needs the metric genericity (`RunReport` plus abstracting the registry deflation vocabulary) that #136 ratified *deferring* (user-ratified @@ -2689,9 +2691,11 @@ cleanly by layer, so the layering is realized only partially: `aura-market` (`session`, `resample`), `aura-strategy` (`bias`/`stop_rule`/ `sizer` + the cost nodes) and `aura-backtest` (`sim_broker`, `position_management`) carry the outer rungs, each depending only on `aura-core`; - the closed node roster moved to `aura-vocabulary`. The remaining in-crate - interweave is `aura-analysis`, which still holds backtest reductions and generic - hurdle math together (phase 5). No crate exists yet for *measurement* (its run + the closed node roster moved to `aura-vocabulary`. The `aura-analysis` + interweave is resolved (phase 5, #291): the backtest reductions live in + `aura-backtest::metrics` beside their `position_management` producer, and + `aura-analysis` is reduced to domain-free statistics + selection provenance + (`[dependencies]` = serde only). No crate exists yet for *measurement* (its run verb is the additive shape dispatch, #286) or *execution* (unbuilt — the C10/C13 edge only). Under this model the `aura-registry → aura-research` edge is column-internal and legal. @@ -2703,7 +2707,9 @@ cleanly by layer, so the layering is realized only partially: retention); (4) split the `aura-std` roster along engine / market / strategy / backtest — **done** (#288: four `aura-core`-only node crates, the closed roster moved to `aura-vocabulary`, the ladder direction enforced by a structural test); - (5) split `aura-analysis` into generic statistics and backtest metrics; (6) + (5) split `aura-analysis` into generic statistics and backtest metrics — + **done** (#291: metrics moved to `aura-backtest::metrics`, `aura-analysis` + reduced to the domain-free half, engine re-export surface name-unchanged); (6) generify the column's metric interface (demand-driven — this is #147). Crate names now realized (`aura-market`/`aura-strategy`/`aura-backtest`/ `aura-vocabulary`); full evidence on #288, #286 and the milestone.