Skip to main content

rustyqlib/risk/
performance.rs

1//! Performance and path-risk statistics: drawdowns and risk-adjusted
2//! return ratios.
3
4/// Maximum drawdown of a value series, as a positive fraction of the
5/// running peak (0.25 = a 25% peak-to-trough fall), with the peak and
6/// trough indices.
7pub fn max_drawdown(values: &[f64]) -> (f64, usize, usize) {
8    assert!(!values.is_empty());
9    let mut peak = values[0];
10    let mut peak_idx = 0;
11    let mut best = 0.0;
12    let mut best_pair = (0, 0);
13    for (i, &v) in values.iter().enumerate() {
14        if v > peak {
15            peak = v;
16            peak_idx = i;
17        }
18        let dd = (peak - v) / peak;
19        if dd > best {
20            best = dd;
21            best_pair = (peak_idx, i);
22        }
23    }
24    (best, best_pair.0, best_pair.1)
25}
26
27/// Annualized Sharpe ratio of per-period returns against a per-period
28/// risk-free rate.
29pub fn sharpe_ratio(returns: &[f64], risk_free_per_period: f64, periods_per_year: f64) -> f64 {
30    let n = returns.len();
31    assert!(n >= 2);
32    let excess: Vec<f64> = returns.iter().map(|r| r - risk_free_per_period).collect();
33    let mean = excess.iter().sum::<f64>() / n as f64;
34    let var = excess.iter().map(|e| (e - mean) * (e - mean)).sum::<f64>() / (n as f64 - 1.0);
35    mean / var.sqrt() * periods_per_year.sqrt()
36}
37
38/// Annualized Sortino ratio: excess return over the downside deviation
39/// (root mean square of returns below the risk-free rate).
40pub fn sortino_ratio(returns: &[f64], risk_free_per_period: f64, periods_per_year: f64) -> f64 {
41    let n = returns.len();
42    assert!(n >= 2);
43    let mean_excess =
44        returns.iter().map(|r| r - risk_free_per_period).sum::<f64>() / n as f64;
45    let downside_sq = returns
46        .iter()
47        .map(|r| (r - risk_free_per_period).min(0.0).powi(2))
48        .sum::<f64>()
49        / n as f64;
50    assert!(downside_sq > 0.0, "no downside observations");
51    mean_excess / downside_sq.sqrt() * periods_per_year.sqrt()
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    #[test]
59    fn drawdown_finds_the_peak_to_trough() {
60        let nav = [100.0, 110.0, 105.0, 120.0, 90.0, 95.0, 130.0];
61        let (dd, peak, trough) = max_drawdown(&nav);
62        assert!((dd - 0.25).abs() < 1e-12, "{dd}"); // 120 -> 90
63        assert_eq!((peak, trough), (3, 4));
64        // monotone series has zero drawdown
65        assert_eq!(max_drawdown(&[1.0, 2.0, 3.0]).0, 0.0);
66    }
67
68    #[test]
69    fn ratios_are_hand_checkable_and_ordered() {
70        // symmetric returns: sharpe and sortino positive, sortino larger
71        // (only half the deviation is downside)
72        let returns = [0.02, -0.01, 0.03, -0.005, 0.015, -0.02, 0.025, 0.01];
73        let sharpe = sharpe_ratio(&returns, 0.0, 252.0);
74        let sortino = sortino_ratio(&returns, 0.0, 252.0);
75        assert!(sharpe > 0.0 && sortino > sharpe, "{sharpe} vs {sortino}");
76        // scaling returns leaves sharpe unchanged
77        let scaled: Vec<f64> = returns.iter().map(|r| r * 3.0).collect();
78        assert!((sharpe_ratio(&scaled, 0.0, 252.0) - sharpe).abs() < 1e-12);
79    }
80}