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        if period > crate::error::MAX_PERIOD {
62            return Err(Error::InvalidPeriod {
63                message: crate::error::PERIOD_ABOVE_MAX,
64            });
65        }
66        Ok(Self {
67            period,
68            count: 0,
69            peak_dq: VecDeque::with_capacity(period),
70            window: VecDeque::with_capacity(period),
71            last: None,
72        })
73    }
74
75    /// Configured rolling-window length.
76    pub const fn period(&self) -> usize {
77        self.period
78    }
79
80    /// Current value if available.
81    pub const fn value(&self) -> Option<f64> {
82        self.last
83    }
84}
85
86impl Indicator for MaxDrawdown {
87    type Input = f64;
88    type Output = f64;
89
90    fn update(&mut self, input: f64) -> Option<f64> {
91        if !input.is_finite() {
92            return None;
93        }
94        self.count += 1;
95        // Drop tail entries dominated by the new value (running peak from the
96        // back side of the window).
97        while let Some(&(_, back)) = self.peak_dq.back() {
98            if back <= input {
99                self.peak_dq.pop_back();
100            } else {
101                break;
102            }
103        }
104        self.peak_dq.push_back((self.count, input));
105        // Window slide.
106        if self.window.len() == self.period {
107            self.window.pop_front();
108        }
109        self.window.push_back(input);
110        let window_lo = self.count.saturating_sub(self.period as u64 - 1);
111        while let Some(&(idx, _)) = self.peak_dq.front() {
112            if idx < window_lo {
113                self.peak_dq.pop_front();
114            } else {
115                break;
116            }
117        }
118        if self.window.len() < self.period {
119            return None;
120        }
121        // Scan the window for the deepest drawdown vs running peak so far.
122        let mut peak = f64::NEG_INFINITY;
123        let mut worst = 0.0_f64;
124        for &v in &self.window {
125            if v > peak {
126                peak = v;
127            }
128            if peak > 0.0 {
129                let dd = (peak - v) / peak;
130                if dd > worst {
131                    worst = dd;
132                }
133            }
134        }
135        self.last = Some(worst);
136        Some(worst)
137    }
138
139    fn reset(&mut self) {
140        self.count = 0;
141        self.peak_dq.clear();
142        self.window.clear();
143        self.last = None;
144    }
145
146    #[inline]
147    fn warmup_period(&self) -> usize {
148        self.period
149    }
150
151    #[inline]
152    fn is_ready(&self) -> bool {
153        self.last.is_some()
154    }
155
156    #[inline]
157    fn name(&self) -> &'static str {
158        "MaxDrawdown"
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::traits::BatchExt;
166    use approx::assert_relative_eq;
167
168    #[test]
169    fn new_rejects_zero_period() {
170        assert!(matches!(MaxDrawdown::new(0), Err(Error::PeriodZero)));
171    }
172
173    #[test]
174    fn accessors_and_metadata() {
175        let mut mdd = MaxDrawdown::new(10).unwrap();
176        assert_eq!(mdd.period(), 10);
177        assert_eq!(mdd.name(), "MaxDrawdown");
178        assert_eq!(mdd.value(), None);
179        assert_eq!(mdd.warmup_period(), 10);
180        for v in 1..=10 {
181            mdd.update(f64::from(v));
182        }
183        assert!(mdd.value().is_some());
184    }
185
186    #[test]
187    fn pure_uptrend_yields_zero() {
188        let mut mdd = MaxDrawdown::new(5).unwrap();
189        let out = mdd.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
190        for v in out.into_iter().flatten() {
191            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
192        }
193    }
194
195    #[test]
196    fn reference_drawdown() {
197        // Window [100, 120, 90]: peak 120, trough 90 -> 25% drawdown.
198        let mut mdd = MaxDrawdown::new(3).unwrap();
199        let out = mdd.batch(&[100.0, 120.0, 90.0]);
200        assert_eq!(out[0], None);
201        assert_eq!(out[1], None);
202        assert_relative_eq!(out[2].unwrap(), 0.25, epsilon = 1e-12);
203    }
204
205    #[test]
206    fn constant_series_yields_zero() {
207        let mut mdd = MaxDrawdown::new(4).unwrap();
208        let out = mdd.batch(&[50.0; 12]);
209        for v in out.into_iter().flatten() {
210            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
211        }
212    }
213
214    #[test]
215    fn ignores_non_finite_input() {
216        let mut mdd = MaxDrawdown::new(3).unwrap();
217        mdd.batch(&[100.0, 90.0, 80.0]);
218        let last = mdd.value();
219        assert_eq!(mdd.update(f64::NAN), None);
220        assert_eq!(mdd.update(f64::INFINITY), None);
221        // The rejected input must not have disturbed the state.
222        assert_eq!(mdd.value(), last);
223    }
224
225    #[test]
226    fn reset_clears_state() {
227        let mut mdd = MaxDrawdown::new(3).unwrap();
228        mdd.batch(&[100.0, 90.0, 80.0]);
229        assert!(mdd.is_ready());
230        mdd.reset();
231        assert!(!mdd.is_ready());
232        assert_eq!(mdd.update(100.0), None);
233    }
234
235    #[test]
236    fn batch_equals_streaming() {
237        let prices: Vec<f64> = (0..60)
238            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
239            .collect();
240        let batch = MaxDrawdown::new(10).unwrap().batch(&prices);
241        let mut s = MaxDrawdown::new(10).unwrap();
242        let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
243        assert_eq!(batch, streamed);
244    }
245
246    #[test]
247    fn non_positive_peak_yields_zero() {
248        // All-zero stream: peak is 0, division skipped, result stays 0.
249        let mut mdd = MaxDrawdown::new(3).unwrap();
250        let out = mdd.batch(&[0.0_f64; 6]);
251        for v in out.into_iter().flatten() {
252            assert_eq!(v, 0.0);
253        }
254    }
255}