use std::collections::BTreeMap;
use nautilus_core::UnixNanos;
use crate::statistic::PortfolioStatistic;
#[repr(C)]
#[derive(Debug, Clone)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.analysis", from_py_object)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
)]
pub struct CAGR {
pub period: usize,
}
impl CAGR {
#[must_use]
pub fn new(period: Option<usize>) -> Self {
Self {
period: period.unwrap_or(252),
}
}
}
impl PortfolioStatistic for CAGR {
type Item = f64;
fn name(&self) -> String {
format!("CAGR ({} days)", self.period)
}
fn calculate_from_returns(&self, returns: &BTreeMap<UnixNanos, f64>) -> Option<Self::Item> {
if returns.is_empty() {
return Some(0.0);
}
let daily_returns = self.downsample_to_daily_bins(returns);
let total_return: f64 = daily_returns.values().map(|&r| 1.0 + r).product::<f64>() - 1.0;
let days = daily_returns.len().max(1) as f64;
let cagr = (1.0 + total_return).powf(self.period as f64 / days) - 1.0;
if cagr.is_finite() {
Some(cagr)
} else {
Some(0.0)
}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
let mut returns = BTreeMap::new();
let nanos_per_day = 86_400_000_000_000;
let start_time = 1_600_000_000_000_000_000;
for (i, &value) in values.iter().enumerate() {
let timestamp = start_time + i as u64 * nanos_per_day;
returns.insert(UnixNanos::from(timestamp), value);
}
returns
}
#[rstest]
fn test_name() {
let cagr = CAGR::new(Some(252));
assert_eq!(cagr.name(), "CAGR (252 days)");
}
#[rstest]
fn test_empty_returns() {
let cagr = CAGR::new(Some(252));
let returns = BTreeMap::new();
let result = cagr.calculate_from_returns(&returns);
assert_eq!(result, Some(0.0));
}
#[rstest]
fn test_positive_cagr() {
let cagr = CAGR::new(Some(252));
let returns = create_returns(&vec![0.001; 252]);
let result = cagr.calculate_from_returns(&returns).unwrap();
assert!((result - 0.288).abs() < 0.01);
}
#[rstest]
fn test_cagr_half_year() {
let cagr = CAGR::new(Some(252));
let daily_return = (1.10_f64.powf(1.0 / 126.0)) - 1.0;
let returns = create_returns(&vec![daily_return; 126]);
let result = cagr.calculate_from_returns(&returns).unwrap();
assert!((result - 0.21).abs() < 0.01);
}
#[rstest]
fn test_negative_returns() {
let cagr = CAGR::new(Some(252));
let returns = create_returns(&vec![-0.001; 252]);
let result = cagr.calculate_from_returns(&returns).unwrap();
assert!(result < 0.0);
}
#[rstest]
fn test_multiple_trades_per_day() {
let cagr = CAGR::new(Some(252));
let mut returns = BTreeMap::new();
let nanos_per_day = 86_400_000_000_000;
let start_time = 1_600_000_000_000_000_000;
for i in 0..500 {
let day = (i * 252) / 500; let timestamp =
start_time + day as u64 * nanos_per_day + (i % 3) as u64 * 1_000_000_000;
returns.insert(UnixNanos::from(timestamp), 0.0005);
}
let result = cagr.calculate_from_returns(&returns).unwrap();
assert!((result - 0.285).abs() < 0.02);
assert!(result > 0.2); }
#[rstest]
fn test_intraday_trading() {
let cagr = CAGR::new(Some(252));
let mut returns = BTreeMap::new();
let start_time = 1_600_000_000_000_000_000;
for i in 0..10 {
let timestamp = start_time + i as u64 * 3_600_000_000_000; returns.insert(UnixNanos::from(timestamp), 0.01);
}
let result = cagr.calculate_from_returns(&returns).unwrap();
assert!(result > 0.0);
assert!(result.is_finite());
}
}