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