Skip to main content

wickra_core/indicators/
median_channel.rs

1//! Median Channel — a robust median ± MAD envelope.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_quantile::quantile_sorted;
7use crate::indicators::sorted_window;
8use crate::traits::Indicator;
9
10/// Median Channel output.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct MedianChannelOutput {
13    /// Upper band: `median + multiplier · MAD`.
14    pub upper: f64,
15    /// Middle line: the rolling median.
16    pub middle: f64,
17    /// Lower band: `median − multiplier · MAD`.
18    pub lower: f64,
19}
20
21/// Median Channel: a robust analogue of Bollinger Bands built from the rolling
22/// median and the median absolute deviation (MAD).
23///
24/// ```text
25/// middle = median(close, period)
26/// MAD    = median( | close_i − middle | )
27/// upper  = middle + multiplier · MAD
28/// lower  = middle − multiplier · MAD
29/// ```
30///
31/// Where [`BollingerBands`](crate::BollingerBands) centre on the mean and scale
32/// by the standard deviation — both of which a single spike can drag
33/// arbitrarily far — the Median Channel uses two order statistics. The
34/// breakdown point of the median and MAD is 50%: up to half the window can be
35/// contaminated before the centre or width is materially distorted. That makes
36/// the channel well suited to noisy, gap-prone, or fat-tailed series where
37/// Bollinger Bands flare on every outlier. Both quantiles use the type-7
38/// interpolation shared with [`RollingQuantile`](crate::RollingQuantile).
39///
40/// # Example
41///
42/// ```
43/// use wickra_core::{Indicator, MedianChannel};
44///
45/// let mut indicator = MedianChannel::new(20, 2.0).unwrap();
46/// let mut last = None;
47/// for i in 0..40 {
48///     last = indicator.update(100.0 + f64::from(i % 5));
49/// }
50/// assert!(last.is_some());
51/// ```
52#[derive(Debug, Clone)]
53pub struct MedianChannel {
54    period: usize,
55    multiplier: f64,
56    window: VecDeque<f64>,
57    /// The window's values in `total_cmp` order, kept sorted as it slides.
58    scratch: Vec<f64>,
59    /// The absolute deviations from the median, in ascending order.
60    deviations: Vec<f64>,
61}
62
63impl MedianChannel {
64    /// Construct a new Median Channel.
65    ///
66    /// # Errors
67    /// Returns [`Error::PeriodZero`] if `period == 0`, or
68    /// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
69    /// positive and finite.
70    pub fn new(period: usize, multiplier: f64) -> Result<Self> {
71        if period == 0 {
72            return Err(Error::PeriodZero);
73        }
74        if period > crate::error::MAX_PERIOD {
75            return Err(Error::InvalidPeriod {
76                message: crate::error::PERIOD_ABOVE_MAX,
77            });
78        }
79        if !multiplier.is_finite() || multiplier <= 0.0 {
80            return Err(Error::NonPositiveMultiplier);
81        }
82        Ok(Self {
83            period,
84            multiplier,
85            window: VecDeque::with_capacity(period),
86            scratch: Vec::with_capacity(period),
87            deviations: Vec::with_capacity(period),
88        })
89    }
90
91    /// Configured period.
92    pub const fn period(&self) -> usize {
93        self.period
94    }
95
96    /// Configured multiplier.
97    pub const fn multiplier(&self) -> f64 {
98        self.multiplier
99    }
100}
101
102impl Indicator for MedianChannel {
103    type Input = f64;
104    type Output = MedianChannelOutput;
105
106    #[inline]
107    fn update(&mut self, value: f64) -> Option<MedianChannelOutput> {
108        if !value.is_finite() {
109            return None;
110        }
111        if self.window.len() == self.period {
112            let oldest = self.window.pop_front().expect("window is full");
113            sorted_window::remove(&mut self.scratch, oldest);
114        }
115        self.window.push_back(value);
116        sorted_window::insert(&mut self.scratch, value);
117        if self.window.len() < self.period {
118            return None;
119        }
120        let median = quantile_sorted(&self.scratch, 0.5);
121
122        // The absolute deviations, sorted by merging the runs either side of
123        // the median.
124        sorted_window::abs_deviations(&self.scratch, median, &mut self.deviations);
125        let mad = quantile_sorted(&self.deviations, 0.5);
126        let offset = self.multiplier * mad;
127
128        Some(MedianChannelOutput {
129            upper: median + offset,
130            middle: median,
131            lower: median - offset,
132        })
133    }
134
135    fn reset(&mut self) {
136        self.window.clear();
137        self.scratch.clear();
138        self.deviations.clear();
139    }
140
141    #[inline]
142    fn warmup_period(&self) -> usize {
143        self.period
144    }
145
146    #[inline]
147    fn is_ready(&self) -> bool {
148        self.window.len() == self.period
149    }
150
151    #[inline]
152    fn name(&self) -> &'static str {
153        "MedianChannel"
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::traits::BatchExt;
161    use approx::assert_relative_eq;
162
163    #[test]
164    fn rejects_zero_period() {
165        assert!(matches!(MedianChannel::new(0, 2.0), Err(Error::PeriodZero)));
166        assert!(MedianChannel::new(1, 2.0).is_ok());
167    }
168
169    #[test]
170    fn rejects_non_positive_multiplier() {
171        assert!(matches!(
172            MedianChannel::new(20, 0.0),
173            Err(Error::NonPositiveMultiplier)
174        ));
175        assert!(matches!(
176            MedianChannel::new(20, -1.0),
177            Err(Error::NonPositiveMultiplier)
178        ));
179        assert!(matches!(
180            MedianChannel::new(20, f64::NAN),
181            Err(Error::NonPositiveMultiplier)
182        ));
183    }
184
185    #[test]
186    fn accessors_and_metadata() {
187        let mc = MedianChannel::new(20, 2.0).unwrap();
188        assert_eq!(mc.period(), 20);
189        assert_relative_eq!(mc.multiplier(), 2.0, epsilon = 1e-12);
190        assert_eq!(mc.warmup_period(), 20);
191        assert_eq!(mc.name(), "MedianChannel");
192        assert!(!mc.is_ready());
193    }
194
195    #[test]
196    fn warms_up_then_emits() {
197        let mut mc = MedianChannel::new(5, 2.0).unwrap();
198        for v in [1.0, 2.0, 3.0, 4.0] {
199            assert!(mc.update(v).is_none());
200        }
201        assert!(mc.update(5.0).is_some());
202        assert!(mc.is_ready());
203    }
204
205    #[test]
206    fn known_channel() {
207        // [1,2,3,4,5]: median 3; |dev| sorted [0,1,1,2,2] -> MAD 1.
208        // upper = 3 + 2*1 = 5; lower = 3 - 2*1 = 1.
209        let mut mc = MedianChannel::new(5, 2.0).unwrap();
210        let out = mc.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
211        let last = out[4].unwrap();
212        assert_relative_eq!(last.middle, 3.0, epsilon = 1e-12);
213        assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
214        assert_relative_eq!(last.lower, 1.0, epsilon = 1e-12);
215    }
216
217    #[test]
218    fn robust_to_outlier() {
219        // Replacing the last value with a huge spike leaves the median centre
220        // unchanged (still the middle order statistic).
221        let mut mc = MedianChannel::new(5, 2.0).unwrap();
222        let out = mc.batch(&[1.0, 2.0, 3.0, 4.0, 1_000.0]);
223        assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12);
224    }
225
226    #[test]
227    fn rolling_window_evicts_oldest() {
228        // Ten values through a period-5 window: only the last five survive,
229        // reproducing the `known_channel` window.
230        let mut mc = MedianChannel::new(5, 2.0).unwrap();
231        let out = mc.batch(&[10.0, 10.0, 10.0, 10.0, 10.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
232        let last = out[9].unwrap();
233        assert_relative_eq!(last.middle, 3.0, epsilon = 1e-12);
234        assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
235        assert_relative_eq!(last.lower, 1.0, epsilon = 1e-12);
236    }
237
238    #[test]
239    fn reset_clears_state() {
240        let mut mc = MedianChannel::new(5, 2.0).unwrap();
241        for v in [1.0, 2.0, 3.0, 4.0, 5.0] {
242            mc.update(v);
243        }
244        assert!(mc.is_ready());
245        mc.reset();
246        assert!(!mc.is_ready());
247        assert!(mc.update(1.0).is_none());
248    }
249}