Skip to main content

fin_primitives/performance/
mod.rs

1//! Portfolio performance metrics.
2//!
3//! Provides [`PerformanceMetrics`] and [`PerformanceCalculator`] for computing
4//! risk-adjusted return metrics including Sharpe, Sortino, Calmar, Omega,
5//! Information Ratio, Max Drawdown, and CAGR.
6
7/// Aggregate portfolio performance metrics.
8#[derive(Debug, Clone, PartialEq)]
9pub struct PerformanceMetrics {
10    /// Sharpe ratio: excess return per unit of total volatility (annualised).
11    pub sharpe_ratio: f64,
12    /// Sortino ratio: excess return per unit of downside deviation (annualised).
13    pub sortino_ratio: f64,
14    /// Calmar ratio: annualised return divided by maximum drawdown.
15    pub calmar_ratio: f64,
16    /// Omega ratio: probability-weighted gains above threshold vs losses below.
17    pub omega_ratio: f64,
18    /// Information ratio: active return divided by tracking error.
19    pub information_ratio: f64,
20    /// Maximum peak-to-trough decline on the cumulative return series.
21    pub max_drawdown: f64,
22    /// Compound annual growth rate.
23    pub cagr: f64,
24}
25
26/// Stateless calculator for portfolio performance metrics.
27pub struct PerformanceCalculator;
28
29impl PerformanceCalculator {
30    /// Compute the annualised Sharpe ratio.
31    ///
32    /// `(mean_return - risk_free_rate) / std_return * sqrt(252)`
33    ///
34    /// Returns `0.0` if `returns` has fewer than 2 elements or std dev is zero.
35    pub fn sharpe_ratio(returns: &[f64], risk_free_rate: f64) -> f64 {
36        if returns.len() < 2 {
37            return 0.0;
38        }
39        let mean = mean(returns);
40        let std = std_dev(returns);
41        if std == 0.0 {
42            return 0.0;
43        }
44        (mean - risk_free_rate) / std * 252_f64.sqrt()
45    }
46
47    /// Compute the annualised Sortino ratio using downside deviation only.
48    ///
49    /// Downside deviation is computed over returns below `target`.
50    ///
51    /// Returns `0.0` if `returns` is empty or downside deviation is zero.
52    pub fn sortino_ratio(returns: &[f64], risk_free_rate: f64, target: f64) -> f64 {
53        if returns.is_empty() {
54            return 0.0;
55        }
56        let mean = mean(returns);
57        let downside_var: f64 = returns
58            .iter()
59            .map(|&r| {
60                let diff = r - target;
61                if diff < 0.0 { diff * diff } else { 0.0 }
62            })
63            .sum::<f64>()
64            / returns.len() as f64;
65        let downside_dev = downside_var.sqrt();
66        if downside_dev == 0.0 {
67            return 0.0;
68        }
69        (mean - risk_free_rate) / downside_dev * 252_f64.sqrt()
70    }
71
72    /// Compute the Calmar ratio: annualised return / |max_drawdown|.
73    ///
74    /// Uses `252` periods per year.  Returns `0.0` if `returns` is empty or
75    /// max drawdown is zero.
76    pub fn calmar_ratio(returns: &[f64]) -> f64 {
77        if returns.is_empty() {
78            return 0.0;
79        }
80        let ann_return = Self::cagr(returns, 252.0);
81        let md = Self::max_drawdown(returns);
82        if md == 0.0 {
83            return 0.0;
84        }
85        ann_return / md.abs()
86    }
87
88    /// Compute the Omega ratio at the given `threshold`.
89    ///
90    /// `sum(max(r - threshold, 0)) / sum(max(threshold - r, 0))`
91    ///
92    /// Returns `f64::INFINITY` if the loss sum is zero and there are gains,
93    /// or `0.0` if both sums are zero.
94    pub fn omega_ratio(returns: &[f64], threshold: f64) -> f64 {
95        let gains: f64 = returns.iter().map(|&r| (r - threshold).max(0.0)).sum();
96        let losses: f64 = returns.iter().map(|&r| (threshold - r).max(0.0)).sum();
97        if losses == 0.0 {
98            if gains > 0.0 { f64::INFINITY } else { 0.0 }
99        } else {
100            gains / losses
101        }
102    }
103
104    /// Compute the Information Ratio: active return / tracking error.
105    ///
106    /// Active return = mean(returns - benchmark_returns).
107    /// Tracking error = std dev of (returns - benchmark_returns).
108    ///
109    /// Returns `0.0` if lengths differ, fewer than 2 observations, or
110    /// tracking error is zero.
111    pub fn information_ratio(returns: &[f64], benchmark_returns: &[f64]) -> f64 {
112        if returns.len() != benchmark_returns.len() || returns.len() < 2 {
113            return 0.0;
114        }
115        let active: Vec<f64> = returns
116            .iter()
117            .zip(benchmark_returns.iter())
118            .map(|(&r, &b)| r - b)
119            .collect();
120        let mean_active = mean(&active);
121        let te = std_dev(&active);
122        if te == 0.0 {
123            return 0.0;
124        }
125        mean_active / te
126    }
127
128    /// Compute the maximum peak-to-trough drawdown on the cumulative return series.
129    ///
130    /// Cumulative returns are computed as product of `(1 + r)` factors.
131    /// Returns a positive value representing the magnitude of the worst decline,
132    /// or `0.0` if `returns` is empty.
133    pub fn max_drawdown(returns: &[f64]) -> f64 {
134        if returns.is_empty() {
135            return 0.0;
136        }
137        let mut peak = 1.0_f64;
138        let mut cum = 1.0_f64;
139        let mut max_dd = 0.0_f64;
140        for &r in returns {
141            cum *= 1.0 + r;
142            if cum > peak {
143                peak = cum;
144            }
145            let dd = (peak - cum) / peak;
146            if dd > max_dd {
147                max_dd = dd;
148            }
149        }
150        max_dd
151    }
152
153    /// Compute the Compound Annual Growth Rate.
154    ///
155    /// `(product(1 + r))^(periods_per_year / n) - 1`
156    ///
157    /// Returns `0.0` if `returns` is empty.
158    pub fn cagr(returns: &[f64], periods_per_year: f64) -> f64 {
159        if returns.is_empty() {
160            return 0.0;
161        }
162        let n = returns.len() as f64;
163        let total: f64 = returns.iter().fold(1.0, |acc, &r| acc * (1.0 + r));
164        total.powf(periods_per_year / n) - 1.0
165    }
166
167    /// Compute all performance metrics in one pass.
168    ///
169    /// If `benchmark` is `None` the information ratio is set to `0.0`.
170    /// `risk_free_rate` is a per-period (not annualised) rate, matching the
171    /// frequency of `returns`.
172    pub fn compute_all(
173        returns: &[f64],
174        benchmark: Option<&[f64]>,
175        risk_free_rate: f64,
176    ) -> PerformanceMetrics {
177        let sharpe_ratio = Self::sharpe_ratio(returns, risk_free_rate);
178        let sortino_ratio = Self::sortino_ratio(returns, risk_free_rate, risk_free_rate);
179        let calmar_ratio = Self::calmar_ratio(returns);
180        let omega_ratio = Self::omega_ratio(returns, risk_free_rate);
181        let information_ratio = benchmark
182            .map(|b| Self::information_ratio(returns, b))
183            .unwrap_or(0.0);
184        let max_drawdown = Self::max_drawdown(returns);
185        let cagr = Self::cagr(returns, 252.0);
186
187        PerformanceMetrics {
188            sharpe_ratio,
189            sortino_ratio,
190            calmar_ratio,
191            omega_ratio,
192            information_ratio,
193            max_drawdown,
194            cagr,
195        }
196    }
197}
198
199// ---------------------------------------------------------------------------
200// Internal helpers
201// ---------------------------------------------------------------------------
202
203fn mean(xs: &[f64]) -> f64 {
204    if xs.is_empty() {
205        return 0.0;
206    }
207    xs.iter().sum::<f64>() / xs.len() as f64
208}
209
210/// Sample standard deviation (n-1 denominator).
211fn std_dev(xs: &[f64]) -> f64 {
212    if xs.len() < 2 {
213        return 0.0;
214    }
215    let m = mean(xs);
216    let var = xs.iter().map(|&x| (x - m).powi(2)).sum::<f64>() / (xs.len() - 1) as f64;
217    var.sqrt()
218}
219
220// ---------------------------------------------------------------------------
221// Tests
222// ---------------------------------------------------------------------------
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    /// Simple daily returns: +1 %, -0.5 %, +2 %, -1 %, +1.5 %
229    fn sample_returns() -> Vec<f64> {
230        vec![0.01, -0.005, 0.02, -0.01, 0.015]
231    }
232
233    #[test]
234    fn test_sharpe_positive_returns() {
235        let r = sample_returns();
236        let s = PerformanceCalculator::sharpe_ratio(&r, 0.0);
237        // Mean is positive, std > 0, result should be positive
238        assert!(s > 0.0, "Sharpe should be positive for net-positive returns");
239    }
240
241    #[test]
242    fn test_sharpe_empty() {
243        assert_eq!(PerformanceCalculator::sharpe_ratio(&[], 0.0), 0.0);
244    }
245
246    #[test]
247    fn test_sharpe_single_element() {
248        assert_eq!(PerformanceCalculator::sharpe_ratio(&[0.01], 0.0), 0.0);
249    }
250
251    #[test]
252    fn test_sharpe_zero_std() {
253        // All identical returns → std == 0
254        let r = vec![0.01, 0.01, 0.01];
255        assert_eq!(PerformanceCalculator::sharpe_ratio(&r, 0.0), 0.0);
256    }
257
258    #[test]
259    fn test_sortino_positive() {
260        let r = sample_returns();
261        let s = PerformanceCalculator::sortino_ratio(&r, 0.0, 0.0);
262        assert!(s > 0.0);
263    }
264
265    #[test]
266    fn test_sortino_empty() {
267        assert_eq!(PerformanceCalculator::sortino_ratio(&[], 0.0, 0.0), 0.0);
268    }
269
270    #[test]
271    fn test_sortino_no_downside() {
272        // All returns > target → downside dev == 0 → 0.0
273        let r = vec![0.01, 0.02, 0.03];
274        assert_eq!(PerformanceCalculator::sortino_ratio(&r, 0.0, 0.0), 0.0);
275    }
276
277    #[test]
278    fn test_max_drawdown_known() {
279        // Cumulative: 1.10, 1.05, 1.20, 0.96, 1.20
280        // Peak at 1.20, trough at 0.96 → dd = (1.20-0.96)/1.20 = 0.20
281        let r = vec![0.10, -0.045_454, 0.142_857, -0.20, 0.25];
282        let md = PerformanceCalculator::max_drawdown(&r);
283        assert!(md > 0.0 && md < 1.0, "Max drawdown should be (0, 1)");
284    }
285
286    #[test]
287    fn test_max_drawdown_monotone_up() {
288        let r = vec![0.01, 0.02, 0.03];
289        assert_eq!(PerformanceCalculator::max_drawdown(&r), 0.0);
290    }
291
292    #[test]
293    fn test_max_drawdown_empty() {
294        assert_eq!(PerformanceCalculator::max_drawdown(&[]), 0.0);
295    }
296
297    #[test]
298    fn test_cagr_flat() {
299        // All-zero returns → CAGR = 0
300        let r = vec![0.0; 252];
301        let cagr = PerformanceCalculator::cagr(&r, 252.0);
302        assert!((cagr).abs() < 1e-10);
303    }
304
305    #[test]
306    fn test_cagr_known() {
307        // 252 daily returns of 0.001 → ~+28.% annualised
308        let r = vec![0.001; 252];
309        let cagr = PerformanceCalculator::cagr(&r, 252.0);
310        assert!(cagr > 0.0);
311        // (1.001)^252 - 1 ≈ 0.2848
312        let expected = 1.001_f64.powi(252) - 1.0;
313        assert!((cagr - expected).abs() < 1e-8);
314    }
315
316    #[test]
317    fn test_omega_all_above_threshold() {
318        let r = vec![0.01, 0.02, 0.03];
319        let o = PerformanceCalculator::omega_ratio(&r, 0.0);
320        assert!(o.is_infinite(), "Omega should be +Inf when no losses");
321    }
322
323    #[test]
324    fn test_omega_all_below_threshold() {
325        let r = vec![-0.01, -0.02, -0.03];
326        let o = PerformanceCalculator::omega_ratio(&r, 0.0);
327        assert_eq!(o, 0.0);
328    }
329
330    #[test]
331    fn test_omega_mixed() {
332        // returns [0.02, -0.01] threshold 0.0 → gains=0.02, losses=0.01 → omega=2.0
333        let r = vec![0.02, -0.01];
334        let o = PerformanceCalculator::omega_ratio(&r, 0.0);
335        assert!((o - 2.0).abs() < 1e-10);
336    }
337
338    #[test]
339    fn test_information_ratio_length_mismatch() {
340        let r = vec![0.01, 0.02];
341        let b = vec![0.01];
342        assert_eq!(PerformanceCalculator::information_ratio(&r, &b), 0.0);
343    }
344
345    #[test]
346    fn test_information_ratio_identical() {
347        let r = vec![0.01, 0.02, 0.03];
348        let b = r.clone();
349        // active returns all 0 → IR = 0
350        assert_eq!(PerformanceCalculator::information_ratio(&r, &b), 0.0);
351    }
352
353    #[test]
354    fn test_information_ratio_positive() {
355        let r = vec![0.02, 0.03, 0.04];
356        let b = vec![0.01, 0.01, 0.01];
357        let ir = PerformanceCalculator::information_ratio(&r, &b);
358        assert!(ir > 0.0);
359    }
360
361    #[test]
362    fn test_calmar_empty() {
363        assert_eq!(PerformanceCalculator::calmar_ratio(&[]), 0.0);
364    }
365
366    #[test]
367    fn test_calmar_no_drawdown() {
368        // All positive → max_drawdown = 0 → calmar = 0
369        let r = vec![0.01; 252];
370        assert_eq!(PerformanceCalculator::calmar_ratio(&r), 0.0);
371    }
372
373    #[test]
374    fn test_compute_all_returns_struct() {
375        let r = sample_returns();
376        let bench = vec![0.005, 0.005, 0.005, 0.005, 0.005];
377        let m = PerformanceCalculator::compute_all(&r, Some(&bench), 0.0001);
378        // Just verify the struct is populated (not NaN)
379        assert!(!m.sharpe_ratio.is_nan());
380        assert!(!m.sortino_ratio.is_nan());
381        assert!(!m.calmar_ratio.is_nan());
382        assert!(!m.omega_ratio.is_nan());
383        assert!(!m.information_ratio.is_nan());
384        assert!(!m.max_drawdown.is_nan());
385        assert!(!m.cagr.is_nan());
386    }
387
388    #[test]
389    fn test_compute_all_no_benchmark() {
390        let r = sample_returns();
391        let m = PerformanceCalculator::compute_all(&r, None, 0.0);
392        assert_eq!(m.information_ratio, 0.0);
393    }
394}