Skip to main content

wickra_core/indicators/
median_absolute_deviation.rs

1//! Rolling Median Absolute Deviation (MAD), a robust dispersion estimator.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Median Absolute Deviation of the last `period` values.
9///
10/// ```text
11/// med   = median(window)
12/// MAD   = median( |x_i − med|  for x_i in window )
13/// ```
14///
15/// MAD is the median analogue of the standard deviation: it is a robust
16/// dispersion measure that ignores extreme outliers (a single huge spike
17/// barely moves the result) and is widely used as a sturdier alternative
18/// to `StdDev` for risk reporting on heavy-tailed return distributions.
19/// Multiplying MAD by `1.4826` produces a consistent estimator of the
20/// underlying Gaussian standard deviation (the "robust σ"); Wickra returns
21/// the raw MAD so the caller chooses whether to scale.
22///
23/// Each `update` is O(period log period): the window is kept as a deque
24/// and copied into a small scratch buffer that is sorted twice (once to
25/// pick the median, once to pick the median of absolute deviations). The
26/// rolling structure makes the constant factor low; for the typical
27/// period range (10–100) this is dwarfed by the streaming overhead.
28///
29/// # Example
30///
31/// ```
32/// use wickra_core::{Indicator, MedianAbsoluteDeviation};
33///
34/// let mut indicator = MedianAbsoluteDeviation::new(20).unwrap();
35/// let mut last = None;
36/// for i in 0..40 {
37///     last = indicator.update(100.0 + f64::from(i));
38/// }
39/// assert!(last.is_some());
40/// ```
41#[derive(Debug, Clone)]
42pub struct MedianAbsoluteDeviation {
43    period: usize,
44    window: VecDeque<f64>,
45    /// Reusable scratch buffer to avoid allocating per `update`.
46    scratch: Vec<f64>,
47}
48
49impl MedianAbsoluteDeviation {
50    /// Construct a new rolling MAD with the given period.
51    ///
52    /// # Errors
53    /// Returns [`Error::PeriodZero`] if `period == 0`.
54    pub fn new(period: usize) -> Result<Self> {
55        if period == 0 {
56            return Err(Error::PeriodZero);
57        }
58        if period > crate::error::MAX_PERIOD {
59            return Err(Error::InvalidPeriod {
60                message: crate::error::PERIOD_ABOVE_MAX,
61            });
62        }
63        Ok(Self {
64            period,
65            window: VecDeque::with_capacity(period),
66            scratch: Vec::with_capacity(period),
67        })
68    }
69
70    /// Configured period.
71    pub const fn period(&self) -> usize {
72        self.period
73    }
74}
75
76/// Sort a slice of `f64` in-place using total ordering (NaN-safe).
77fn sort_finite(buf: &mut [f64]) {
78    buf.sort_by(f64::total_cmp);
79}
80
81/// Median of a sorted, non-empty slice.
82fn median_sorted(sorted: &[f64]) -> f64 {
83    let n = sorted.len();
84    let mid = n / 2;
85    if n % 2 == 0 {
86        f64::midpoint(sorted[mid - 1], sorted[mid])
87    } else {
88        sorted[mid]
89    }
90}
91
92impl Indicator for MedianAbsoluteDeviation {
93    type Input = f64;
94    type Output = f64;
95
96    #[inline]
97    fn update(&mut self, value: f64) -> Option<f64> {
98        if !value.is_finite() {
99            return None;
100        }
101        if self.window.len() == self.period {
102            self.window.pop_front();
103        }
104        self.window.push_back(value);
105        if self.window.len() < self.period {
106            return None;
107        }
108        // Copy into scratch and sort to find the window median.
109        self.scratch.clear();
110        self.scratch.extend(self.window.iter().copied());
111        sort_finite(&mut self.scratch);
112        let med = median_sorted(&self.scratch);
113        // Replace with absolute deviations and sort again.
114        for x in &mut self.scratch {
115            *x = (*x - med).abs();
116        }
117        sort_finite(&mut self.scratch);
118        Some(median_sorted(&self.scratch))
119    }
120
121    fn reset(&mut self) {
122        self.window.clear();
123        self.scratch.clear();
124    }
125
126    #[inline]
127    fn warmup_period(&self) -> usize {
128        self.period
129    }
130
131    #[inline]
132    fn is_ready(&self) -> bool {
133        self.window.len() == self.period
134    }
135
136    #[inline]
137    fn name(&self) -> &'static str {
138        "MedianAbsoluteDeviation"
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::traits::BatchExt;
146    use approx::assert_relative_eq;
147
148    #[test]
149    fn rejects_zero_period() {
150        assert!(matches!(
151            MedianAbsoluteDeviation::new(0),
152            Err(Error::PeriodZero)
153        ));
154    }
155
156    #[test]
157    fn accessors_and_metadata() {
158        let m = MedianAbsoluteDeviation::new(14).unwrap();
159        assert_eq!(m.period(), 14);
160        assert_eq!(m.warmup_period(), 14);
161        assert_eq!(m.name(), "MedianAbsoluteDeviation");
162    }
163
164    #[test]
165    fn reference_value() {
166        // [1, 1, 2, 2, 4, 6, 9]: median = 2, deviations [1,1,0,0,2,4,7],
167        // sorted [0,0,1,1,2,4,7] → median = 1.
168        let mut m = MedianAbsoluteDeviation::new(7).unwrap();
169        let out = m.batch(&[1.0, 1.0, 2.0, 2.0, 4.0, 6.0, 9.0]);
170        assert_relative_eq!(out[6].unwrap(), 1.0, epsilon = 1e-12);
171    }
172
173    #[test]
174    fn constant_series_yields_zero() {
175        let mut m = MedianAbsoluteDeviation::new(5).unwrap();
176        for v in m.batch(&[42.0; 20]).into_iter().flatten() {
177            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
178        }
179    }
180
181    #[test]
182    fn ignores_single_extreme_outlier() {
183        // A window of 9 equal values plus 1 huge outlier still has MAD = 0,
184        // because more than half the window agrees on the median and the
185        // deviations majority are zero.
186        let mut m = MedianAbsoluteDeviation::new(10).unwrap();
187        let mut prices = vec![5.0; 9];
188        prices.push(1_000.0);
189        let last = m.batch(&prices).into_iter().flatten().last().unwrap();
190        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
191    }
192
193    #[test]
194    fn reset_clears_state() {
195        let mut m = MedianAbsoluteDeviation::new(5).unwrap();
196        m.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
197        assert!(m.is_ready());
198        m.reset();
199        assert!(!m.is_ready());
200        assert_eq!(m.update(1.0), None);
201    }
202
203    #[test]
204    fn batch_equals_streaming() {
205        let prices: Vec<f64> = (0..60)
206            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
207            .collect();
208        let batch = MedianAbsoluteDeviation::new(14).unwrap().batch(&prices);
209        let mut b = MedianAbsoluteDeviation::new(14).unwrap();
210        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
211        assert_eq!(batch, streamed);
212    }
213}