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        Ok(Self {
59            period,
60            window: VecDeque::with_capacity(period),
61            scratch: Vec::with_capacity(period),
62        })
63    }
64
65    /// Configured period.
66    pub const fn period(&self) -> usize {
67        self.period
68    }
69}
70
71/// Sort a slice of `f64` in-place using total ordering (NaN-safe).
72fn sort_finite(buf: &mut [f64]) {
73    buf.sort_by(f64::total_cmp);
74}
75
76/// Median of a sorted, non-empty slice.
77fn median_sorted(sorted: &[f64]) -> f64 {
78    let n = sorted.len();
79    let mid = n / 2;
80    if n % 2 == 0 {
81        (sorted[mid - 1] + sorted[mid]) * 0.5
82    } else {
83        sorted[mid]
84    }
85}
86
87impl Indicator for MedianAbsoluteDeviation {
88    type Input = f64;
89    type Output = f64;
90
91    fn update(&mut self, value: f64) -> Option<f64> {
92        if self.window.len() == self.period {
93            self.window.pop_front();
94        }
95        self.window.push_back(value);
96        if self.window.len() < self.period {
97            return None;
98        }
99        // Copy into scratch and sort to find the window median.
100        self.scratch.clear();
101        self.scratch.extend(self.window.iter().copied());
102        sort_finite(&mut self.scratch);
103        let med = median_sorted(&self.scratch);
104        // Replace with absolute deviations and sort again.
105        for x in &mut self.scratch {
106            *x = (*x - med).abs();
107        }
108        sort_finite(&mut self.scratch);
109        Some(median_sorted(&self.scratch))
110    }
111
112    fn reset(&mut self) {
113        self.window.clear();
114        self.scratch.clear();
115    }
116
117    fn warmup_period(&self) -> usize {
118        self.period
119    }
120
121    fn is_ready(&self) -> bool {
122        self.window.len() == self.period
123    }
124
125    fn name(&self) -> &'static str {
126        "MedianAbsoluteDeviation"
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::traits::BatchExt;
134    use approx::assert_relative_eq;
135
136    #[test]
137    fn rejects_zero_period() {
138        assert!(matches!(
139            MedianAbsoluteDeviation::new(0),
140            Err(Error::PeriodZero)
141        ));
142    }
143
144    #[test]
145    fn accessors_and_metadata() {
146        let m = MedianAbsoluteDeviation::new(14).unwrap();
147        assert_eq!(m.period(), 14);
148        assert_eq!(m.warmup_period(), 14);
149        assert_eq!(m.name(), "MedianAbsoluteDeviation");
150    }
151
152    #[test]
153    fn reference_value() {
154        // [1, 1, 2, 2, 4, 6, 9]: median = 2, deviations [1,1,0,0,2,4,7],
155        // sorted [0,0,1,1,2,4,7] → median = 1.
156        let mut m = MedianAbsoluteDeviation::new(7).unwrap();
157        let out = m.batch(&[1.0, 1.0, 2.0, 2.0, 4.0, 6.0, 9.0]);
158        assert_relative_eq!(out[6].unwrap(), 1.0, epsilon = 1e-12);
159    }
160
161    #[test]
162    fn constant_series_yields_zero() {
163        let mut m = MedianAbsoluteDeviation::new(5).unwrap();
164        for v in m.batch(&[42.0; 20]).into_iter().flatten() {
165            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
166        }
167    }
168
169    #[test]
170    fn ignores_single_extreme_outlier() {
171        // A window of 9 equal values plus 1 huge outlier still has MAD = 0,
172        // because more than half the window agrees on the median and the
173        // deviations majority are zero.
174        let mut m = MedianAbsoluteDeviation::new(10).unwrap();
175        let mut prices = vec![5.0; 9];
176        prices.push(1_000.0);
177        let last = m.batch(&prices).into_iter().flatten().last().unwrap();
178        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
179    }
180
181    #[test]
182    fn reset_clears_state() {
183        let mut m = MedianAbsoluteDeviation::new(5).unwrap();
184        m.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
185        assert!(m.is_ready());
186        m.reset();
187        assert!(!m.is_ready());
188        assert_eq!(m.update(1.0), None);
189    }
190
191    #[test]
192    fn batch_equals_streaming() {
193        let prices: Vec<f64> = (0..60)
194            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
195            .collect();
196        let batch = MedianAbsoluteDeviation::new(14).unwrap().batch(&prices);
197        let mut b = MedianAbsoluteDeviation::new(14).unwrap();
198        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
199        assert_eq!(batch, streamed);
200    }
201}