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