nautilus_analysis/statistics/cagr.rs
1// -------------------------------------------------------------------------------------------------
2// Copyright (C) 2015-2025 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//! Compound Annual Growth Rate (CAGR) statistic.
17
18use std::collections::BTreeMap;
19
20use nautilus_core::UnixNanos;
21
22use crate::statistic::PortfolioStatistic;
23
24/// Calculates the Compound Annual Growth Rate (CAGR) for returns.
25///
26/// CAGR represents the mean annual growth rate of an investment over a specified period,
27/// assuming the profits were reinvested at the end of each period.
28///
29/// Formula: CAGR = (Ending Value / Beginning Value)^(Period/Days) - 1
30///
31/// For returns: CAGR = ((1 + Total Return)^(Period/Days)) - 1
32#[repr(C)]
33#[derive(Debug, Clone)]
34#[cfg_attr(
35 feature = "python",
36 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.analysis")
37)]
38pub struct CAGR {
39 /// The number of periods per year for annualization (e.g., 252 for trading days).
40 pub period: usize,
41}
42
43impl CAGR {
44 /// Creates a new [`CAGR`] instance.
45 #[must_use]
46 pub fn new(period: Option<usize>) -> Self {
47 Self {
48 period: period.unwrap_or(252),
49 }
50 }
51}
52
53impl PortfolioStatistic for CAGR {
54 type Item = f64;
55
56 fn name(&self) -> String {
57 format!("CAGR ({} days)", self.period)
58 }
59
60 fn calculate_from_returns(&self, returns: &BTreeMap<UnixNanos, f64>) -> Option<Self::Item> {
61 if returns.is_empty() {
62 return Some(0.0);
63 }
64
65 // Downsample to daily bins to count actual trading days (not calendar days or trade count)
66 let daily_returns = self.downsample_to_daily_bins(returns);
67
68 // Calculate total return (cumulative)
69 let total_return: f64 = daily_returns.values().map(|&r| 1.0 + r).product::<f64>() - 1.0;
70
71 // Use the number of trading days (bins) for annualization
72 // Minimum of 1 day to handle intraday-only strategies
73 let days = daily_returns.len().max(1) as f64;
74
75 // CAGR = (1 + total_return)^(period/days) - 1
76 let cagr = (1.0 + total_return).powf(self.period as f64 / days) - 1.0;
77
78 if cagr.is_finite() {
79 Some(cagr)
80 } else {
81 Some(0.0)
82 }
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use rstest::rstest;
89
90 use super::*;
91
92 fn create_returns(values: Vec<f64>) -> BTreeMap<UnixNanos, f64> {
93 let mut returns = BTreeMap::new();
94 let nanos_per_day = 86_400_000_000_000;
95 let start_time = 1_600_000_000_000_000_000;
96
97 for (i, &value) in values.iter().enumerate() {
98 let timestamp = start_time + i as u64 * nanos_per_day;
99 returns.insert(UnixNanos::from(timestamp), value);
100 }
101
102 returns
103 }
104
105 #[rstest]
106 fn test_name() {
107 let cagr = CAGR::new(Some(252));
108 assert_eq!(cagr.name(), "CAGR (252 days)");
109 }
110
111 #[rstest]
112 fn test_empty_returns() {
113 let cagr = CAGR::new(Some(252));
114 let returns = BTreeMap::new();
115 let result = cagr.calculate_from_returns(&returns);
116 assert_eq!(result, Some(0.0));
117 }
118
119 #[rstest]
120 fn test_positive_cagr() {
121 let cagr = CAGR::new(Some(252));
122 // Simulate 252 days with 0.1% daily return
123 // Total return = (1.001)^252 - 1 ≈ 0.288 (28.8%)
124 // CAGR should be approximately same as total return for full year
125 let returns = create_returns(vec![0.001; 252]);
126 let result = cagr.calculate_from_returns(&returns).unwrap();
127
128 // For 252 days of 0.1% daily return
129 // CAGR = (1 + 0.288)^(252/252) - 1 = 0.288
130 assert!((result - 0.288).abs() < 0.01);
131 }
132
133 #[rstest]
134 fn test_cagr_half_year() {
135 let cagr = CAGR::new(Some(252));
136 // Simulate 126 days (half year) with total return of 10%
137 let daily_return = (1.10_f64.powf(1.0 / 126.0)) - 1.0;
138 let returns = create_returns(vec![daily_return; 126]);
139 let result = cagr.calculate_from_returns(&returns).unwrap();
140
141 // CAGR should annualize the 10% half-year return
142 // CAGR = (1.10)^(252/126) - 1 = (1.10)^2 - 1 ≈ 0.21 (21%)
143 assert!((result - 0.21).abs() < 0.01);
144 }
145
146 #[rstest]
147 fn test_negative_returns() {
148 let cagr = CAGR::new(Some(252));
149 // Simulate losses
150 let returns = create_returns(vec![-0.001; 252]);
151 let result = cagr.calculate_from_returns(&returns).unwrap();
152
153 // Should be negative
154 assert!(result < 0.0);
155 }
156
157 #[rstest]
158 fn test_multiple_trades_per_day() {
159 let cagr = CAGR::new(Some(252));
160
161 // Simulate 500 trades over 252 days
162 let mut returns = BTreeMap::new();
163 let nanos_per_day = 86_400_000_000_000;
164 let start_time = 1_600_000_000_000_000_000;
165
166 // Create 500 trades with small returns spread across 252 days (~2 trades per day)
167 for i in 0..500 {
168 let day = (i * 252) / 500; // Map trade index to day
169 let timestamp =
170 start_time + day as u64 * nanos_per_day + (i % 3) as u64 * 1_000_000_000;
171 returns.insert(UnixNanos::from(timestamp), 0.0005);
172 }
173
174 let result = cagr.calculate_from_returns(&returns).unwrap();
175
176 // With downsample_to_daily_bins, we get 252 bins (trading days)
177 // Daily returns are aggregated, then we compound and annualize
178 // The CAGR should reflect 252 trading days, NOT 500 trades
179 assert!((result - 0.285).abs() < 0.02);
180 assert!(result > 0.2); // Should be much higher than what trade-count formula would give
181 }
182
183 #[rstest]
184 fn test_intraday_trading() {
185 let cagr = CAGR::new(Some(252));
186
187 // Simulate multiple trades within a single day
188 let mut returns = BTreeMap::new();
189 let start_time = 1_600_000_000_000_000_000;
190
191 // 10 trades within the same day, each with 1% return
192 for i in 0..10 {
193 let timestamp = start_time + i as u64 * 3_600_000_000_000; // 1 hour apart
194 returns.insert(UnixNanos::from(timestamp), 0.01);
195 }
196
197 let result = cagr.calculate_from_returns(&returns).unwrap();
198
199 // Total return: (1.01)^10 - 1 ≈ 0.1046 (10.46%)
200 // This should be treated as 1 trading day
201 // Annualized: (1.1046)^(252/1) - 1 = very large number
202 // The key is it should NOT return 0.0
203 assert!(result > 0.0);
204 assert!(result.is_finite());
205 }
206}