feat(stage1-r): Sizer + RiskExecutor + R-metric enrichment (iter 2)
Iteration 2 of cycle 0065 (Stage-1 R signal quality) — the node + metric layer. Builds on iter-1's PositionManagement dense R-record + core summarize_r. What ships: - PositionManagement gains a 4th `size` input (slot 3 -> col 10), fed by the Sizer. R is computed size-INVARIANTLY (pure stop-distance ratio), so size never touches realized_r — pinned by a RED test (scaling size leaves every realized_r unchanged) at the node and again end-to-end through the executor. - Sizer (aura-std): `size = risk_budget / stop_distance` — flat-1R (risk_budget 1.0 => one risk unit per trade, size inversely proportional to the stop, not a constant). Reads bias (firing/presence) + stop_distance; the Stage-2 fixed-fractional sizer slots in unchanged (swap risk_budget for risk_fraction*equity, same node shape). - summarize_r enrichment: SQN (sqrt(n)*mean_R/sample-stdev_R; n<2 or zero-variance -> 0), conviction_terciles_r (E[R] by |bias_at_entry| tercile), net_expectancy_r (gross minus one round-trip cost per trade, charged in R via the latched_dist recovered from the entry_price/stop_price columns). summarize_r gains a `round_trip_cost` param (price units). The net-of-cost recovery is tested through the real producer->consumer seam, not just hand-built rows. - RunMetrics.r: Option<RMetrics> with #[serde(default, skip_serializing_if = "Option::is_none")] — legacy runs.jsonl (no `r` key) deserialise to None and a pip-only run's on-disk shape stays byte-unchanged (C14/C18 back-compat). Every RunMetrics literal threaded (report.rs, aura-registry, aura-engine/mc). - RiskExecutor (aura-engine integration-test fixture, sibling of vol_stop_composite): FixedStop -> Sizer -> PositionManagement behind open bias+price input roles, price fanned to both the stop and PM, the Sizer's size into PM. Bias is produced in-graph from the single price source (a second bias *source* would k-way-merge into separate cycles and mark stale prices). The Veto is a DOCUMENTED SEAM, not a runtime node (a pass-through identity is what C19/C23 DCE deletes). Tests: the composite bootstraps + runs + folds to the documented hand value; R invariant under risk_budget while the size column scales; a live-folded RMetrics survives the RunMetrics serde round-trip. The dense-record size column (10) is brought under the cross-crate layout guard (stage1_r_e2e r_col_indices_match_producer_field_layout) so the executor fixture's size-invariance read is drift-protected like the others. Scope: the CLI/recording surface (#129) is sub-split into a separate iteration 3 and is NOT in this commit. Verified: cargo build --workspace clean; cargo test --workspace 500 passed, 0 failed; cargo clippy --workspace --all-targets -D warnings clean. refs #117 #127 #128 #129
This commit is contained in:
@@ -209,6 +209,7 @@ mod tests {
|
||||
total_pips: v,
|
||||
max_drawdown: v,
|
||||
exposure_sign_flips: v as u64,
|
||||
r: None,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -27,11 +27,17 @@ pub struct RunMetrics {
|
||||
/// A turnover proxy: it counts long<->short reversals *and* transitions
|
||||
/// into/out of flat — the plain sign-change count over the exposure series.
|
||||
pub exposure_sign_flips: u64,
|
||||
/// Optional Stage-1 R metrics block. `None` for a pip-only run (and for legacy
|
||||
/// `runs.jsonl` written before this field existed — `serde(default)`); omitted from
|
||||
/// the JSON entirely when absent (`skip_serializing_if`), so the pip-only on-disk
|
||||
/// shape stays byte-unchanged (C14/C18 back-compat).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub r: Option<RMetrics>,
|
||||
}
|
||||
|
||||
/// R-based signal-quality metrics (Stage-1), reduced from a `PositionManagement` dense
|
||||
/// record stream by [`summarize_r`]. Account- and instrument-agnostic (pure R). The
|
||||
/// iteration-2 fields (SQN, conviction terciles, net-of-cost) are added later.
|
||||
/// 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, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RMetrics {
|
||||
pub expectancy_r: f64, // mean realised R over all trades (equal-weighted; headline)
|
||||
@@ -42,6 +48,9 @@ pub struct RMetrics {
|
||||
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
|
||||
pub net_expectancy_r: f64, // mean(R - round_trip_cost / latched_dist) — churn-honest
|
||||
pub conviction_terciles_r: [f64; 3], // E[R] by |bias_at_entry| tercile (asc); <3 trades -> [0,0,0]
|
||||
}
|
||||
|
||||
// Dense `PositionManagement` record column indices — the lockstep contract with
|
||||
@@ -50,6 +59,9 @@ pub struct RMetrics {
|
||||
mod r_col {
|
||||
pub const CLOSED: usize = 0;
|
||||
pub const REALIZED_R: usize = 1;
|
||||
pub const ENTRY_PRICE: usize = 6;
|
||||
pub const STOP_PRICE: usize = 7;
|
||||
pub const BIAS_AT_ENTRY_ABS: usize = 9;
|
||||
pub const OPEN: usize = 11;
|
||||
pub const UNREALIZED_R: usize = 12;
|
||||
}
|
||||
@@ -58,29 +70,56 @@ mod r_col {
|
||||
/// 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>)]) -> RMetrics {
|
||||
// Rows are the producer's dense `PositionManagement` records, always `PM_WIDTH`
|
||||
// (14) columns wide — pinned by `stage1_r_e2e.rs`. Trust that layout and index
|
||||
// every column directly: a guard on one column while the next is a bare `[]`
|
||||
// index would buy nothing (a short row panics either way).
|
||||
let mut rs: Vec<f64> = Vec::new();
|
||||
let mut n_open_at_end = 0u64;
|
||||
pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)], round_trip_cost: f64) -> RMetrics {
|
||||
// Collect one entry per trade: its realised R, the entry-conviction |bias|, and the
|
||||
// latched R-distance (|entry - stop|, the frozen R-denominator) recovered from the
|
||||
// record. The ledger is the closed rows; a position still open on the last row is
|
||||
// force-closed at its unrealized R (a window-end trade).
|
||||
struct Trade {
|
||||
r: f64,
|
||||
bias_abs: f64,
|
||||
latched: f64,
|
||||
}
|
||||
let mut trades: Vec<Trade> = Vec::new();
|
||||
for (_, row) in record {
|
||||
if row[r_col::CLOSED].as_bool() {
|
||||
rs.push(row[r_col::REALIZED_R].as_f64());
|
||||
trades.push(Trade {
|
||||
r: row[r_col::REALIZED_R].as_f64(),
|
||||
bias_abs: row[r_col::BIAS_AT_ENTRY_ABS].as_f64(),
|
||||
latched: (row[r_col::ENTRY_PRICE].as_f64() - row[r_col::STOP_PRICE].as_f64()).abs(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut n_open_at_end = 0u64;
|
||||
if let Some((_, last)) = record.last()
|
||||
&& last[r_col::OPEN].as_bool()
|
||||
{
|
||||
rs.push(last[r_col::UNREALIZED_R].as_f64());
|
||||
trades.push(Trade {
|
||||
r: last[r_col::UNREALIZED_R].as_f64(),
|
||||
bias_abs: last[r_col::BIAS_AT_ENTRY_ABS].as_f64(),
|
||||
latched: (last[r_col::ENTRY_PRICE].as_f64() - last[r_col::STOP_PRICE].as_f64()).abs(),
|
||||
});
|
||||
n_open_at_end = 1;
|
||||
}
|
||||
let n = rs.len() as u64;
|
||||
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 };
|
||||
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,
|
||||
net_expectancy_r: 0.0,
|
||||
conviction_terciles_r: [0.0; 3],
|
||||
};
|
||||
}
|
||||
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();
|
||||
@@ -92,12 +131,52 @@ pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)]) -> RMetrics {
|
||||
let mut max_dd = 0.0_f64;
|
||||
for &r in &rs {
|
||||
cum += r;
|
||||
if cum > peak { peak = cum; }
|
||||
if cum > peak {
|
||||
peak = cum;
|
||||
}
|
||||
let dd = peak - cum;
|
||||
if dd > max_dd { max_dd = dd; }
|
||||
if dd > max_dd {
|
||||
max_dd = dd;
|
||||
}
|
||||
}
|
||||
// SQN = √n · mean / sample-stdev. n < 2 or zero variance -> 0.0 (dispersion undefined).
|
||||
let sqn = if n < 2 {
|
||||
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 } else { 0.0 }
|
||||
};
|
||||
// net-of-cost: subtract one round-trip spread (price units) per trade, expressed in R
|
||||
// by dividing by that trade's latched R-distance (a zero distance contributes no cost).
|
||||
let net_sum: f64 = trades
|
||||
.iter()
|
||||
.map(|t| t.r - if t.latched > 0.0 { round_trip_cost / t.latched } else { 0.0 })
|
||||
.sum();
|
||||
let net_expectancy_r = net_sum / n as f64;
|
||||
// conviction terciles: sort by |bias_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: sum / n as f64,
|
||||
expectancy_r: mean,
|
||||
n_trades: n,
|
||||
win_rate: wins.len() as f64 / n as f64,
|
||||
avg_win_r: avg(&wins),
|
||||
@@ -105,6 +184,9 @@ pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)]) -> RMetrics {
|
||||
profit_factor: if sum_loss < 0.0 { sum_win / (-sum_loss) } else { 0.0 },
|
||||
max_r_drawdown: max_dd,
|
||||
n_open_at_end,
|
||||
sqn,
|
||||
net_expectancy_r,
|
||||
conviction_terciles_r,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +317,7 @@ pub fn summarize(
|
||||
prev = Some(s);
|
||||
}
|
||||
|
||||
RunMetrics { total_pips, max_drawdown, exposure_sign_flips }
|
||||
RunMetrics { total_pips, max_drawdown, exposure_sign_flips, r: None }
|
||||
}
|
||||
|
||||
/// Three-way sign: `-1.0` / `0.0` / `+1.0`. Unlike `f64::signum` (which returns
|
||||
@@ -582,12 +664,29 @@ mod tests {
|
||||
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
|
||||
// bias_at_entry_abs (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::BIAS_AT_ENTRY_ABS] = Scalar::f64(bias_abs);
|
||||
v[r_col::OPEN] = Scalar::bool(false);
|
||||
(Timestamp(0), v)
|
||||
}
|
||||
#[test]
|
||||
fn summarize_r_is_zero_on_empty() {
|
||||
let m = summarize_r(&[]);
|
||||
let m = summarize_r(&[], 0.0);
|
||||
assert_eq!(m.n_trades, 0);
|
||||
assert_eq!(m.expectancy_r, 0.0);
|
||||
assert_eq!(m.max_r_drawdown, 0.0);
|
||||
assert_eq!(m.sqn, 0.0);
|
||||
assert_eq!(m.net_expectancy_r, 0.0);
|
||||
assert_eq!(m.conviction_terciles_r, [0.0; 3]);
|
||||
}
|
||||
#[test]
|
||||
fn summarize_r_expectancy_winrate_profit_factor() {
|
||||
@@ -598,7 +697,7 @@ mod tests {
|
||||
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);
|
||||
let m = summarize_r(&rec, 0.0);
|
||||
assert_eq!(m.n_trades, 3);
|
||||
assert!((m.expectancy_r - (2.0 / 3.0)).abs() < 1e-9);
|
||||
assert!((m.win_rate - (2.0 / 3.0)).abs() < 1e-9);
|
||||
@@ -609,7 +708,7 @@ mod tests {
|
||||
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);
|
||||
let m = summarize_r(&rec, 0.0);
|
||||
assert_eq!(m.n_trades, 2);
|
||||
assert_eq!(m.n_open_at_end, 1);
|
||||
assert!((m.expectancy_r - 0.25).abs() < 1e-9); // (1 + -0.5)/2
|
||||
@@ -618,7 +717,80 @@ mod tests {
|
||||
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);
|
||||
assert!((summarize_r(&rec, 0.0).max_r_drawdown - 2.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_r_sqn_is_sqrt_n_mean_over_stdev() {
|
||||
// R = [1, 1, 1, -1]: mean 0.5; sample var = ((0.5)^2*3 + (1.5)^2)/3 = 1.0; sd = 1;
|
||||
// sqn = sqrt(4)*0.5/1 = 1.0.
|
||||
let rec = vec![
|
||||
r_row(true, 1.0, false, 0.0),
|
||||
r_row(true, 1.0, false, 0.0),
|
||||
r_row(true, 1.0, false, 0.0),
|
||||
r_row(true, -1.0, false, 0.0),
|
||||
];
|
||||
let m = summarize_r(&rec, 0.0);
|
||||
assert!((m.sqn - 1.0).abs() < 1e-9, "sqn = sqrt(n)*mean/sd; got {}", m.sqn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_r_sqn_zero_when_under_two_trades_or_zero_variance() {
|
||||
// n < 2 -> 0
|
||||
assert_eq!(summarize_r(&[r_row(true, 2.0, false, 0.0)], 0.0).sqn, 0.0);
|
||||
// identical R -> zero variance -> 0
|
||||
let flat = vec![r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0), r_row(true, 1.0, false, 0.0)];
|
||||
assert_eq!(summarize_r(&flat, 0.0).sqn, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_r_conviction_terciles_order_by_bias() {
|
||||
// Calibrated: |bias| correlates with R. Sorted ascending by |bias|, the three
|
||||
// terciles (2 trades each) are [-2,-1]->-1.5, [0,0]->0, [+1,+2]->+1.5.
|
||||
let calibrated = vec![
|
||||
r_row_full(-2.0, 100.0, 99.0, 0.1),
|
||||
r_row_full(-1.0, 100.0, 99.0, 0.2),
|
||||
r_row_full(0.0, 100.0, 99.0, 0.5),
|
||||
r_row_full(0.0, 100.0, 99.0, 0.6),
|
||||
r_row_full(1.0, 100.0, 99.0, 0.9),
|
||||
r_row_full(2.0, 100.0, 99.0, 1.0),
|
||||
];
|
||||
let m = summarize_r(&calibrated, 0.0);
|
||||
assert!((m.conviction_terciles_r[0] - (-1.5)).abs() < 1e-9, "low tercile: {:?}", m.conviction_terciles_r);
|
||||
assert!((m.conviction_terciles_r[2] - 1.5).abs() < 1e-9, "high tercile: {:?}", m.conviction_terciles_r);
|
||||
assert!(m.conviction_terciles_r[2] > m.conviction_terciles_r[0], "high conviction must out-earn low when calibrated");
|
||||
|
||||
// Anti-calibrated: high |bias| earns LESS. The ordering must INVERT — proving the
|
||||
// metric reads |bias|, not trade order. Same R multiset, |bias| reversed.
|
||||
let anti = vec![
|
||||
r_row_full(2.0, 100.0, 99.0, 0.1),
|
||||
r_row_full(1.0, 100.0, 99.0, 0.2),
|
||||
r_row_full(0.0, 100.0, 99.0, 0.5),
|
||||
r_row_full(0.0, 100.0, 99.0, 0.6),
|
||||
r_row_full(-1.0, 100.0, 99.0, 0.9),
|
||||
r_row_full(-2.0, 100.0, 99.0, 1.0),
|
||||
];
|
||||
let a = summarize_r(&anti, 0.0);
|
||||
assert!(a.conviction_terciles_r[2] < a.conviction_terciles_r[0], "anti-calibrated must invert: {:?}", a.conviction_terciles_r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_r_conviction_terciles_zero_under_three_trades() {
|
||||
let rec = vec![r_row_full(2.0, 100.0, 99.0, 0.5), r_row_full(-1.0, 100.0, 99.0, 0.5)];
|
||||
assert_eq!(summarize_r(&rec, 0.0).conviction_terciles_r, [0.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_r_net_of_cost_subtracts_round_trip_per_trade() {
|
||||
// one trade: R=+2, entry 100, stop 90 -> latched 10. round_trip_cost 1.0 (price
|
||||
// units) -> cost in R = 1/10 = 0.1. gross E[R]=2.0, net = 1.9.
|
||||
let rec = vec![r_row_full(2.0, 100.0, 90.0, 0.5)];
|
||||
let gross = summarize_r(&rec, 0.0);
|
||||
let net = summarize_r(&rec, 1.0);
|
||||
assert!((gross.expectancy_r - 2.0).abs() < 1e-9);
|
||||
assert!((gross.net_expectancy_r - 2.0).abs() < 1e-9, "zero cost -> net == gross");
|
||||
assert!((net.expectancy_r - 2.0).abs() < 1e-9, "cost never changes gross expectancy");
|
||||
assert!((net.net_expectancy_r - 1.9).abs() < 1e-9, "net = 2 - 1/10; got {}", net.net_expectancy_r);
|
||||
}
|
||||
|
||||
fn samples(values: &[f64]) -> Vec<(Timestamp, f64)> {
|
||||
@@ -712,6 +884,7 @@ mod tests {
|
||||
total_pips: 12.0,
|
||||
max_drawdown: 1.0,
|
||||
exposure_sign_flips: 1,
|
||||
r: None,
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
@@ -735,7 +908,7 @@ mod tests {
|
||||
seed: 0,
|
||||
broker: "sim-optimal(pip_size=1.0)".to_string(),
|
||||
},
|
||||
metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1 },
|
||||
metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1, r: None },
|
||||
};
|
||||
// stdout (to_json) and disk (serde_json::to_string) are now the same bytes.
|
||||
assert_eq!(report.to_json(), serde_json::to_string(&report).unwrap());
|
||||
@@ -755,7 +928,7 @@ mod tests {
|
||||
seed: 0,
|
||||
broker: "sim-optimal(pip_size=1.0)".to_string(),
|
||||
},
|
||||
metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1 },
|
||||
metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1, r: None },
|
||||
};
|
||||
let json = serde_json::to_string(&report).expect("serialize RunReport");
|
||||
// window is a 2-element [from, to] array (Timestamp newtype is transparent)
|
||||
@@ -764,6 +937,48 @@ mod tests {
|
||||
assert_eq!(back, report);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runmetrics_with_r_block_round_trips() {
|
||||
let m = RunMetrics {
|
||||
total_pips: 3.0,
|
||||
max_drawdown: 1.0,
|
||||
exposure_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,
|
||||
net_expectancy_r: 0.4,
|
||||
conviction_terciles_r: [-0.5, 0.5, 1.5],
|
||||
}),
|
||||
};
|
||||
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 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, exposure_sign_flips: 0, r: None };
|
||||
let json = serde_json::to_string(&m).expect("serialize");
|
||||
assert!(!json.contains("\"r\""), "a None r must be omitted from the JSON: {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_on_ts_aligns_streams_of_different_cardinality() {
|
||||
// spine fires every bar; side A is one row shorter (no ts 10, like cold
|
||||
|
||||
Reference in New Issue
Block a user