nautilus_analysis/statistics/
cagr.rs1use std::collections::BTreeMap;
19
20use nautilus_core::UnixNanos;
21use nautilus_model::position::Position;
22
23use crate::statistic::PortfolioStatistic;
24
25#[repr(C)]
40#[derive(Debug, Clone)]
41#[cfg_attr(
42 feature = "python",
43 pyo3::pyclass(module = "nautilus_trader.analysis", from_py_object)
44)]
45#[cfg_attr(
46 feature = "python",
47 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
48)]
49pub struct CAGR {
50 pub period: usize,
52}
53
54impl CAGR {
55 #[must_use]
57 pub fn new(period: Option<usize>) -> Self {
58 Self {
59 period: period.unwrap_or(252),
60 }
61 }
62}
63
64impl PortfolioStatistic for CAGR {
65 type Item = f64;
66
67 fn name(&self) -> String {
68 format!("CAGR ({} days)", self.period)
69 }
70
71 fn calculate_from_returns(&self, returns: &BTreeMap<UnixNanos, f64>) -> Option<Self::Item> {
72 if returns.is_empty() {
73 return Some(0.0);
74 }
75
76 let daily_returns = self.downsample_to_daily_bins(returns);
78
79 let total_return: f64 = daily_returns.values().map(|&r| 1.0 + r).product::<f64>() - 1.0;
81
82 let days = daily_returns.len().max(1) as f64;
85
86 let ending_value = 1.0 + total_return;
88 if ending_value < 0.0 {
89 return Some(f64::NAN);
90 }
91 let cagr = ending_value.powf(self.period as f64 / days) - 1.0;
92
93 if cagr.is_finite() {
94 Some(cagr)
95 } else {
96 Some(f64::NAN)
97 }
98 }
99 fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
100 None
101 }
102
103 fn calculate_from_positions(&self, _positions: &[Position]) -> Option<Self::Item> {
104 None
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use rstest::rstest;
111
112 use super::*;
113
114 fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
115 let mut returns = BTreeMap::new();
116 let nanos_per_day = 86_400_000_000_000;
117 let start_time = 1_600_000_000_000_000_000;
118
119 for (i, &value) in values.iter().enumerate() {
120 let timestamp = start_time + i as u64 * nanos_per_day;
121 returns.insert(UnixNanos::from(timestamp), value);
122 }
123
124 returns
125 }
126
127 #[rstest]
128 fn test_name() {
129 let cagr = CAGR::new(Some(252));
130 assert_eq!(cagr.name(), "CAGR (252 days)");
131 }
132
133 #[rstest]
134 fn test_empty_returns() {
135 let cagr = CAGR::new(Some(252));
136 let returns = BTreeMap::new();
137 let result = cagr.calculate_from_returns(&returns);
138 assert_eq!(result, Some(0.0));
139 }
140
141 #[rstest]
142 fn test_positive_cagr() {
143 let cagr = CAGR::new(Some(252));
144 let returns = create_returns(&vec![0.001; 252]);
148 let result = cagr.calculate_from_returns(&returns).unwrap();
149
150 assert!((result - 0.288).abs() < 0.01);
153 }
154
155 #[rstest]
156 fn test_cagr_half_year() {
157 let cagr = CAGR::new(Some(252));
158 let daily_return = (1.10_f64.powf(1.0 / 126.0)) - 1.0;
160 let returns = create_returns(&vec![daily_return; 126]);
161 let result = cagr.calculate_from_returns(&returns).unwrap();
162
163 assert!((result - 0.21).abs() < 0.01);
166 }
167
168 #[rstest]
169 fn test_negative_returns() {
170 let cagr = CAGR::new(Some(252));
171 let returns = create_returns(&vec![-0.001; 252]);
173 let result = cagr.calculate_from_returns(&returns).unwrap();
174
175 assert!(result < 0.0);
177 }
178
179 #[rstest]
180 #[case(5)]
181 #[case(252)]
182 fn test_undefined_cagr_returns_nan(#[case] days: usize) {
183 let cagr = CAGR::new(Some(252));
184 let mut values = vec![0.0; days];
185 values[0] = -1.5;
186 let returns = create_returns(&values);
187
188 let result = cagr.calculate_from_returns(&returns).unwrap();
189
190 assert!(result.is_nan());
191 }
192
193 #[rstest]
194 fn test_multiple_trades_per_day() {
195 let cagr = CAGR::new(Some(252));
196
197 let mut returns = BTreeMap::new();
199 let nanos_per_day = 86_400_000_000_000;
200 let start_time = 1_600_000_000_000_000_000;
201
202 for i in 0..500 {
204 let day = (i * 252) / 500; let timestamp =
206 start_time + day as u64 * nanos_per_day + (i % 3) as u64 * 1_000_000_000;
207 returns.insert(UnixNanos::from(timestamp), 0.0005);
208 }
209
210 let result = cagr.calculate_from_returns(&returns).unwrap();
211
212 assert!((result - 0.285).abs() < 0.02);
216 assert!(result > 0.2); }
218
219 #[rstest]
220 fn test_intraday_trading() {
221 let cagr = CAGR::new(Some(252));
222
223 let mut returns = BTreeMap::new();
225 let start_time = 1_600_000_000_000_000_000;
226
227 for i in 0..10 {
229 let timestamp = start_time + i as u64 * 3_600_000_000_000; returns.insert(UnixNanos::from(timestamp), 0.01);
231 }
232
233 let result = cagr.calculate_from_returns(&returns).unwrap();
234
235 assert!(result > 0.0);
240 assert!(result.is_finite());
241 }
242}