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