Skip to main content

wickra_core/indicators/
running_drawdown.rs

1//! Running Drawdown — since-inception maximum drawdown over an unbounded stream.
2
3use crate::traits::Indicator;
4
5/// Since-inception maximum drawdown, with the current drawdown and running peak.
6///
7/// The input is treated as an equity-curve sample (or any non-negative value
8/// series). Unlike [`MaxDrawdown`](crate::MaxDrawdown), which looks at a
9/// trailing `period`-bar window, this indicator never forgets: it folds every
10/// value since construction (or the last `reset`) into three numbers and keeps
11/// them in O(1) memory:
12///
13/// ```text
14/// peak_t        = max(input over [0..=t])
15/// current_t     = (peak_t − input_t) / peak_t     (0 while peak_t ≤ 0)
16/// max_t         = max(current over [0..=t])
17/// ```
18///
19/// `update` returns the maximum drawdown so far as a non-negative fraction
20/// (`0.20` = 20 % drop from peak), the same convention as
21/// [`MaxDrawdown`](crate::MaxDrawdown). A monotonically rising equity curve
22/// has a maximum drawdown of `0`. The result equals `MaxDrawdown` with a
23/// `period` at least as long as the stream.
24///
25/// There are no parameters and no warmup: a value is emitted from the first
26/// finite input on, and each `update` is O(1).
27///
28/// # Example
29///
30/// ```
31/// use wickra_core::{Indicator, RunningDrawdown};
32///
33/// let mut dd = RunningDrawdown::new();
34/// assert_eq!(dd.update(100.0), Some(0.0));
35/// // Equity peaks at 110 then drops to 88 — a 20% drawdown.
36/// assert_eq!(dd.update(110.0), Some(0.0));
37/// assert!((dd.update(88.0).unwrap() - 0.20).abs() < 1e-9);
38/// // Recovering shrinks the current drawdown but not the maximum.
39/// dd.update(99.0);
40/// assert!((dd.max_drawdown().unwrap() - 0.20).abs() < 1e-9);
41/// assert!((dd.current_drawdown().unwrap() - 0.10).abs() < 1e-9);
42/// assert_eq!(dd.peak(), Some(110.0));
43/// ```
44#[derive(Debug, Clone)]
45pub struct RunningDrawdown {
46    peak: f64,
47    current: f64,
48    max: f64,
49    seen: bool,
50}
51
52impl RunningDrawdown {
53    /// Construct a new Running Drawdown.
54    pub const fn new() -> Self {
55        Self {
56            peak: f64::NEG_INFINITY,
57            current: 0.0,
58            max: 0.0,
59            seen: false,
60        }
61    }
62
63    /// Maximum drawdown since the first value, as a non-negative fraction.
64    pub const fn max_drawdown(&self) -> Option<f64> {
65        if self.seen {
66            Some(self.max)
67        } else {
68            None
69        }
70    }
71
72    /// Drawdown of the latest value from the running peak, as a non-negative
73    /// fraction (`0` at a new peak).
74    pub const fn current_drawdown(&self) -> Option<f64> {
75        if self.seen {
76            Some(self.current)
77        } else {
78            None
79        }
80    }
81
82    /// Highest value seen since the first value.
83    pub const fn peak(&self) -> Option<f64> {
84        if self.seen {
85            Some(self.peak)
86        } else {
87            None
88        }
89    }
90}
91
92impl Default for RunningDrawdown {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98impl Indicator for RunningDrawdown {
99    type Input = f64;
100    type Output = f64;
101
102    #[inline]
103    fn update(&mut self, input: f64) -> Option<f64> {
104        if !input.is_finite() {
105            return None;
106        }
107        if input > self.peak {
108            self.peak = input;
109        }
110        // Same guard as `MaxDrawdown`: a non-positive peak has no fractional
111        // drawdown, so it stays at 0.
112        self.current = if self.peak > 0.0 {
113            (self.peak - input) / self.peak
114        } else {
115            0.0
116        };
117        if self.current > self.max {
118            self.max = self.current;
119        }
120        self.seen = true;
121        Some(self.max)
122    }
123
124    fn reset(&mut self) {
125        *self = Self::new();
126    }
127
128    #[inline]
129    fn warmup_period(&self) -> usize {
130        1
131    }
132
133    #[inline]
134    fn is_ready(&self) -> bool {
135        self.seen
136    }
137
138    #[inline]
139    fn name(&self) -> &'static str {
140        "RunningDrawdown"
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::indicators::MaxDrawdown;
148    use crate::traits::BatchExt;
149    use approx::assert_relative_eq;
150
151    #[test]
152    fn accessors_and_metadata() {
153        let mut dd = RunningDrawdown::new();
154        assert_eq!(dd.name(), "RunningDrawdown");
155        assert_eq!(dd.warmup_period(), 1);
156        assert_eq!(dd.max_drawdown(), None);
157        assert_eq!(dd.current_drawdown(), None);
158        assert_eq!(dd.peak(), None);
159        dd.update(100.0);
160        assert_eq!(dd.max_drawdown(), Some(0.0));
161        assert_eq!(dd.current_drawdown(), Some(0.0));
162        assert_eq!(dd.peak(), Some(100.0));
163    }
164
165    #[test]
166    fn default_matches_new() {
167        let mut a = RunningDrawdown::default();
168        let mut b = RunningDrawdown::new();
169        assert_eq!(a.warmup_period(), b.warmup_period());
170        assert_eq!(a.is_ready(), b.is_ready());
171        assert_eq!(a.peak(), b.peak());
172        assert_eq!(a.update(100.0), b.update(100.0));
173        assert_eq!(a.update(80.0), b.update(80.0));
174    }
175
176    #[test]
177    fn first_value_emits_immediately() {
178        let mut dd = RunningDrawdown::new();
179        assert_eq!(dd.update(100.0), Some(0.0));
180    }
181
182    #[test]
183    fn reference_drawdown() {
184        // Peak 120, trough 90 -> 25 %; recovery to 110 leaves the max at 25 %
185        // and the current drawdown at (120 - 110) / 120.
186        let mut dd = RunningDrawdown::new();
187        let out = dd.batch(&[100.0, 120.0, 90.0, 110.0]);
188        assert_eq!(out[0], Some(0.0));
189        assert_eq!(out[1], Some(0.0));
190        assert_relative_eq!(out[2].unwrap(), 0.25, epsilon = 1e-12);
191        assert_relative_eq!(out[3].unwrap(), 0.25, epsilon = 1e-12);
192        assert_relative_eq!(
193            dd.current_drawdown().unwrap(),
194            10.0 / 120.0,
195            epsilon = 1e-12
196        );
197        assert_eq!(dd.peak(), Some(120.0));
198    }
199
200    #[test]
201    fn remembers_a_drawdown_older_than_any_window() {
202        let mut dd = RunningDrawdown::new();
203        dd.batch(&[100.0, 50.0]);
204        for v in 0..1000 {
205            dd.update(100.0 + f64::from(v));
206        }
207        assert_relative_eq!(dd.max_drawdown().unwrap(), 0.5, epsilon = 1e-12);
208        assert_relative_eq!(dd.current_drawdown().unwrap(), 0.0, epsilon = 1e-12);
209    }
210
211    #[test]
212    fn pure_uptrend_yields_zero() {
213        let mut dd = RunningDrawdown::new();
214        let out = dd.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
215        for v in out.into_iter().flatten() {
216            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
217        }
218    }
219
220    #[test]
221    fn non_positive_peak_yields_zero() {
222        // Peak never exceeds 0, so the fractional drawdown is undefined and
223        // stays 0 -- the same as `MaxDrawdown`.
224        let mut dd = RunningDrawdown::new();
225        let out = dd.batch(&[-1.0, -5.0, -3.0, 0.0, -2.0]);
226        for v in out.into_iter().flatten() {
227            assert_eq!(v, 0.0);
228        }
229        assert_eq!(dd.current_drawdown(), Some(0.0));
230    }
231
232    #[test]
233    fn ignores_non_finite_input() {
234        let mut dd = RunningDrawdown::new();
235        dd.batch(&[100.0, 90.0, 80.0]);
236        let (max, cur, peak) = (dd.max_drawdown(), dd.current_drawdown(), dd.peak());
237        assert_eq!(dd.update(f64::NAN), None);
238        assert_eq!(dd.update(f64::INFINITY), None);
239        assert_eq!(dd.update(f64::NEG_INFINITY), None);
240        // The rejected input must not have disturbed the state.
241        assert_eq!(dd.max_drawdown(), max);
242        assert_eq!(dd.current_drawdown(), cur);
243        assert_eq!(dd.peak(), peak);
244    }
245
246    #[test]
247    fn non_finite_first_input_leaves_it_unseeded() {
248        let mut dd = RunningDrawdown::new();
249        assert_eq!(dd.update(f64::NAN), None);
250        assert!(!dd.is_ready());
251        assert_eq!(dd.peak(), None);
252    }
253
254    #[test]
255    fn reset_clears_state() {
256        let mut dd = RunningDrawdown::new();
257        dd.batch(&[100.0, 90.0, 80.0]);
258        assert!(dd.is_ready());
259        dd.reset();
260        assert!(!dd.is_ready());
261        assert_eq!(dd.max_drawdown(), None);
262        assert_eq!(dd.peak(), None);
263        assert_eq!(dd.update(100.0), Some(0.0));
264    }
265
266    #[test]
267    fn batch_equals_streaming() {
268        let prices: Vec<f64> = (0..60)
269            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
270            .collect();
271        let batch = RunningDrawdown::new().batch(&prices);
272        let mut s = RunningDrawdown::new();
273        let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
274        assert_eq!(batch, streamed);
275    }
276
277    #[test]
278    fn matches_max_drawdown_with_a_period_covering_the_stream() {
279        // Includes a non-positive stretch and a recovery, so the guard and the
280        // peak tracking are both exercised against the windowed indicator.
281        let prices: Vec<f64> = (0..200)
282            .map(|i| 50.0 + (f64::from(i) * 0.17).sin() * 60.0 + f64::from(i) * 0.1)
283            .collect();
284        let period = prices.len();
285        let mut windowed = MaxDrawdown::new(period).unwrap();
286        let mut running = RunningDrawdown::new();
287        for (i, &p) in prices.iter().enumerate() {
288            let w = windowed.update(p);
289            let r = running.update(p);
290            // `MaxDrawdown` is silent until its window fills; compare once it
291            // has a value, and on the last bar always.
292            if let Some(w) = w {
293                assert_relative_eq!(r.unwrap(), w, epsilon = 1e-12);
294            } else {
295                assert!(i + 1 < period);
296            }
297        }
298        assert!(windowed.value().is_some());
299    }
300
301    #[test]
302    fn matches_max_drawdown_at_every_bar_via_growing_periods() {
303        // For each prefix length n, MaxDrawdown(n) over exactly that prefix is
304        // the since-inception value at bar n.
305        let prices: Vec<f64> = (0..40)
306            .map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 25.0)
307            .collect();
308        let mut running = RunningDrawdown::new();
309        for (i, &p) in prices.iter().enumerate() {
310            let r = running.update(p).unwrap();
311            let w = MaxDrawdown::new(i + 1)
312                .unwrap()
313                .batch(&prices[..=i])
314                .pop()
315                .flatten()
316                .unwrap();
317            assert_relative_eq!(r, w, epsilon = 1e-12);
318        }
319    }
320}