From ba97a6e8197d1ea468bdabda693c299e8bd08738 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sun, 29 Mar 2026 18:42:43 +0200 Subject: [PATCH] =?UTF-8?q?fix(metrics):=20replace=20hardcoded=20=E2=88=9A?= =?UTF-8?q?252=20annualization=20with=20=E2=88=9AN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharpe/Sortino were annualized with √252 (daily trading assumption). For intraday strategies with hundreds of trades per eval window, this inflated magnitudes ~10x, causing Sharpe=-1.4 while Omega=10.9 on the same return series — mathematically contradictory. Fix: scale by √N where N = actual number of returns in the series. This gives the Sharpe of the evaluation window, not a synthetic annual. - evaluation/metrics.rs: Sharpe, Sortino, Calmar all fixed - trainer/metrics.rs: val_loss Sharpe (compute_validation_loss) - ppo.rs: epoch Sharpe proxy Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/ml-dqn/src/evaluation/metrics.rs | 42 +++++++++---------- crates/ml/src/trainers/dqn/trainer/metrics.rs | 2 +- crates/ml/src/trainers/ppo.rs | 2 +- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/crates/ml-dqn/src/evaluation/metrics.rs b/crates/ml-dqn/src/evaluation/metrics.rs index 670e3bc77..c86e9d0f4 100644 --- a/crates/ml-dqn/src/evaluation/metrics.rs +++ b/crates/ml-dqn/src/evaluation/metrics.rs @@ -117,11 +117,10 @@ impl PerformanceMetrics { // Calculate advanced risk metrics let sortino_ratio = calculate_sortino_ratio(&returns); - let annualized_return = if !returns.is_empty() { - (returns.iter().sum::() / returns.len() as f64) * 252.0 - } else { - 0.0 - }; + // Total return over the evaluation window (sum of per-trade returns). + // Not annualized — Calmar uses this directly against max drawdown. + let total_return = returns.iter().sum::(); + let annualized_return = total_return; let calmar_ratio = calculate_calmar_ratio(annualized_return, max_drawdown_pct); let (var_95, cvar_95) = calculate_var_cvar(&returns, 0.05); let omega_ratio = calculate_omega_ratio(&returns, 0.0); @@ -181,23 +180,25 @@ fn calculate_max_drawdown(equity_curve: &[f32]) -> f64 { max_drawdown.min(100.0) } -/// Calculate Sharpe ratio from returns +/// Calculate Sharpe ratio from per-trade returns. /// -/// Assumes 252 trading days per year, 0% risk-free rate +/// Annualization uses the actual number of trades, not a hardcoded 252. +/// For N trades over the evaluation window, we scale by √N to get the +/// ratio for the full window, then don't further annualize — the result +/// is the Sharpe of the evaluation period, not a synthetic annual figure. fn calculate_sharpe_ratio(returns: &[f64]) -> f64 { if returns.len() < 2 { return 0.0; } - // Calculate mean return - let mean_return = returns.iter().sum::() / returns.len() as f64; + let n = returns.len() as f64; + let mean_return = returns.iter().sum::() / n; - // Calculate standard deviation of returns let variance = returns .iter() .map(|&r| (r - mean_return).powi(2)) .sum::() - / returns.len() as f64; + / n; let std_dev = variance.sqrt(); @@ -205,21 +206,22 @@ fn calculate_sharpe_ratio(returns: &[f64]) -> f64 { return 0.0; } - // Annualize: sqrt(252 trades/year) assuming daily trades - (mean_return / std_dev) * (252.0_f64).sqrt() + // Scale by √N: converts per-trade ratio to evaluation-window ratio. + // This is equivalent to Sharpe of the cumulative return over the window. + (mean_return / std_dev) * n.sqrt() } -/// Calculate Sortino ratio, focusing on downside deviation +/// Calculate Sortino ratio, focusing on downside deviation. /// -/// Superior to Sharpe ratio as it only penalizes downside volatility +/// Uses √N scaling (same as Sharpe) for consistent annualization. fn calculate_sortino_ratio(returns: &[f64]) -> f64 { if returns.len() < 2 { return 0.0; } const RISK_FREE_RATE: f64 = 0.0; - let mean_return = returns.iter().sum::() / returns.len() as f64; + let n = returns.len() as f64; + let mean_return = returns.iter().sum::() / n; - // Calculate downside deviation (only negative returns) let downside_returns: Vec = returns .iter() .filter(|&&r| r < RISK_FREE_RATE) @@ -227,17 +229,15 @@ fn calculate_sortino_ratio(returns: &[f64]) -> f64 { .collect(); if downside_returns.is_empty() { - // All returns are non-negative: infinite Sortino, capped at 100.0 - // to avoid distorting composite scores. return if mean_return > 0.0 { 100.0 } else { 0.0 }; } let downside_deviation = - (downside_returns.iter().sum::() / returns.len() as f64).sqrt(); + (downside_returns.iter().sum::() / n).sqrt(); if downside_deviation > 0.0 { let sortino = (mean_return - RISK_FREE_RATE) / downside_deviation; - sortino * (252.0_f64).sqrt() // Annualize + sortino * n.sqrt() } else { 0.0 } diff --git a/crates/ml/src/trainers/dqn/trainer/metrics.rs b/crates/ml/src/trainers/dqn/trainer/metrics.rs index 7f6b2c7d3..9b1c4a4f6 100644 --- a/crates/ml/src/trainers/dqn/trainer/metrics.rs +++ b/crates/ml/src/trainers/dqn/trainer/metrics.rs @@ -578,7 +578,7 @@ impl DQNTrainer { }; let std_val = var_scalar.sqrt(); let val_sharpe = if std_val > 1e-10 { - (mean_scalar / std_val) * (252.0_f64).sqrt() + (mean_scalar / std_val) * n.sqrt() } else { 0.0 }; diff --git a/crates/ml/src/trainers/ppo.rs b/crates/ml/src/trainers/ppo.rs index 57d713ad9..b5810040a 100644 --- a/crates/ml/src/trainers/ppo.rs +++ b/crates/ml/src/trainers/ppo.rs @@ -646,7 +646,7 @@ impl PpoTrainer { // PPO doesn't run a backtest per epoch; use reward mean/std as proxy { let epoch_sharpe = if std_reward > 1e-10 { - (mean_reward / std_reward) as f64 * (252.0_f64).sqrt() + (mean_reward / std_reward) as f64 * (self.hyperparams.batch_size as f64).sqrt() } else { 0.0 };