From 94c88eb7e1738419114bae66c1dbe7ecb7430ecc Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 26 Jun 2026 23:49:03 +0200 Subject: [PATCH] refactor(0079): extract the trading-analysis leaf into aura-analysis (refs #136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aura-engine is documented (C16) and named as the reusable, domain-agnostic reactive SoA substrate, yet `report.rs` concentrated the trading-domain analysis layer inside it. This cycle relocates the pure-domain leaf into a new `aura-analysis` crate (deps: aura-core + serde only), erecting the engine/domain seam before the Stage-2 broker milestone deposits more currency/position-event code into the same module. Behaviour-preserving (C1): the full workspace suite stays green unchanged (665 passed), including the C18 on-disk byte-identity goldens (cli_run, the registry legacy-line loads). Moved to aura-analysis (bodies verbatim — no float expression re-associated): RunMetrics, RMetrics (+ its hand-written PartialEq), the private r_col column indices + SQN_CAP, summarize_r, r_metrics_from_rs, derive_position_events, PositionAction (+ From/TryFrom), PositionEvent, and the deflation stats helpers inv_norm_cdf / expected_max_of_normals. Their unit tests and private helpers (r_row / r_row_full / pm_row) relocate with them — tests live with their subject (the r_col helpers reach the private module directly, which a re-export cannot bridge). Stays in aura-engine for this cycle: the generic trace plumbing (ColumnarTrace, join_on_ts, JoinedRow, f64_field), RunManifest, RunReport, and summarize (it bridges recorded trace columns into RunMetrics, so it is trace-coupled). The mis-placed orchestration types FamilySelection / SelectionMode also stay (their relocation is entangled with the registry-vocabulary fork, deferred). report.rs pub-re-exports the moved symbols, so aura-engine's lib.rs re-export block and every `crate::report::X` / `aura_engine::X` consumer (aura-registry, aura-cli, aura-composites, aura-ingest) compile byte-unchanged; only the new crate, the workspace member list, aura-engine's Cargo.toml dep, and report.rs itself change. Deferred to later cycles (genuine design forks, route through specify when reached): making RunReport generic over a metric type M and re-bounding sweep/mc/walkforward; relocating the aura-registry Metric / metric_cmp / rank vocabulary; relocating FamilySelection / SelectionMode. Fork decisions logged on #136. --- Cargo.lock | 10 + Cargo.toml | 1 + crates/aura-analysis/Cargo.toml | 16 + crates/aura-analysis/src/lib.rs | 1010 ++++++++++++++++++++++++++++++ crates/aura-engine/Cargo.toml | 5 + crates/aura-engine/src/report.rs | 1004 +---------------------------- 6 files changed, 1052 insertions(+), 994 deletions(-) create mode 100644 crates/aura-analysis/Cargo.toml create mode 100644 crates/aura-analysis/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 5d63310..89eedeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "aura-analysis" +version = "0.1.0" +dependencies = [ + "aura-core", + "serde", + "serde_json", +] + [[package]] name = "aura-cli" version = "0.1.0" @@ -84,6 +93,7 @@ dependencies = [ name = "aura-engine" version = "0.1.0" dependencies = [ + "aura-analysis", "aura-core", "aura-std", "chrono", diff --git a/Cargo.toml b/Cargo.toml index de2b06d..8032dfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/aura-core", "crates/aura-std", "crates/aura-engine", + "crates/aura-analysis", "crates/aura-composites", "crates/aura-cli", "crates/aura-ingest", diff --git a/crates/aura-analysis/Cargo.toml b/crates/aura-analysis/Cargo.toml new file mode 100644 index 0000000..739802b --- /dev/null +++ b/crates/aura-analysis/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "aura-analysis" +edition.workspace = true +version.workspace = true +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 = { workspace = true } +# serde_json renders RunReport JSON for both the registry (disk) and stdout +# (RunReport::to_json) — one shape, no hand-rolled writer (amended C16). +serde_json = { workspace = true } diff --git a/crates/aura-analysis/src/lib.rs b/crates/aura-analysis/src/lib.rs new file mode 100644 index 0000000..e9a2fe3 --- /dev/null +++ b/crates/aura-analysis/src/lib.rs @@ -0,0 +1,1010 @@ +//! 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}; + +/// 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 Stage-1 R metrics block. `None` for a pip-only run (and for legacy + /// `runs.jsonl` written before this field existed — `serde(default)`); omitted from + /// the JSON entirely when absent (`skip_serializing_if`), so the pip-only on-disk + /// shape stays byte-unchanged (C14/C18 back-compat). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub r: Option, +} + +/// R-based signal-quality metrics (Stage-1), reduced from a `PositionManagement` dense +/// record stream by [`summarize_r`]. Account- and instrument-agnostic (pure R). 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 - round_trip_cost / latched_dist) — churn-honest + 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 `stage1_r_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; + pub const ENTRY_PRICE: usize = 6; + 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; +} + +/// 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)], round_trip_cost: f64) -> RMetrics { + // Collect one entry per trade: its realised R, the entry-conviction |bias|, and the + // latched R-distance (|entry - stop|, the frozen R-denominator) recovered from the + // record. The ledger is the closed rows; a position still open on the last row is + // force-closed at its unrealized R (a window-end trade). + struct Trade { + r: f64, + bias_abs: f64, + latched: f64, + } + let mut trades: Vec = Vec::new(); + for (_, row) in record { + if row[r_col::CLOSED].as_bool() { + trades.push(Trade { + r: row[r_col::REALIZED_R].as_f64(), + bias_abs: row[r_col::CONVICTION_AT_ENTRY].as_f64(), + latched: (row[r_col::ENTRY_PRICE].as_f64() - row[r_col::STOP_PRICE].as_f64()).abs(), + }); + } + } + let mut n_open_at_end = 0u64; + if let Some((_, last)) = record.last() + && last[r_col::OPEN].as_bool() + { + trades.push(Trade { + r: last[r_col::UNREALIZED_R].as_f64(), + bias_abs: last[r_col::CONVICTION_AT_ENTRY].as_f64(), + latched: (last[r_col::ENTRY_PRICE].as_f64() - last[r_col::STOP_PRICE].as_f64()).abs(), + }); + n_open_at_end = 1; + } + let n = 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 one round-trip spread (price units) per trade, expressed in R + // by dividing by that trade's latched R-distance (a zero distance contributes no cost). + let net_sum: f64 = trades + .iter() + .map(|t| t.r - if t.latched > 0.0 { round_trip_cost / t.latched } else { 0.0 }) + .sum(); + let net_expectancy_r = net_sum / n as f64; + // conviction terciles: sort by 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 Stage-1 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 (Stage-1 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) + } + #[test] + fn summarize_r_is_zero_on_empty() { + let m = summarize_r(&[], 0.0); + assert_eq!(m.n_trades, 0); + assert_eq!(m.expectancy_r, 0.0); + assert_eq!(m.max_r_drawdown, 0.0); + assert_eq!(m.sqn, 0.0); + assert_eq!(m.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, 0.0); + assert_eq!(m.n_trades, 3); + assert!((m.expectancy_r - (2.0 / 3.0)).abs() < 1e-9); + assert!((m.win_rate - (2.0 / 3.0)).abs() < 1e-9); + 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, 0.0); + assert_eq!(m.n_trades, 2); + assert_eq!(m.n_open_at_end, 1); + assert!((m.expectancy_r - 0.25).abs() < 1e-9); // (1 + -0.5)/2 + } + #[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, 0.0).max_r_drawdown - 2.0).abs() < 1e-9); + } + + #[test] + fn summarize_r_sqn_is_sqrt_n_mean_over_stdev() { + // R = [1, 1, 1, -1]: mean 0.5; sample var = ((0.5)^2*3 + (1.5)^2)/3 = 1.0; sd = 1; + // sqn = sqrt(4)*0.5/1 = 1.0. + let rec = vec![ + r_row(true, 1.0, false, 0.0), + r_row(true, 1.0, false, 0.0), + r_row(true, 1.0, false, 0.0), + r_row(true, -1.0, false, 0.0), + ]; + let m = summarize_r(&rec, 0.0); + assert!((m.sqn - 1.0).abs() < 1e-9, "sqn = sqrt(n)*mean/sd; got {}", m.sqn); + } + + #[test] + fn summarize_r_sqn_zero_when_under_two_trades_or_zero_variance() { + // n < 2 -> 0 + assert_eq!(summarize_r(&[r_row(true, 2.0, false, 0.0)], 0.0).sqn, 0.0); + // identical R -> zero variance -> 0 + let flat = vec![r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0)]; + assert_eq!(summarize_r(&flat, 0.0).sqn, 0.0); + } + + #[test] + fn summarize_r_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, 0.0); + 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, 0.0); + 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, 0.0); + assert!((m.conviction_terciles_r[0] - (-1.5)).abs() < 1e-9, "low tercile: {:?}", m.conviction_terciles_r); + assert!((m.conviction_terciles_r[2] - 1.5).abs() < 1e-9, "high tercile: {:?}", m.conviction_terciles_r); + assert!(m.conviction_terciles_r[2] > m.conviction_terciles_r[0], "high conviction must out-earn low when calibrated"); + + // Anti-calibrated: high |bias| earns LESS. The ordering must INVERT — proving the + // metric reads |bias|, not trade order. Same R multiset, |bias| reversed. + let anti = vec![ + r_row_full(2.0, 100.0, 99.0, 0.1), + r_row_full(1.0, 100.0, 99.0, 0.2), + r_row_full(0.0, 100.0, 99.0, 0.5), + r_row_full(0.0, 100.0, 99.0, 0.6), + r_row_full(-1.0, 100.0, 99.0, 0.9), + r_row_full(-2.0, 100.0, 99.0, 1.0), + ]; + let a = summarize_r(&anti, 0.0); + assert!(a.conviction_terciles_r[2] < a.conviction_terciles_r[0], "anti-calibrated must invert: {:?}", a.conviction_terciles_r); + } + + #[test] + fn summarize_r_conviction_terciles_zero_under_three_trades() { + let rec = vec![r_row_full(2.0, 100.0, 99.0, 0.5), r_row_full(-1.0, 100.0, 99.0, 0.5)]; + assert_eq!(summarize_r(&rec, 0.0).conviction_terciles_r, [0.0; 3]); + } + + #[test] + fn summarize_r_net_of_cost_subtracts_round_trip_per_trade() { + // one trade: R=+2, entry 100, stop 90 -> latched 10. round_trip_cost 1.0 (price + // units) -> cost in R = 1/10 = 0.1. gross E[R]=2.0, net = 1.9. + let rec = vec![r_row_full(2.0, 100.0, 90.0, 0.5)]; + let gross = summarize_r(&rec, 0.0); + let net = summarize_r(&rec, 1.0); + assert!((gross.expectancy_r - 2.0).abs() < 1e-9); + assert!((gross.net_expectancy_r - 2.0).abs() < 1e-9, "zero cost -> net == gross"); + assert!((net.expectancy_r - 2.0).abs() < 1e-9, "cost never changes gross expectancy"); + assert!((net.net_expectancy_r - 1.9).abs() < 1e-9, "net = 2 - 1/10; got {}", net.net_expectancy_r); + } + + // 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, 0.0); + 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(&[], 0.0); + 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, 0.0); + 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(), 0.0); + 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(), 0.0); + 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 Stage-1) 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-engine/Cargo.toml b/crates/aura-engine/Cargo.toml index 582eea2..c98fb61 100644 --- a/crates/aura-engine/Cargo.toml +++ b/crates/aura-engine/Cargo.toml @@ -7,6 +7,11 @@ 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 = { path = "../aura-analysis" } # 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). diff --git a/crates/aura-engine/src/report.rs b/crates/aura-engine/src/report.rs index cfb4e6a..725eb6d 100644 --- a/crates/aura-engine/src/report.rs +++ b/crates/aura-engine/src/report.rs @@ -11,32 +11,16 @@ use aura_core::{Scalar, ScalarKind, SeriesFold, Timestamp}; use std::collections::HashMap; -/// 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 Stage-1 R metrics block. `None` for a pip-only run (and for legacy - /// `runs.jsonl` written before this field existed — `serde(default)`); omitted from - /// the JSON entirely when absent (`skip_serializing_if`), so the pip-only on-disk - /// shape stays byte-unchanged (C14/C18 back-compat). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub r: Option, -} +// 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, + PositionAction, PositionEvent, RMetrics, RunMetrics, +}; /// Which selection objective produced the record (additive provenance, C23). /// `Argmax` is the bare-best pick (cycle 0076), deflated for the number of trials. @@ -81,381 +65,6 @@ pub struct FamilySelection { pub n_neighbours: Option, } -/// R-based signal-quality metrics (Stage-1), 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 - round_trip_cost / latched_dist) — churn-honest - 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 `stage1_r_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; - pub const ENTRY_PRICE: usize = 6; - 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; -} - -/// 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)], round_trip_cost: f64) -> RMetrics { - // Collect one entry per trade: its realised R, the entry-conviction |bias|, and the - // latched R-distance (|entry - stop|, the frozen R-denominator) recovered from the - // record. The ledger is the closed rows; a position still open on the last row is - // force-closed at its unrealized R (a window-end trade). - struct Trade { - r: f64, - bias_abs: f64, - latched: f64, - } - let mut trades: Vec = Vec::new(); - for (_, row) in record { - if row[r_col::CLOSED].as_bool() { - trades.push(Trade { - r: row[r_col::REALIZED_R].as_f64(), - bias_abs: row[r_col::CONVICTION_AT_ENTRY].as_f64(), - latched: (row[r_col::ENTRY_PRICE].as_f64() - row[r_col::STOP_PRICE].as_f64()).abs(), - }); - } - } - let mut n_open_at_end = 0u64; - if let Some((_, last)) = record.last() - && last[r_col::OPEN].as_bool() - { - trades.push(Trade { - r: last[r_col::UNREALIZED_R].as_f64(), - bias_abs: last[r_col::CONVICTION_AT_ENTRY].as_f64(), - latched: (last[r_col::ENTRY_PRICE].as_f64() - last[r_col::STOP_PRICE].as_f64()).abs(), - }); - n_open_at_end = 1; - } - let n = 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 one round-trip spread (price units) per trade, expressed in R - // by dividing by that trade's latched R-distance (a zero distance contributes no cost). - let net_sum: f64 = trades - .iter() - .map(|t| t.r - if t.latched > 0.0 { round_trip_cost / t.latched } else { 0.0 }) - .sum(); - let net_expectancy_r = net_sum / n as f64; - // conviction terciles: sort by 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 Stage-1 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 (Stage-1 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, -} - /// The reproducible run descriptor (C18). **Caller-supplied**: the engine /// cannot introspect a git commit, an RNG seed, or a broker label — the World /// that bootstraps and runs the harness fills these in. @@ -687,56 +296,6 @@ fn scalar_to_f64(s: Scalar) -> 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::*; @@ -745,26 +304,6 @@ mod tests { use aura_std::{Bias, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc; - #[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 runmanifest_without_selection_field_deserialises_to_none() { let json = r#"{"commit":"c","params":[],"window":[0,0],"seed":0,"broker":"b"}"#; @@ -864,57 +403,6 @@ mod tests { assert_eq!(sel.n_neighbours, None); } - #[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); - } - /// The declared signature of a `Recorder` over one f64 column (the sink shape /// the two-sink harness uses). fn f64_recorder_sig() -> NodeSchema { @@ -1026,279 +514,6 @@ mod tests { assert_eq!(r1.to_json(), r2.to_json()); } - // 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) - } - #[test] - fn summarize_r_is_zero_on_empty() { - let m = summarize_r(&[], 0.0); - assert_eq!(m.n_trades, 0); - assert_eq!(m.expectancy_r, 0.0); - assert_eq!(m.max_r_drawdown, 0.0); - assert_eq!(m.sqn, 0.0); - assert_eq!(m.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, 0.0); - assert_eq!(m.n_trades, 3); - assert!((m.expectancy_r - (2.0 / 3.0)).abs() < 1e-9); - assert!((m.win_rate - (2.0 / 3.0)).abs() < 1e-9); - 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, 0.0); - assert_eq!(m.n_trades, 2); - assert_eq!(m.n_open_at_end, 1); - assert!((m.expectancy_r - 0.25).abs() < 1e-9); // (1 + -0.5)/2 - } - #[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, 0.0).max_r_drawdown - 2.0).abs() < 1e-9); - } - - #[test] - fn summarize_r_sqn_is_sqrt_n_mean_over_stdev() { - // R = [1, 1, 1, -1]: mean 0.5; sample var = ((0.5)^2*3 + (1.5)^2)/3 = 1.0; sd = 1; - // sqn = sqrt(4)*0.5/1 = 1.0. - let rec = vec![ - r_row(true, 1.0, false, 0.0), - r_row(true, 1.0, false, 0.0), - r_row(true, 1.0, false, 0.0), - r_row(true, -1.0, false, 0.0), - ]; - let m = summarize_r(&rec, 0.0); - assert!((m.sqn - 1.0).abs() < 1e-9, "sqn = sqrt(n)*mean/sd; got {}", m.sqn); - } - - #[test] - fn summarize_r_sqn_zero_when_under_two_trades_or_zero_variance() { - // n < 2 -> 0 - assert_eq!(summarize_r(&[r_row(true, 2.0, false, 0.0)], 0.0).sqn, 0.0); - // identical R -> zero variance -> 0 - let flat = vec![r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0)]; - assert_eq!(summarize_r(&flat, 0.0).sqn, 0.0); - } - - #[test] - fn summarize_r_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, 0.0); - 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, 0.0); - 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, 0.0); - assert!((m.conviction_terciles_r[0] - (-1.5)).abs() < 1e-9, "low tercile: {:?}", m.conviction_terciles_r); - assert!((m.conviction_terciles_r[2] - 1.5).abs() < 1e-9, "high tercile: {:?}", m.conviction_terciles_r); - assert!(m.conviction_terciles_r[2] > m.conviction_terciles_r[0], "high conviction must out-earn low when calibrated"); - - // Anti-calibrated: high |bias| earns LESS. The ordering must INVERT — proving the - // metric reads |bias|, not trade order. Same R multiset, |bias| reversed. - let anti = vec![ - r_row_full(2.0, 100.0, 99.0, 0.1), - r_row_full(1.0, 100.0, 99.0, 0.2), - r_row_full(0.0, 100.0, 99.0, 0.5), - r_row_full(0.0, 100.0, 99.0, 0.6), - r_row_full(-1.0, 100.0, 99.0, 0.9), - r_row_full(-2.0, 100.0, 99.0, 1.0), - ]; - let a = summarize_r(&anti, 0.0); - assert!(a.conviction_terciles_r[2] < a.conviction_terciles_r[0], "anti-calibrated must invert: {:?}", a.conviction_terciles_r); - } - - #[test] - fn summarize_r_conviction_terciles_zero_under_three_trades() { - let rec = vec![r_row_full(2.0, 100.0, 99.0, 0.5), r_row_full(-1.0, 100.0, 99.0, 0.5)]; - assert_eq!(summarize_r(&rec, 0.0).conviction_terciles_r, [0.0; 3]); - } - - #[test] - fn summarize_r_net_of_cost_subtracts_round_trip_per_trade() { - // one trade: R=+2, entry 100, stop 90 -> latched 10. round_trip_cost 1.0 (price - // units) -> cost in R = 1/10 = 0.1. gross E[R]=2.0, net = 1.9. - let rec = vec![r_row_full(2.0, 100.0, 90.0, 0.5)]; - let gross = summarize_r(&rec, 0.0); - let net = summarize_r(&rec, 1.0); - assert!((gross.expectancy_r - 2.0).abs() < 1e-9); - assert!((gross.net_expectancy_r - 2.0).abs() < 1e-9, "zero cost -> net == gross"); - assert!((net.expectancy_r - 2.0).abs() < 1e-9, "cost never changes gross expectancy"); - assert!((net.net_expectancy_r - 1.9).abs() < 1e-9, "net = 2 - 1/10; got {}", net.net_expectancy_r); - } - - // 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); - } - fn samples(values: &[f64]) -> Vec<(Timestamp, f64)> { values .iter() @@ -1449,72 +664,6 @@ mod tests { assert_eq!(back, report); } - #[test] - fn runmetrics_with_r_block_round_trips() { - let m = RunMetrics { - total_pips: 3.0, - max_drawdown: 1.0, - 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 join_on_ts_aligns_streams_of_different_cardinality() { // spine fires every bar; side A is one row shorter (no ts 10, like cold @@ -1650,137 +799,4 @@ mod tests { let rows = vec![(Timestamp(1), vec![Scalar::f64(1.0)])]; let _ = ColumnarTrace::from_rows("narrow", &[ScalarKind::F64, ScalarKind::F64], &rows); } - - #[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, 0.0); - 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(&[], 0.0); - 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, 0.0); - 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(), 0.0); - 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(), 0.0); - 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 Stage-1) 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); - } }