//! 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}; /// Which selection objective produced the record (additive provenance, C23). /// `Argmax` is the bare-best pick (cycle 0076), deflated for the number of trials. /// `PlateauMean` / `PlateauWorst` (cycle 0077) argmax the neighbourhood-smoothed /// surface instead, so peak-vs-plateau runs stay distinguishable on this field. #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub enum SelectionMode { Argmax, PlateauMean, PlateauWorst, } /// Selection-provenance for a sweep winner. The selection RULE (`mode`) and its /// ANNOTATION are orthogonal: `Argmax` carries the trials-deflation annotation /// (`deflated_score` / `overfit_probability` / `n_resamples` / `block_len` / /// `seed`); `Plateau*` carries the smoothing annotation (`neighbourhood_score` / /// `n_neighbours`). Each annotation is `Option`, present iff its rule produced the /// record; all are additive — recorded, never re-ranking (C23). A legacy line /// (pre-0077) carries the deflation scalars as bare values that deserialize to /// `Some` (serde default), so it still loads (C14/C18). #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct FamilySelection { pub selection_metric: String, pub n_trials: usize, pub raw_winner_metric: f64, pub mode: SelectionMode, // deflation annotation (present iff mode == Argmax with a deflation run) #[serde(default, skip_serializing_if = "Option::is_none")] pub deflated_score: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub overfit_probability: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub n_resamples: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub block_len: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub seed: Option, // plateau annotation (present iff mode is Plateau*) #[serde(default, skip_serializing_if = "Option::is_none")] pub neighbourhood_score: Option, #[serde(default, skip_serializing_if = "Option::is_none")] 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] /// Realised R per closed trade, in trade order — an in-memory conduit for the /// OOS R-series bootstrap. 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 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 // 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], 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; // 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, trade_rs: 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 under the cost = 0 invariant), /// `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], 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, // cost = 0 -> net == gross (frictionless) conviction_terciles_r: [0.0; 3], 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 /// boundaries); the boundary branches return ±inf as a defined limit. // Acklam's published coefficients verbatim; some carry more decimals than an // f64 holds (clippy::excessive_precision) — kept literal as reference constants. #[allow(clippy::excessive_precision)] pub fn inv_norm_cdf(p: f64) -> f64 { const A: [f64; 6] = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00]; const B: [f64; 5] = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, 6.680131188771972e+01, -1.328068155288572e+01]; const C: [f64; 6] = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00]; const D: [f64; 4] = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, 3.754408661907416e+00]; const P_LOW: f64 = 0.02425; if p <= 0.0 { return f64::NEG_INFINITY; } if p >= 1.0 { return f64::INFINITY; } if p < P_LOW { let q = (-2.0 * p.ln()).sqrt(); (((((C[0]*q + C[1])*q + C[2])*q + C[3])*q + C[4])*q + C[5]) / ((((D[0]*q + D[1])*q + D[2])*q + D[3])*q + 1.0) } else if p <= 1.0 - P_LOW { let q = p - 0.5; let r = q * q; (((((A[0]*r + A[1])*r + A[2])*r + A[3])*r + A[4])*r + A[5]) * q / (((((B[0]*r + B[1])*r + B[2])*r + B[3])*r + B[4])*r + 1.0) } else { let q = (-2.0 * (1.0 - p).ln()).sqrt(); -(((((C[0]*q + C[1])*q + C[2])*q + C[3])*q + C[4])*q + C[5]) / ((((D[0]*q + D[1])*q + D[2])*q + D[3])*q + 1.0) } } /// Expected maximum of `k` i.i.d. standard normals — the hurdle a search of `k` /// configurations clears by chance alone. `k <= 1` -> `0.0` (a single trial has no /// multiple-comparison inflation, and guards the `Φ⁻¹(1 - 1/k) -> Φ⁻¹(0)` /// divergence). For `k >= 2`: /// `(1 - γ)·Φ⁻¹(1 - 1/k) + γ·Φ⁻¹(1 - 1/(k·e))`, `γ = 0.5772156649…`. Pure (C1). pub fn expected_max_of_normals(k: usize) -> f64 { if k <= 1 { return 0.0; } const GAMMA: f64 = 0.577_215_664_901_532_9; let kf = k as f64; (1.0 - GAMMA) * inv_norm_cdf(1.0 - 1.0 / kf) + GAMMA * inv_norm_cdf(1.0 - 1.0 / (kf * std::f64::consts::E)) } #[cfg(test)] mod tests { use super::*; #[test] fn inv_norm_cdf_matches_known_quantiles() { assert!((inv_norm_cdf(0.975) - 1.959964).abs() < 1e-3); assert!(inv_norm_cdf(0.5).abs() < 1e-9); assert!((inv_norm_cdf(0.1) + inv_norm_cdf(0.9)).abs() < 1e-9); // symmetry } #[test] fn expected_max_of_normals_is_zero_at_one_and_monotone() { assert_eq!(expected_max_of_normals(1), 0.0); assert_eq!(expected_max_of_normals(0), 0.0); let (e2, e4, e10, e100) = ( expected_max_of_normals(2), expected_max_of_normals(4), expected_max_of_normals(10), expected_max_of_normals(100), ); assert!(e2 < e4 && e4 < e10 && e10 < e100); // strictly increasing 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); } // 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], 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_trade_rs_in_trade_order() { // two closed trades (R = +2.0, then -1.0) over a minimal PositionManagement // record; 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.trade_rs, vec![2.0, -1.0]); assert_eq!(m.n_trades, 2); } #[test] fn summarize_r_empty_record_has_empty_trade_rs() { let m = summarize_r(&[], &[]); assert!(m.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 /// `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.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.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_trade_rs() { // two RMetrics equal in every metric but differing in trade_rs compare EQUAL // (trade_rs is an in-memory conduit, excluded from equality) — this is what // keeps serialize->deserialize round-trips equal (trade_rs is serde-skipped, // so it deserializes empty). let a = summarize_r(&pm_record_two_closed_trades(), &[]); let mut b = a.clone(); b.trade_rs = Vec::new(); assert_eq!(a, b); } #[test] fn populated_trade_rs_is_absent_from_serialized_json() { // the in-memory conduit must never reach the wire: a populated 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.trade_rs, vec![2.0, -1.0], "precondition: trade_rs is populated"); let json = serde_json::to_string(&m).expect("RMetrics serializes"); assert!(!json.contains("trade_rs"), "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); } }