pub(crate) fn kelly_criterion(win_rate: f64, avg_win_pct: f64, avg_loss_pct: f64) -> f64 {
let abs_loss = avg_loss_pct.abs();
if abs_loss == 0.0 {
return if avg_win_pct > 0.0 { f64::MAX } else { 0.0 };
}
if avg_win_pct == 0.0 {
return 0.0;
}
let r = avg_win_pct / abs_loss;
win_rate - (1.0 - win_rate) / r
}
pub(crate) fn omega_ratio(returns: &[f64]) -> f64 {
let gains: f64 = returns.iter().map(|&r| r.max(0.0)).sum();
let losses: f64 = returns.iter().map(|&r| (-r).max(0.0)).sum();
if losses == 0.0 {
if gains > 0.0 { f64::MAX } else { 0.0 }
} else {
gains / losses
}
}
pub(crate) fn ulcer_index(drawdown_pcts: &[f64]) -> f64 {
if drawdown_pcts.is_empty() {
return 0.0;
}
let sum_sq: f64 = drawdown_pcts.iter().map(|d| (d * 100.0).powi(2)).sum();
(sum_sq / drawdown_pcts.len() as f64).sqrt()
}
fn excess_stats(strategy_returns: &[f64], benchmark_returns: &[f64]) -> Option<(f64, f64)> {
let n = strategy_returns.len();
if n < 2 || n != benchmark_returns.len() {
return None;
}
let excess: Vec<f64> = strategy_returns
.iter()
.zip(benchmark_returns.iter())
.map(|(s, b)| s - b)
.collect();
let mean = excess.iter().sum::<f64>() / n as f64;
let variance = excess.iter().map(|e| (e - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
Some((mean, variance.sqrt()))
}
pub(crate) fn tracking_error(
strategy_returns: &[f64],
benchmark_returns: &[f64],
periods_per_year: f64,
) -> Option<f64> {
excess_stats(strategy_returns, benchmark_returns).map(|(_, std)| std * periods_per_year.sqrt())
}
pub(crate) fn information_ratio(
strategy_returns: &[f64],
benchmark_returns: &[f64],
periods_per_year: f64,
) -> Option<f64> {
let (mean, std) = excess_stats(strategy_returns, benchmark_returns)?;
if std > 0.0 {
Some((mean / std) * periods_per_year.sqrt())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_kelly_criterion() {
let kelly = kelly_criterion(0.6, 10.0, -5.0);
assert!(
(kelly - 0.4).abs() < 1e-9,
"Kelly should be 0.4, got {kelly}"
);
assert_eq!(kelly_criterion(1.0, 10.0, 0.0), f64::MAX);
assert_eq!(kelly_criterion(0.0, 0.0, 0.0), 0.0);
}
#[test]
fn test_omega_ratio() {
assert_eq!(omega_ratio(&[1.0, 2.0, 3.0]), f64::MAX);
assert_eq!(omega_ratio(&[-1.0, -2.0, -3.0]), 0.0);
let omega = omega_ratio(&[2.0, -1.0, 3.0, -2.0]);
assert!((omega - 5.0 / 3.0).abs() < 1e-9, "got {omega}");
}
#[test]
fn test_ulcer_index() {
assert_eq!(ulcer_index(&[]), 0.0);
assert_eq!(ulcer_index(&[0.0, 0.0, 0.0]), 0.0);
let ui = ulcer_index(&[0.10, 0.20]);
assert!((ui - 250f64.sqrt()).abs() < 1e-9, "got {ui}");
}
#[test]
fn test_tracking_error_and_information_ratio() {
let strategy = vec![0.01, 0.02, -0.01, 0.03, 0.00];
let benchmark = vec![0.005, 0.01, -0.02, 0.02, 0.01];
let te = tracking_error(&strategy, &benchmark, 252.0).unwrap();
assert!(te > 0.0);
let ir = information_ratio(&strategy, &benchmark, 252.0).unwrap();
assert!(ir.is_finite());
}
#[test]
fn test_information_ratio_insufficient_data() {
assert!(information_ratio(&[0.01], &[0.01], 252.0).is_none());
assert!(information_ratio(&[0.01, 0.02], &[0.01], 252.0).is_none());
}
#[test]
fn test_information_ratio_zero_tracking_error() {
let r = vec![0.01, 0.02, 0.03, -0.01];
assert!(information_ratio(&r, &r, 252.0).is_none());
assert!(tracking_error(&r, &r, 252.0).unwrap() == 0.0);
}
}