feat(0075): walk-forward strategy-selectable + R-reporting (iter 1)
Make `aura walkforward --strategy stage1-r [--real <SYM>]` roll IS->OOS windows, sweep the stage1-r grid in-sample, pick the winner by an R metric (sqn_normalized), run it out-of-sample, and report per-window + pooled OOS R-metrics. The bare SMA walkforward path is byte-identical (its dispatch arm is verbatim today's body; the pooled `oos_r` block is emitted only when a window carries an `r` block). Engine: - RMetrics gains an in-memory `trade_rs: Vec<f64>` (realised R per closed trade), excluded from serde (`#[serde(skip)]`) and from a hand-written PartialEq, so the C18 wire shape and every existing equality assertion / round-trip stay unchanged. summarize_r now retains the per-trade R vector it used to drop. - New `r_metrics_from_rs(&[f64])` reduces a flat (pooled across-window) R series to RMetrics. Its R-distribution arithmetic is copied verbatim from summarize_r (the byte-pinned floats must not be algebraically refactored); the two copies are guarded in lockstep by a cross-reducer equality test. net_expectancy_r = expectancy_r (exact at the Stage-1 cost=0 invariant); conviction_terciles_r = [0,0,0] (per-trade conviction is not pooled). CLI: - walkforward gains --strategy + the four stage1-r grid flags (reusing parse_csv_list / Stage1RGrid); walkforward_family is strategy-dispatched. - Windowed stage1-r helpers: stage1_r_sweep_over (reduce-mode folded IS sweep, O(trades)/member) and run_oos_r (non-reduce OOS run, for the stitched pip-equity curve), plus stage1_r_space. Frictionless Stage-1 R (costs are Stage-2). Verified: cargo build clean, full `cargo test --workspace` green (SMA walkforward, synthetic mc, stage1_r_single_run_output_golden, and the C18 round-trips all preserved), clippy -D warnings clean. Monte-Carlo R-bootstrap is iter 2. refs #139
This commit is contained in:
@@ -62,8 +62,9 @@ pub use harness::{
|
||||
VecSource,
|
||||
};
|
||||
pub use report::{
|
||||
derive_position_events, f64_field, join_on_ts, summarize, summarize_r, ColumnarTrace,
|
||||
JoinedRow, PositionAction, PositionEvent, RMetrics, RunManifest, RunMetrics, RunReport,
|
||||
derive_position_events, f64_field, join_on_ts, r_metrics_from_rs, summarize, summarize_r,
|
||||
ColumnarTrace, JoinedRow, PositionAction, PositionEvent, RMetrics, RunManifest, RunMetrics,
|
||||
RunReport,
|
||||
};
|
||||
pub use sweep::{
|
||||
sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint,
|
||||
|
||||
@@ -41,7 +41,7 @@ pub struct RunMetrics {
|
||||
/// R-based signal-quality metrics (Stage-1), 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, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[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,
|
||||
@@ -59,6 +59,30 @@ pub struct RMetrics {
|
||||
pub sqn_normalized: f64,
|
||||
pub net_expectancy_r: f64, // mean(R - round_trip_cost / latched_dist) — churn-honest
|
||||
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
|
||||
@@ -132,6 +156,7 @@ pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)], round_trip_cost: f64) ->
|
||||
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();
|
||||
@@ -216,6 +241,73 @@ pub fn summarize_r(record: &[(Timestamp, Vec<Scalar>)], round_trip_cost: f64) ->
|
||||
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 Stage-1 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 (Stage-1 frictionless)
|
||||
conviction_terciles_r: [0.0; 3],
|
||||
trade_rs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1145,6 +1237,7 @@ mod tests {
|
||||
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");
|
||||
@@ -1326,4 +1419,127 @@ mod tests {
|
||||
let rows = vec![(Timestamp(1), vec![Scalar::f64(1.0)])];
|
||||
let _ = ColumnarTrace::from_rows("narrow", &[ScalarKind::F64, ScalarKind::F64], &rows);
|
||||
}
|
||||
|
||||
#[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, 0.0);
|
||||
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(&[], 0.0);
|
||||
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, 0.0);
|
||||
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(), 0.0);
|
||||
let mut b = a.clone();
|
||||
b.trade_rs = Vec::new();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
/// 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 Stage-1) 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user