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:
2026-06-26 11:21:27 +02:00
parent 2593af86f8
commit f286bb85f7
5 changed files with 542 additions and 47 deletions
+3 -2
View File
@@ -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,
+217 -1
View File
@@ -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);
}
}