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