Skip to main content

wickra_core/indicators/
average_drawdown.rs

1//! Rolling Average Drawdown.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling Average Drawdown.
9///
10/// Input is treated as an equity-curve sample. Over the trailing window of
11/// `period` values the indicator identifies each **distinct drawdown episode**
12/// — a stretch where equity is below the running peak — and reports the **mean
13/// of the episodes' maximum depths**:
14///
15/// ```text
16/// episode opens when equity < running peak
17/// episode closes when equity reaches a new peak (full recovery)
18/// depth(episode) = (episode_peak − episode_trough) / episode_peak
19/// AvgDD          = mean(depth over episodes in window)   (0 if no drawdown)
20/// ```
21///
22/// This is the conventional "average drawdown" (mean depth across separate
23/// drawdowns), which is distinct from the [`crate::PainIndex`] — the latter
24/// averages the under-water fraction at *every* bar, so a long shallow
25/// drawdown weighs more there than here. Output is a non-negative fraction
26/// (`0.05` ≈ 5 % mean episode depth).
27///
28/// Each `update` is O(period).
29#[derive(Debug, Clone)]
30pub struct AverageDrawdown {
31    period: usize,
32    window: VecDeque<f64>,
33}
34
35impl AverageDrawdown {
36    /// Construct a new rolling Average Drawdown.
37    ///
38    /// # Errors
39    /// Returns [`Error::PeriodZero`] if `period == 0`.
40    pub fn new(period: usize) -> Result<Self> {
41        if period == 0 {
42            return Err(Error::PeriodZero);
43        }
44        if period > crate::error::MAX_PERIOD {
45            return Err(Error::InvalidPeriod {
46                message: crate::error::PERIOD_ABOVE_MAX,
47            });
48        }
49        Ok(Self {
50            period,
51            window: VecDeque::with_capacity(period),
52        })
53    }
54
55    /// Configured window length.
56    pub const fn period(&self) -> usize {
57        self.period
58    }
59}
60
61impl Indicator for AverageDrawdown {
62    type Input = f64;
63    type Output = f64;
64
65    fn update(&mut self, input: f64) -> Option<f64> {
66        if !input.is_finite() {
67            return None;
68        }
69        if self.window.len() == self.period {
70            self.window.pop_front();
71        }
72        self.window.push_back(input);
73        if self.window.len() < self.period {
74            return None;
75        }
76        let mut peak = f64::NEG_INFINITY;
77        let mut sum_depth = 0.0_f64;
78        let mut episodes = 0_u32;
79        let mut in_dd = false;
80        let mut episode_peak = 0.0_f64;
81        let mut episode_trough = 0.0_f64;
82        for &v in &self.window {
83            if v >= peak {
84                if in_dd {
85                    if episode_peak > 0.0 {
86                        sum_depth += (episode_peak - episode_trough) / episode_peak;
87                        episodes += 1;
88                    }
89                    in_dd = false;
90                }
91                peak = v;
92            } else if in_dd {
93                if v < episode_trough {
94                    episode_trough = v;
95                }
96            } else {
97                in_dd = true;
98                episode_peak = peak;
99                episode_trough = v;
100            }
101        }
102        if in_dd && episode_peak > 0.0 {
103            sum_depth += (episode_peak - episode_trough) / episode_peak;
104            episodes += 1;
105        }
106        Some(if episodes == 0 {
107            0.0
108        } else {
109            sum_depth / f64::from(episodes)
110        })
111    }
112
113    fn reset(&mut self) {
114        self.window.clear();
115    }
116
117    #[inline]
118    fn warmup_period(&self) -> usize {
119        self.period
120    }
121
122    #[inline]
123    fn is_ready(&self) -> bool {
124        self.window.len() == self.period
125    }
126
127    #[inline]
128    fn name(&self) -> &'static str {
129        "AverageDrawdown"
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::traits::BatchExt;
137    use approx::assert_relative_eq;
138
139    #[test]
140    fn rejects_zero_period() {
141        assert!(matches!(AverageDrawdown::new(0), Err(Error::PeriodZero)));
142    }
143
144    #[test]
145    fn accessors_and_metadata() {
146        let a = AverageDrawdown::new(10).unwrap();
147        assert_eq!(a.period(), 10);
148        assert_eq!(a.name(), "AverageDrawdown");
149        assert_eq!(a.warmup_period(), 10);
150    }
151
152    #[test]
153    fn pure_uptrend_yields_zero() {
154        let mut a = AverageDrawdown::new(5).unwrap();
155        let out = a.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
156        for v in out.into_iter().flatten() {
157            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
158        }
159    }
160
161    #[test]
162    fn reference_value() {
163        // window [100, 120, 90, 110]: one drawdown episode, opened at 90 (peak
164        // 120) and never recovering to 120 within the window. Its depth is
165        // (120 - 90) / 120 = 0.25; 110 stays inside the same episode and does
166        // not deepen the trough. One episode -> AvgDD = 0.25.
167        let mut a = AverageDrawdown::new(4).unwrap();
168        let out = a.batch(&[100.0, 120.0, 90.0, 110.0]);
169        assert_relative_eq!(out[3].unwrap(), 0.25, epsilon = 1e-12);
170    }
171
172    #[test]
173    fn averages_distinct_episodes() {
174        // [100, 90, 100, 80, 100]: episode 1 troughs at 90 then recovers to 100
175        // -> depth 0.10; episode 2 troughs at 80 then recovers -> depth 0.20.
176        // Mean of the two episode depths = 0.15 (distinct from the Pain Index,
177        // which would weight every under-water bar instead).
178        let mut a = AverageDrawdown::new(5).unwrap();
179        let out = a.batch(&[100.0, 90.0, 100.0, 80.0, 100.0]);
180        assert_relative_eq!(out[4].unwrap(), 0.15, epsilon = 1e-12);
181    }
182
183    #[test]
184    fn ignores_non_finite_input() {
185        let mut a = AverageDrawdown::new(3).unwrap();
186        assert_eq!(a.update(f64::NAN), None);
187        assert_eq!(a.update(f64::INFINITY), None);
188    }
189
190    #[test]
191    fn reset_clears_state() {
192        let mut a = AverageDrawdown::new(3).unwrap();
193        a.batch(&[100.0, 90.0, 110.0]);
194        assert!(a.is_ready());
195        a.reset();
196        assert!(!a.is_ready());
197        assert_eq!(a.update(100.0), None);
198    }
199
200    #[test]
201    fn batch_equals_streaming() {
202        let prices: Vec<f64> = (0..40)
203            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
204            .collect();
205        let batch = AverageDrawdown::new(10).unwrap().batch(&prices);
206        let mut s = AverageDrawdown::new(10).unwrap();
207        let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
208        assert_eq!(batch, streamed);
209    }
210
211    #[test]
212    fn non_positive_peak_yields_zero() {
213        let mut a = AverageDrawdown::new(3).unwrap();
214        let out = a.batch(&[0.0_f64; 6]);
215        for v in out.into_iter().flatten() {
216            assert_eq!(v, 0.0);
217        }
218    }
219}