d8c6938027
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
1491 lines
65 KiB
Rust
1491 lines
65 KiB
Rust
//! 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(¢red, 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(¢red, 2, &mut rng_b);
|
||
let want = member_metric_from_rs(&resampled, RunMetricKey::ExpectancyR);
|
||
|
||
assert_eq!(got, want);
|
||
}
|
||
}
|