Skip to main content

nautilus_analysis/statistics/
cagr.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 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;
21use nautilus_model::position::Position;
22
23use crate::statistic::PortfolioStatistic;
24
25/// Calculates the Compound Annual Growth Rate (CAGR) for returns.
26///
27/// CAGR represents the mean annual growth rate of an investment over a specified period,
28/// assuming the profits were reinvested at the end of each period.
29///
30/// Formula: CAGR = (Ending Value / Beginning Value)^(Period/Days) - 1
31///
32/// For returns: CAGR = ((1 + Total Return)^(Period/Days)) - 1
33///
34/// # References
35///
36/// - Bacon, C. R. (2008). *Practical Portfolio Performance Measurement and Attribution*
37///   (2nd ed.). Wiley.
38/// - CFA Institute Level I Curriculum: Quantitative Methods
39#[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    /// The number of periods per year for annualization (e.g., 252 for trading days).
51    pub period: usize,
52}
53
54impl CAGR {
55    /// Creates a new [`CAGR`] instance.
56    #[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        // Downsample to daily bins to count actual trading days (not calendar days or trade count)
77        let daily_returns = self.downsample_to_daily_bins(returns);
78
79        // Calculate total return (cumulative)
80        let total_return: f64 = daily_returns.values().map(|&r| 1.0 + r).product::<f64>() - 1.0;
81
82        // Use the number of trading days (bins) for annualization
83        // Minimum of 1 day to handle intraday-only strategies
84        let days = daily_returns.len().max(1) as f64;
85
86        // CAGR = (1 + total_return)^(period/days) - 1
87        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        // Simulate 252 days with 0.1% daily return
145        // Total return = (1.001)^252 - 1 ≈ 0.288 (28.8%)
146        // CAGR should be approximately same as total return for full year
147        let returns = create_returns(&vec![0.001; 252]);
148        let result = cagr.calculate_from_returns(&returns).unwrap();
149
150        // For 252 days of 0.1% daily return
151        // CAGR = (1 + 0.288)^(252/252) - 1 = 0.288
152        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        // Simulate 126 days (half year) with total return of 10%
159        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        // CAGR should annualize the 10% half-year return
164        // CAGR = (1.10)^(252/126) - 1 = (1.10)^2 - 1 ≈ 0.21 (21%)
165        assert!((result - 0.21).abs() < 0.01);
166    }
167
168    #[rstest]
169    fn test_negative_returns() {
170        let cagr = CAGR::new(Some(252));
171        // Simulate losses
172        let returns = create_returns(&vec![-0.001; 252]);
173        let result = cagr.calculate_from_returns(&returns).unwrap();
174
175        // Should be negative
176        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        // Simulate 500 trades over 252 days
198        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        // Create 500 trades with small returns spread across 252 days (~2 trades per day)
203        for i in 0..500 {
204            let day = (i * 252) / 500; // Map trade index to day
205            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        // With downsample_to_daily_bins, we get 252 bins (trading days)
213        // Daily returns are aggregated, then we compound and annualize
214        // The CAGR should reflect 252 trading days, NOT 500 trades
215        assert!((result - 0.285).abs() < 0.02);
216        assert!(result > 0.2); // Should be much higher than what trade-count formula would give
217    }
218
219    #[rstest]
220    fn test_intraday_trading() {
221        let cagr = CAGR::new(Some(252));
222
223        // Simulate multiple trades within a single day
224        let mut returns = BTreeMap::new();
225        let start_time = 1_600_000_000_000_000_000;
226
227        // 10 trades within the same day, each with 1% return
228        for i in 0..10 {
229            let timestamp = start_time + i as u64 * 3_600_000_000_000; // 1 hour apart
230            returns.insert(UnixNanos::from(timestamp), 0.01);
231        }
232
233        let result = cagr.calculate_from_returns(&returns).unwrap();
234
235        // Total return: (1.01)^10 - 1 ≈ 0.1046 (10.46%)
236        // This should be treated as 1 trading day
237        // Annualized: (1.1046)^(252/1) - 1 = very large number
238        // The key is it should NOT return 0.0
239        assert!(result > 0.0);
240        assert!(result.is_finite());
241    }
242}