Skip to main content

wickra_core/indicators/
max_drawdown.rs

1//! Maximum Drawdown over a rolling window.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling Maximum Drawdown — the deepest peak-to-trough decline within the
9/// trailing window.
10///
11/// The input is treated as an equity-curve sample (or any non-negative value
12/// series). For each bar the indicator computes the largest fractional decline
13/// from any prior peak inside the trailing `period`-bar window:
14///
15/// ```text
16/// drawdown_t = (equity_t − peak_t) / peak_t        (a negative number)
17/// MaxDrawdown = min(drawdown_t over window)        (most-negative value)
18/// ```
19///
20/// Output is the magnitude of the worst drawdown as a non-negative fraction
21/// (`0.20` = 20 % drop from peak). A monotonically rising equity curve has a
22/// max drawdown of `0`. Setting `period` greater than or equal to the number of
23/// bars you will ever feed makes the metric effectively *cumulative* — the
24/// indicator never forgets the global peak.
25///
26/// Each `update` is amortised O(1): the running peak is tracked with a
27/// monotonically-decreasing deque.
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Indicator, MaxDrawdown};
33///
34/// let mut mdd = MaxDrawdown::new(10).unwrap();
35/// // Equity peaks at 110 then drops to 88 — a 20% drawdown.
36/// for v in [100.0, 110.0, 100.0, 95.0, 88.0, 90.0, 92.0, 95.0, 100.0, 105.0] {
37///     mdd.update(v);
38/// }
39/// assert!((mdd.update(106.0).unwrap() - 0.20).abs() < 1e-9);
40/// ```
41#[derive(Debug, Clone)]
42pub struct MaxDrawdown {
43    period: usize,
44    count: u64,
45    /// Monotonically-decreasing deque of `(index, value)` over the trailing
46    /// window. Front is the trailing peak in O(1).
47    peak_dq: VecDeque<(u64, f64)>,
48    window: VecDeque<f64>,
49    last: Option<f64>,
50}
51
52impl MaxDrawdown {
53    /// Construct a new rolling Max Drawdown.
54    ///
55    /// # Errors
56    /// Returns [`Error::PeriodZero`] if `period == 0`.
57    pub fn new(period: usize) -> Result<Self> {
58        if period == 0 {
59            return Err(Error::PeriodZero);
60        }
61        Ok(Self {
62            period,
63            count: 0,
64            peak_dq: VecDeque::with_capacity(period),
65            window: VecDeque::with_capacity(period),
66            last: None,
67        })
68    }
69
70    /// Configured rolling-window length.
71    pub const fn period(&self) -> usize {
72        self.period
73    }
74
75    /// Current value if available.
76    pub const fn value(&self) -> Option<f64> {
77        self.last
78    }
79}
80
81impl Indicator for MaxDrawdown {
82    type Input = f64;
83    type Output = f64;
84
85    fn update(&mut self, input: f64) -> Option<f64> {
86        if !input.is_finite() {
87            return self.last;
88        }
89        self.count += 1;
90        // Drop tail entries dominated by the new value (running peak from the
91        // back side of the window).
92        while let Some(&(_, back)) = self.peak_dq.back() {
93            if back <= input {
94                self.peak_dq.pop_back();
95            } else {
96                break;
97            }
98        }
99        self.peak_dq.push_back((self.count, input));
100        // Window slide.
101        if self.window.len() == self.period {
102            self.window.pop_front();
103        }
104        self.window.push_back(input);
105        let window_lo = self.count.saturating_sub(self.period as u64 - 1);
106        while let Some(&(idx, _)) = self.peak_dq.front() {
107            if idx < window_lo {
108                self.peak_dq.pop_front();
109            } else {
110                break;
111            }
112        }
113        if self.window.len() < self.period {
114            return None;
115        }
116        // Scan the window for the deepest drawdown vs running peak so far.
117        let mut peak = f64::NEG_INFINITY;
118        let mut worst = 0.0_f64;
119        for &v in &self.window {
120            if v > peak {
121                peak = v;
122            }
123            if peak > 0.0 {
124                let dd = (peak - v) / peak;
125                if dd > worst {
126                    worst = dd;
127                }
128            }
129        }
130        self.last = Some(worst);
131        Some(worst)
132    }
133
134    fn reset(&mut self) {
135        self.count = 0;
136        self.peak_dq.clear();
137        self.window.clear();
138        self.last = None;
139    }
140
141    fn warmup_period(&self) -> usize {
142        self.period
143    }
144
145    fn is_ready(&self) -> bool {
146        self.last.is_some()
147    }
148
149    fn name(&self) -> &'static str {
150        "MaxDrawdown"
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::traits::BatchExt;
158    use approx::assert_relative_eq;
159
160    #[test]
161    fn new_rejects_zero_period() {
162        assert!(matches!(MaxDrawdown::new(0), Err(Error::PeriodZero)));
163    }
164
165    #[test]
166    fn accessors_and_metadata() {
167        let mut mdd = MaxDrawdown::new(10).unwrap();
168        assert_eq!(mdd.period(), 10);
169        assert_eq!(mdd.name(), "MaxDrawdown");
170        assert_eq!(mdd.value(), None);
171        assert_eq!(mdd.warmup_period(), 10);
172        for v in 1..=10 {
173            mdd.update(f64::from(v));
174        }
175        assert!(mdd.value().is_some());
176    }
177
178    #[test]
179    fn pure_uptrend_yields_zero() {
180        let mut mdd = MaxDrawdown::new(5).unwrap();
181        let out = mdd.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
182        for v in out.into_iter().flatten() {
183            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
184        }
185    }
186
187    #[test]
188    fn reference_drawdown() {
189        // Window [100, 120, 90]: peak 120, trough 90 -> 25% drawdown.
190        let mut mdd = MaxDrawdown::new(3).unwrap();
191        let out = mdd.batch(&[100.0, 120.0, 90.0]);
192        assert_eq!(out[0], None);
193        assert_eq!(out[1], None);
194        assert_relative_eq!(out[2].unwrap(), 0.25, epsilon = 1e-12);
195    }
196
197    #[test]
198    fn constant_series_yields_zero() {
199        let mut mdd = MaxDrawdown::new(4).unwrap();
200        let out = mdd.batch(&[50.0; 12]);
201        for v in out.into_iter().flatten() {
202            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
203        }
204    }
205
206    #[test]
207    fn ignores_non_finite_input() {
208        let mut mdd = MaxDrawdown::new(3).unwrap();
209        mdd.batch(&[100.0, 90.0, 80.0]);
210        let last = mdd.value();
211        assert_eq!(mdd.update(f64::NAN), last);
212        assert_eq!(mdd.update(f64::INFINITY), last);
213    }
214
215    #[test]
216    fn reset_clears_state() {
217        let mut mdd = MaxDrawdown::new(3).unwrap();
218        mdd.batch(&[100.0, 90.0, 80.0]);
219        assert!(mdd.is_ready());
220        mdd.reset();
221        assert!(!mdd.is_ready());
222        assert_eq!(mdd.update(100.0), None);
223    }
224
225    #[test]
226    fn batch_equals_streaming() {
227        let prices: Vec<f64> = (0..60)
228            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
229            .collect();
230        let batch = MaxDrawdown::new(10).unwrap().batch(&prices);
231        let mut s = MaxDrawdown::new(10).unwrap();
232        let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
233        assert_eq!(batch, streamed);
234    }
235
236    #[test]
237    fn non_positive_peak_yields_zero() {
238        // All-zero stream: peak is 0, division skipped, result stays 0.
239        let mut mdd = MaxDrawdown::new(3).unwrap();
240        let out = mdd.batch(&[0.0_f64; 6]);
241        for v in out.into_iter().flatten() {
242            assert_eq!(v, 0.0);
243        }
244    }
245}