fix(metrics): unify return basis — total_return from same series as Omega

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) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-29 20:03:48 +02:00
parent ed7d070a29
commit 7bfceda441

View File

@@ -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<f64> = {
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);