nautilus_analysis/statistics/
calmar_ratio.rs1use std::collections::BTreeMap;
19
20use nautilus_core::UnixNanos;
21
22use crate::{
23 statistic::PortfolioStatistic,
24 statistics::{cagr::CAGR, max_drawdown::MaxDrawdown},
25};
26
27#[repr(C)]
37#[derive(Debug, Clone)]
38#[cfg_attr(
39 feature = "python",
40 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.analysis", from_py_object)
41)]
42#[cfg_attr(
43 feature = "python",
44 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
45)]
46pub struct CalmarRatio {
47 pub period: usize,
49}
50
51impl CalmarRatio {
52 #[must_use]
54 pub fn new(period: Option<usize>) -> Self {
55 Self {
56 period: period.unwrap_or(252),
57 }
58 }
59}
60
61impl PortfolioStatistic for CalmarRatio {
62 type Item = f64;
63
64 fn name(&self) -> String {
65 format!("Calmar Ratio ({} days)", self.period)
66 }
67
68 fn calculate_from_returns(&self, returns: &BTreeMap<UnixNanos, f64>) -> Option<Self::Item> {
69 if returns.is_empty() {
70 return Some(f64::NAN);
71 }
72
73 let cagr_stat = CAGR::new(Some(self.period));
75 let cagr = cagr_stat.calculate_from_returns(returns)?;
76
77 let max_dd_stat = MaxDrawdown::new();
79 let max_dd = max_dd_stat.calculate_from_returns(returns)?;
80
81 if max_dd.abs() < f64::EPSILON {
85 return Some(f64::NAN);
86 }
87
88 let calmar = cagr / max_dd.abs();
89
90 if calmar.is_finite() {
91 Some(calmar)
92 } else {
93 Some(f64::NAN)
94 }
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use rstest::rstest;
101
102 use super::*;
103
104 fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
105 let mut returns = BTreeMap::new();
106 let nanos_per_day = 86_400_000_000_000;
107 let start_time = 1_600_000_000_000_000_000;
108
109 for (i, &value) in values.iter().enumerate() {
110 let timestamp = start_time + i as u64 * nanos_per_day;
111 returns.insert(UnixNanos::from(timestamp), value);
112 }
113
114 returns
115 }
116
117 #[rstest]
118 fn test_name() {
119 let ratio = CalmarRatio::new(Some(252));
120 assert_eq!(ratio.name(), "Calmar Ratio (252 days)");
121 }
122
123 #[rstest]
124 fn test_empty_returns() {
125 let ratio = CalmarRatio::new(Some(252));
126 let returns = BTreeMap::new();
127 let result = ratio.calculate_from_returns(&returns);
128 assert!(result.is_some());
129 assert!(result.unwrap().is_nan());
130 }
131
132 #[rstest]
133 fn test_no_drawdown() {
134 let ratio = CalmarRatio::new(Some(252));
135 let returns = create_returns(&vec![0.01; 252]);
137 let result = ratio.calculate_from_returns(&returns);
138
139 assert!(result.is_some());
141 assert!(result.unwrap().is_nan());
142 }
143
144 #[rstest]
145 fn test_positive_ratio() {
146 let ratio = CalmarRatio::new(Some(252));
147 let mut returns_vec = vec![0.001; 200]; returns_vec.extend(vec![-0.002; 52]); let returns = create_returns(&returns_vec);
154 let result = ratio.calculate_from_returns(&returns).unwrap();
155
156 assert!(result > 0.0);
158 }
159
160 #[rstest]
161 fn test_high_calmar_better() {
162 let ratio = CalmarRatio::new(Some(252));
163
164 let returns_a = create_returns(&vec![0.002; 252]);
166 let calmar_a = ratio.calculate_from_returns(&returns_a);
167
168 let returns_b = create_returns(&vec![0.001; 252]);
170 let calmar_b = ratio.calculate_from_returns(&returns_b);
171
172 assert!(calmar_a.is_some());
175 assert!(calmar_b.is_some());
176 }
177}