Skip to main content

wickra_core/indicators/
median_ma.rs

1//! Median Moving Average.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Median Moving Average — the rolling median of the last `period` inputs.
9///
10/// For an odd `period` the output is the middle order statistic of the window;
11/// for an even `period` it is the average of the two central values. Because it
12/// is a rank statistic rather than a sum, the median MA is far more robust to
13/// single outliers than the [`Sma`](crate::Sma): a lone spike shifts the rank
14/// by at most one position instead of dragging the whole average.
15///
16/// Each `update` slides the window and computes the median by sorting a copy of
17/// the `period` buffered values — O(`period` · log `period`) per step, with the
18/// period fixed and bounded.
19///
20/// # Example
21///
22/// ```
23/// use wickra_core::{Indicator, MedianMa};
24///
25/// let mut indicator = MedianMa::new(5).unwrap();
26/// let mut last = None;
27/// for i in 0..80 {
28///     last = indicator.update(100.0 + f64::from(i));
29/// }
30/// assert!(last.is_some());
31/// ```
32#[derive(Debug, Clone)]
33pub struct MedianMa {
34    period: usize,
35    window: VecDeque<f64>,
36}
37
38impl MedianMa {
39    /// Construct a new median moving average over `period` inputs.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`Error::PeriodZero`] if `period == 0`.
44    pub fn new(period: usize) -> Result<Self> {
45        if period == 0 {
46            return Err(Error::PeriodZero);
47        }
48        Ok(Self {
49            period,
50            window: VecDeque::with_capacity(period),
51        })
52    }
53
54    /// Configured period.
55    pub const fn period(&self) -> usize {
56        self.period
57    }
58
59    /// Current value if the window is full.
60    pub fn value(&self) -> Option<f64> {
61        if self.window.len() != self.period {
62            return None;
63        }
64        let mut sorted: Vec<f64> = self.window.iter().copied().collect();
65        sorted.sort_by(|a, b| a.partial_cmp(b).expect("window holds only finite values"));
66        let mid = self.period / 2;
67        if self.period % 2 == 1 {
68            Some(sorted[mid])
69        } else {
70            Some(f64::midpoint(sorted[mid - 1], sorted[mid]))
71        }
72    }
73}
74
75impl Indicator for MedianMa {
76    type Input = f64;
77    type Output = f64;
78
79    fn update(&mut self, input: f64) -> Option<f64> {
80        if !input.is_finite() {
81            return self.value();
82        }
83        if self.window.len() == self.period {
84            self.window.pop_front();
85        }
86        self.window.push_back(input);
87        self.value()
88    }
89
90    fn reset(&mut self) {
91        self.window.clear();
92    }
93
94    fn warmup_period(&self) -> usize {
95        self.period
96    }
97
98    fn is_ready(&self) -> bool {
99        self.window.len() == self.period
100    }
101
102    fn name(&self) -> &'static str {
103        "MedianMA"
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::traits::BatchExt;
111    use approx::assert_relative_eq;
112
113    #[test]
114    fn new_rejects_zero_period() {
115        assert!(matches!(MedianMa::new(0), Err(Error::PeriodZero)));
116    }
117
118    /// Cover the const accessor `period` and the Indicator-impl `warmup_period`
119    /// + `name`.
120    #[test]
121    fn accessors_and_metadata() {
122        let mma = MedianMa::new(7).unwrap();
123        assert_eq!(mma.period(), 7);
124        assert_eq!(mma.warmup_period(), 7);
125        assert_eq!(mma.name(), "MedianMA");
126    }
127
128    #[test]
129    fn warmup_returns_none_then_odd_median() {
130        let mut mma = MedianMa::new(3).unwrap();
131        assert_eq!(mma.update(5.0), None);
132        assert_eq!(mma.update(1.0), None);
133        // median of [5, 1, 3] = 3 (middle order statistic).
134        assert_relative_eq!(mma.update(3.0).unwrap(), 3.0, epsilon = 1e-12);
135    }
136
137    #[test]
138    fn even_period_averages_two_central_values() {
139        // median of [1, 2, 3, 4] = (2 + 3) / 2 = 2.5.
140        let mut mma = MedianMa::new(4).unwrap();
141        let v = mma.batch(&[1.0, 2.0, 3.0, 4.0]);
142        assert_relative_eq!(v[3].unwrap(), 2.5, epsilon = 1e-12);
143    }
144
145    #[test]
146    fn robust_to_single_outlier() {
147        // A lone spike does not move the median of an odd window the way it
148        // would move an SMA. median of [10, 11, 9999] = 11.
149        let mut mma = MedianMa::new(3).unwrap();
150        let v = mma.batch(&[10.0, 11.0, 9999.0]);
151        assert_relative_eq!(v[2].unwrap(), 11.0, epsilon = 1e-12);
152    }
153
154    #[test]
155    fn period_one_is_pass_through() {
156        let mut mma = MedianMa::new(1).unwrap();
157        assert_relative_eq!(mma.update(5.5).unwrap(), 5.5, epsilon = 1e-12);
158        assert_relative_eq!(mma.update(7.5).unwrap(), 7.5, epsilon = 1e-12);
159    }
160
161    #[test]
162    fn slides_window_correctly() {
163        // After [1,2,3] the window slides to [2,3,4] -> median 3, then [3,4,5] -> 4.
164        let mut mma = MedianMa::new(3).unwrap();
165        let v = mma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
166        assert_relative_eq!(v[2].unwrap(), 2.0, epsilon = 1e-12);
167        assert_relative_eq!(v[3].unwrap(), 3.0, epsilon = 1e-12);
168        assert_relative_eq!(v[4].unwrap(), 4.0, epsilon = 1e-12);
169    }
170
171    #[test]
172    fn reset_clears_state() {
173        let mut mma = MedianMa::new(4).unwrap();
174        mma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
175        assert!(mma.is_ready());
176        mma.reset();
177        assert!(!mma.is_ready());
178        assert_eq!(mma.update(10.0), None);
179    }
180
181    #[test]
182    fn batch_equals_streaming() {
183        let prices: Vec<f64> = (1..=20).map(|i| (f64::from(i) * 0.7).sin() * 5.0).collect();
184        let mut a = MedianMa::new(5).unwrap();
185        let mut b = MedianMa::new(5).unwrap();
186        assert_eq!(
187            a.batch(&prices),
188            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
189        );
190    }
191
192    #[test]
193    fn ignores_non_finite_input_but_keeps_state() {
194        let mut mma = MedianMa::new(3).unwrap();
195        mma.update(5.0);
196        mma.update(1.0);
197        let ready = mma
198            .update(3.0)
199            .expect("MedianMA(3) ready after three inputs");
200        assert_eq!(mma.update(f64::NAN), Some(ready));
201        assert_eq!(mma.update(f64::INFINITY), Some(ready));
202        // Window still [5, 1, 3] -> next real input slides to [1, 3, 8] -> median 3.
203        assert_relative_eq!(mma.update(8.0).unwrap(), 3.0, epsilon = 1e-12);
204    }
205}