Skip to main content

wickra_core/indicators/
max_drawdown.rs

1//! Maximum Drawdown over a rolling window.
2
3use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6/// Rolling Maximum Drawdown — the deepest peak-to-trough decline within the
7/// trailing window.
8///
9/// The input is treated as an equity-curve sample (or any non-negative value
10/// series). For each bar the indicator computes the largest fractional decline
11/// from any prior peak inside the trailing `period`-bar window:
12///
13/// ```text
14/// drawdown_t = (equity_t − peak_t) / peak_t        (a negative number)
15/// MaxDrawdown = min(drawdown_t over window)        (most-negative value)
16/// ```
17///
18/// `peak_t` is the running maximum from the oldest value in the window up to
19/// `t`, and only a positive peak counts.
20///
21/// Output is the magnitude of the worst drawdown as a non-negative fraction
22/// (`0.20` = 20 % drop from peak). A monotonically rising equity curve has a
23/// max drawdown of `0`. Setting `period` greater than or equal to the number of
24/// bars you will ever feed makes the metric effectively *cumulative* — the
25/// indicator never forgets the global peak.
26///
27/// Each `update` is amortised O(1). Within the window the running peaks split
28/// the values into segments, each a peak followed by the values below it, and a
29/// segment's deepest drawdown is `(peak − low) / peak`. The window is held as
30/// two halves: the older one is rebuilt from the newer one every `period`
31/// updates, storing for each value the peak and worst drawdown from it to the
32/// end of the half, and the newer one keeps its own running peaks with the low
33/// of each segment. Because the subtraction and the division both round
34/// monotonically, the result is bit for bit the one a rescan of the window
35/// gives.
36///
37/// # Example
38///
39/// ```
40/// use wickra_core::{Indicator, MaxDrawdown};
41///
42/// let mut mdd = MaxDrawdown::new(10).unwrap();
43/// // Equity peaks at 110 then drops to 88 — a 20% drawdown.
44/// for v in [100.0, 110.0, 100.0, 95.0, 88.0, 90.0, 92.0, 95.0, 100.0, 105.0] {
45///     mdd.update(v);
46/// }
47/// assert!((mdd.update(106.0).unwrap() - 0.20).abs() < 1e-9);
48/// ```
49#[derive(Debug, Clone)]
50pub struct MaxDrawdown {
51    period: usize,
52    /// Older half of the window, oldest value last so `pop` drops it.
53    front: Vec<FrontEntry>,
54    /// Newer half of the window, in arrival order.
55    back: Vec<f64>,
56    /// Running peaks of `back`, each with the low of its segment.
57    records: Vec<Record>,
58    /// First record above the front's peak; the records before it sit in the
59    /// segment of that peak.
60    cursor: usize,
61    /// Worst drawdown among the closed records from `cursor` on (the last
62    /// record's segment is still open).
63    closed_worst: f64,
64    /// Running peaks of the run walked so far while rebuilding `front`.
65    scratch: Vec<Segment>,
66    last: Option<f64>,
67}
68
69/// One value of the older half.
70#[derive(Debug, Clone, Copy)]
71struct FrontEntry {
72    /// Highest value from this one to the newest of the older half.
73    peak: f64,
74    /// Max drawdown of the run from this value to the newest of the older half.
75    worst: f64,
76}
77
78/// A running peak of the newer half.
79#[derive(Debug, Clone, Copy)]
80struct Record {
81    peak: f64,
82    /// Lowest value from the peak up to the next running peak.
83    low: f64,
84    /// Lowest value of the newer half up to the next running peak.
85    low_so_far: f64,
86}
87
88/// A running peak of the run walked while rebuilding the older half.
89#[derive(Debug, Clone, Copy)]
90struct Segment {
91    peak: f64,
92    low: f64,
93    /// Max drawdown of the run from this peak to the newest of the older half.
94    worst: f64,
95}
96
97/// Drawdown of `low` from `peak`; a non-positive peak counts as none.
98fn drawdown(peak: f64, low: f64) -> f64 {
99    if peak > 0.0 {
100        (peak - low) / peak
101    } else {
102        0.0
103    }
104}
105
106impl MaxDrawdown {
107    /// Construct a new rolling Max Drawdown.
108    ///
109    /// # Errors
110    /// Returns [`Error::PeriodZero`] if `period == 0`.
111    pub fn new(period: usize) -> Result<Self> {
112        if period == 0 {
113            return Err(Error::PeriodZero);
114        }
115        if period > crate::error::MAX_PERIOD {
116            return Err(Error::InvalidPeriod {
117                message: crate::error::PERIOD_ABOVE_MAX,
118            });
119        }
120        Ok(Self {
121            period,
122            front: Vec::new(),
123            back: Vec::new(),
124            records: Vec::new(),
125            cursor: 0,
126            closed_worst: 0.0,
127            scratch: Vec::new(),
128            last: None,
129        })
130    }
131
132    /// Configured rolling-window length.
133    pub const fn period(&self) -> usize {
134        self.period
135    }
136
137    /// Current value if available.
138    pub const fn value(&self) -> Option<f64> {
139        self.last
140    }
141
142    fn push_back(&mut self, value: f64) {
143        self.back.push(value);
144        if let Some(open) = self.records.last_mut().filter(|r| value <= r.peak) {
145            open.low = open.low.min(value);
146            open.low_so_far = open.low_so_far.min(value);
147            return;
148        }
149        let mut low_so_far = value;
150        if let Some(closed) = self.records.last() {
151            low_so_far = low_so_far.min(closed.low_so_far);
152            if self.cursor < self.records.len() {
153                self.closed_worst = self.closed_worst.max(drawdown(closed.peak, closed.low));
154            }
155        }
156        self.records.push(Record {
157            peak: value,
158            low: value,
159            low_so_far,
160        });
161    }
162
163    /// Move the newer half into the older one, walking it newest first.
164    fn refill_front(&mut self) {
165        self.scratch.clear();
166        let mut peak = f64::NEG_INFINITY;
167        for &value in self.back.iter().rev() {
168            // Running peaks no higher than `value` fall into its segment.
169            let mut low = value;
170            while let Some(seg) = self.scratch.last().copied().filter(|s| s.peak <= value) {
171                low = low.min(seg.low);
172                self.scratch.pop();
173            }
174            let worst = self
175                .scratch
176                .last()
177                .map_or(0.0, |s| s.worst)
178                .max(drawdown(value, low));
179            self.scratch.push(Segment {
180                peak: value,
181                low,
182                worst,
183            });
184            peak = peak.max(value);
185            self.front.push(FrontEntry { peak, worst });
186        }
187        self.back.clear();
188        self.records.clear();
189        self.cursor = 0;
190        self.closed_worst = 0.0;
191    }
192
193    fn worst(&mut self) -> f64 {
194        let (peak, front_worst) = self
195            .front
196            .last()
197            .map_or((f64::NEG_INFINITY, 0.0), |e| (e.peak, e.worst));
198        // The front's peak only falls between refills, so the cursor only
199        // moves left, except past a new open record the peak still covers.
200        while self.cursor < self.records.len() && self.records[self.cursor].peak <= peak {
201            self.cursor += 1;
202        }
203        while self.cursor > 0 && self.records[self.cursor - 1].peak > peak {
204            self.cursor -= 1;
205            if self.cursor + 1 < self.records.len() {
206                let r = self.records[self.cursor];
207                self.closed_worst = self.closed_worst.max(drawdown(r.peak, r.low));
208            }
209        }
210        let mut worst = front_worst.max(self.closed_worst);
211        if self.cursor < self.records.len() {
212            let open = self.records[self.records.len() - 1];
213            worst = worst.max(drawdown(open.peak, open.low));
214        }
215        if self.cursor > 0 {
216            // The values before the cursor still have the front's peak.
217            worst = worst.max(drawdown(peak, self.records[self.cursor - 1].low_so_far));
218        }
219        worst
220    }
221}
222
223impl Indicator for MaxDrawdown {
224    type Input = f64;
225    type Output = f64;
226
227    fn update(&mut self, input: f64) -> Option<f64> {
228        if !input.is_finite() {
229            return None;
230        }
231        self.push_back(input);
232        if self.front.len() + self.back.len() > self.period {
233            if self.front.is_empty() {
234                self.refill_front();
235            }
236            self.front.pop();
237        }
238        if self.front.len() + self.back.len() < self.period {
239            return None;
240        }
241        let worst = self.worst();
242        self.last = Some(worst);
243        Some(worst)
244    }
245
246    fn reset(&mut self) {
247        self.front.clear();
248        self.back.clear();
249        self.records.clear();
250        self.cursor = 0;
251        self.closed_worst = 0.0;
252        self.last = None;
253    }
254
255    #[inline]
256    fn warmup_period(&self) -> usize {
257        self.period
258    }
259
260    #[inline]
261    fn is_ready(&self) -> bool {
262        self.last.is_some()
263    }
264
265    #[inline]
266    fn name(&self) -> &'static str {
267        "MaxDrawdown"
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::traits::BatchExt;
275    use approx::assert_relative_eq;
276
277    /// The definition, rescanning every window.
278    fn rescan(prices: &[f64], period: usize) -> Vec<Option<f64>> {
279        (0..prices.len())
280            .map(|i| {
281                if i + 1 < period {
282                    return None;
283                }
284                let mut peak = f64::NEG_INFINITY;
285                let mut worst = 0.0_f64;
286                for &v in &prices[i + 1 - period..=i] {
287                    if v > peak {
288                        peak = v;
289                    }
290                    if peak > 0.0 {
291                        let dd = (peak - v) / peak;
292                        if dd > worst {
293                            worst = dd;
294                        }
295                    }
296                }
297                Some(worst)
298            })
299            .collect()
300    }
301
302    fn bits(out: &[Option<f64>]) -> Vec<Option<u64>> {
303        out.iter().map(|v| v.map(f64::to_bits)).collect()
304    }
305
306    #[test]
307    fn new_rejects_zero_period() {
308        assert!(matches!(MaxDrawdown::new(0), Err(Error::PeriodZero)));
309    }
310
311    #[test]
312    fn accessors_and_metadata() {
313        let mut mdd = MaxDrawdown::new(10).unwrap();
314        assert_eq!(mdd.period(), 10);
315        assert_eq!(mdd.name(), "MaxDrawdown");
316        assert_eq!(mdd.value(), None);
317        assert_eq!(mdd.warmup_period(), 10);
318        for v in 1..=10 {
319            mdd.update(f64::from(v));
320        }
321        assert!(mdd.value().is_some());
322    }
323
324    #[test]
325    fn pure_uptrend_yields_zero() {
326        let mut mdd = MaxDrawdown::new(5).unwrap();
327        let out = mdd.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
328        for v in out.into_iter().flatten() {
329            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
330        }
331    }
332
333    #[test]
334    fn reference_drawdown() {
335        // Window [100, 120, 90]: peak 120, trough 90 -> 25% drawdown.
336        let mut mdd = MaxDrawdown::new(3).unwrap();
337        let out = mdd.batch(&[100.0, 120.0, 90.0]);
338        assert_eq!(out[0], None);
339        assert_eq!(out[1], None);
340        assert_relative_eq!(out[2].unwrap(), 0.25, epsilon = 1e-12);
341    }
342
343    #[test]
344    fn constant_series_yields_zero() {
345        let mut mdd = MaxDrawdown::new(4).unwrap();
346        let out = mdd.batch(&[50.0; 12]);
347        for v in out.into_iter().flatten() {
348            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
349        }
350    }
351
352    #[test]
353    fn ignores_non_finite_input() {
354        let mut mdd = MaxDrawdown::new(3).unwrap();
355        mdd.batch(&[100.0, 90.0, 80.0]);
356        let last = mdd.value();
357        assert_eq!(mdd.update(f64::NAN), None);
358        assert_eq!(mdd.update(f64::INFINITY), None);
359        // The rejected input must not have disturbed the state.
360        assert_eq!(mdd.value(), last);
361    }
362
363    #[test]
364    fn reset_clears_state() {
365        let mut mdd = MaxDrawdown::new(3).unwrap();
366        mdd.batch(&[100.0, 90.0, 80.0]);
367        assert!(mdd.is_ready());
368        mdd.reset();
369        assert!(!mdd.is_ready());
370        assert_eq!(mdd.update(100.0), None);
371    }
372
373    #[test]
374    fn reset_then_replay_matches_fresh() {
375        let prices: Vec<f64> = (0..90)
376            .map(|i| 100.0 + (f64::from(i) * 0.7).sin() * 15.0)
377            .collect();
378        let mut mdd = MaxDrawdown::new(8).unwrap();
379        let first = mdd.batch(&prices);
380        mdd.reset();
381        assert_eq!(bits(&mdd.batch(&prices)), bits(&first));
382    }
383
384    #[test]
385    fn batch_equals_streaming() {
386        let prices: Vec<f64> = (0..60)
387            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
388            .collect();
389        let batch = MaxDrawdown::new(10).unwrap().batch(&prices);
390        let mut s = MaxDrawdown::new(10).unwrap();
391        let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
392        assert_eq!(batch, streamed);
393    }
394
395    #[test]
396    fn non_positive_peak_yields_zero() {
397        // All-zero stream: peak is 0, division skipped, result stays 0.
398        let mut mdd = MaxDrawdown::new(3).unwrap();
399        let out = mdd.batch(&[0.0_f64; 6]);
400        for v in out.into_iter().flatten() {
401            assert_eq!(v, 0.0);
402        }
403    }
404
405    #[test]
406    fn matches_rescan_on_long_series() {
407        // Swings, a long slide (one segment spanning many refills), a negative
408        // stretch and a flat run, at periods from 1 to longer than a swing.
409        let mut prices: Vec<f64> = (0..1500)
410            .map(|i| 100.0 + (f64::from(i) * 0.05).sin() * 40.0 + (f64::from(i) * 0.9).cos() * 3.0)
411            .collect();
412        prices.extend((0..400).map(|i| 500.0 - f64::from(i)));
413        prices.extend((0..200).map(|i| -20.0 + (f64::from(i) * 0.4).sin() * 25.0));
414        prices.extend([42.0; 50]);
415        for period in [1, 2, 3, 7, 64, 250, 701] {
416            let got = MaxDrawdown::new(period).unwrap().batch(&prices);
417            assert_eq!(
418                bits(&got),
419                bits(&rescan(&prices, period)),
420                "period {period}"
421            );
422        }
423    }
424
425    proptest::proptest! {
426        #![proptest_config(proptest::test_runner::Config::with_cases(256))]
427        #[test]
428        fn matches_rescan_bit_for_bit(
429            period in 1usize..24,
430            // Whole numbers make ties and repeated peaks common.
431            ticks in proptest::collection::vec(-30i32..90, 0..300),
432            scale in proptest::sample::select(vec![1.0_f64, 0.37, 1e-3, 1e6]),
433        ) {
434            let prices: Vec<f64> = ticks.iter().map(|&t| f64::from(t) * scale).collect();
435            let got = MaxDrawdown::new(period).unwrap().batch(&prices);
436            proptest::prop_assert_eq!(bits(&got), bits(&rescan(&prices, period)));
437        }
438    }
439
440    #[test]
441    fn rejects_period_above_maximum() {
442        assert!(matches!(
443            MaxDrawdown::new(crate::error::MAX_PERIOD + 1),
444            Err(Error::InvalidPeriod { .. })
445        ));
446    }
447}