4de6d5cbad
The stage1 ordinal has been dead vocabulary since the C10 reframe dropped the two-stage research model: a 1 structurally implies a 2 that no longer exists. The family is renamed by its live discriminator - the R yardstick - with members named by their signal, uniform with their signal-named siblings (sma, macd, momentum): - selectors: stage1-r -> r-sma, stage1-breakout -> r-breakout, stage1-meanrev -> r-meanrev (old tokens are usage errors, exit 2 - no silent alias) - identifiers: Strategy::RSma/RBreakout/RMeanRev, HarnessKind::RSma, r_sma_*/r_breakout_*/r_meanrev_*, wrap_r, run_signal_r, RGrid, R_SMA_* - persisted identity: the sma_signal composite (param prefix sma_signal.fast.length / .slow.length; fixtures regenerated; content-ids shift - no test pins a literal hash, the registry parses no record names) - e2e test files git-mv'd to r_sma_e2e / r_breakout_e2e / r_meanrev_e2e - dead Stage-1/Stage-2 prose reworded to post-C10 vocabulary across rustdoc, Cargo.tomls, ledger live lines, and the glossary (historical entries stay; fieldtests corpus untouched) - CLAUDE.md invariant 7 rewritten to record the ratified C10 reframe faithfully (the token-swap alone would have laundered the retired gated-currency/realistic-broker design into unmarked live prose); the unbacked account-mode clause dropped Verification: cargo build/test --workspace green (51 targets), clippy -D warnings clean, doc build clean, acceptance grep gate leaves exactly the one resampling-stage false positive (harness.rs), smoke: --harness r-sma runs, --harness stage1-r exits 2 with the new usage line. Decision log: forks and rationale recorded on the issue (reconciliation + implementation-phase comments). closes #174
1132 lines
51 KiB
Rust
1132 lines
51 KiB
Rust
//! Pure trading-domain analysis leaf (C1/C12): the post-run reductions over a
|
||
//! run's recorded streams that are functions of the recorded data alone —
|
||
//! R-based signal-quality metrics, the broker-independent position-event table,
|
||
//! and the multiple-comparison hurdle math. Split out of `aura-engine`'s
|
||
//! `report` module (issue #136) so the trading-domain arithmetic depends only on
|
||
//! `aura-core` + serde, not on the engine's trace-plumbing. Every type's serde
|
||
//! shape is byte-pinned by C18 goldens; the bodies are moved verbatim (no float
|
||
//! expression re-associated).
|
||
|
||
use aura_core::{Scalar, Timestamp};
|
||
|
||
/// Which selection objective produced the record (additive provenance, C23).
|
||
/// `Argmax` is the bare-best pick (cycle 0076), deflated for the number of trials.
|
||
/// `PlateauMean` / `PlateauWorst` (cycle 0077) argmax the neighbourhood-smoothed
|
||
/// surface instead, so peak-vs-plateau runs stay distinguishable on this field.
|
||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub enum SelectionMode {
|
||
Argmax,
|
||
PlateauMean,
|
||
PlateauWorst,
|
||
}
|
||
|
||
/// Selection-provenance for a sweep winner. The selection RULE (`mode`) and its
|
||
/// ANNOTATION are orthogonal: `Argmax` carries the trials-deflation annotation
|
||
/// (`deflated_score` / `overfit_probability` / `n_resamples` / `block_len` /
|
||
/// `seed`); `Plateau*` carries the smoothing annotation (`neighbourhood_score` /
|
||
/// `n_neighbours`). Each annotation is `Option`, present iff its rule produced the
|
||
/// record; all are additive — recorded, never re-ranking (C23). A legacy line
|
||
/// (pre-0077) carries the deflation scalars as bare values that deserialize to
|
||
/// `Some` (serde default), so it still loads (C14/C18).
|
||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct FamilySelection {
|
||
pub selection_metric: String,
|
||
pub n_trials: usize,
|
||
pub raw_winner_metric: f64,
|
||
pub mode: SelectionMode,
|
||
// deflation annotation (present iff mode == Argmax with a deflation run)
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub deflated_score: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub overfit_probability: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub n_resamples: Option<usize>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub block_len: Option<usize>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub seed: Option<u64>,
|
||
// plateau annotation (present iff mode is Plateau*)
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub neighbourhood_score: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub n_neighbours: Option<usize>,
|
||
}
|
||
|
||
/// Summary metrics reduced from a run's recorded streams — the `-> metrics`
|
||
/// half of C12's atomic sim unit. Pure function of the recorded streams.
|
||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct RunMetrics {
|
||
/// Final cumulative pip equity — the last value of the (cumulative)
|
||
/// pip-equity curve. `0.0` if the curve is empty.
|
||
pub total_pips: f64,
|
||
/// Largest peak-to-trough drop on the cumulative pip curve:
|
||
/// `max_t (running_peak(t) - equity(t))`, always `>= 0.0` (`0.0` if the
|
||
/// curve is monotonic non-decreasing or empty).
|
||
pub max_drawdown: f64,
|
||
/// Count of adjacent recorded bias samples whose sign differs (a zero
|
||
/// bias normalizes to sign `0`, so flat is distinct from long/short).
|
||
/// A turnover proxy: it counts long<->short reversals *and* transitions
|
||
/// into/out of flat — the plain sign-change count over the bias series.
|
||
/// The serde alias accepts the pre-rename `exposure_sign_flips` key so legacy
|
||
/// `runs.jsonl` lines still deserialise (C14/C18 back-compat).
|
||
#[serde(alias = "exposure_sign_flips")]
|
||
pub bias_sign_flips: u64,
|
||
/// Optional R metrics block. `None` for a pip-only run (and for legacy
|
||
/// `runs.jsonl` written before this field existed — `serde(default)`); omitted from
|
||
/// the JSON entirely when absent (`skip_serializing_if`), so the pip-only on-disk
|
||
/// shape stays byte-unchanged (C14/C18 back-compat).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub r: Option<RMetrics>,
|
||
}
|
||
|
||
/// R-based signal-quality metrics, reduced from a `PositionManagement` dense
|
||
/// record stream by [`summarize_r`]. Account- and instrument-agnostic (pure R). Carries
|
||
/// the enriched dispersion/churn fields (SQN, conviction terciles, net-of-cost).
|
||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||
pub struct RMetrics {
|
||
pub expectancy_r: f64, // mean realised R over all trades (equal-weighted; headline)
|
||
pub n_trades: u64,
|
||
pub win_rate: f64, // fraction with R > 0
|
||
pub avg_win_r: f64,
|
||
pub avg_loss_r: f64,
|
||
pub profit_factor: f64, // sum(win R) / |sum(loss R)|; 0.0 if no losses or no trades
|
||
pub max_r_drawdown: f64, // worst peak-to-trough on the by-trade cumulative-R curve, >= 0
|
||
pub n_open_at_end: u64, // positions force-closed at window end (counted, not hidden)
|
||
pub sqn: f64, // √n · mean_R / sample-stdev_R; n<2 or zero-variance -> 0.0
|
||
/// (mean_R / sample-stdev_R) · √(min(n, SQN_CAP)): the n-normalized SQN
|
||
/// ("SQN score", Van Tharp), turnover-robust vs. the raw `sqn`. n<2 or
|
||
/// zero-variance -> 0.0.
|
||
#[serde(default)]
|
||
pub sqn_normalized: f64,
|
||
pub net_expectancy_r: f64, // mean(R - cost_in_r) folded from the cost-model stream; == gross when empty
|
||
pub conviction_terciles_r: [f64; 3], // E[R] by conviction_at_entry tercile (asc); <3 trades -> [0,0,0]
|
||
/// Realised R per closed trade, in trade order — an in-memory conduit for the
|
||
/// OOS R-series bootstrap. Excluded from serde (`skip`) so the C18 wire
|
||
/// shape is unchanged, and from `PartialEq` (below) so every existing
|
||
/// `RMetrics`/`RunReport` equality assertion and round-trip stays green.
|
||
#[serde(skip)]
|
||
pub trade_rs: Vec<f64>,
|
||
}
|
||
|
||
impl PartialEq for RMetrics {
|
||
fn eq(&self, o: &Self) -> bool {
|
||
self.expectancy_r == o.expectancy_r
|
||
&& self.n_trades == o.n_trades
|
||
&& self.win_rate == o.win_rate
|
||
&& self.avg_win_r == o.avg_win_r
|
||
&& self.avg_loss_r == o.avg_loss_r
|
||
&& self.profit_factor == o.profit_factor
|
||
&& self.max_r_drawdown == o.max_r_drawdown
|
||
&& self.n_open_at_end == o.n_open_at_end
|
||
&& self.sqn == o.sqn
|
||
&& self.sqn_normalized == o.sqn_normalized
|
||
&& self.net_expectancy_r == o.net_expectancy_r
|
||
&& self.conviction_terciles_r == o.conviction_terciles_r
|
||
// trade_rs deliberately excluded
|
||
}
|
||
}
|
||
|
||
// Dense `PositionManagement` record column indices — the lockstep contract with
|
||
// aura-std's FIELD_NAMES/RECORD_KINDS. The record crosses the crate boundary as a
|
||
// type-erased `Scalar` vector (C4 SoA), so it is read positionally, not by importing
|
||
// the producer's types; the layout is shared by convention and `r_sma_e2e.rs`
|
||
// guards that it matches.
|
||
mod r_col {
|
||
pub const CLOSED: usize = 0;
|
||
pub const REALIZED_R: usize = 1;
|
||
pub const DIRECTION: usize = 4;
|
||
// entry/stop define the latched distance (1R). Since the net-of-cost fold reads the
|
||
// cost-in-R straight from the cost stream (the aura-std `ConstantCost` producer recovers
|
||
// the distance), this crate's reductions no longer touch these two columns; they stay in
|
||
// the documented layout contract and are exercised by the test fixtures.
|
||
#[allow(dead_code)]
|
||
pub const ENTRY_PRICE: usize = 6;
|
||
#[allow(dead_code)]
|
||
pub const STOP_PRICE: usize = 7;
|
||
pub const CONVICTION_AT_ENTRY: usize = 9;
|
||
pub const SIZE: usize = 10;
|
||
pub const OPEN: usize = 11;
|
||
pub const UNREALIZED_R: usize = 12;
|
||
}
|
||
|
||
// Cost-model record column indices — the lockstep contract with aura-std's
|
||
// `ConstantCost` output schema (`cost_in_r` / `cum_cost_in_r` / `open_cost_in_r`).
|
||
// Read positionally across the crate boundary as a type-erased `Scalar` vector
|
||
// (C4 SoA), like `r_col`. `summarize_r` folds the per-close cost (col 0) and the
|
||
// window-end open trade's would-be cost (col 2); the running sum (col 1) is not
|
||
// folded, so it is intentionally not named here.
|
||
mod cost_col {
|
||
pub const COST_IN_R: usize = 0;
|
||
pub const OPEN_COST_IN_R: usize = 2;
|
||
}
|
||
|
||
/// Van Tharp's conventional trade-count cap for the n-normalized SQN ("SQN
|
||
/// score"): capping n stops the single-number objective rewarding sheer turnover.
|
||
const SQN_CAP: u64 = 100;
|
||
|
||
/// Reduce a `PositionManagement` dense record stream into R-metrics. Pure (C1).
|
||
/// The trade ledger is the rows where `closed_this_cycle`; a position still open on the
|
||
/// last row is force-closed at its `unrealized_r` (a window-end trade — never silently
|
||
/// folded as unrealised MtM). Empty input -> a well-defined all-zero `RMetrics`.
|
||
pub fn summarize_r(
|
||
record: &[(Timestamp, Vec<Scalar>)],
|
||
cost: &[(Timestamp, Vec<Scalar>)],
|
||
) -> RMetrics {
|
||
// Collect one entry per trade: its realised R, the entry-conviction |bias|, and the
|
||
// per-trade cost-in-R the cost-model graph supplied (read positionally from the
|
||
// co-temporal `cost` stream). The ledger is the closed rows; a position still open on
|
||
// the last row is force-closed at its unrealized R (a window-end trade).
|
||
struct Trade {
|
||
r: f64,
|
||
bias_abs: f64,
|
||
cost: f64,
|
||
}
|
||
// Positional join: the cost stream is co-temporal 1:1 with the record (both recorded
|
||
// per cycle off the same executor cadence — a cost node emits exactly when PM emits),
|
||
// so `record[i]` and `cost[i]` are the same cycle. A closed row `i` reads `cost[i]`'s
|
||
// `cost_in_r` (col 0); the window-end open last row reads its `open_cost_in_r` (col 2).
|
||
// An empty `cost` slice ⇒ every `.get(i)` is `None` ⇒ cost `0.0` (the gross-R baseline).
|
||
// Positional avoids the shared-timestamp collision a `ts`-keyed map would have (C4:
|
||
// same `ts` can be two cycles).
|
||
let mut trades: Vec<Trade> = Vec::new();
|
||
for (i, (_, row)) in record.iter().enumerate() {
|
||
if row[r_col::CLOSED].as_bool() {
|
||
let c = cost.get(i).map(|(_, cr)| cr[cost_col::COST_IN_R].as_f64()).unwrap_or(0.0);
|
||
trades.push(Trade {
|
||
r: row[r_col::REALIZED_R].as_f64(),
|
||
bias_abs: row[r_col::CONVICTION_AT_ENTRY].as_f64(),
|
||
cost: c,
|
||
});
|
||
}
|
||
}
|
||
let mut n_open_at_end = 0u64;
|
||
if let Some((_, last)) = record.last()
|
||
&& last[r_col::OPEN].as_bool()
|
||
{
|
||
let c = cost
|
||
.get(record.len() - 1)
|
||
.map(|(_, cr)| cr[cost_col::OPEN_COST_IN_R].as_f64())
|
||
.unwrap_or(0.0);
|
||
trades.push(Trade {
|
||
r: last[r_col::UNREALIZED_R].as_f64(),
|
||
bias_abs: last[r_col::CONVICTION_AT_ENTRY].as_f64(),
|
||
cost: c,
|
||
});
|
||
n_open_at_end = 1;
|
||
}
|
||
let n = trades.len() as u64;
|
||
if n == 0 {
|
||
return RMetrics {
|
||
expectancy_r: 0.0,
|
||
n_trades: 0,
|
||
win_rate: 0.0,
|
||
avg_win_r: 0.0,
|
||
avg_loss_r: 0.0,
|
||
profit_factor: 0.0,
|
||
max_r_drawdown: 0.0,
|
||
n_open_at_end: 0,
|
||
sqn: 0.0,
|
||
sqn_normalized: 0.0,
|
||
net_expectancy_r: 0.0,
|
||
conviction_terciles_r: [0.0; 3],
|
||
trade_rs: Vec::new(),
|
||
};
|
||
}
|
||
let rs: Vec<f64> = trades.iter().map(|t| t.r).collect();
|
||
let sum: f64 = rs.iter().sum();
|
||
let mean = sum / n as f64;
|
||
let wins: Vec<f64> = rs.iter().copied().filter(|&r| r > 0.0).collect();
|
||
let losses: Vec<f64> = rs.iter().copied().filter(|&r| r <= 0.0).collect();
|
||
let sum_win: f64 = wins.iter().sum();
|
||
let sum_loss: f64 = losses.iter().sum(); // <= 0
|
||
let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 };
|
||
// by-trade cumulative-R drawdown
|
||
let mut peak = f64::NEG_INFINITY;
|
||
let mut cum = 0.0;
|
||
let mut max_dd = 0.0_f64;
|
||
for &r in &rs {
|
||
cum += r;
|
||
if cum > peak {
|
||
peak = cum;
|
||
}
|
||
let dd = peak - cum;
|
||
if dd > max_dd {
|
||
max_dd = dd;
|
||
}
|
||
}
|
||
// SQN = √n · mean / sample-stdev (raw, Van Tharp). SQN100 = √(min(n,SQN_CAP)) ·
|
||
// mean / sd — the same dispersion ratio with the trade count capped
|
||
// (turnover-robust). n < 2 or zero variance -> 0.0 for both (dispersion undefined).
|
||
let (sqn, sqn_normalized) = if n < 2 {
|
||
(0.0, 0.0)
|
||
} else {
|
||
let var = rs.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0);
|
||
let sd = var.sqrt();
|
||
if sd > 0.0 {
|
||
// raw sqn kept bit-identical to the pre-0067 expression (sqrt(n)*mean/sd,
|
||
// not factored via a shared ratio) so the existing metric is byte-unchanged.
|
||
(
|
||
(n as f64).sqrt() * mean / sd,
|
||
(n.min(SQN_CAP) as f64).sqrt() * mean / sd,
|
||
)
|
||
} else {
|
||
(0.0, 0.0)
|
||
}
|
||
};
|
||
// net-of-cost: subtract the cost-model's per-trade cost-in-R (from the cost stream;
|
||
// an empty stream is the gross-R baseline — every trade's cost is 0.0).
|
||
let net_sum: f64 = trades.iter().map(|t| t.r - t.cost).sum();
|
||
let net_expectancy_r = net_sum / n as f64;
|
||
// conviction terciles: sort by conviction_at_entry ascending, split into three contiguous
|
||
// near-equal-count buckets (floor boundaries i*n/3), E[R] per bucket. < 3 trades -> 0s.
|
||
let conviction_terciles_r = if n < 3 {
|
||
[0.0; 3]
|
||
} else {
|
||
let mut by_conv: Vec<&Trade> = trades.iter().collect();
|
||
by_conv.sort_by(|a, b| a.bias_abs.total_cmp(&b.bias_abs));
|
||
let nn = by_conv.len();
|
||
let mut out = [0.0; 3];
|
||
for (i, slot) in out.iter_mut().enumerate() {
|
||
let lo = i * nn / 3;
|
||
let hi = (i + 1) * nn / 3;
|
||
let bucket = &by_conv[lo..hi];
|
||
*slot = if bucket.is_empty() {
|
||
0.0
|
||
} else {
|
||
bucket.iter().map(|t| t.r).sum::<f64>() / bucket.len() as f64
|
||
};
|
||
}
|
||
out
|
||
};
|
||
RMetrics {
|
||
expectancy_r: mean,
|
||
n_trades: n,
|
||
win_rate: wins.len() as f64 / n as f64,
|
||
avg_win_r: avg(&wins),
|
||
avg_loss_r: avg(&losses),
|
||
profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 },
|
||
max_r_drawdown: max_dd,
|
||
n_open_at_end,
|
||
sqn,
|
||
sqn_normalized,
|
||
net_expectancy_r,
|
||
conviction_terciles_r,
|
||
trade_rs: rs,
|
||
}
|
||
}
|
||
|
||
/// Reduce a flat per-trade R series (e.g. the pooled across-window OOS series of a
|
||
/// walk-forward) into `RMetrics`. The R-distribution fields (mean / win-rate / profit
|
||
/// factor / max-drawdown / SQN) duplicate [`summarize_r`]'s arithmetic byte-for-byte —
|
||
/// deliberately copied, not factored, so neither pinned-float expression shifts under
|
||
/// IEEE-754 non-associativity. MAINTENANCE COUPLING: the two copies must be edited in
|
||
/// lockstep; the only guard that they agree is the cross-reducer equality test
|
||
/// `summarize_r_includes_open_trade_and_matches_r_metrics_from_rs` — touch one copy
|
||
/// without the other and that test is the sole tripwire. The
|
||
/// fields a flat R series cannot carry are set honestly: `n_open_at_end = 0`,
|
||
/// `net_expectancy_r = expectancy_r` (exact under the cost = 0 invariant),
|
||
/// `conviction_terciles_r = [0,0,0]` (per-trade conviction is not pooled). Empty
|
||
/// input -> a well-defined all-zero `RMetrics`.
|
||
pub fn r_metrics_from_rs(rs: &[f64]) -> RMetrics {
|
||
let n = rs.len() as u64;
|
||
if n == 0 {
|
||
return RMetrics {
|
||
expectancy_r: 0.0, n_trades: 0, win_rate: 0.0, avg_win_r: 0.0,
|
||
avg_loss_r: 0.0, profit_factor: 0.0, max_r_drawdown: 0.0,
|
||
n_open_at_end: 0, sqn: 0.0, sqn_normalized: 0.0, net_expectancy_r: 0.0,
|
||
conviction_terciles_r: [0.0; 3], trade_rs: Vec::new(),
|
||
};
|
||
}
|
||
let sum: f64 = rs.iter().sum();
|
||
let mean = sum / n as f64;
|
||
let wins: Vec<f64> = rs.iter().copied().filter(|&r| r > 0.0).collect();
|
||
let losses: Vec<f64> = rs.iter().copied().filter(|&r| r <= 0.0).collect();
|
||
let sum_win: f64 = wins.iter().sum();
|
||
let sum_loss: f64 = losses.iter().sum();
|
||
let avg = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 };
|
||
let mut peak = f64::NEG_INFINITY;
|
||
let mut cum = 0.0;
|
||
let mut max_dd = 0.0_f64;
|
||
for &r in rs {
|
||
cum += r;
|
||
if cum > peak { peak = cum; }
|
||
let dd = peak - cum;
|
||
if dd > max_dd { max_dd = dd; }
|
||
}
|
||
let (sqn, sqn_normalized) = if n < 2 {
|
||
(0.0, 0.0)
|
||
} else {
|
||
let var = rs.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0);
|
||
let sd = var.sqrt();
|
||
if sd > 0.0 {
|
||
((n as f64).sqrt() * mean / sd, (n.min(SQN_CAP) as f64).sqrt() * mean / sd)
|
||
} else {
|
||
(0.0, 0.0)
|
||
}
|
||
};
|
||
RMetrics {
|
||
expectancy_r: mean,
|
||
n_trades: n,
|
||
win_rate: wins.len() as f64 / n as f64,
|
||
avg_win_r: avg(&wins),
|
||
avg_loss_r: avg(&losses),
|
||
profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 },
|
||
max_r_drawdown: max_dd,
|
||
n_open_at_end: 0,
|
||
sqn,
|
||
sqn_normalized,
|
||
net_expectancy_r: mean, // cost = 0 -> net == gross (frictionless)
|
||
conviction_terciles_r: [0.0; 3],
|
||
trade_rs: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Derive the broker-independent position-event table from a `PositionManagement`
|
||
/// dense record (read positionally, C7 SoA), as the first difference of the
|
||
/// executed book (`deal = target - book - in_flight`; `in_flight = 0` for the
|
||
/// instant-fill backtest). Pure (C1); no look-ahead — each event's `event_ts` is
|
||
/// its own cycle (C2). `instrument_id` is supplied by the caller (the engine never
|
||
/// imports an instrument spec from `aura-ingest`). A reversal emits `Close` then the
|
||
/// opposite open at one `event_ts` (close first); a position still open on the last
|
||
/// row emits its open with no synthetic `Close` (the table records actual executed events —
|
||
/// unlike `summarize_r`, which force-closes for the R metric).
|
||
pub fn derive_position_events(
|
||
record: &[(Timestamp, Vec<Scalar>)],
|
||
instrument_id: i64,
|
||
) -> Vec<PositionEvent> {
|
||
// The derive's single-position book (in_flight is structurally 0).
|
||
struct Book {
|
||
position_id: i64,
|
||
volume: f64,
|
||
}
|
||
let mut out: Vec<PositionEvent> = Vec::new();
|
||
let mut book: Option<Book> = None;
|
||
let mut next_id: i64 = 0;
|
||
for (ts, row) in record {
|
||
// 1) close first: the book held into this cycle exited this cycle.
|
||
if row[r_col::CLOSED].as_bool()
|
||
&& let Some(b) = book.take()
|
||
{
|
||
out.push(PositionEvent {
|
||
event_ts: *ts,
|
||
action: PositionAction::Close,
|
||
position_id: b.position_id,
|
||
instrument_id,
|
||
volume: b.volume,
|
||
});
|
||
}
|
||
// 2) then open: a position is open at cycle end the book is not tracking.
|
||
if row[r_col::OPEN].as_bool() && book.is_none() {
|
||
let dir = row[r_col::DIRECTION].as_i64();
|
||
let volume = row[r_col::SIZE].as_f64();
|
||
let action = if dir >= 0 { PositionAction::Buy } else { PositionAction::Sell };
|
||
out.push(PositionEvent {
|
||
event_ts: *ts,
|
||
action,
|
||
position_id: next_id,
|
||
instrument_id,
|
||
volume,
|
||
});
|
||
book = Some(Book { position_id: next_id, volume });
|
||
next_id += 1;
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The three position-event actions (C10). Direction IS the action; volume is
|
||
/// unsigned. Serde-encoded as its i64 mapping (`Buy=0, Sell=1, Close=2`) so the
|
||
/// persisted/columnar form stays C7-scalar and ledger-faithful (`action: i64`).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
#[serde(into = "i64", try_from = "i64")]
|
||
pub enum PositionAction {
|
||
Buy,
|
||
Sell,
|
||
Close,
|
||
}
|
||
|
||
impl From<PositionAction> for i64 {
|
||
fn from(a: PositionAction) -> i64 {
|
||
match a {
|
||
PositionAction::Buy => 0,
|
||
PositionAction::Sell => 1,
|
||
PositionAction::Close => 2,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl TryFrom<i64> for PositionAction {
|
||
type Error = String;
|
||
fn try_from(v: i64) -> Result<Self, Self::Error> {
|
||
match v {
|
||
0 => Ok(PositionAction::Buy),
|
||
1 => Ok(PositionAction::Sell),
|
||
2 => Ok(PositionAction::Close),
|
||
other => Err(format!("invalid PositionAction i64: {other}")),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One row of C10's derived position-event table — the broker-independent audit
|
||
/// view (the first difference of the exposure state). A post-run value type
|
||
/// (sibling of [`RunMetrics`]), NOT a per-`eval` node output (C8). Multiple events
|
||
/// may share one `event_ts` (a reversal: Close then open, close-before-open). No
|
||
/// `open_ts` — a position's open time is its opening event's `event_ts`.
|
||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct PositionEvent {
|
||
pub event_ts: Timestamp,
|
||
pub action: PositionAction,
|
||
/// Monotonic, assigned at open. A Close references an existing `position_id`.
|
||
pub position_id: i64,
|
||
pub instrument_id: i64,
|
||
/// Lots, unsigned. A partial close carries its own (smaller) volume.
|
||
pub volume: f64,
|
||
}
|
||
|
||
/// Inverse standard-normal CDF (quantile function), Acklam's rational
|
||
/// approximation — absolute error < ~1.2e-9 over `p ∈ (0,1)`. Pure (C1).
|
||
/// Callers pass strictly-interior `p` (`k >= 2` keeps the argument off the
|
||
/// boundaries); the boundary branches return ±inf as a defined limit.
|
||
// Acklam's published coefficients verbatim; some carry more decimals than an
|
||
// f64 holds (clippy::excessive_precision) — kept literal as reference constants.
|
||
#[allow(clippy::excessive_precision)]
|
||
pub fn inv_norm_cdf(p: f64) -> f64 {
|
||
const A: [f64; 6] = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
|
||
1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00];
|
||
const B: [f64; 5] = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
|
||
6.680131188771972e+01, -1.328068155288572e+01];
|
||
const C: [f64; 6] = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
|
||
-2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00];
|
||
const D: [f64; 4] = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00,
|
||
3.754408661907416e+00];
|
||
const P_LOW: f64 = 0.02425;
|
||
if p <= 0.0 { return f64::NEG_INFINITY; }
|
||
if p >= 1.0 { return f64::INFINITY; }
|
||
if p < P_LOW {
|
||
let q = (-2.0 * p.ln()).sqrt();
|
||
(((((C[0]*q + C[1])*q + C[2])*q + C[3])*q + C[4])*q + C[5])
|
||
/ ((((D[0]*q + D[1])*q + D[2])*q + D[3])*q + 1.0)
|
||
} else if p <= 1.0 - P_LOW {
|
||
let q = p - 0.5;
|
||
let r = q * q;
|
||
(((((A[0]*r + A[1])*r + A[2])*r + A[3])*r + A[4])*r + A[5]) * q
|
||
/ (((((B[0]*r + B[1])*r + B[2])*r + B[3])*r + B[4])*r + 1.0)
|
||
} else {
|
||
let q = (-2.0 * (1.0 - p).ln()).sqrt();
|
||
-(((((C[0]*q + C[1])*q + C[2])*q + C[3])*q + C[4])*q + C[5])
|
||
/ ((((D[0]*q + D[1])*q + D[2])*q + D[3])*q + 1.0)
|
||
}
|
||
}
|
||
|
||
/// Expected maximum of `k` i.i.d. standard normals — the hurdle a search of `k`
|
||
/// configurations clears by chance alone. `k <= 1` -> `0.0` (a single trial has no
|
||
/// multiple-comparison inflation, and guards the `Φ⁻¹(1 - 1/k) -> Φ⁻¹(0)`
|
||
/// divergence). For `k >= 2`:
|
||
/// `(1 - γ)·Φ⁻¹(1 - 1/k) + γ·Φ⁻¹(1 - 1/(k·e))`, `γ = 0.5772156649…`. Pure (C1).
|
||
pub fn expected_max_of_normals(k: usize) -> f64 {
|
||
if k <= 1 {
|
||
return 0.0;
|
||
}
|
||
const GAMMA: f64 = 0.577_215_664_901_532_9;
|
||
let kf = k as f64;
|
||
(1.0 - GAMMA) * inv_norm_cdf(1.0 - 1.0 / kf)
|
||
+ GAMMA * inv_norm_cdf(1.0 - 1.0 / (kf * std::f64::consts::E))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn inv_norm_cdf_matches_known_quantiles() {
|
||
assert!((inv_norm_cdf(0.975) - 1.959964).abs() < 1e-3);
|
||
assert!(inv_norm_cdf(0.5).abs() < 1e-9);
|
||
assert!((inv_norm_cdf(0.1) + inv_norm_cdf(0.9)).abs() < 1e-9); // symmetry
|
||
}
|
||
|
||
#[test]
|
||
fn expected_max_of_normals_is_zero_at_one_and_monotone() {
|
||
assert_eq!(expected_max_of_normals(1), 0.0);
|
||
assert_eq!(expected_max_of_normals(0), 0.0);
|
||
let (e2, e4, e10, e100) = (
|
||
expected_max_of_normals(2), expected_max_of_normals(4),
|
||
expected_max_of_normals(10), expected_max_of_normals(100),
|
||
);
|
||
assert!(e2 < e4 && e4 < e10 && e10 < e100); // strictly increasing
|
||
assert!((1.0..1.1).contains(&e4)); // ~1.05 (not the √(2 ln 4)=1.66 asymptote)
|
||
assert!((2.4..2.7).contains(&e100)); // ~2.53
|
||
}
|
||
|
||
#[test]
|
||
fn position_action_round_trips_through_i64() {
|
||
for a in [PositionAction::Buy, PositionAction::Sell, PositionAction::Close] {
|
||
let n: i64 = a.into();
|
||
assert_eq!(PositionAction::try_from(n), Ok(a));
|
||
}
|
||
assert_eq!(i64::from(PositionAction::Buy), 0);
|
||
assert_eq!(i64::from(PositionAction::Sell), 1);
|
||
assert_eq!(i64::from(PositionAction::Close), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn position_action_rejects_out_of_range_i64() {
|
||
assert!(PositionAction::try_from(3).is_err());
|
||
assert!(PositionAction::try_from(-1).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn position_event_serde_round_trips_with_bare_int_action() {
|
||
let ev = PositionEvent {
|
||
event_ts: Timestamp(42),
|
||
action: PositionAction::Sell,
|
||
position_id: 7,
|
||
instrument_id: 3,
|
||
volume: 0.5,
|
||
};
|
||
let json = serde_json::to_string(&ev).expect("serialize");
|
||
// action encodes as a bare integer (C7 scalar shape), not a tagged enum
|
||
assert!(json.contains("\"action\":1"), "action not bare-int encoded: {json}");
|
||
let back: PositionEvent = serde_json::from_str(&json).expect("deserialize");
|
||
assert_eq!(back, ev);
|
||
}
|
||
|
||
#[test]
|
||
fn position_events_may_share_one_event_ts_on_reversal() {
|
||
// a stop-and-reverse: Close then open at the SAME event_ts (close-before-open).
|
||
let ts = Timestamp(100);
|
||
let close = PositionEvent {
|
||
event_ts: ts, action: PositionAction::Close,
|
||
position_id: 1, instrument_id: 3, volume: 0.5,
|
||
};
|
||
let open = PositionEvent {
|
||
event_ts: ts, action: PositionAction::Sell,
|
||
position_id: 2, instrument_id: 3, volume: 0.5,
|
||
};
|
||
let table = [close, open];
|
||
assert_eq!(table[0].event_ts, table[1].event_ts);
|
||
assert_eq!(table[0].action, PositionAction::Close);
|
||
assert_eq!(table[1].action, PositionAction::Sell);
|
||
}
|
||
|
||
// Build a minimal dense record row: only the columns summarize_r reads matter.
|
||
// The four written slots reference `r_col::*` (not bare literals) so the helper
|
||
// tracks the same constants it exists to test — a literal would mask drift in them.
|
||
fn r_row(closed: bool, realized: f64, open: bool, unreal: f64) -> (Timestamp, Vec<Scalar>) {
|
||
let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1];
|
||
v[r_col::CLOSED] = Scalar::bool(closed);
|
||
v[r_col::REALIZED_R] = Scalar::f64(realized);
|
||
v[r_col::OPEN] = Scalar::bool(open);
|
||
v[r_col::UNREALIZED_R] = Scalar::f64(unreal);
|
||
(Timestamp(0), v)
|
||
}
|
||
|
||
// A fuller closed-trade dense row: also sets entry_price (6), stop_price (7) and
|
||
// conviction_at_entry (9) — the geometry summarize_r recovers latched_dist and
|
||
// conviction from. Width up to UNREALIZED_R+1 (summarize_r never reads col 13).
|
||
fn r_row_full(realized: f64, entry: f64, stop: f64, bias_abs: f64) -> (Timestamp, Vec<Scalar>) {
|
||
let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1];
|
||
v[r_col::CLOSED] = Scalar::bool(true);
|
||
v[r_col::REALIZED_R] = Scalar::f64(realized);
|
||
v[r_col::ENTRY_PRICE] = Scalar::f64(entry);
|
||
v[r_col::STOP_PRICE] = Scalar::f64(stop);
|
||
v[r_col::CONVICTION_AT_ENTRY] = Scalar::f64(bias_abs);
|
||
v[r_col::OPEN] = Scalar::bool(false);
|
||
(Timestamp(0), v)
|
||
}
|
||
|
||
// A 14-wide PositionManagement dense row carrying BOTH the closed-trade geometry
|
||
// (entry/stop/conviction) AND the open-at-end fields (open/unrealized_r) — the one
|
||
// fixture the cost-stream subsumption test needs (`r_row_full` forces closed=true, so
|
||
// it cannot build a window-end open row). Only the columns summarize_r reads are set;
|
||
// the rest default to f64(0.0) (never read), mirroring the existing row helpers.
|
||
fn row14(closed: bool, r: f64, entry: f64, stop: f64, conv: f64, open: bool, unreal: f64) -> Vec<Scalar> {
|
||
let mut v = vec![Scalar::f64(0.0); 14];
|
||
v[r_col::CLOSED] = Scalar::bool(closed);
|
||
v[r_col::REALIZED_R] = Scalar::f64(r);
|
||
v[r_col::ENTRY_PRICE] = Scalar::f64(entry);
|
||
v[r_col::STOP_PRICE] = Scalar::f64(stop);
|
||
v[r_col::CONVICTION_AT_ENTRY] = Scalar::f64(conv);
|
||
v[r_col::OPEN] = Scalar::bool(open);
|
||
v[r_col::UNREALIZED_R] = Scalar::f64(unreal);
|
||
v
|
||
}
|
||
#[test]
|
||
fn summarize_r_is_zero_on_empty() {
|
||
let m = summarize_r(&[], &[]);
|
||
assert_eq!(m.n_trades, 0);
|
||
assert_eq!(m.expectancy_r, 0.0);
|
||
assert_eq!(m.max_r_drawdown, 0.0);
|
||
assert_eq!(m.sqn, 0.0);
|
||
assert_eq!(m.sqn_normalized, 0.0);
|
||
assert_eq!(m.net_expectancy_r, 0.0);
|
||
assert_eq!(m.conviction_terciles_r, [0.0; 3]);
|
||
}
|
||
#[test]
|
||
fn summarize_r_expectancy_winrate_profit_factor() {
|
||
// three closed trades: +2, -1, +1 (no open at end). E[R] = 2/3.
|
||
let rec = vec![
|
||
r_row(true, 2.0, true, 0.0),
|
||
r_row(false, 0.0, true, 0.0),
|
||
r_row(true, -1.0, true, 0.0),
|
||
r_row(true, 1.0, false, 0.0), // last row not open
|
||
];
|
||
let m = summarize_r(&rec, &[]);
|
||
assert_eq!(m.n_trades, 3);
|
||
assert!((m.expectancy_r - (2.0 / 3.0)).abs() < 1e-9);
|
||
assert!((m.win_rate - (2.0 / 3.0)).abs() < 1e-9);
|
||
assert!((m.profit_factor - 3.0).abs() < 1e-9); // (2+1)/1
|
||
assert_eq!(m.n_open_at_end, 0);
|
||
}
|
||
#[test]
|
||
fn summarize_r_force_closes_open_position_at_window_end() {
|
||
// one closed +1, then last row open with unrealized -0.5 -> a window-end trade.
|
||
let rec = vec![r_row(true, 1.0, true, 0.0), r_row(false, 0.0, true, -0.5)];
|
||
let m = summarize_r(&rec, &[]);
|
||
assert_eq!(m.n_trades, 2);
|
||
assert_eq!(m.n_open_at_end, 1);
|
||
assert!((m.expectancy_r - 0.25).abs() < 1e-9); // (1 + -0.5)/2
|
||
}
|
||
#[test]
|
||
fn summarize_r_max_drawdown_on_by_trade_curve() {
|
||
// cum: +3, +1 (dd 2), +4 -> max dd = 2.
|
||
let rec = vec![r_row(true, 3.0, false, 0.0), r_row(true, -2.0, false, 0.0), r_row(true, 3.0, false, 0.0)];
|
||
assert!((summarize_r(&rec, &[]).max_r_drawdown - 2.0).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn summarize_r_sqn_is_sqrt_n_mean_over_stdev() {
|
||
// R = [1, 1, 1, -1]: mean 0.5; sample var = ((0.5)^2*3 + (1.5)^2)/3 = 1.0; sd = 1;
|
||
// sqn = sqrt(4)*0.5/1 = 1.0.
|
||
let rec = vec![
|
||
r_row(true, 1.0, false, 0.0),
|
||
r_row(true, 1.0, false, 0.0),
|
||
r_row(true, 1.0, false, 0.0),
|
||
r_row(true, -1.0, false, 0.0),
|
||
];
|
||
let m = summarize_r(&rec, &[]);
|
||
assert!((m.sqn - 1.0).abs() < 1e-9, "sqn = sqrt(n)*mean/sd; got {}", m.sqn);
|
||
}
|
||
|
||
#[test]
|
||
fn summarize_r_sqn_zero_when_under_two_trades_or_zero_variance() {
|
||
// n < 2 -> 0
|
||
assert_eq!(summarize_r(&[r_row(true, 2.0, false, 0.0)], &[]).sqn, 0.0);
|
||
// identical R -> zero variance -> 0
|
||
let flat = vec![r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0)];
|
||
assert_eq!(summarize_r(&flat, &[]).sqn, 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn summarize_r_sqn_normalized_caps_trade_count_at_100() {
|
||
// 144 closed trades alternating R = 1.0 / 2.0 (nonzero variance, sd > 0).
|
||
// raw sqn = √144·q, SQN100 = √(min(144,100))·q = √100·q for the SAME ratio
|
||
// q = mean/sd, so sqn / sqn_normalized = √(144/100) = 1.2, independent of q.
|
||
let rec: Vec<_> = (0..144)
|
||
.map(|i| r_row(true, if i % 2 == 0 { 1.0 } else { 2.0 }, false, 0.0))
|
||
.collect();
|
||
let m = summarize_r(&rec, &[]);
|
||
assert_eq!(m.n_trades, 144);
|
||
assert!(m.sqn_normalized > 0.0, "sqn_normalized nonzero; got {}", m.sqn_normalized);
|
||
assert!(m.sqn_normalized < m.sqn, "capped SQN100 < raw sqn for n > 100");
|
||
assert!(
|
||
(m.sqn / m.sqn_normalized - 1.2).abs() < 1e-9,
|
||
"sqn / sqn_normalized = √(144/100) = 1.2; got {}",
|
||
m.sqn / m.sqn_normalized
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn summarize_r_sqn_normalized_equals_raw_sqn_below_cap() {
|
||
// n = 4 <= 100 -> cap inactive -> SQN100 == raw sqn (same ledger as
|
||
// summarize_r_sqn_is_sqrt_n_mean_over_stdev, where sqn = 1.0).
|
||
let rec = vec![
|
||
r_row(true, 1.0, false, 0.0),
|
||
r_row(true, 1.0, false, 0.0),
|
||
r_row(true, 1.0, false, 0.0),
|
||
r_row(true, -1.0, false, 0.0),
|
||
];
|
||
let m = summarize_r(&rec, &[]);
|
||
assert!((m.sqn_normalized - m.sqn).abs() < 1e-12, "below cap, SQN100 == sqn");
|
||
assert!((m.sqn_normalized - 1.0).abs() < 1e-9, "sqn_normalized = 1.0 here");
|
||
}
|
||
|
||
#[test]
|
||
fn summarize_r_conviction_terciles_order_by_bias() {
|
||
// Calibrated: |bias| correlates with R. Sorted ascending by |bias|, the three
|
||
// terciles (2 trades each) are [-2,-1]->-1.5, [0,0]->0, [+1,+2]->+1.5.
|
||
let calibrated = vec![
|
||
r_row_full(-2.0, 100.0, 99.0, 0.1),
|
||
r_row_full(-1.0, 100.0, 99.0, 0.2),
|
||
r_row_full(0.0, 100.0, 99.0, 0.5),
|
||
r_row_full(0.0, 100.0, 99.0, 0.6),
|
||
r_row_full(1.0, 100.0, 99.0, 0.9),
|
||
r_row_full(2.0, 100.0, 99.0, 1.0),
|
||
];
|
||
let m = summarize_r(&calibrated, &[]);
|
||
assert!((m.conviction_terciles_r[0] - (-1.5)).abs() < 1e-9, "low tercile: {:?}", m.conviction_terciles_r);
|
||
assert!((m.conviction_terciles_r[2] - 1.5).abs() < 1e-9, "high tercile: {:?}", m.conviction_terciles_r);
|
||
assert!(m.conviction_terciles_r[2] > m.conviction_terciles_r[0], "high conviction must out-earn low when calibrated");
|
||
|
||
// Anti-calibrated: high |bias| earns LESS. The ordering must INVERT — proving the
|
||
// metric reads |bias|, not trade order. Same R multiset, |bias| reversed.
|
||
let anti = vec![
|
||
r_row_full(2.0, 100.0, 99.0, 0.1),
|
||
r_row_full(1.0, 100.0, 99.0, 0.2),
|
||
r_row_full(0.0, 100.0, 99.0, 0.5),
|
||
r_row_full(0.0, 100.0, 99.0, 0.6),
|
||
r_row_full(-1.0, 100.0, 99.0, 0.9),
|
||
r_row_full(-2.0, 100.0, 99.0, 1.0),
|
||
];
|
||
let a = summarize_r(&anti, &[]);
|
||
assert!(a.conviction_terciles_r[2] < a.conviction_terciles_r[0], "anti-calibrated must invert: {:?}", a.conviction_terciles_r);
|
||
}
|
||
|
||
#[test]
|
||
fn summarize_r_conviction_terciles_zero_under_three_trades() {
|
||
let rec = vec![r_row_full(2.0, 100.0, 99.0, 0.5), r_row_full(-1.0, 100.0, 99.0, 0.5)];
|
||
assert_eq!(summarize_r(&rec, &[]).conviction_terciles_r, [0.0; 3]);
|
||
}
|
||
|
||
#[test]
|
||
fn summarize_r_net_of_cost_subtracts_round_trip_per_trade() {
|
||
// one closed trade: R=+2, entry 100, stop 90 -> latched 10. A ConstantCost(1.0)
|
||
// node (1.0 price units) emits cost_in_r = 1.0/10 = 0.1 on the closed row (col 0).
|
||
// gross E[R]=2.0, net = 1.9. The empty stream is the gross baseline (net == gross).
|
||
let rec = vec![r_row_full(2.0, 100.0, 90.0, 0.5)];
|
||
let gross = summarize_r(&rec, &[]);
|
||
// producer 3-wide row [cost_in_r, cum_cost_in_r, open_cost_in_r]: closed -> 0.1 / 0.1 / 0.
|
||
let cost = vec![(Timestamp(0), vec![Scalar::f64(0.1), Scalar::f64(0.1), Scalar::f64(0.0)])];
|
||
let net = summarize_r(&rec, &cost);
|
||
assert!((gross.expectancy_r - 2.0).abs() < 1e-9);
|
||
assert!((gross.net_expectancy_r - 2.0).abs() < 1e-9, "zero cost -> net == gross");
|
||
assert!((net.expectancy_r - 2.0).abs() < 1e-9, "cost never changes gross expectancy");
|
||
assert!((net.net_expectancy_r - 1.9).abs() < 1e-9, "net = 2 - 1/10; got {}", net.net_expectancy_r);
|
||
}
|
||
|
||
/// Property: `summarize_r` folding the cost stream reproduces the retired scalar
|
||
/// `round_trip_cost` argument EXACTLY — `net_expectancy_r == mean(rᵢ − costᵢ)` over
|
||
/// BOTH the closed trade (cost from the stream's col 0) and the window-end open trade
|
||
/// (cost from col 2), with the cost-in-R taken straight from the co-temporal stream a
|
||
/// `ConstantCost` node emits (not recomputed from latched). Exercises a nonzero cost,
|
||
/// so it proves exact subsumption — not merely the cost=0 baseline.
|
||
#[test]
|
||
fn summarize_r_folds_cost_stream_exactly_like_the_scalar_over_all_trades() {
|
||
// Trade 1: closed, entry 100 stop 96 (latched 4), realized R = 1.0.
|
||
// Trade 2: still open at window end, entry 100 stop 98 (latched 2), unrealized R = 0.5.
|
||
let c = 2.0_f64;
|
||
let record = vec![
|
||
(Timestamp(0), row14(true, 1.0, 100.0, 96.0, 1.0, false, 0.0)),
|
||
(Timestamp(1), row14(false, 0.0, 100.0, 98.0, 1.0, true, 0.5)),
|
||
];
|
||
// Cost stream the ConstantCost(2.0) node emits, co-temporal — the producer's
|
||
// 3-wide row [cost_in_r, cum_cost_in_r, open_cost_in_r].
|
||
let cost = vec![
|
||
// closed row: 0.5 charged on close (col 0), cum 0.5, no open cost.
|
||
(Timestamp(0), vec![Scalar::f64(c / 4.0), Scalar::f64(c / 4.0), Scalar::f64(0.0)]),
|
||
// open row: no close cost, cum unchanged, would-be open cost 1.0 (col 2).
|
||
(Timestamp(1), vec![Scalar::f64(0.0), Scalar::f64(c / 4.0), Scalar::f64(c / 2.0)]),
|
||
];
|
||
let m = summarize_r(&record, &cost);
|
||
// gross E[R] = mean(1.0, 0.5) = 0.75; net = mean(1.0-0.5, 0.5-1.0) = mean(0.5,-0.5) = 0.0
|
||
assert!((m.expectancy_r - 0.75).abs() < 1e-12);
|
||
assert!((m.net_expectancy_r - 0.0).abs() < 1e-12);
|
||
}
|
||
|
||
// One PositionManagement dense record row for the derive tests: only the columns
|
||
// derive_position_events reads (closed, direction, size, open) are set; the rest
|
||
// default to 0. Distinct per-row timestamps (the derive keys events on the row's
|
||
// own cycle, not the entry_ts column).
|
||
fn pm_row(ts: i64, closed: bool, dir: i64, size: f64, open: bool) -> (Timestamp, Vec<Scalar>) {
|
||
let mut v = vec![Scalar::f64(0.0); r_col::UNREALIZED_R + 1];
|
||
v[r_col::CLOSED] = Scalar::bool(closed);
|
||
v[r_col::DIRECTION] = Scalar::i64(dir);
|
||
v[r_col::SIZE] = Scalar::f64(size);
|
||
v[r_col::OPEN] = Scalar::bool(open);
|
||
(Timestamp(ts), v)
|
||
}
|
||
|
||
#[test]
|
||
fn derive_reversal_emits_close_then_opposite_open_at_one_ts() {
|
||
let rec = vec![
|
||
pm_row(10, false, 1, 2.0, true), // open long
|
||
pm_row(20, true, -1, 3.0, true), // reversal: close long, reopen short
|
||
pm_row(30, true, -1, 3.0, false), // close short, flat
|
||
];
|
||
let ev = derive_position_events(&rec, 42);
|
||
assert_eq!(ev.len(), 4);
|
||
assert_eq!(ev[0].action, PositionAction::Buy);
|
||
assert_eq!(ev[0].event_ts, Timestamp(10));
|
||
assert_eq!(ev[0].position_id, 0);
|
||
assert_eq!(ev[0].volume, 2.0);
|
||
assert_eq!(ev[0].instrument_id, 42);
|
||
assert_eq!(ev[1].action, PositionAction::Close);
|
||
assert_eq!(ev[1].position_id, 0);
|
||
assert_eq!(ev[1].event_ts, Timestamp(20));
|
||
assert_eq!(ev[1].volume, 2.0); // close sizes the actual (old) book, not the new leg
|
||
assert_eq!(ev[2].action, PositionAction::Sell);
|
||
assert_eq!(ev[2].position_id, 1);
|
||
assert_eq!(ev[2].event_ts, Timestamp(20)); // same instant as the close
|
||
assert_eq!(ev[2].volume, 3.0);
|
||
assert_eq!(ev[3].action, PositionAction::Close);
|
||
assert_eq!(ev[3].position_id, 1);
|
||
assert_eq!(ev[3].event_ts, Timestamp(30));
|
||
}
|
||
|
||
#[test]
|
||
fn derive_normal_open_hold_close_lifecycle() {
|
||
let rec = vec![
|
||
pm_row(1, false, 1, 1.0, true), // open long
|
||
pm_row(2, false, 1, 1.0, true), // hold (no event)
|
||
pm_row(3, true, 1, 1.0, false), // close to flat
|
||
];
|
||
let ev = derive_position_events(&rec, 9);
|
||
assert_eq!(ev.len(), 2);
|
||
assert_eq!(ev[0].action, PositionAction::Buy);
|
||
assert_eq!(ev[0].event_ts, Timestamp(1));
|
||
assert_eq!(ev[1].action, PositionAction::Close);
|
||
assert_eq!(ev[1].event_ts, Timestamp(3));
|
||
assert_eq!(ev[1].position_id, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn derive_short_entry_emits_sell() {
|
||
let ev = derive_position_events(&[pm_row(5, false, -1, 1.0, true)], 0);
|
||
assert_eq!(ev.len(), 1);
|
||
assert_eq!(ev[0].action, PositionAction::Sell);
|
||
assert_eq!(ev[0].position_id, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn derive_window_end_open_has_no_synthetic_close() {
|
||
// opened and never closed in-window -> open event only, no Close.
|
||
let rec = vec![
|
||
pm_row(1, false, 1, 1.0, true),
|
||
pm_row(2, false, 1, 1.0, true), // still open on the last row
|
||
];
|
||
let ev = derive_position_events(&rec, 0);
|
||
assert_eq!(ev.len(), 1);
|
||
assert_eq!(ev[0].action, PositionAction::Buy);
|
||
}
|
||
|
||
#[test]
|
||
fn derive_empty_record_is_empty_table() {
|
||
let rec: Vec<(Timestamp, Vec<Scalar>)> = vec![];
|
||
assert!(derive_position_events(&rec, 0).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn derive_position_ids_are_monotonic_across_trades() {
|
||
let rec = vec![
|
||
pm_row(1, false, 1, 1.0, true), // open id0
|
||
pm_row(2, true, 1, 1.0, false), // close id0
|
||
pm_row(3, false, -1, 1.0, true), // open id1 (short)
|
||
pm_row(4, true, -1, 1.0, false), // close id1
|
||
];
|
||
let ev = derive_position_events(&rec, 0);
|
||
assert_eq!(ev.len(), 4);
|
||
assert_eq!(ev[0].position_id, 0);
|
||
assert_eq!(ev[1].position_id, 0);
|
||
assert_eq!(ev[2].position_id, 1);
|
||
assert_eq!(ev[3].position_id, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn runmetrics_with_r_block_round_trips() {
|
||
let m = RunMetrics {
|
||
total_pips: 3.0,
|
||
max_drawdown: 1.0,
|
||
bias_sign_flips: 2,
|
||
r: Some(RMetrics {
|
||
expectancy_r: 0.5,
|
||
n_trades: 4,
|
||
win_rate: 0.5,
|
||
avg_win_r: 1.5,
|
||
avg_loss_r: -0.5,
|
||
profit_factor: 3.0,
|
||
max_r_drawdown: 0.5,
|
||
n_open_at_end: 1,
|
||
sqn: 1.0,
|
||
sqn_normalized: 1.0,
|
||
net_expectancy_r: 0.4,
|
||
conviction_terciles_r: [-0.5, 0.5, 1.5],
|
||
trade_rs: Vec::new(),
|
||
}),
|
||
};
|
||
let json = serde_json::to_string(&m).expect("serialize");
|
||
assert!(json.contains("\"r\":{"), "r block present when Some: {json}");
|
||
let back: RunMetrics = serde_json::from_str(&json).expect("deserialize");
|
||
assert_eq!(back, m);
|
||
}
|
||
|
||
#[test]
|
||
fn rmetrics_deserializes_without_sqn_normalized_field_to_zero() {
|
||
// C18 back-compat: a pre-0067 `r:` block (serialized before sqn_normalized
|
||
// existed) lacks the field; serde(default) must fill it with 0.0.
|
||
let legacy = r#"{"expectancy_r":1.0,"n_trades":4,"win_rate":0.5,"avg_win_r":1.5,"avg_loss_r":-0.5,"profit_factor":3.0,"max_r_drawdown":0.5,"n_open_at_end":0,"sqn":1.0,"net_expectancy_r":0.4,"conviction_terciles_r":[0.0,0.0,0.0]}"#;
|
||
let m: RMetrics = serde_json::from_str(legacy).expect("legacy RMetrics deserializes");
|
||
assert_eq!(m.sqn_normalized, 0.0);
|
||
assert_eq!(m.sqn, 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_exposure_sign_flips_key_reads_via_alias_and_new_output_uses_bias() {
|
||
// a legacy runs.jsonl metrics line carries the OLD key — the serde alias must accept it.
|
||
let legacy = r#"{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":3}"#;
|
||
let m: RunMetrics = serde_json::from_str(legacy).expect("legacy exposure_sign_flips deserialises");
|
||
assert_eq!(m.bias_sign_flips, 3);
|
||
// new output serialises the NEW key, and the old key is gone from output.
|
||
let json = serde_json::to_string(&m).unwrap();
|
||
assert!(json.contains("\"bias_sign_flips\":3"), "new output uses bias_sign_flips: {json}");
|
||
assert!(!json.contains("exposure_sign_flips"), "old key absent from new output: {json}");
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_runmetrics_without_r_field_deserialises_to_none() {
|
||
// a pre-`r` runs.jsonl line: no `r` key at all.
|
||
let legacy = r#"{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":1}"#;
|
||
let m: RunMetrics = serde_json::from_str(legacy).expect("legacy line still deserialises");
|
||
assert_eq!(m.r, None);
|
||
assert_eq!(m.total_pips, 12.0);
|
||
}
|
||
|
||
#[test]
|
||
fn runmetrics_none_r_is_omitted_from_json() {
|
||
let m = RunMetrics { total_pips: 1.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None };
|
||
let json = serde_json::to_string(&m).expect("serialize");
|
||
assert!(!json.contains("\"r\""), "a None r must be omitted from the JSON: {json}");
|
||
}
|
||
|
||
#[test]
|
||
fn summarize_r_populates_trade_rs_in_trade_order() {
|
||
// two closed trades (R = +2.0, then -1.0) over a minimal PositionManagement
|
||
// record; trade_rs must carry [2.0, -1.0] in trade order.
|
||
let rec = pm_record_two_closed_trades(); // helper below
|
||
let m = summarize_r(&rec, &[]);
|
||
assert_eq!(m.trade_rs, vec![2.0, -1.0]);
|
||
assert_eq!(m.n_trades, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn summarize_r_empty_record_has_empty_trade_rs() {
|
||
let m = summarize_r(&[], &[]);
|
||
assert!(m.trade_rs.is_empty());
|
||
assert_eq!(m.n_trades, 0);
|
||
}
|
||
|
||
/// Property: a position still open on the last row is folded into the trade
|
||
/// ledger at its `unrealized_r` (a window-end trade), so `summarize_r`'s
|
||
/// `trade_rs` carries that synthetic open trade's R and `n_trades` counts it.
|
||
/// This is the one case where the two reducers' inputs differ in meaning — it
|
||
/// is exactly the per-trade R series the OOS conduit hands `r_metrics_from_rs`,
|
||
/// so the two must agree on the R-distribution arithmetic for an
|
||
/// open-at-end series. Closed +2.0 then open-at-end +0.5 -> rs [2.0, 0.5].
|
||
#[test]
|
||
fn summarize_r_includes_open_trade_and_matches_r_metrics_from_rs() {
|
||
let rec = pm_record_closed_then_open_at_end();
|
||
let m = summarize_r(&rec, &[]);
|
||
assert_eq!(m.trade_rs, vec![2.0, 0.5]);
|
||
assert_eq!(m.n_trades, 2);
|
||
assert_eq!(m.n_open_at_end, 1);
|
||
// Feed the open-at-end pooled series through the flat reducer: the
|
||
// R-distribution fields (the verbatim-copied arithmetic) must agree.
|
||
let pooled = r_metrics_from_rs(&m.trade_rs);
|
||
assert_eq!(pooled.n_trades, m.n_trades);
|
||
assert_eq!(pooled.expectancy_r, m.expectancy_r);
|
||
assert_eq!(pooled.win_rate, m.win_rate);
|
||
assert_eq!(pooled.avg_win_r, m.avg_win_r);
|
||
assert_eq!(pooled.avg_loss_r, m.avg_loss_r);
|
||
assert_eq!(pooled.profit_factor, m.profit_factor);
|
||
assert_eq!(pooled.max_r_drawdown, m.max_r_drawdown);
|
||
assert_eq!(pooled.sqn, m.sqn);
|
||
assert_eq!(pooled.sqn_normalized, m.sqn_normalized);
|
||
}
|
||
|
||
#[test]
|
||
fn rmetrics_partial_eq_ignores_trade_rs() {
|
||
// two RMetrics equal in every metric but differing in trade_rs compare EQUAL
|
||
// (trade_rs is an in-memory conduit, excluded from equality) — this is what
|
||
// keeps serialize->deserialize round-trips equal (trade_rs is serde-skipped,
|
||
// so it deserializes empty).
|
||
let a = summarize_r(&pm_record_two_closed_trades(), &[]);
|
||
let mut b = a.clone();
|
||
b.trade_rs = Vec::new();
|
||
assert_eq!(a, b);
|
||
}
|
||
|
||
#[test]
|
||
fn populated_trade_rs_is_absent_from_serialized_json() {
|
||
// the in-memory conduit must never reach the wire: a populated trade_rs is
|
||
// dropped by #[serde(skip)], so the C18 on-disk shape is byte-unperturbed (#139).
|
||
let m = summarize_r(&pm_record_two_closed_trades(), &[]);
|
||
assert_eq!(m.trade_rs, vec![2.0, -1.0], "precondition: trade_rs is populated");
|
||
let json = serde_json::to_string(&m).expect("RMetrics serializes");
|
||
assert!(!json.contains("trade_rs"), "trade_rs must not reach the wire: {json}");
|
||
}
|
||
|
||
/// A minimal dense PositionManagement record with two closed trades at R = +2, -1.
|
||
/// Columns per `r_col` (CLOSED=0, REALIZED_R=1, DIRECTION=4, ENTRY_PRICE=6,
|
||
/// STOP_PRICE=7, CONVICTION_AT_ENTRY=9, SIZE=10, OPEN=11, UNREALIZED_R=12); width 13.
|
||
fn pm_record_two_closed_trades() -> Vec<(Timestamp, Vec<Scalar>)> {
|
||
let row = |closed: bool, r: f64, open: bool| {
|
||
let mut c = vec![Scalar::f64(0.0); 13];
|
||
c[0] = Scalar::bool(closed);
|
||
c[1] = Scalar::f64(r);
|
||
c[6] = Scalar::f64(1.0); // entry
|
||
c[7] = Scalar::f64(0.5); // stop -> latched 0.5
|
||
c[9] = Scalar::f64(0.3); // conviction
|
||
c[11] = Scalar::bool(open);
|
||
c
|
||
};
|
||
vec![
|
||
(Timestamp(1), row(true, 2.0, false)),
|
||
(Timestamp(2), row(true, -1.0, false)),
|
||
]
|
||
}
|
||
|
||
/// A dense PositionManagement record whose last row is still open: one closed
|
||
/// trade at R = +2, then a position open at cycle end carrying UNREALIZED_R
|
||
/// = +0.5 (col 12, the field `summarize_r` reads for the window-end trade).
|
||
/// Same column map / width as `pm_record_two_closed_trades`.
|
||
fn pm_record_closed_then_open_at_end() -> Vec<(Timestamp, Vec<Scalar>)> {
|
||
let row = |closed: bool, realized_r: f64, open: bool, unrealized_r: f64| {
|
||
let mut c = vec![Scalar::f64(0.0); 13];
|
||
c[0] = Scalar::bool(closed);
|
||
c[1] = Scalar::f64(realized_r);
|
||
c[6] = Scalar::f64(1.0); // entry
|
||
c[7] = Scalar::f64(0.5); // stop -> latched 0.5
|
||
c[9] = Scalar::f64(0.3); // conviction
|
||
c[11] = Scalar::bool(open);
|
||
c[12] = Scalar::f64(unrealized_r);
|
||
c
|
||
};
|
||
vec![
|
||
(Timestamp(1), row(true, 2.0, false, 0.0)),
|
||
(Timestamp(2), row(false, 0.0, true, 0.5)),
|
||
]
|
||
}
|
||
|
||
#[test]
|
||
fn r_metrics_from_rs_folds_a_flat_series() {
|
||
// pooled across-window R series [2.0, -1.0, 1.0]: expectancy = 2/3, 2 wins of 3,
|
||
// profit_factor = (2+1)/1 = 3. At cost 0 (frictionless) net == gross.
|
||
// conviction terciles are not pooled -> [0,0,0]; n_open_at_end is not a pooled
|
||
// concept -> 0.
|
||
let m = r_metrics_from_rs(&[2.0, -1.0, 1.0]);
|
||
assert_eq!(m.n_trades, 3);
|
||
assert!((m.expectancy_r - 2.0 / 3.0).abs() < 1e-12);
|
||
assert!((m.win_rate - 2.0 / 3.0).abs() < 1e-12);
|
||
assert!((m.profit_factor - 3.0).abs() < 1e-12);
|
||
assert_eq!(m.net_expectancy_r, m.expectancy_r);
|
||
assert_eq!(m.conviction_terciles_r, [0.0; 3]);
|
||
assert_eq!(m.n_open_at_end, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn r_metrics_from_rs_empty_is_all_zero() {
|
||
let m = r_metrics_from_rs(&[]);
|
||
assert_eq!(m.n_trades, 0);
|
||
assert_eq!(m.expectancy_r, 0.0);
|
||
assert_eq!(m.sqn, 0.0);
|
||
}
|
||
}
|