fix(rl): eval_summary aggregates across all b_size accounts (E.4-E.5)
Replaces the broken single-account + scale-mismatch slicing block in
alpha_rl_train.rs eval phase with proper per-account aggregation.
Pre-fix (cluster v11 alpha-rl-8ll7j, b=1024):
head_before_eval = sim.read_total_trade_count() // aggregate=1,203,376
all_records = sim.read_trade_records(0) // backtest 0 only, ≤1024
// 1.2M > 1024 → wrap branch fires → eval_records = ALL 1024 records
// from account 0, mixing train+eval trades
eval_summary: n_trades=1024 pnl=$61,513 wr=0.217 — MEANINGLESS
Post-fix (this commit, b=16 local smoke):
head_before_per_b = sim.read_per_backtest_trade_counts()
all_per_b = sim.read_trade_records_all()
// for each backtest: slice ring by per-account head_before vs head_after,
// handle wrap correctly, aggregate eval-only records
eval_summary: n_trades=226 pnl=$-41,137 wr=0.376
n_eval_trades_seen=226 (= captured) n_dropped=0 n_pre_eval_wrapped=0
Local smoke validation (b=16, 1k train + 250 eval, fold 1 ES.FUT all):
- n_trades jumped 58 → 226 (4× — confirms aggregation across 16 accts)
- per-account pre-eval heads: min=16 max=59 sum=596 (correct scale)
- n_eval_trades_seen == n_trades (no wrap at this scale)
- eval_summary.json gained 4 new fields:
n_eval_trades_seen, n_eval_trades_dropped,
n_pre_eval_trades_wrapped, b_size
Existing keys (n_trades, total_pnl_usd, profit_factor, sharpe_ann,
max_drawdown_usd, win_rate) preserved for downstream G8-gate / Argo
aggregator consumers (per spec §3).
Cluster validation pending Phase A cluster (alpha-rl-jz48s) completion
to avoid alpha_rl_train.rs branch conflict. After Phase A merges,
this fix can submit alongside.
Spec: docs/superpowers/specs/2026-05-31-eval-summary-trade-aggregation-design.md
Plan: docs/superpowers/plans/2026-05-31-eval-summary-trade-aggregation.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -742,12 +742,22 @@ fn main() -> Result<()> {
|
||||
.reset_session_state()
|
||||
.context("reset_session_state at train→eval boundary")?;
|
||||
|
||||
let head_before_eval = sim
|
||||
.read_total_trade_count()
|
||||
.context("read trade count pre-eval")?;
|
||||
let head_before_per_b = sim
|
||||
.read_per_backtest_trade_counts()
|
||||
.context("snapshot per-backtest trade-count pre-eval")?;
|
||||
let head_before_min = head_before_per_b.iter().min().copied().unwrap_or(0);
|
||||
let head_before_max = head_before_per_b.iter().max().copied().unwrap_or(0);
|
||||
let head_before_sum: u64 =
|
||||
head_before_per_b.iter().map(|&h| h as u64).sum();
|
||||
eprintln!(
|
||||
"── eval phase: {} steps on {} held-out files (trade-record checkpoint head={}) ──",
|
||||
cli.n_eval_steps, eval_files.len(), head_before_eval
|
||||
"── eval phase: {} steps on {} held-out files; pre-eval head per b_size={} \
|
||||
accounts: min={} max={} sum={} ──",
|
||||
cli.n_eval_steps,
|
||||
eval_files.len(),
|
||||
head_before_per_b.len(),
|
||||
head_before_min,
|
||||
head_before_max,
|
||||
head_before_sum
|
||||
);
|
||||
|
||||
let eval_loader_cfg = MultiHorizonLoaderConfig {
|
||||
@@ -985,22 +995,82 @@ fn main() -> Result<()> {
|
||||
}
|
||||
eval_diag.flush().context("eval diag: final flush")?;
|
||||
|
||||
// Drain trade records, slice to eval-only, compute summary.
|
||||
let all_records = sim
|
||||
.read_trade_records(0)
|
||||
.context("read trade records post-eval")?;
|
||||
let head_before_usize = head_before_eval as usize;
|
||||
let eval_records: Vec<_> = if all_records.len() > head_before_usize {
|
||||
all_records[head_before_usize..].to_vec()
|
||||
} else {
|
||||
// Trade log wrapped past TRADE_LOG_CAP — use what we have.
|
||||
// For smoke (b_size=1, ≤200 trades) this branch never fires.
|
||||
// Aggregate eval-phase trades across ALL b_size accounts, with
|
||||
// correct per-account head_before slicing. Replaces the previous
|
||||
// single-account read of backtest 0 + aggregate-vs-per-account
|
||||
// scale mismatch (see spec
|
||||
// docs/superpowers/specs/2026-05-31-eval-summary-trade-aggregation-design.md
|
||||
// for the root-cause analysis).
|
||||
let all_per_b = sim
|
||||
.read_trade_records_all()
|
||||
.context("read all backtests' trade records post-eval")?;
|
||||
let head_after_per_b = sim
|
||||
.read_per_backtest_trade_counts()
|
||||
.context("snapshot per-backtest trade-count post-eval")?;
|
||||
|
||||
let cap_u32 = ml_backtesting::lob::TRADE_LOG_CAP as u32;
|
||||
let mut eval_records: Vec<ml_backtesting::order::TradeRecord> = Vec::new();
|
||||
let mut n_eval_trades_seen: u64 = 0;
|
||||
let mut n_eval_trades_dropped: u64 = 0;
|
||||
let mut n_pre_eval_wrapped: u64 = 0;
|
||||
|
||||
for (b, records) in all_per_b.iter().enumerate() {
|
||||
let head_before = head_before_per_b[b];
|
||||
let head_after = head_after_per_b[b];
|
||||
let eval_count_total = head_after.saturating_sub(head_before);
|
||||
n_eval_trades_seen += eval_count_total as u64;
|
||||
|
||||
// The ring's contents cover cumulative-stream indices
|
||||
// [max(0, head_after - cap), head_after).
|
||||
let ring_start_stream_idx = head_after.saturating_sub(cap_u32);
|
||||
|
||||
if head_before < ring_start_stream_idx {
|
||||
// Some pre-eval trades had wrapped out before eval started —
|
||||
// diagnostic only, doesn't affect eval slicing.
|
||||
n_pre_eval_wrapped += (ring_start_stream_idx - head_before) as u64;
|
||||
}
|
||||
|
||||
if eval_count_total > cap_u32 {
|
||||
// Eval-phase trades wrapped (lost). Should be 0 with
|
||||
// TRADE_LOG_CAP=4096 at typical cluster scale.
|
||||
n_eval_trades_dropped += (eval_count_total - cap_u32) as u64;
|
||||
}
|
||||
|
||||
// Slice the ring contents to eval-only.
|
||||
// ring index of first eval trade = head_before − ring_start_stream_idx
|
||||
// (clamped at 0 if all pre-eval already wrapped out).
|
||||
let eval_start_in_ring =
|
||||
head_before.saturating_sub(ring_start_stream_idx) as usize;
|
||||
if eval_start_in_ring < records.len() {
|
||||
eval_records.extend_from_slice(&records[eval_start_in_ring..]);
|
||||
}
|
||||
}
|
||||
|
||||
if n_eval_trades_dropped > 0 {
|
||||
eprintln!(
|
||||
"warning: trade log wrapped — head_before={} but only {} records readable",
|
||||
head_before_usize, all_records.len()
|
||||
"warning: {} eval trades wrapped out across {} accounts \
|
||||
(TRADE_LOG_CAP={}); summary based on {} captured eval trades",
|
||||
n_eval_trades_dropped,
|
||||
all_per_b.len(),
|
||||
cap_u32,
|
||||
eval_records.len()
|
||||
);
|
||||
all_records
|
||||
};
|
||||
}
|
||||
if n_pre_eval_wrapped > 0 {
|
||||
eprintln!(
|
||||
"info: {} pre-eval trades had already wrapped before eval phase \
|
||||
(no effect on eval summary)",
|
||||
n_pre_eval_wrapped
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"eval phase trade accounting: n_eval_trades_seen={} n_captured={} \
|
||||
n_dropped={} b_size={}",
|
||||
n_eval_trades_seen,
|
||||
eval_records.len(),
|
||||
n_eval_trades_dropped,
|
||||
all_per_b.len()
|
||||
);
|
||||
|
||||
// Synthesise a pnl_curve from per-trade cumulative PnL for
|
||||
// compute_summary's max_drawdown calc.
|
||||
@@ -1014,19 +1084,42 @@ fn main() -> Result<()> {
|
||||
ml_backtesting::artifacts::compute_summary(&eval_records, &pnl_curve);
|
||||
|
||||
eprintln!(
|
||||
"eval summary: n_trades={} pnl_usd={:.2} pf={:.3} sharpe_ann={:.3} max_dd_usd={:.2} win_rate={:.3}",
|
||||
"eval summary: n_trades={} pnl_usd={:.2} pf={:.3} sharpe_ann={:.3} \
|
||||
max_dd_usd={:.2} win_rate={:.3} | seen={} dropped={} b={}",
|
||||
eval_summary.n_trades,
|
||||
eval_summary.total_pnl_usd,
|
||||
eval_summary.profit_factor,
|
||||
eval_summary.sharpe_ann,
|
||||
eval_summary.max_drawdown_usd,
|
||||
eval_summary.win_rate,
|
||||
n_eval_trades_seen,
|
||||
n_eval_trades_dropped,
|
||||
all_per_b.len(),
|
||||
);
|
||||
|
||||
// Write eval_summary.json with the existing compute_summary fields
|
||||
// PLUS four new aggregation-aware fields:
|
||||
// n_eval_trades_seen — true total cumulative dones across b_size
|
||||
// n_eval_trades_dropped — eval trades lost to ring wrap (0 at typical scale)
|
||||
// n_pre_eval_trades_wrapped — pre-eval trades wrapped before eval (diagnostic)
|
||||
// b_size — context for downstream interpretation
|
||||
let aggregated_summary = serde_json::json!({
|
||||
"n_trades": eval_summary.n_trades,
|
||||
"total_pnl_usd": eval_summary.total_pnl_usd,
|
||||
"profit_factor": eval_summary.profit_factor,
|
||||
"sharpe_ann": eval_summary.sharpe_ann,
|
||||
"max_drawdown_usd": eval_summary.max_drawdown_usd,
|
||||
"win_rate": eval_summary.win_rate,
|
||||
"n_eval_trades_seen": n_eval_trades_seen,
|
||||
"n_eval_trades_dropped": n_eval_trades_dropped,
|
||||
"n_pre_eval_trades_wrapped": n_pre_eval_wrapped,
|
||||
"b_size": cli.n_backtests,
|
||||
});
|
||||
|
||||
let eval_summary_path = cli.out.join("eval_summary.json");
|
||||
let f = std::fs::File::create(&eval_summary_path)
|
||||
.with_context(|| format!("create {}", eval_summary_path.display()))?;
|
||||
serde_json::to_writer_pretty(f, &eval_summary)
|
||||
serde_json::to_writer_pretty(f, &aggregated_summary)
|
||||
.with_context(|| format!("write {}", eval_summary_path.display()))?;
|
||||
eprintln!("eval summary written: {}", eval_summary_path.display());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user