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. The indicator scans the
11/// trailing window of `period` values, tracks the running peak inside the
12/// window, and reports the **mean** of all bar-by-bar drawdowns (the average
13/// "pain" of being under water):
14///
15/// ```text
16/// drawdown_t = (peak_t − equity_t) / peak_t  (running peak inside window)
17/// AvgDD      = mean(drawdown_t over window)
18/// ```
19///
20/// Output is non-negative (a fraction; `0.05` ≈ 5 % average drawdown). This
21/// is the **Pain Index** under a different name — see [`crate::PainIndex`]
22/// for the same metric exposed under its conventional label.
23///
24/// Each `update` is O(period).
25#[derive(Debug, Clone)]
26pub struct AverageDrawdown {
27    period: usize,
28    window: VecDeque<f64>,
29}
30
31impl AverageDrawdown {
32    /// Construct a new rolling Average Drawdown.
33    ///
34    /// # Errors
35    /// Returns [`Error::PeriodZero`] if `period == 0`.
36    pub fn new(period: usize) -> Result<Self> {
37        if period == 0 {
38            return Err(Error::PeriodZero);
39        }
40        Ok(Self {
41            period,
42            window: VecDeque::with_capacity(period),
43        })
44    }
45
46    /// Configured window length.
47    pub const fn period(&self) -> usize {
48        self.period
49    }
50}
51
52impl Indicator for AverageDrawdown {
53    type Input = f64;
54    type Output = f64;
55
56    fn update(&mut self, input: f64) -> Option<f64> {
57        if !input.is_finite() {
58            return None;
59        }
60        if self.window.len() == self.period {
61            self.window.pop_front();
62        }
63        self.window.push_back(input);
64        if self.window.len() < self.period {
65            return None;
66        }
67        let mut peak = f64::NEG_INFINITY;
68        let mut sum_dd = 0.0_f64;
69        for &v in &self.window {
70            if v > peak {
71                peak = v;
72            }
73            if peak > 0.0 {
74                sum_dd += (peak - v) / peak;
75            }
76        }
77        Some(sum_dd / self.period as f64)
78    }
79
80    fn reset(&mut self) {
81        self.window.clear();
82    }
83
84    fn warmup_period(&self) -> usize {
85        self.period
86    }
87
88    fn is_ready(&self) -> bool {
89        self.window.len() == self.period
90    }
91
92    fn name(&self) -> &'static str {
93        "AverageDrawdown"
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::traits::BatchExt;
101    use approx::assert_relative_eq;
102
103    #[test]
104    fn rejects_zero_period() {
105        assert!(matches!(AverageDrawdown::new(0), Err(Error::PeriodZero)));
106    }
107
108    #[test]
109    fn accessors_and_metadata() {
110        let a = AverageDrawdown::new(10).unwrap();
111        assert_eq!(a.period(), 10);
112        assert_eq!(a.name(), "AverageDrawdown");
113        assert_eq!(a.warmup_period(), 10);
114    }
115
116    #[test]
117    fn pure_uptrend_yields_zero() {
118        let mut a = AverageDrawdown::new(5).unwrap();
119        let out = a.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
120        for v in out.into_iter().flatten() {
121            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
122        }
123    }
124
125    #[test]
126    fn reference_value() {
127        // window [100, 120, 90, 110]:
128        // peaks: 100, 120, 120, 120; dd: 0, 0, (30/120)=.25, (10/120)=.0833...
129        // avg = (.25 + .0833...) / 4 = .0833...
130        let mut a = AverageDrawdown::new(4).unwrap();
131        let out = a.batch(&[100.0, 120.0, 90.0, 110.0]);
132        let expected = (0.25 + (10.0 / 120.0)) / 4.0;
133        assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-12);
134    }
135
136    #[test]
137    fn ignores_non_finite_input() {
138        let mut a = AverageDrawdown::new(3).unwrap();
139        assert_eq!(a.update(f64::NAN), None);
140        assert_eq!(a.update(f64::INFINITY), None);
141    }
142
143    #[test]
144    fn reset_clears_state() {
145        let mut a = AverageDrawdown::new(3).unwrap();
146        a.batch(&[100.0, 90.0, 110.0]);
147        assert!(a.is_ready());
148        a.reset();
149        assert!(!a.is_ready());
150        assert_eq!(a.update(100.0), None);
151    }
152
153    #[test]
154    fn batch_equals_streaming() {
155        let prices: Vec<f64> = (0..40)
156            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
157            .collect();
158        let batch = AverageDrawdown::new(10).unwrap().batch(&prices);
159        let mut s = AverageDrawdown::new(10).unwrap();
160        let streamed: Vec<_> = prices.iter().map(|p| s.update(*p)).collect();
161        assert_eq!(batch, streamed);
162    }
163
164    #[test]
165    fn non_positive_peak_yields_zero() {
166        let mut a = AverageDrawdown::new(3).unwrap();
167        let out = a.batch(&[0.0_f64; 6]);
168        for v in out.into_iter().flatten() {
169            assert_eq!(v, 0.0);
170        }
171    }
172}