mod beta;
mod cvar;
mod drawdown;
mod ratios;
mod var;
pub use self::beta::beta;
pub use self::cvar::{historical_cvar, parametric_cvar};
pub use self::drawdown::max_drawdown;
pub use self::ratios::{
calmar_ratio, information_ratio, kelly_criterion, omega_ratio, sharpe_ratio, sortino_ratio,
tracking_error, ulcer_index, win_loss_stats,
};
pub use self::var::{historical_var, parametric_var};
use crate::models::chart::Candle;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RiskSummary {
pub var_95: f64,
pub var_99: f64,
pub parametric_var_95: f64,
pub cvar_95: f64,
pub cvar_99: f64,
pub parametric_cvar_95: f64,
pub omega: f64,
pub kelly: f64,
pub sharpe: Option<f64>,
pub sortino: Option<f64>,
pub calmar: Option<f64>,
pub beta: Option<f64>,
pub max_drawdown: f64,
pub max_drawdown_recovery_periods: Option<u64>,
pub ulcer_index: f64,
pub information_ratio: Option<f64>,
pub tracking_error: Option<f64>,
}
pub(crate) fn candles_to_returns(candles: &[Candle]) -> Vec<f64> {
candles
.windows(2)
.map(|w| (w[1].close - w[0].close) / w[0].close)
.collect()
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TradingCalendar {
Exchange,
Forex,
Crypto,
}
#[allow(dead_code)]
impl TradingCalendar {
fn trading_days(self) -> f64 {
match self {
TradingCalendar::Exchange => 252.0,
TradingCalendar::Forex => 260.0,
TradingCalendar::Crypto => 365.0,
}
}
fn session_hours(self) -> f64 {
match self {
TradingCalendar::Exchange => 6.5,
TradingCalendar::Forex | TradingCalendar::Crypto => 24.0,
}
}
}
#[allow(dead_code)]
pub(crate) fn periods_per_year(interval: crate::Interval, cal: TradingCalendar) -> f64 {
use crate::Interval;
let days = cal.trading_days();
match interval {
Interval::OneDay => days,
Interval::FiveDays | Interval::OneWeek => 52.0,
Interval::OneMonth => 12.0,
Interval::ThreeMonths => 4.0,
Interval::NinetyMinutes => days * cal.session_hours() / 1.5,
Interval::OneHour => days * cal.session_hours(),
Interval::ThirtyMinutes => days * cal.session_hours() * 2.0,
Interval::FifteenMinutes => days * cal.session_hours() * 4.0,
Interval::FiveMinutes => days * cal.session_hours() * 12.0,
Interval::TwoMinutes => days * cal.session_hours() * 30.0,
Interval::OneMinute => days * cal.session_hours() * 60.0,
}
}
pub(crate) fn compute_risk_summary(
candles: &[Candle],
benchmark_returns: Option<&[f64]>,
) -> RiskSummary {
compute_risk_summary_with_periods(candles, benchmark_returns, 252.0)
}
pub(crate) fn compute_risk_summary_with_periods(
candles: &[Candle],
benchmark_returns: Option<&[f64]>,
periods_per_year: f64,
) -> RiskSummary {
let returns = candles_to_returns(candles);
let mut sorted = returns.clone();
sorted.sort_by(|a, b| a.total_cmp(b));
let stats = ratios::mean_and_std(&returns);
let var_95 = var::historical_var_sorted(&sorted, 0.95).unwrap_or(0.0);
let var_99 = var::historical_var_sorted(&sorted, 0.99).unwrap_or(0.0);
let parametric_var_95 = stats
.map(|(m, s)| var::parametric_var_with_stats(m, s, 0.95))
.unwrap_or(0.0);
let cvar_95 = cvar::historical_cvar_sorted(&sorted, 0.95).unwrap_or(0.0);
let cvar_99 = cvar::historical_cvar_sorted(&sorted, 0.99).unwrap_or(0.0);
let parametric_cvar_95 = stats
.map(|(m, s)| cvar::parametric_cvar_with_stats(m, s, 0.95))
.unwrap_or(0.0);
let omega = omega_ratio(&returns);
let (win_rate, avg_win_pct, avg_loss_pct) = win_loss_stats(&returns);
let kelly = kelly_criterion(win_rate, avg_win_pct, avg_loss_pct);
let sharpe = stats.and_then(|(m, s)| ratios::sharpe_with_stats(m, s, 0.0, periods_per_year));
let sortino = sortino_ratio(&returns, 0.0, periods_per_year);
let dd = max_drawdown(&returns);
let total_return = returns.iter().fold(1.0_f64, |acc, r| acc * (1.0 + r)) - 1.0;
let years = returns.len() as f64 / periods_per_year;
let calmar = calmar_ratio(total_return, years, dd.max_drawdown);
let ulcer_index_val = ulcer_index(&returns);
let beta_val = benchmark_returns.and_then(|br| beta(&returns, br));
let information_ratio_val =
benchmark_returns.and_then(|br| information_ratio(&returns, br, periods_per_year));
let tracking_error_val =
benchmark_returns.and_then(|br| tracking_error(&returns, br, periods_per_year));
RiskSummary {
var_95,
var_99,
parametric_var_95,
cvar_95,
cvar_99,
parametric_cvar_95,
omega,
kelly,
sharpe,
sortino,
calmar,
beta: beta_val,
max_drawdown: dd.max_drawdown,
max_drawdown_recovery_periods: dd.recovery_periods,
ulcer_index: ulcer_index_val,
information_ratio: information_ratio_val,
tracking_error: tracking_error_val,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_candle(close: f64) -> Candle {
Candle {
timestamp: 0,
open: close,
high: close,
low: close,
close,
volume: 1_000_000,
adj_close: None,
provider_id: None,
}
}
#[test]
fn test_compute_risk_summary_flat() {
let candles: Vec<Candle> = (0..=252).map(|_| make_candle(100.0)).collect();
let summary = compute_risk_summary(&candles, None);
assert_eq!(summary.var_95, 0.0);
assert_eq!(summary.max_drawdown, 0.0);
assert!(summary.sharpe.is_none());
assert_eq!(summary.cvar_95, 0.0);
assert_eq!(summary.cvar_99, 0.0);
assert_eq!(summary.parametric_cvar_95, 0.0);
assert_eq!(summary.omega, 0.0);
assert_eq!(summary.kelly, 0.0);
assert_eq!(summary.ulcer_index, 0.0);
assert!(summary.information_ratio.is_none());
assert!(summary.tracking_error.is_none());
}
#[test]
fn test_cvar_at_least_as_severe_as_var() {
let closes: Vec<f64> = (0..60)
.map(|i| 100.0 - i as f64 * 0.5 + if i % 5 == 0 { -8.0 } else { 0.0 })
.collect();
let candles: Vec<Candle> = closes.into_iter().map(make_candle).collect();
let summary = compute_risk_summary(&candles, None);
assert!(
summary.cvar_95 >= summary.var_95,
"cvar_95 ({}) should be >= var_95 ({})",
summary.cvar_95,
summary.var_95
);
assert!(summary.parametric_cvar_95 >= summary.parametric_var_95);
}
#[test]
fn test_drawdown_produces_positive_ulcer_index() {
let closes = [100.0, 110.0, 80.0, 85.0, 105.0, 115.0];
let candles: Vec<Candle> = closes.into_iter().map(make_candle).collect();
let summary = compute_risk_summary(&candles, None);
assert!(summary.ulcer_index > 0.0);
assert!(summary.max_drawdown > 0.0);
}
#[test]
fn test_information_ratio_and_tracking_error_with_benchmark() {
let asset_closes: Vec<f64> = (0..30).map(|i| 100.0 + i as f64 * 1.2).collect();
let bench_closes: Vec<f64> = (0..30).map(|i| 100.0 + i as f64 * 0.8).collect();
let candles: Vec<Candle> = asset_closes.into_iter().map(make_candle).collect();
let bench_candles: Vec<Candle> = bench_closes.into_iter().map(make_candle).collect();
let bench_returns = candles_to_returns(&bench_candles);
let summary = compute_risk_summary(&candles, Some(&bench_returns));
assert!(summary.information_ratio.is_some());
assert!(summary.tracking_error.is_some());
assert!(summary.tracking_error.unwrap() > 0.0);
assert!(summary.information_ratio.unwrap() > 0.0);
}
#[test]
fn test_candles_to_returns_basic() {
let candles = vec![make_candle(100.0), make_candle(110.0), make_candle(99.0)];
let returns = candles_to_returns(&candles);
assert_eq!(returns.len(), 2);
assert!((returns[0] - 0.10).abs() < 1e-9);
assert!((returns[1] - (-0.1)).abs() < 0.01);
}
#[test]
fn test_candles_to_returns_empty_and_single() {
assert!(candles_to_returns(&[]).is_empty());
assert!(candles_to_returns(&[make_candle(100.0)]).is_empty());
let empty_summary = compute_risk_summary(&[], None);
assert_eq!(empty_summary.var_95, 0.0);
assert_eq!(empty_summary.var_99, 0.0);
assert_eq!(empty_summary.parametric_var_95, 0.0);
assert!(empty_summary.sharpe.is_none());
let single_summary = compute_risk_summary(&[make_candle(100.0)], None);
assert_eq!(single_summary.var_95, 0.0);
assert_eq!(single_summary.var_99, 0.0);
assert_eq!(single_summary.parametric_var_95, 0.0);
assert!(single_summary.sharpe.is_none());
}
#[test]
fn test_periods_per_year_by_calendar() {
use crate::Interval;
assert_eq!(
periods_per_year(Interval::OneDay, TradingCalendar::Exchange),
252.0
);
assert_eq!(
periods_per_year(Interval::OneDay, TradingCalendar::Forex),
260.0
);
assert_eq!(
periods_per_year(Interval::OneDay, TradingCalendar::Crypto),
365.0
);
assert_eq!(
periods_per_year(Interval::OneWeek, TradingCalendar::Crypto),
52.0
);
assert!(
periods_per_year(Interval::OneHour, TradingCalendar::Crypto)
> periods_per_year(Interval::OneHour, TradingCalendar::Exchange)
);
}
#[test]
fn test_annualization_factor_changes_sharpe() {
let candles: Vec<Candle> = (0..50).map(|i| make_candle(100.0 + i as f64)).collect();
let daily = compute_risk_summary_with_periods(&candles, None, 252.0);
let crypto = compute_risk_summary_with_periods(&candles, None, 365.0);
assert!(daily.sharpe.is_some() && crypto.sharpe.is_some());
assert!(crypto.sharpe.unwrap() > daily.sharpe.unwrap());
}
#[test]
fn shared_stats_match_standalone_functions() {
let returns: Vec<f64> = (0..2_000)
.map(|i| ((i as f64 * 0.37).sin()) * 0.02 - 0.0001)
.collect();
let mut sorted = returns.clone();
sorted.sort_by(|a, b| a.total_cmp(b));
let (mean, std_dev) = crate::risk::ratios::mean_and_std(&returns).unwrap();
assert_eq!(
crate::risk::var::historical_var_sorted(&sorted, 0.95),
crate::risk::historical_var(&returns, 0.95)
);
assert_eq!(
crate::risk::var::historical_var_sorted(&sorted, 0.99),
crate::risk::historical_var(&returns, 0.99)
);
assert_eq!(
Some(crate::risk::var::parametric_var_with_stats(
mean, std_dev, 0.95
)),
crate::risk::parametric_var(&returns, 0.95)
);
assert_eq!(
crate::risk::ratios::sharpe_with_stats(mean, std_dev, 0.0, 252.0),
crate::risk::sharpe_ratio(&returns, 0.0, 252.0)
);
}
}