Files
Aura/crates/aura-analysis/src/lib.rs
T
Brummel 4de6d5cbad rename: retire the stage1-* family for the r-family (r-sma / r-breakout / r-meanrev)
The stage1 ordinal has been dead vocabulary since the C10 reframe dropped
the two-stage research model: a 1 structurally implies a 2 that no longer
exists. The family is renamed by its live discriminator - the R yardstick -
with members named by their signal, uniform with their signal-named
siblings (sma, macd, momentum):

- selectors: stage1-r -> r-sma, stage1-breakout -> r-breakout,
  stage1-meanrev -> r-meanrev (old tokens are usage errors, exit 2 - no
  silent alias)
- identifiers: Strategy::RSma/RBreakout/RMeanRev, HarnessKind::RSma,
  r_sma_*/r_breakout_*/r_meanrev_*, wrap_r, run_signal_r, RGrid, R_SMA_*
- persisted identity: the sma_signal composite (param prefix
  sma_signal.fast.length / .slow.length; fixtures regenerated; content-ids
  shift - no test pins a literal hash, the registry parses no record names)
- e2e test files git-mv'd to r_sma_e2e / r_breakout_e2e / r_meanrev_e2e
- dead Stage-1/Stage-2 prose reworded to post-C10 vocabulary across
  rustdoc, Cargo.tomls, ledger live lines, and the glossary (historical
  entries stay; fieldtests corpus untouched)
- CLAUDE.md invariant 7 rewritten to record the ratified C10 reframe
  faithfully (the token-swap alone would have laundered the retired
  gated-currency/realistic-broker design into unmarked live prose); the
  unbacked account-mode clause dropped

Verification: cargo build/test --workspace green (51 targets), clippy
-D warnings clean, doc build clean, acceptance grep gate leaves exactly
the one resampling-stage false positive (harness.rs), smoke: --harness
r-sma runs, --harness stage1-r exits 2 with the new usage line.

Decision log: forks and rationale recorded on the issue (reconciliation
+ implementation-phase comments).

closes #174
2026-07-02 12:03:09 +02:00

1132 lines
51 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overfit_probability: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub n_resamples: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_len: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
// plateau annotation (present iff mode is Plateau*)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub neighbourhood_score: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub n_neighbours: Option<usize>,
}
/// 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<RMetrics>,
}
/// 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<f64>,
}
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<Scalar>)],
cost: &[(Timestamp, Vec<Scalar>)],
) -> 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<Trade> = 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<f64> = trades.iter().map(|t| t.r).collect();
let sum: f64 = rs.iter().sum();
let mean = sum / n as f64;
let wins: Vec<f64> = rs.iter().copied().filter(|&r| r > 0.0).collect();
let losses: Vec<f64> = 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::<f64>() / 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::<f64>() / (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::<f64>() / 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<f64> = rs.iter().copied().filter(|&r| r > 0.0).collect();
let losses: Vec<f64> = 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::<f64>() / 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::<f64>() / (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<Scalar>)],
instrument_id: i64,
) -> Vec<PositionEvent> {
// The derive's single-position book (in_flight is structurally 0).
struct Book {
position_id: i64,
volume: f64,
}
let mut out: Vec<PositionEvent> = Vec::new();
let mut book: Option<Book> = 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<PositionAction> for i64 {
fn from(a: PositionAction) -> i64 {
match a {
PositionAction::Buy => 0,
PositionAction::Sell => 1,
PositionAction::Close => 2,
}
}
}
impl TryFrom<i64> for PositionAction {
type Error = String;
fn try_from(v: i64) -> Result<Self, Self::Error> {
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<Scalar>) {
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<Scalar>) {
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<Scalar> {
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<Scalar>) {
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<Scalar>)> = 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<Scalar>)> {
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<Scalar>)> {
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);
}
}