Skip to main content

nautilus_analysis/statistics/
calmar_ratio.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Calmar Ratio statistic.
17
18use std::collections::BTreeMap;
19
20use nautilus_core::UnixNanos;
21use nautilus_model::position::Position;
22
23use crate::{
24    statistic::PortfolioStatistic,
25    statistics::{cagr::CAGR, max_drawdown::MaxDrawdown},
26};
27
28/// Calculates the Calmar Ratio for returns.
29///
30/// The Calmar Ratio is a function of the fund's average compounded annual rate
31/// of return versus its maximum drawdown. The higher the Calmar ratio, the better
32/// it performed on a risk-adjusted basis during the given time frame.
33///
34/// Formula: Calmar Ratio = CAGR / |Max Drawdown|
35///
36/// # References
37///
38/// - Young, T. W. (1991). "Calmar Ratio: A Smoother Tool". *Futures*, 20(1).
39/// - Bacon, C. R. (2008). *Practical Portfolio Performance Measurement and Attribution*
40///   (2nd ed.). Wiley.
41#[repr(C)]
42#[derive(Debug, Clone)]
43#[cfg_attr(
44    feature = "python",
45    pyo3::pyclass(module = "nautilus_trader.analysis", from_py_object)
46)]
47#[cfg_attr(
48    feature = "python",
49    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
50)]
51pub struct CalmarRatio {
52    /// The number of periods per year for CAGR calculation (e.g., 252 for trading days).
53    pub period: usize,
54}
55
56impl CalmarRatio {
57    /// Creates a new [`CalmarRatio`] instance.
58    #[must_use]
59    pub fn new(period: Option<usize>) -> Self {
60        Self {
61            period: period.unwrap_or(252),
62        }
63    }
64}
65
66impl PortfolioStatistic for CalmarRatio {
67    type Item = f64;
68
69    fn name(&self) -> String {
70        format!("Calmar Ratio ({} days)", self.period)
71    }
72
73    fn calculate_from_returns(&self, returns: &BTreeMap<UnixNanos, f64>) -> Option<Self::Item> {
74        if returns.is_empty() {
75            return Some(f64::NAN);
76        }
77
78        // Calculate CAGR
79        let cagr_stat = CAGR::new(Some(self.period));
80        let cagr = cagr_stat.calculate_from_returns(returns)?;
81
82        // Calculate Max Drawdown
83        let max_dd_stat = MaxDrawdown::new();
84        let max_dd = max_dd_stat.calculate_from_returns(returns)?;
85
86        // Calmar = CAGR / |Max Drawdown|
87        // Max Drawdown is already negative, so we use abs
88        // When no drawdown exists, the ratio is undefined
89        if max_dd.abs() < f64::EPSILON {
90            return Some(f64::NAN);
91        }
92
93        let calmar = cagr / max_dd.abs();
94
95        if calmar.is_finite() {
96            Some(calmar)
97        } else {
98            Some(f64::NAN)
99        }
100    }
101    fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
102        None
103    }
104
105    fn calculate_from_positions(&self, _positions: &[Position]) -> Option<Self::Item> {
106        None
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use nautilus_core::approx_eq;
113    use rstest::rstest;
114
115    use super::*;
116
117    fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
118        let mut returns = BTreeMap::new();
119        let nanos_per_day = 86_400_000_000_000;
120        let start_time = 1_600_000_000_000_000_000;
121
122        for (i, &value) in values.iter().enumerate() {
123            let timestamp = start_time + i as u64 * nanos_per_day;
124            returns.insert(UnixNanos::from(timestamp), value);
125        }
126
127        returns
128    }
129
130    #[rstest]
131    fn test_name() {
132        let ratio = CalmarRatio::new(Some(252));
133        assert_eq!(ratio.name(), "Calmar Ratio (252 days)");
134    }
135
136    #[rstest]
137    fn test_empty_returns() {
138        let ratio = CalmarRatio::new(Some(252));
139        let returns = BTreeMap::new();
140        let result = ratio.calculate_from_returns(&returns);
141        assert!(result.is_some());
142        assert!(result.unwrap().is_nan());
143    }
144
145    #[rstest]
146    fn test_no_drawdown() {
147        let ratio = CalmarRatio::new(Some(252));
148        // Only positive returns, no drawdown
149        let returns = create_returns(&vec![0.01; 252]);
150        let result = ratio.calculate_from_returns(&returns);
151
152        // Should be NaN when no drawdown (undefined ratio)
153        assert!(result.is_some());
154        assert!(result.unwrap().is_nan());
155    }
156
157    #[rstest]
158    fn test_known_value() {
159        // period = 5 over 5 daily bins makes CAGR equal the total return:
160        //   total = 1.1 * 0.9 * 1.5 * 0.8 * 1.1 - 1 = 0.3068
161        //   CAGR = (1.3068)^(5/5) - 1 = 0.3068
162        //   equity = [1.1, 0.99, 1.485, 1.188, 1.3068]; max drawdown = (1.485 - 1.188) / 1.485 = 0.20
163        //   Calmar = 0.3068 / 0.20 = 1.534
164        let ratio = CalmarRatio::new(Some(5));
165        let returns = create_returns(&[0.10, -0.10, 0.50, -0.20, 0.10]);
166        let result = ratio.calculate_from_returns(&returns).unwrap();
167        assert!(approx_eq!(f64, result, 1.534, epsilon = 1e-9));
168    }
169
170    #[rstest]
171    #[case(5)]
172    #[case(252)]
173    fn test_undefined_cagr_propagates_to_calmar_ratio(#[case] days: usize) {
174        let ratio = CalmarRatio::new(Some(252));
175        let mut values = vec![0.0; days];
176        values[0] = -1.5;
177        let returns = create_returns(&values);
178
179        let result = ratio.calculate_from_returns(&returns).unwrap();
180
181        assert!(result.is_nan());
182    }
183
184    #[rstest]
185    fn test_positive_ratio() {
186        let ratio = CalmarRatio::new(Some(252));
187        // Simulate a year with 20% CAGR and 10% max drawdown
188        // Daily return for 20% annual: (1.20)^(1/252) - 1
189        let mut returns_vec = vec![0.001; 200]; // Small positive returns
190        // Add a drawdown period
191        returns_vec.extend(vec![-0.002; 52]); // Small negative returns
192
193        let returns = create_returns(&returns_vec);
194        let result = ratio.calculate_from_returns(&returns).unwrap();
195
196        // Calmar should be positive (CAGR / |Max DD|)
197        assert!(result > 0.0);
198    }
199
200    #[rstest]
201    fn test_high_calmar_better() {
202        let ratio = CalmarRatio::new(Some(252));
203
204        // Strategy A: Higher return, same drawdown
205        let returns_a = create_returns(&vec![0.002; 252]);
206        let calmar_a = ratio.calculate_from_returns(&returns_a);
207
208        // Strategy B: Lower return
209        let returns_b = create_returns(&vec![0.001; 252]);
210        let calmar_b = ratio.calculate_from_returns(&returns_b);
211
212        // Higher CAGR should give higher Calmar (assuming same drawdown pattern)
213        // This test just verifies both calculate successfully
214        assert!(calmar_a.is_some());
215        assert!(calmar_b.is_some());
216    }
217}