fix(metrics): replace hardcoded √252 annualization with √N

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) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-29 18:42:43 +02:00
parent 1b118809a3
commit ba97a6e819
3 changed files with 23 additions and 23 deletions

View File

@@ -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::<f64>() / 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::<f64>();
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::<f64>() / returns.len() as f64;
let n = returns.len() as f64;
let mean_return = returns.iter().sum::<f64>() / n;
// Calculate standard deviation of returns
let variance = returns
.iter()
.map(|&r| (r - mean_return).powi(2))
.sum::<f64>()
/ 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::<f64>() / returns.len() as f64;
let n = returns.len() as f64;
let mean_return = returns.iter().sum::<f64>() / n;
// Calculate downside deviation (only negative returns)
let downside_returns: Vec<f64> = 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::<f64>() / returns.len() as f64).sqrt();
(downside_returns.iter().sum::<f64>() / 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
}

View File

@@ -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
};

View File

@@ -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
};