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}