Files
Aura/crates/aura-backtest/src/metrics.rs
T
claude d8c6938027 feat(engine, market, strategy, backtest): NodeSchema.doc threading across the domain crates
C29 compile/unit seam, tasks 4+5 of the self-description plan.

aura-engine (task 4): derive_signature stamps doc: "" with the stance
recorded in-code -- a derived composite signature is graph wiring, not
a vocabulary entry; no seam walks its doc, the described surface is the
composite's own doc at the register seam. All in-crate test literals
thread doc: "test-only schema".

Domain crates (task 5): the 12 production builder sites in
aura-market / aura-strategy / aura-backtest carry authored meaning
lines; test sites in aura-backtest / aura-composites / aura-ingest
thread the test-only doc.

Three texts were corrected against the actual node semantics after
quality review rather than kept from the first authoring pass:
SimBroker is the frictionless integrator of held exposure times price
return into cumulative pip equity (no fills/stops/lifecycle -- that is
PositionManagement's domain), the shared cost-node line names the
PM-geometry inputs and both charge modes (AtClose / PerHeldCycle)
instead of a "per-trade gross R" mapping, and LongOnly's line
conditions on its enabled param and speaks port-term "exposure".

Gates: cargo test green for all six touched crates (engine, market,
strategy, backtest, composites, ingest); the workspace-wide gate
follows task 6 (aura-runner is the one remaining unthreaded site,
E0063 by design until then).

refs #316
2026-07-23 16:08:26 +02:00

1491 lines
65 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.
//! Backtest metrics (C28 backtest layer): the post-run reductions over a run's
//! recorded streams that are functions of the recorded data alone — R-based
//! signal-quality metrics (`summarize_r` / `r_metrics_from_rs`) and the
//! broker-independent position-event table (`derive_position_events`). Moved
//! verbatim from `aura-analysis` (issue #291, C28 phase 5; originally split out
//! of `aura-engine`'s `report` module, issue #136) — beside the
//! `position_management` producer whose record layout these reductions read.
//! Every type's serde shape is byte-pinned by C18 goldens; no float expression
//! re-associated.
use aura_core::{Scalar, SeriesFold, Timestamp};
use aura_engine::{resample_block, MetricVocabulary, SplitMix64};
/// Reduce a run's recorded pip-equity + exposure streams into summary metrics.
/// Pure — identical inputs yield identical metrics (C1/C12). Timestamps are
/// carried in the input to match exactly what a sink records; the reduction
/// itself is value-only (it does not read the timestamps).
pub fn summarize(
equity: &[(Timestamp, f64)],
exposure: &[(Timestamp, f64)],
) -> RunMetrics {
// total_pips + max_drawdown fold over equity; sign_flips folds over exposure.
// SeriesFold is the single arithmetic source of truth (shared with the
// SeriesReducer sink), so this is byte-identical to the prior inline loops.
let mut eqf = SeriesFold::new();
for &(_, v) in equity {
eqf.fold(v);
}
let mut exf = SeriesFold::new();
for &(_, v) in exposure {
exf.fold(v);
}
RunMetrics {
total_pips: eqf.last,
max_drawdown: eqf.max_drawdown,
bias_sign_flips: exf.sign_flips,
r: None,
}
}
/// 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]
/// Cost-netted realised R per closed trade (`r cost_in_r`), in trade
/// order — an in-memory conduit for the OOS R-series bootstrap. Equals
/// the gross series bit-for-bit when no cost model is bound (an empty
/// cost stream is the documented gross-R baseline: cost `0.0` per
/// trade). Excluded from serde (`skip`) so the C18 wire shape is
/// unchanged, and from `PartialEq` (below) so every existing
/// `RMetrics`/`RunReport` equality assertion and round-trip stays green.
#[serde(skip)]
pub net_trade_rs: Vec<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
// net_trade_rs deliberately excluded
}
}
// Dense `PositionManagement` record column indices — the lockstep contract with
// `crate::position_management::{FIELD_NAMES, RECORD_KINDS}` (intra-crate since
// #291). The record crosses the node/reduction seam as a type-erased `Scalar`
// vector (C4 SoA), so it is read positionally by convention, not by importing
// the producer's consts; `r_sma_e2e.rs` guards that the layout matches.
mod r_col {
pub const CLOSED: usize = 0;
pub const REALIZED_R: usize = 1;
pub const DIRECTION: usize = 4;
// entry/stop define the latched distance (1R). Since the net-of-cost fold reads the
// cost-in-R straight from the cost stream (the aura-std `ConstantCost` producer recovers
// the distance), this crate's reductions no longer touch these two columns; they stay in
// the documented layout contract and are exercised by the test fixtures.
#[allow(dead_code)]
pub const ENTRY_PRICE: usize = 6;
#[allow(dead_code)]
pub const STOP_PRICE: usize = 7;
pub const CONVICTION_AT_ENTRY: usize = 9;
pub const SIZE: usize = 10;
pub const OPEN: usize = 11;
pub const UNREALIZED_R: usize = 12;
}
// Cost-model record column indices — the lockstep contract with aura-std's
// `ConstantCost` output schema (`cost_in_r` / `cum_cost_in_r` / `open_cost_in_r`).
// Read positionally across the crate boundary as a type-erased `Scalar` vector
// (C4 SoA), like `r_col`. `summarize_r` folds the per-close cost (col 0) and the
// window-end open trade's would-be cost (col 2); the running sum (col 1) is not
// folded, so it is intentionally not named here.
mod cost_col {
pub const COST_IN_R: usize = 0;
pub const OPEN_COST_IN_R: usize = 2;
}
/// Van Tharp's conventional trade-count cap for the n-normalized SQN ("SQN
/// score"): capping n stops the single-number objective rewarding sheer turnover.
const SQN_CAP: u64 = 100;
/// Reduce a `PositionManagement` dense record stream into R-metrics. Pure (C1).
/// The trade ledger is the rows where `closed_this_cycle`; a position still open on the
/// last row is force-closed at its `unrealized_r` (a window-end trade — never silently
/// folded as unrealised MtM). Empty input -> a well-defined all-zero `RMetrics`.
pub fn summarize_r(
record: &[(Timestamp, Vec<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],
net_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;
// The per-trade net series the OOS bootstrap conduit carries (#259). A
// separate materialization, deliberately NOT factored through `net_sum`:
// that expression's tokens are byte-pinned (see the SQN note above).
let net_rs: Vec<f64> = trades.iter().map(|t| t.r - t.cost).collect();
// conviction terciles: sort by conviction_at_entry ascending, split into three contiguous
// near-equal-count buckets (floor boundaries i*n/3), E[R] per bucket. < 3 trades -> 0s.
let conviction_terciles_r = if n < 3 {
[0.0; 3]
} else {
let mut by_conv: Vec<&Trade> = trades.iter().collect();
by_conv.sort_by(|a, b| a.bias_abs.total_cmp(&b.bias_abs));
let nn = by_conv.len();
let mut out = [0.0; 3];
for (i, slot) in out.iter_mut().enumerate() {
let lo = i * nn / 3;
let hi = (i + 1) * nn / 3;
let bucket = &by_conv[lo..hi];
*slot = if bucket.is_empty() {
0.0
} else {
bucket.iter().map(|t| t.r).sum::<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,
net_trade_rs: net_rs,
}
}
/// Reduce a flat per-trade R series (e.g. the pooled across-window OOS series of a
/// walk-forward) into `RMetrics`. The R-distribution fields (mean / win-rate / profit
/// factor / max-drawdown / SQN) duplicate [`summarize_r`]'s arithmetic byte-for-byte —
/// deliberately copied, not factored, so neither pinned-float expression shifts under
/// IEEE-754 non-associativity. MAINTENANCE COUPLING: the two copies must be edited in
/// lockstep; the only guard that they agree is the cross-reducer equality test
/// `summarize_r_includes_open_trade_and_matches_r_metrics_from_rs` — touch one copy
/// without the other and that test is the sole tripwire. The
/// fields a flat R series cannot carry are set honestly: `n_open_at_end = 0`,
/// `net_expectancy_r = expectancy_r` (exact: the input series is already
/// cost-netted — the conduit carries `r cost_in_r`),
/// `conviction_terciles_r = [0,0,0]` (per-trade conviction is not pooled). Empty
/// input -> a well-defined all-zero `RMetrics`.
pub fn r_metrics_from_rs(rs: &[f64]) -> RMetrics {
let n = rs.len() as u64;
if n == 0 {
return RMetrics {
expectancy_r: 0.0, n_trades: 0, win_rate: 0.0, avg_win_r: 0.0,
avg_loss_r: 0.0, profit_factor: 0.0, max_r_drawdown: 0.0,
n_open_at_end: 0, sqn: 0.0, sqn_normalized: 0.0, net_expectancy_r: 0.0,
conviction_terciles_r: [0.0; 3], net_trade_rs: Vec::new(),
};
}
let sum: f64 = rs.iter().sum();
let mean = sum / n as f64;
let wins: Vec<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, // input series is already net -> net == mean
conviction_terciles_r: [0.0; 3],
net_trade_rs: Vec::new(),
}
}
/// Derive the broker-independent position-event table from a `PositionManagement`
/// dense record (read positionally, C7 SoA), as the first difference of the
/// executed book (`deal = target - book - in_flight`; `in_flight = 0` for the
/// instant-fill backtest). Pure (C1); no look-ahead — each event's `event_ts` is
/// its own cycle (C2). `instrument_id` is supplied by the caller (the engine never
/// imports an instrument spec from `aura-ingest`). A reversal emits `Close` then the
/// opposite open at one `event_ts` (close first); a position still open on the last
/// row emits its open with no synthetic `Close` (the table records actual executed events —
/// unlike `summarize_r`, which force-closes for the R metric).
pub fn derive_position_events(
record: &[(Timestamp, Vec<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,
}
/// The backtest metric vocabulary's resolved keys (was aura-registry's
/// private `Metric` enum — the vocabulary is supplied by backtest now, not
/// baked into the registry; #147). The runtime hot path branches on this
/// rather than re-parsing the name.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RunMetricKey {
TotalPips,
MaxDrawdown,
BiasSignFlips,
Sqn,
SqnNormalized,
ExpectancyR,
NetExpectancyR,
}
impl RunMetricKey {
/// C10: the four R-denominated keys — the account/instrument-agnostic
/// set the R-only registry gates (`check_r_metric`, `generalization`)
/// accept.
pub fn r_based(self) -> bool {
matches!(
self,
Self::Sqn | Self::SqnNormalized | Self::ExpectancyR | Self::NetExpectancyR
)
}
}
/// The four R-based names, in refusal-prose order (the NonRMetric message).
pub const R_BASED_METRICS: &[&str] =
&["sqn", "sqn_normalized", "expectancy_r", "net_expectancy_r"];
/// The rankable roster, in refusal-prose order — the roster this vocabulary
/// resolves against (#147). The registry's refusal prose and
/// `aura_campaign::RANKABLE_METRICS` (a re-export) both derive from this
/// single source; neither hand-copies it anymore.
pub const RANKABLE_METRICS: &[&str] = &[
"total_pips",
"max_drawdown",
"bias_sign_flips",
"sqn",
"sqn_normalized",
"expectancy_r",
"net_expectancy_r",
];
/// Recompute the selected R metric from a trade-R slice (reuses
/// `r_metrics_from_rs`, the same arithmetic `summarize_r` uses) — the
/// deflation null's member reduction (was aura-registry-private, #147).
fn member_metric_from_rs(rs: &[f64], key: RunMetricKey) -> f64 {
let rm = r_metrics_from_rs(rs);
match key {
RunMetricKey::Sqn => rm.sqn,
RunMetricKey::SqnNormalized => rm.sqn_normalized,
RunMetricKey::ExpectancyR => rm.expectancy_r,
RunMetricKey::NetExpectancyR => rm.net_expectancy_r,
_ => unreachable!("member_metric_from_rs is R-only"),
}
}
impl MetricVocabulary for RunMetrics {
type Key = RunMetricKey;
fn resolve(name: &str) -> Option<RunMetricKey> {
Some(match name {
"total_pips" => RunMetricKey::TotalPips,
"max_drawdown" => RunMetricKey::MaxDrawdown,
// accept the new name AND the pre-rename one (CLI back-compat).
"bias_sign_flips" | "exposure_sign_flips" => RunMetricKey::BiasSignFlips,
"sqn" => RunMetricKey::Sqn,
"sqn_normalized" => RunMetricKey::SqnNormalized,
"expectancy_r" => RunMetricKey::ExpectancyR,
"net_expectancy_r" => RunMetricKey::NetExpectancyR,
_ => return None,
})
}
fn known() -> &'static [&'static str] {
RANKABLE_METRICS
}
fn higher_is_better(key: RunMetricKey) -> bool {
!matches!(key, RunMetricKey::MaxDrawdown | RunMetricKey::BiasSignFlips)
}
fn value(&self, key: RunMetricKey) -> f64 {
fn r_get(m: &RunMetrics, f: impl Fn(&RMetrics) -> f64) -> f64 {
m.r.as_ref().map(f).unwrap_or(f64::NEG_INFINITY)
}
match key {
RunMetricKey::TotalPips => self.total_pips,
RunMetricKey::MaxDrawdown => self.max_drawdown,
RunMetricKey::BiasSignFlips => self.bias_sign_flips as f64,
RunMetricKey::Sqn => r_get(self, |r| r.sqn),
RunMetricKey::SqnNormalized => r_get(self, |r| r.sqn_normalized),
RunMetricKey::ExpectancyR => r_get(self, |r| r.expectancy_r),
RunMetricKey::NetExpectancyR => r_get(self, |r| r.net_expectancy_r),
}
}
fn has_resampling_null(key: RunMetricKey) -> bool {
key.r_based()
}
/// # Panics
/// Panics (via `member_metric_from_rs`'s `unreachable!`) if `key` is not
/// R-based — callers must gate on `has_resampling_null(key)` before
/// drawing, exactly as documented at the trait level.
fn null_draw(&self, key: RunMetricKey, block_len: usize, rng: &mut SplitMix64) -> Option<f64> {
let rs: &[f64] = self.r.as_ref().map(|r| r.net_trade_rs.as_slice()).unwrap_or(&[]);
if rs.is_empty() {
return None; // consumes no rng — bit-parity with the pre-#147 fold
}
let mean = rs.iter().sum::<f64>() / rs.len() as f64;
let centred: Vec<f64> = rs.iter().map(|x| x - mean).collect();
let bl = block_len.clamp(1, rs.len());
Some(member_metric_from_rs(&resample_block(&centred, bl, rng), key))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn position_action_round_trips_through_i64() {
for a in [PositionAction::Buy, PositionAction::Sell, PositionAction::Close] {
let n: i64 = a.into();
assert_eq!(PositionAction::try_from(n), Ok(a));
}
assert_eq!(i64::from(PositionAction::Buy), 0);
assert_eq!(i64::from(PositionAction::Sell), 1);
assert_eq!(i64::from(PositionAction::Close), 2);
}
#[test]
fn position_action_rejects_out_of_range_i64() {
assert!(PositionAction::try_from(3).is_err());
assert!(PositionAction::try_from(-1).is_err());
}
#[test]
fn position_event_serde_round_trips_with_bare_int_action() {
let ev = PositionEvent {
event_ts: Timestamp(42),
action: PositionAction::Sell,
position_id: 7,
instrument_id: 3,
volume: 0.5,
};
let json = serde_json::to_string(&ev).expect("serialize");
// action encodes as a bare integer (C7 scalar shape), not a tagged enum
assert!(json.contains("\"action\":1"), "action not bare-int encoded: {json}");
let back: PositionEvent = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, ev);
}
#[test]
fn position_events_may_share_one_event_ts_on_reversal() {
// a stop-and-reverse: Close then open at the SAME event_ts (close-before-open).
let ts = Timestamp(100);
let close = PositionEvent {
event_ts: ts, action: PositionAction::Close,
position_id: 1, instrument_id: 3, volume: 0.5,
};
let open = PositionEvent {
event_ts: ts, action: PositionAction::Sell,
position_id: 2, instrument_id: 3, volume: 0.5,
};
let table = [close, open];
assert_eq!(table[0].event_ts, table[1].event_ts);
assert_eq!(table[0].action, PositionAction::Close);
assert_eq!(table[1].action, PositionAction::Sell);
}
// Build a minimal dense record row: only the columns summarize_r reads matter.
// The four written slots reference `r_col::*` (not bare literals) so the helper
// tracks the same constants it exists to test — a literal would mask drift in them.
fn r_row(closed: bool, realized: f64, open: bool, unreal: f64) -> (Timestamp, Vec<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);
}
/// Property (#259): the bootstrap conduit `net_trade_rs` carries the
/// COST-NETTED per-trade R (`r cost_in_r`) in trade order — the same
/// per-trade values `net_expectancy_r` averages — while every gross
/// scalar stays cost-free. With an empty cost stream the conduit equals
/// the gross series bit-for-bit (cost 0.0 per trade), which is what
/// keeps uncosted campaigns byte-identical.
#[test]
fn summarize_r_net_trade_rs_is_the_cost_netted_series_in_trade_order() {
let c = 2.0_f64;
let record = vec![
(Timestamp(0), row14(true, 1.0, 100.0, 96.0, 1.0, false, 0.0)),
(Timestamp(1), row14(false, 0.0, 100.0, 98.0, 1.0, true, 0.5)),
];
let cost = vec![
(Timestamp(0), vec![Scalar::f64(c / 4.0), Scalar::f64(c / 4.0), Scalar::f64(0.0)]),
(Timestamp(1), vec![Scalar::f64(0.0), Scalar::f64(c / 4.0), Scalar::f64(c / 2.0)]),
];
let m = summarize_r(&record, &cost);
assert_eq!(m.net_trade_rs, vec![1.0 - 0.5, 0.5 - 1.0], "net conduit = r cost, trade order");
assert!((m.expectancy_r - 0.75).abs() < 1e-12, "gross scalars stay cost-free");
let gross = summarize_r(&record, &[]);
assert_eq!(gross.net_trade_rs, vec![1.0, 0.5], "empty cost stream ⇒ conduit == gross series");
}
// One PositionManagement dense record row for the derive tests: only the columns
// derive_position_events reads (closed, direction, size, open) are set; the rest
// default to 0. Distinct per-row timestamps (the derive keys events on the row's
// own cycle, not the entry_ts column).
fn pm_row(ts: i64, closed: bool, dir: i64, size: f64, open: bool) -> (Timestamp, Vec<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],
net_trade_rs: Vec::new(),
}),
};
let json = serde_json::to_string(&m).expect("serialize");
assert!(json.contains("\"r\":{"), "r block present when Some: {json}");
let back: RunMetrics = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, m);
}
#[test]
fn rmetrics_deserializes_without_sqn_normalized_field_to_zero() {
// C18 back-compat: a pre-0067 `r:` block (serialized before sqn_normalized
// existed) lacks the field; serde(default) must fill it with 0.0.
let legacy = r#"{"expectancy_r":1.0,"n_trades":4,"win_rate":0.5,"avg_win_r":1.5,"avg_loss_r":-0.5,"profit_factor":3.0,"max_r_drawdown":0.5,"n_open_at_end":0,"sqn":1.0,"net_expectancy_r":0.4,"conviction_terciles_r":[0.0,0.0,0.0]}"#;
let m: RMetrics = serde_json::from_str(legacy).expect("legacy RMetrics deserializes");
assert_eq!(m.sqn_normalized, 0.0);
assert_eq!(m.sqn, 1.0);
}
#[test]
fn legacy_exposure_sign_flips_key_reads_via_alias_and_new_output_uses_bias() {
// a legacy runs.jsonl metrics line carries the OLD key — the serde alias must accept it.
let legacy = r#"{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":3}"#;
let m: RunMetrics = serde_json::from_str(legacy).expect("legacy exposure_sign_flips deserialises");
assert_eq!(m.bias_sign_flips, 3);
// new output serialises the NEW key, and the old key is gone from output.
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("\"bias_sign_flips\":3"), "new output uses bias_sign_flips: {json}");
assert!(!json.contains("exposure_sign_flips"), "old key absent from new output: {json}");
}
#[test]
fn legacy_runmetrics_without_r_field_deserialises_to_none() {
// a pre-`r` runs.jsonl line: no `r` key at all.
let legacy = r#"{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":1}"#;
let m: RunMetrics = serde_json::from_str(legacy).expect("legacy line still deserialises");
assert_eq!(m.r, None);
assert_eq!(m.total_pips, 12.0);
}
#[test]
fn runmetrics_none_r_is_omitted_from_json() {
let m = RunMetrics { total_pips: 1.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None };
let json = serde_json::to_string(&m).expect("serialize");
assert!(!json.contains("\"r\""), "a None r must be omitted from the JSON: {json}");
}
#[test]
fn summarize_r_populates_net_trade_rs_in_trade_order() {
// two closed trades (R = +2.0, then -1.0) over a minimal PositionManagement
// record; net_trade_rs must carry [2.0, -1.0] in trade order.
let rec = pm_record_two_closed_trades(); // helper below
let m = summarize_r(&rec, &[]);
assert_eq!(m.net_trade_rs, vec![2.0, -1.0]);
assert_eq!(m.n_trades, 2);
}
#[test]
fn summarize_r_empty_record_has_empty_net_trade_rs() {
let m = summarize_r(&[], &[]);
assert!(m.net_trade_rs.is_empty());
assert_eq!(m.n_trades, 0);
}
/// Property: a position still open on the last row is folded into the trade
/// ledger at its `unrealized_r` (a window-end trade), so `summarize_r`'s
/// `net_trade_rs` carries that synthetic open trade's R and `n_trades` counts it.
/// This is the one case where the two reducers' inputs differ in meaning — it
/// is exactly the per-trade R series the OOS conduit hands `r_metrics_from_rs`,
/// so the two must agree on the R-distribution arithmetic for an
/// open-at-end series. Closed +2.0 then open-at-end +0.5 -> rs [2.0, 0.5].
#[test]
fn summarize_r_includes_open_trade_and_matches_r_metrics_from_rs() {
let rec = pm_record_closed_then_open_at_end();
let m = summarize_r(&rec, &[]);
assert_eq!(m.net_trade_rs, vec![2.0, 0.5]);
assert_eq!(m.n_trades, 2);
assert_eq!(m.n_open_at_end, 1);
// Feed the open-at-end pooled series through the flat reducer: the
// R-distribution fields (the verbatim-copied arithmetic) must agree.
let pooled = r_metrics_from_rs(&m.net_trade_rs);
assert_eq!(pooled.n_trades, m.n_trades);
assert_eq!(pooled.expectancy_r, m.expectancy_r);
assert_eq!(pooled.win_rate, m.win_rate);
assert_eq!(pooled.avg_win_r, m.avg_win_r);
assert_eq!(pooled.avg_loss_r, m.avg_loss_r);
assert_eq!(pooled.profit_factor, m.profit_factor);
assert_eq!(pooled.max_r_drawdown, m.max_r_drawdown);
assert_eq!(pooled.sqn, m.sqn);
assert_eq!(pooled.sqn_normalized, m.sqn_normalized);
}
#[test]
fn rmetrics_partial_eq_ignores_net_trade_rs() {
// two RMetrics equal in every metric but differing in net_trade_rs compare EQUAL
// (net_trade_rs is an in-memory conduit, excluded from equality) — this is what
// keeps serialize->deserialize round-trips equal (net_trade_rs is serde-skipped,
// so it deserializes empty).
let a = summarize_r(&pm_record_two_closed_trades(), &[]);
let mut b = a.clone();
b.net_trade_rs = Vec::new();
assert_eq!(a, b);
}
#[test]
fn populated_net_trade_rs_is_absent_from_serialized_json() {
// the in-memory conduit must never reach the wire: a populated net_trade_rs is
// dropped by #[serde(skip)], so the C18 on-disk shape is byte-unperturbed (#139).
let m = summarize_r(&pm_record_two_closed_trades(), &[]);
assert_eq!(m.net_trade_rs, vec![2.0, -1.0], "precondition: net_trade_rs is populated");
let json = serde_json::to_string(&m).expect("RMetrics serializes");
assert!(!json.contains("net_trade_rs"), "net_trade_rs must not reach the wire: {json}");
}
/// A minimal dense PositionManagement record with two closed trades at R = +2, -1.
/// Columns per `r_col` (CLOSED=0, REALIZED_R=1, DIRECTION=4, ENTRY_PRICE=6,
/// STOP_PRICE=7, CONVICTION_AT_ENTRY=9, SIZE=10, OPEN=11, UNREALIZED_R=12); width 13.
fn pm_record_two_closed_trades() -> Vec<(Timestamp, Vec<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);
}
// The `summarize` end-to-end + unit tests, moved verbatim from
// `aura-engine::report` (#291, C28 phase 2): `run_once` drives a real
// bootstrapped Harness through public aura-engine/aura-std/aura-strategy
// APIs, so it needs no engine-private item.
use aura_core::{Firing, NodeSchema, PortSpec, ScalarKind};
use aura_engine::{f64_field, Edge, FlatGraph, Harness, RunManifest, RunReport, SourceSpec, Target, VecSource};
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use crate::SimBroker;
use std::sync::mpsc;
/// The declared signature of a `Recorder` over one f64 column (the sink shape
/// the two-sink harness uses).
fn f64_recorder_sig() -> NodeSchema {
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
doc: "test-only schema",
}
}
/// Build an f64 source stream from (timestamp, value) points (mirrors the
/// harness.rs test helper; the e2e test needs its own copy — the harness
/// test module's is private to that module).
fn f64_stream(points: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> {
points.iter().map(|&(t, v)| (Timestamp(t), Scalar::f64(v))).collect()
}
/// Bootstrap the cycle-0007 signal-quality harness with TWO sinks: one on
/// the SimBroker equity output (node 4 -> node 5) and one on the Bias
/// output (node 3 -> node 6). Returns the harness plus the two receivers.
#[allow(clippy::type_complexity)]
fn build_two_sink_harness() -> (
Harness,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let h = Harness::bootstrap(FlatGraph {
nodes: vec![
Box::new(Sma::new(2)), // 0
Box::new(Sma::new(4)), // 1
Box::new(Sub::new()), // 2
Box::new(Bias::new(0.5)), // 3
Box::new(SimBroker::new(0.0001)), // 4
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
],
signatures: vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Bias::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
f64_recorder_sig(),
f64_recorder_sig(),
],
sources: vec![SourceSpec::raw(ScalarKind::F64, vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 }, // price into the broker
])],
edges: vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> sink 5
Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // exposure -> sink 6
],
taps: Vec::new(),
})
.expect("valid signal-quality DAG");
(h, rx_eq, rx_ex)
}
fn run_once() -> RunReport<RunMetrics> {
let (mut h, rx_eq, rx_ex) = build_two_sink_harness();
h.run(vec![Box::new(VecSource::new(f64_stream(&[
(1, 1.0000),
(2, 1.0010),
(3, 1.0025),
(4, 1.0020),
(5, 1.0040),
])))]);
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: RunManifest {
commit: "test-commit".to_string(),
params: vec![
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("bias_scale".to_string(), Scalar::f64(0.5)),
],
defaults: vec![],
window: (Timestamp(1), Timestamp(5)),
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics,
}
}
#[test]
fn report_is_deterministic_end_to_end() {
let r1 = run_once();
let r2 = run_once();
// a run actually emitted metrics over a non-empty pip curve
assert!(r1.metrics.total_pips.is_finite());
// same manifest -> same metrics (C1/C12): two runs are bit-identical
assert_eq!(r1.metrics, r2.metrics);
assert_eq!(r1.to_json(), r2.to_json());
}
fn samples(values: &[f64]) -> Vec<(Timestamp, f64)> {
values
.iter()
.enumerate()
.map(|(i, &v)| (Timestamp(i as i64 + 1), v))
.collect()
}
#[test]
fn summarize_total_pips_is_last_cumulative_value() {
let equity = samples(&[0.0, 5.0, 4.0, 12.0]);
let m = summarize(&equity, &[]);
assert_eq!(m.total_pips, 12.0);
}
#[test]
fn summarize_is_zero_on_empty_streams() {
let m = summarize(&[], &[]);
assert_eq!(m.total_pips, 0.0);
assert_eq!(m.max_drawdown, 0.0);
assert_eq!(m.bias_sign_flips, 0);
}
#[test]
fn summarize_max_drawdown_is_worst_peak_to_trough() {
// peak 10 then trough 5 (drop 5), recovers to 8; worst drop is 5,
// not the final drop (10 -> 8 = 2).
let equity = samples(&[0.0, 10.0, 5.0, 8.0]);
let m = summarize(&equity, &[]);
assert_eq!(m.max_drawdown, 5.0);
}
#[test]
fn summarize_max_drawdown_zero_on_monotonic_curve() {
let equity = samples(&[0.0, 1.0, 2.0, 3.0]);
let m = summarize(&equity, &[]);
assert_eq!(m.max_drawdown, 0.0);
}
#[test]
fn summarize_sign_flips_counts_signum_changes() {
// signum series: + + - 0 - -> flips at +->-, -->0, 0->- = 3.
let exposure = samples(&[0.5, 0.5, -0.5, 0.0, -0.5]);
let m = summarize(&[], &exposure);
assert_eq!(m.bias_sign_flips, 3);
}
#[test]
fn summarize_sign_flips_zero_on_constant_sign() {
let exposure = samples(&[0.2, 0.5, 1.0, 0.7]);
let m = summarize(&[], &exposure);
assert_eq!(m.bias_sign_flips, 0);
}
#[test]
fn run_metrics_vocabulary_resolves_and_classifies() {
use aura_engine::MetricVocabulary;
assert!(matches!(
<RunMetrics as MetricVocabulary>::resolve("sqn"),
Some(RunMetricKey::Sqn)
));
// the pre-rename CLI alias must keep resolving (back-compat).
assert!(matches!(
<RunMetrics as MetricVocabulary>::resolve("exposure_sign_flips"),
Some(RunMetricKey::BiasSignFlips)
));
assert!(<RunMetrics as MetricVocabulary>::resolve("sharpe").is_none());
assert_eq!(<RunMetrics as MetricVocabulary>::known(), RANKABLE_METRICS);
assert!(RunMetricKey::Sqn.r_based());
assert!(!RunMetricKey::TotalPips.r_based());
// Oracle-pin R_BASED_METRICS against the classification itself: the
// const feeds the NonRMetric refusal prose, so a divergence would
// misreport the R-gate silently rather than fail a lookup.
let derived: Vec<&str> = RANKABLE_METRICS
.iter()
.copied()
.filter(|n| {
<RunMetrics as MetricVocabulary>::resolve(n)
.expect("roster names resolve")
.r_based()
})
.collect();
assert_eq!(
derived, R_BASED_METRICS,
"R_BASED_METRICS must be exactly the r_based slice of the roster"
);
}
/// Property: an R-based key on a member with no `r` block ranks worst for
/// a higher-is-better metric (`f64::NEG_INFINITY`), never a silent default
/// like `0.0` that could out-rank a genuinely poor member; non-R keys are
/// read straight from the pip-only fields regardless of the `r` block.
#[test]
fn value_r_based_key_reads_neg_infinity_when_r_block_absent() {
let m = RunMetrics { total_pips: 5.0, max_drawdown: 2.0, bias_sign_flips: 1, r: None };
for key in [
RunMetricKey::Sqn,
RunMetricKey::SqnNormalized,
RunMetricKey::ExpectancyR,
RunMetricKey::NetExpectancyR,
] {
assert_eq!(m.value(key), f64::NEG_INFINITY, "missing r block must rank worst");
}
assert_eq!(m.value(RunMetricKey::TotalPips), 5.0);
assert_eq!(m.value(RunMetricKey::MaxDrawdown), 2.0);
assert_eq!(m.value(RunMetricKey::BiasSignFlips), 1.0);
}
/// Property: a member with no trade-R input (no `r` block) yields no null
/// draw AND leaves the rng untouched — the "consumes no rng" contract the
/// implementation comment claims. Verified indirectly (SplitMix64 carries
/// no Clone/PartialEq): the rng's next draw after the no-op call matches a
/// freshly, identically-seeded rng's first draw.
#[test]
fn null_draw_returns_none_and_consumes_no_rng_when_no_trades() {
let m = RunMetrics { total_pips: 0.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None };
let mut rng = SplitMix64::new(11);
let got = m.null_draw(RunMetricKey::Sqn, 2, &mut rng);
assert_eq!(got, None);
let mut fresh = SplitMix64::new(11);
assert_eq!(
rng.next_u64(),
fresh.next_u64(),
"null_draw's None path must consume no rng state"
);
}
/// Property: `null_draw` recomputes the selected key by mean-centring
/// `net_trade_rs`, block-resampling it under the given rng, and reducing
/// via `member_metric_from_rs` — exactly the recipe reproduced here by
/// hand with an identically-seeded rng (C1: same seed -> same draw).
#[test]
fn null_draw_recomputes_the_selected_key_from_a_seeded_block_resample() {
let net_trade_rs = vec![1.0, 2.0, -1.0, 3.0];
let m = RunMetrics {
total_pips: 0.0,
max_drawdown: 0.0,
bias_sign_flips: 0,
r: Some(RMetrics {
expectancy_r: 0.0,
n_trades: 4,
win_rate: 0.0,
avg_win_r: 0.0,
avg_loss_r: 0.0,
profit_factor: 0.0,
max_r_drawdown: 0.0,
n_open_at_end: 0,
sqn: 0.0,
sqn_normalized: 0.0,
net_expectancy_r: 0.0,
conviction_terciles_r: [0.0; 3],
net_trade_rs: net_trade_rs.clone(),
}),
};
let mut rng_a = SplitMix64::new(7);
let got = m
.null_draw(RunMetricKey::ExpectancyR, 2, &mut rng_a)
.expect("nonempty net_trade_rs yields a draw");
let mean = net_trade_rs.iter().sum::<f64>() / net_trade_rs.len() as f64;
let centred: Vec<f64> = net_trade_rs.iter().map(|x| x - mean).collect();
let mut rng_b = SplitMix64::new(7);
let resampled = resample_block(&centred, 2, &mut rng_b);
let want = member_metric_from_rs(&resampled, RunMetricKey::ExpectancyR);
assert_eq!(got, want);
}
}