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