Skip to main content

finance_query/risk/
ratios.rs

1//! Standalone risk-adjusted return ratios.
2//!
3//! These complement the Sharpe ratio already computed inside the backtesting engine,
4//! providing access to these metrics without running a full backtest.
5
6/// Compute the annualised Sharpe Ratio.
7///
8/// `Sharpe = (mean_return - risk_free_rate) / std_dev`, annualised by `sqrt(periods_per_year)`.
9///
10/// # Arguments
11///
12/// * `returns` - Per-period returns as fractions (e.g., daily returns)
13/// * `risk_free_rate` - Risk-free rate **per period** (e.g., 0.0001 for daily ≈ 2.5% annual)
14/// * `periods_per_year` - Trading periods in a year (252 for daily, 52 for weekly)
15///
16/// Returns `None` when fewer than 2 observations or standard deviation is zero.
17pub fn sharpe_ratio(returns: &[f64], risk_free_rate: f64, periods_per_year: f64) -> Option<f64> {
18    let (mean, std_dev) = mean_and_std(returns)?;
19    sharpe_with_stats(mean, std_dev, risk_free_rate, periods_per_year)
20}
21
22/// Sample mean and standard deviation (n-1 denominator).
23pub(crate) fn mean_and_std(returns: &[f64]) -> Option<(f64, f64)> {
24    if returns.len() < 2 {
25        return None;
26    }
27    let mean = returns.iter().sum::<f64>() / returns.len() as f64;
28    let variance =
29        returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (returns.len() - 1) as f64;
30    Some((mean, variance.sqrt()))
31}
32
33/// `sharpe_ratio` given a precomputed mean and standard deviation.
34pub(crate) fn sharpe_with_stats(
35    mean: f64,
36    std_dev: f64,
37    risk_free_rate: f64,
38    periods_per_year: f64,
39) -> Option<f64> {
40    if std_dev == 0.0 {
41        return None;
42    }
43    Some((mean - risk_free_rate) / std_dev * periods_per_year.sqrt())
44}
45
46/// Compute the annualised Sortino Ratio (penalises only downside volatility).
47///
48/// `Sortino = (mean_return - risk_free_rate) / downside_std`, annualised.
49///
50/// Returns `None` when fewer than 2 observations or downside deviation is zero.
51pub fn sortino_ratio(returns: &[f64], risk_free_rate: f64, periods_per_year: f64) -> Option<f64> {
52    if returns.len() < 2 {
53        return None;
54    }
55
56    let mean = returns.iter().sum::<f64>() / returns.len() as f64;
57
58    let downside_variance = returns
59        .iter()
60        .map(|r| {
61            let diff = r - risk_free_rate;
62            if diff < 0.0 { diff.powi(2) } else { 0.0 }
63        })
64        .sum::<f64>()
65        / (returns.len() - 1) as f64;
66
67    let downside_std = downside_variance.sqrt();
68
69    if downside_std == 0.0 {
70        return None;
71    }
72
73    Some((mean - risk_free_rate) / downside_std * periods_per_year.sqrt())
74}
75
76/// Compute the Omega Ratio at a `0.0` threshold: probability-weighted ratio
77/// of gains to losses over the full return distribution.
78///
79/// `Σ max(r, 0) / Σ max(-r, 0)`. More general than Sharpe — considers the
80/// full return distribution rather than only mean and standard deviation.
81/// Returns `f64::MAX` when there are no negative returns, `0.0` when there
82/// are also no positive returns.
83pub fn omega_ratio(returns: &[f64]) -> f64 {
84    crate::perf_metrics::omega_ratio(returns)
85}
86
87/// Compute the Kelly Criterion: optimal fraction of capital to risk, given a
88/// win rate and average win/loss magnitudes (in percent).
89///
90/// `W - (1 - W) / R` where `R = avg_win_pct / abs(avg_loss_pct)`. Returns
91/// `f64::MAX` when there are no losses and wins are positive (unbounded
92/// edge), `0.0` for other degenerate inputs.
93///
94/// Use [`win_loss_stats`] to derive `win_rate`/`avg_win_pct`/`avg_loss_pct`
95/// from a plain return series (treating each positive-return period as a
96/// "win" and each negative-return period as a "loss").
97pub fn kelly_criterion(win_rate: f64, avg_win_pct: f64, avg_loss_pct: f64) -> f64 {
98    crate::perf_metrics::kelly_criterion(win_rate, avg_win_pct, avg_loss_pct)
99}
100
101/// Compute the Ulcer Index: root-mean-square of drawdown depth across a
102/// return series, expressed as a percentage (0–100).
103///
104/// Unlike [`max_drawdown`](super::max_drawdown), penalises both depth and
105/// duration of drawdowns — a long shallow drawdown scores higher than a
106/// brief deep one.
107pub fn ulcer_index(returns: &[f64]) -> f64 {
108    crate::perf_metrics::ulcer_index(&super::drawdown::drawdown_series(returns))
109}
110
111/// Compute the Information Ratio vs a benchmark: annualised mean excess
112/// return divided by tracking error.
113///
114/// Returns `None` when the series differ in length, fewer than 2 aligned
115/// observations are available, or tracking error is zero.
116pub fn information_ratio(
117    asset_returns: &[f64],
118    benchmark_returns: &[f64],
119    periods_per_year: f64,
120) -> Option<f64> {
121    crate::perf_metrics::information_ratio(asset_returns, benchmark_returns, periods_per_year)
122}
123
124/// Compute the tracking error vs a benchmark: annualised standard deviation
125/// of (asset − benchmark) periodic returns.
126///
127/// Returns `None` when the series differ in length or fewer than 2 aligned
128/// observations are available.
129pub fn tracking_error(
130    asset_returns: &[f64],
131    benchmark_returns: &[f64],
132    periods_per_year: f64,
133) -> Option<f64> {
134    crate::perf_metrics::tracking_error(asset_returns, benchmark_returns, periods_per_year)
135}
136
137/// Derive win-rate and average win/loss percentages from a return series,
138/// treating each period as if it were a discrete "trade" (a positive-return
139/// period is a win, a negative-return period is a loss) — the natural
140/// analogue of backtesting trade statistics for a plain returns series with
141/// no explicit trade log. Feeds [`kelly_criterion`].
142///
143/// Returns `(win_rate, avg_win_pct, avg_loss_pct)`, all `0.0` for an empty
144/// series.
145pub fn win_loss_stats(returns: &[f64]) -> (f64, f64, f64) {
146    let total = returns.len();
147    if total == 0 {
148        return (0.0, 0.0, 0.0);
149    }
150
151    let wins: Vec<f64> = returns.iter().copied().filter(|&r| r > 0.0).collect();
152    let losses: Vec<f64> = returns.iter().copied().filter(|&r| r < 0.0).collect();
153
154    let win_rate = wins.len() as f64 / total as f64;
155    let avg_win_pct = if wins.is_empty() {
156        0.0
157    } else {
158        wins.iter().sum::<f64>() / wins.len() as f64 * 100.0
159    };
160    let avg_loss_pct = if losses.is_empty() {
161        0.0
162    } else {
163        losses.iter().sum::<f64>() / losses.len() as f64 * 100.0
164    };
165
166    (win_rate, avg_win_pct, avg_loss_pct)
167}
168
169/// Compute the Calmar Ratio: annualised return divided by maximum drawdown.
170///
171/// # Arguments
172///
173/// * `total_return` - Cumulative return over the entire period (fraction)
174/// * `years` - Length of the period in years
175/// * `max_drawdown` - Maximum drawdown as a positive fraction (e.g., 0.30 = 30%)
176///
177/// Returns `None` when `max_drawdown` is zero.
178pub fn calmar_ratio(total_return: f64, years: f64, max_drawdown: f64) -> Option<f64> {
179    if max_drawdown == 0.0 || years <= 0.0 {
180        return None;
181    }
182    let annualised = (1.0 + total_return).powf(1.0 / years) - 1.0;
183    Some(annualised / max_drawdown)
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn test_sharpe_positive_returns() {
192        let returns = vec![0.001_f64; 252];
193        let s = sharpe_ratio(&returns, 0.0, 252.0).unwrap();
194        assert!(s > 0.0, "Expected positive Sharpe, got {s}");
195    }
196
197    #[test]
198    fn test_sortino_only_positive() {
199        // All positive returns → downside std = 0 → None
200        let returns = vec![0.01_f64; 252];
201        assert!(sortino_ratio(&returns, 0.0, 252.0).is_none());
202    }
203
204    #[test]
205    fn test_calmar_zero_drawdown() {
206        assert!(calmar_ratio(0.20, 2.0, 0.0).is_none());
207    }
208
209    #[test]
210    fn test_calmar_simple() {
211        // 20% total over 2 years, 10% max drawdown
212        // annualised ≈ 9.54%, Calmar ≈ 0.954
213        let c = calmar_ratio(0.20, 2.0, 0.10).unwrap();
214        assert!((c - 0.954).abs() < 0.01, "got {c}");
215    }
216}