Skip to main content

wickra_core/indicators/
calmar_ratio.rs

1//! Rolling Calmar Ratio — return over max drawdown.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling Calmar Ratio.
9///
10/// Input is treated as a single period return. Over the trailing window of
11/// `period` returns the indicator reconstructs the implied equity curve
12/// (cumulative-compounded), measures the worst peak-to-trough drawdown, and
13/// divides the mean return by that drawdown:
14///
15/// ```text
16/// equity_t = ∏(1 + r_i) for i in window up to t
17/// mdd      = max peak-to-trough decline of equity over window
18/// Calmar   = mean(returns) / mdd
19/// ```
20///
21/// If the drawdown is zero (monotonically non-decreasing equity in the
22/// window) the indicator returns `0.0` rather than `NaN` / `Inf`.
23///
24/// The equity curve is recomputed inside the window each `update`, which
25/// keeps each call O(period) — acceptable for typical backtest windows
26/// (`period ≤ 252`).
27///
28/// # Example
29///
30/// ```
31/// use wickra_core::{CalmarRatio, Indicator};
32///
33/// let mut cr = CalmarRatio::new(20).unwrap();
34/// let mut last = None;
35/// for i in 0..40 {
36///     last = cr.update(0.001 + (f64::from(i) * 0.1).sin() * 0.005);
37/// }
38/// assert!(last.is_some());
39/// ```
40#[derive(Debug, Clone)]
41pub struct CalmarRatio {
42    period: usize,
43    window: VecDeque<f64>,
44    sum: f64,
45}
46
47impl CalmarRatio {
48    /// Construct a new rolling Calmar Ratio.
49    ///
50    /// # Errors
51    /// Returns [`Error::InvalidPeriod`] if `period < 2`.
52    pub fn new(period: usize) -> Result<Self> {
53        if period < 2 {
54            return Err(Error::InvalidPeriod {
55                message: "calmar ratio needs period >= 2",
56            });
57        }
58        Ok(Self {
59            period,
60            window: VecDeque::with_capacity(period),
61            sum: 0.0,
62        })
63    }
64
65    /// Configured window length.
66    pub const fn period(&self) -> usize {
67        self.period
68    }
69}
70
71impl Indicator for CalmarRatio {
72    type Input = f64;
73    type Output = f64;
74
75    fn update(&mut self, input: f64) -> Option<f64> {
76        if !input.is_finite() {
77            return None;
78        }
79        if self.window.len() == self.period {
80            let old = self.window.pop_front().expect("non-empty");
81            self.sum -= old;
82        }
83        self.window.push_back(input);
84        self.sum += input;
85        if self.window.len() < self.period {
86            return None;
87        }
88        let n = self.period as f64;
89        let mean = self.sum / n;
90        // Build equity curve and track the worst peak-to-trough drawdown.
91        let mut equity = 1.0_f64;
92        let mut peak = 1.0_f64;
93        let mut mdd = 0.0_f64;
94        for &r in &self.window {
95            equity *= 1.0 + r;
96            if equity > peak {
97                peak = equity;
98            }
99            // peak starts at 1.0 and never decreases, so peak > 0 by construction.
100            let dd = (peak - equity) / peak;
101            if dd > mdd {
102                mdd = dd;
103            }
104        }
105        if mdd == 0.0 {
106            return Some(0.0);
107        }
108        Some(mean / mdd)
109    }
110
111    fn reset(&mut self) {
112        self.window.clear();
113        self.sum = 0.0;
114    }
115
116    fn warmup_period(&self) -> usize {
117        self.period
118    }
119
120    fn is_ready(&self) -> bool {
121        self.window.len() == self.period
122    }
123
124    fn name(&self) -> &'static str {
125        "CalmarRatio"
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::traits::BatchExt;
133    use approx::assert_relative_eq;
134
135    #[test]
136    fn rejects_period_less_than_two() {
137        assert!(matches!(
138            CalmarRatio::new(1),
139            Err(Error::InvalidPeriod { .. })
140        ));
141    }
142
143    #[test]
144    fn accessors_and_metadata() {
145        let c = CalmarRatio::new(10).unwrap();
146        assert_eq!(c.period(), 10);
147        assert_eq!(c.name(), "CalmarRatio");
148        assert_eq!(c.warmup_period(), 10);
149    }
150
151    #[test]
152    fn pure_uptrend_yields_zero() {
153        // All positive returns -> no drawdown -> Calmar = 0 by convention.
154        let mut c = CalmarRatio::new(5).unwrap();
155        let out = c.batch(&[0.01; 10]);
156        for v in out.into_iter().flatten() {
157            assert_eq!(v, 0.0);
158        }
159    }
160
161    #[test]
162    fn reference_value() {
163        // returns = [0.10, -0.20, 0.05]
164        // equity: 1.0 -> 1.10 -> 0.88 -> 0.924
165        // peak 1.10, trough 0.88 -> mdd = 0.20.
166        // mean = (0.10 - 0.20 + 0.05) / 3 ≈ -0.01666...
167        // Calmar = -0.01666... / 0.20 ≈ -0.08333...
168        let mut c = CalmarRatio::new(3).unwrap();
169        let out = c.batch(&[0.10, -0.20, 0.05]);
170        let mean = (0.10 - 0.20 + 0.05) / 3.0;
171        let expected = mean / 0.20;
172        assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
173    }
174
175    #[test]
176    fn ignores_non_finite_input() {
177        let mut c = CalmarRatio::new(3).unwrap();
178        assert_eq!(c.update(f64::NAN), None);
179        assert_eq!(c.update(f64::INFINITY), None);
180    }
181
182    #[test]
183    fn reset_clears_state() {
184        let mut c = CalmarRatio::new(3).unwrap();
185        c.batch(&[0.10, -0.20, 0.05]);
186        assert!(c.is_ready());
187        c.reset();
188        assert!(!c.is_ready());
189        assert_eq!(c.update(0.01), None);
190    }
191
192    #[test]
193    fn batch_equals_streaming() {
194        let returns: Vec<f64> = (0..50)
195            .map(|i| 0.001 + (f64::from(i) * 0.25).sin() * 0.02)
196            .collect();
197        let batch = CalmarRatio::new(10).unwrap().batch(&returns);
198        let mut s = CalmarRatio::new(10).unwrap();
199        let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
200        assert_eq!(batch, streamed);
201    }
202}