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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user