wickra_core/indicators/
median_channel.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_quantile::quantile_sorted;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct MedianChannelOutput {
12 pub upper: f64,
14 pub middle: f64,
16 pub lower: f64,
18}
19
20#[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 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 pub const fn period(&self) -> usize {
85 self.period
86 }
87
88 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 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 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 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}