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 !value.is_finite() {
93            return None;
94        }
95        if self.window.len() == self.period {
96            self.window.pop_front();
97        }
98        self.window.push_back(value);
99        if self.window.len() < self.period {
100            return None;
101        }
102        // Copy into scratch and sort to find the window median.
103        self.scratch.clear();
104        self.scratch.extend(self.window.iter().copied());
105        sort_finite(&mut self.scratch);
106        let med = median_sorted(&self.scratch);
107        // Replace with absolute deviations and sort again.
108        for x in &mut self.scratch {
109            *x = (*x - med).abs();
110        }
111        sort_finite(&mut self.scratch);
112        Some(median_sorted(&self.scratch))
113    }
114
115    fn reset(&mut self) {
116        self.window.clear();
117        self.scratch.clear();
118    }
119
120    fn warmup_period(&self) -> usize {
121        self.period
122    }
123
124    fn is_ready(&self) -> bool {
125        self.window.len() == self.period
126    }
127
128    fn name(&self) -> &'static str {
129        "MedianAbsoluteDeviation"
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!(
142            MedianAbsoluteDeviation::new(0),
143            Err(Error::PeriodZero)
144        ));
145    }
146
147    #[test]
148    fn accessors_and_metadata() {
149        let m = MedianAbsoluteDeviation::new(14).unwrap();
150        assert_eq!(m.period(), 14);
151        assert_eq!(m.warmup_period(), 14);
152        assert_eq!(m.name(), "MedianAbsoluteDeviation");
153    }
154
155    #[test]
156    fn reference_value() {
157        // [1, 1, 2, 2, 4, 6, 9]: median = 2, deviations [1,1,0,0,2,4,7],
158        // sorted [0,0,1,1,2,4,7] → median = 1.
159        let mut m = MedianAbsoluteDeviation::new(7).unwrap();
160        let out = m.batch(&[1.0, 1.0, 2.0, 2.0, 4.0, 6.0, 9.0]);
161        assert_relative_eq!(out[6].unwrap(), 1.0, epsilon = 1e-12);
162    }
163
164    #[test]
165    fn constant_series_yields_zero() {
166        let mut m = MedianAbsoluteDeviation::new(5).unwrap();
167        for v in m.batch(&[42.0; 20]).into_iter().flatten() {
168            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
169        }
170    }
171
172    #[test]
173    fn ignores_single_extreme_outlier() {
174        // A window of 9 equal values plus 1 huge outlier still has MAD = 0,
175        // because more than half the window agrees on the median and the
176        // deviations majority are zero.
177        let mut m = MedianAbsoluteDeviation::new(10).unwrap();
178        let mut prices = vec![5.0; 9];
179        prices.push(1_000.0);
180        let last = m.batch(&prices).into_iter().flatten().last().unwrap();
181        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
182    }
183
184    #[test]
185    fn reset_clears_state() {
186        let mut m = MedianAbsoluteDeviation::new(5).unwrap();
187        m.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
188        assert!(m.is_ready());
189        m.reset();
190        assert!(!m.is_ready());
191        assert_eq!(m.update(1.0), None);
192    }
193
194    #[test]
195    fn batch_equals_streaming() {
196        let prices: Vec<f64> = (0..60)
197            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
198            .collect();
199        let batch = MedianAbsoluteDeviation::new(14).unwrap().batch(&prices);
200        let mut b = MedianAbsoluteDeviation::new(14).unwrap();
201        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
202        assert_eq!(batch, streamed);
203    }
204}