From 7bfceda4414acdbd988d32f00f62cafec4bf6e90 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sun, 29 Mar 2026 20:03:48 +0200 Subject: [PATCH] =?UTF-8?q?fix(metrics):=20unify=20return=20basis=20?= =?UTF-8?q?=E2=80=94=20total=5Freturn=20from=20same=20series=20as=20Omega?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit total_return_pct used simple pnl/initial_capital (arithmetic). Omega/Sharpe/Sortino used pnl/current_equity (compounding per-trade). These diverge after drawdown: Omega>1 but negative total_return was mathematically impossible yet appeared in every run. Fix: total_return_pct = ∏(1+r_i)-1 using the same per-trade returns that feed Omega. Now Omega>1 ⟺ positive total_return. Always. Single-pass equity curve + returns computation. No separate loops. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/ml-dqn/src/evaluation/metrics.rs | 52 +++++++++++-------------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/crates/ml-dqn/src/evaluation/metrics.rs b/crates/ml-dqn/src/evaluation/metrics.rs index a56586ec3..7b02ce4e8 100644 --- a/crates/ml-dqn/src/evaluation/metrics.rs +++ b/crates/ml-dqn/src/evaluation/metrics.rs @@ -72,14 +72,30 @@ impl PerformanceMetrics { }; } - // Calculate total PnL - let total_pnl: f32 = trades.iter().map(|t| t.pnl).sum(); - let final_equity = (initial_capital + total_pnl) as f64; + // Build equity curve and per-trade returns in a single pass. + // Returns are pnl/equity_at_trade — same basis for Sharpe, Omega, AND total_return. + // This ensures Omega>1 ⟺ positive total return (no arithmetic/compounding mismatch). + let mut equity_curve = Vec::with_capacity(trades.len() + 1); + equity_curve.push(initial_capital); + let mut returns = Vec::with_capacity(trades.len()); - // Calculate total return percentage - let total_return_pct = (total_pnl as f64 / initial_capital as f64) * 100.0; + for trade in trades { + let equity = *equity_curve.last().unwrap(); + let r = if (equity as f64).abs() > 1e-8 { + trade.pnl as f64 / equity as f64 + } else { + 0.0 + }; + returns.push(r); + equity_curve.push(equity + trade.pnl); + } + + let final_equity = *equity_curve.last().unwrap() as f64; + let total_pnl = final_equity - initial_capital as f64; + // Total return from the SAME return series that feeds Omega/Sharpe: + // product of (1+r_i) - 1, i.e. compounding return. Consistent with Omega. + let total_return_pct = (returns.iter().fold(1.0_f64, |acc, r| acc * (1.0 + r)) - 1.0) * 100.0; - // Calculate win rate let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count(); let win_rate = if trades.is_empty() { 0.0 @@ -87,36 +103,14 @@ impl PerformanceMetrics { (winning_trades as f64 / trades.len() as f64) * 100.0 }; - // Calculate average trade PnL let avg_trade_pnl = if trades.is_empty() { 0.0 } else { - total_pnl as f64 / trades.len() as f64 + total_pnl / trades.len() as f64 }; - // Calculate equity curve for drawdown and Sharpe - let mut equity_curve = Vec::with_capacity(trades.len() + 1); - equity_curve.push(initial_capital); - - for trade in trades { - let last_equity = equity_curve.last().copied().unwrap_or(initial_capital); - equity_curve.push(last_equity + trade.pnl); - } - - // Calculate maximum drawdown let max_drawdown_pct = calculate_max_drawdown(&equity_curve); - // Per-trade returns relative to equity AT TIME OF TRADE (not initial capital). - // Using initial_capital deflates losses after drawdown, inflating Omega/Sortino. - let returns: Vec = { - let mut equity = initial_capital as f64; - trades.iter().map(|t| { - let r = if equity.abs() > 1e-8 { t.pnl as f64 / equity } else { 0.0 }; - equity += t.pnl as f64; - r - }).collect() - }; - // Calculate Sharpe ratio from trade returns let sharpe_ratio = calculate_sharpe_ratio(&returns);