nautilus_analysis/statistics/
cagr.rs1use std::collections::BTreeMap;
19
20use nautilus_core::UnixNanos;
21
22use crate::statistic::PortfolioStatistic;
23
24#[repr(C)]
33#[derive(Debug, Clone)]
34#[cfg_attr(
35 feature = "python",
36 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.analysis", from_py_object)
37)]
38#[cfg_attr(
39 feature = "python",
40 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
41)]
42pub struct CAGR {
43 pub period: usize,
45}
46
47impl CAGR {
48 #[must_use]
50 pub fn new(period: Option<usize>) -> Self {
51 Self {
52 period: period.unwrap_or(252),
53 }
54 }
55}
56
57impl PortfolioStatistic for CAGR {
58 type Item = f64;
59
60 fn name(&self) -> String {
61 format!("CAGR ({} days)", self.period)
62 }
63
64 fn calculate_from_returns(&self, returns: &BTreeMap<UnixNanos, f64>) -> Option<Self::Item> {
65 if returns.is_empty() {
66 return Some(0.0);
67 }
68
69 let daily_returns = self.downsample_to_daily_bins(returns);
71
72 let total_return: f64 = daily_returns.values().map(|&r| 1.0 + r).product::<f64>() - 1.0;
74
75 let days = daily_returns.len().max(1) as f64;
78
79 let cagr = (1.0 + total_return).powf(self.period as f64 / days) - 1.0;
81
82 if cagr.is_finite() {
83 Some(cagr)
84 } else {
85 Some(0.0)
86 }
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use rstest::rstest;
93
94 use super::*;
95
96 fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
97 let mut returns = BTreeMap::new();
98 let nanos_per_day = 86_400_000_000_000;
99 let start_time = 1_600_000_000_000_000_000;
100
101 for (i, &value) in values.iter().enumerate() {
102 let timestamp = start_time + i as u64 * nanos_per_day;
103 returns.insert(UnixNanos::from(timestamp), value);
104 }
105
106 returns
107 }
108
109 #[rstest]
110 fn test_name() {
111 let cagr = CAGR::new(Some(252));
112 assert_eq!(cagr.name(), "CAGR (252 days)");
113 }
114
115 #[rstest]
116 fn test_empty_returns() {
117 let cagr = CAGR::new(Some(252));
118 let returns = BTreeMap::new();
119 let result = cagr.calculate_from_returns(&returns);
120 assert_eq!(result, Some(0.0));
121 }
122
123 #[rstest]
124 fn test_positive_cagr() {
125 let cagr = CAGR::new(Some(252));
126 let returns = create_returns(&vec![0.001; 252]);
130 let result = cagr.calculate_from_returns(&returns).unwrap();
131
132 assert!((result - 0.288).abs() < 0.01);
135 }
136
137 #[rstest]
138 fn test_cagr_half_year() {
139 let cagr = CAGR::new(Some(252));
140 let daily_return = (1.10_f64.powf(1.0 / 126.0)) - 1.0;
142 let returns = create_returns(&vec![daily_return; 126]);
143 let result = cagr.calculate_from_returns(&returns).unwrap();
144
145 assert!((result - 0.21).abs() < 0.01);
148 }
149
150 #[rstest]
151 fn test_negative_returns() {
152 let cagr = CAGR::new(Some(252));
153 let returns = create_returns(&vec![-0.001; 252]);
155 let result = cagr.calculate_from_returns(&returns).unwrap();
156
157 assert!(result < 0.0);
159 }
160
161 #[rstest]
162 fn test_multiple_trades_per_day() {
163 let cagr = CAGR::new(Some(252));
164
165 let mut returns = BTreeMap::new();
167 let nanos_per_day = 86_400_000_000_000;
168 let start_time = 1_600_000_000_000_000_000;
169
170 for i in 0..500 {
172 let day = (i * 252) / 500; let timestamp =
174 start_time + day as u64 * nanos_per_day + (i % 3) as u64 * 1_000_000_000;
175 returns.insert(UnixNanos::from(timestamp), 0.0005);
176 }
177
178 let result = cagr.calculate_from_returns(&returns).unwrap();
179
180 assert!((result - 0.285).abs() < 0.02);
184 assert!(result > 0.2); }
186
187 #[rstest]
188 fn test_intraday_trading() {
189 let cagr = CAGR::new(Some(252));
190
191 let mut returns = BTreeMap::new();
193 let start_time = 1_600_000_000_000_000_000;
194
195 for i in 0..10 {
197 let timestamp = start_time + i as u64 * 3_600_000_000_000; returns.insert(UnixNanos::from(timestamp), 0.01);
199 }
200
201 let result = cagr.calculate_from_returns(&returns).unwrap();
202
203 assert!(result > 0.0);
208 assert!(result.is_finite());
209 }
210}