wickra_core/indicators/
median_absolute_deviation.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::sorted_window;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
43pub struct MedianAbsoluteDeviation {
44 period: usize,
45 window: VecDeque<f64>,
46 sorted: Vec<f64>,
48 scratch: Vec<f64>,
50}
51
52impl MedianAbsoluteDeviation {
53 pub fn new(period: usize) -> Result<Self> {
58 if period == 0 {
59 return Err(Error::PeriodZero);
60 }
61 if period > crate::error::MAX_PERIOD {
62 return Err(Error::InvalidPeriod {
63 message: crate::error::PERIOD_ABOVE_MAX,
64 });
65 }
66 Ok(Self {
67 period,
68 window: VecDeque::with_capacity(period),
69 sorted: Vec::with_capacity(period),
70 scratch: Vec::with_capacity(period),
71 })
72 }
73
74 pub const fn period(&self) -> usize {
76 self.period
77 }
78}
79
80fn median_sorted(sorted: &[f64]) -> f64 {
82 let n = sorted.len();
83 let mid = n / 2;
84 if n % 2 == 0 {
85 f64::midpoint(sorted[mid - 1], sorted[mid])
86 } else {
87 sorted[mid]
88 }
89}
90
91impl Indicator for MedianAbsoluteDeviation {
92 type Input = f64;
93 type Output = f64;
94
95 #[inline]
96 fn update(&mut self, value: f64) -> Option<f64> {
97 if !value.is_finite() {
98 return None;
99 }
100 if self.window.len() == self.period {
101 let oldest = self.window.pop_front().expect("window is full");
102 sorted_window::remove(&mut self.sorted, oldest);
103 }
104 self.window.push_back(value);
105 sorted_window::insert(&mut self.sorted, value);
106 if self.window.len() < self.period {
107 return None;
108 }
109 let med = median_sorted(&self.sorted);
110 sorted_window::abs_deviations(&self.sorted, med, &mut self.scratch);
113 Some(median_sorted(&self.scratch))
114 }
115
116 fn reset(&mut self) {
117 self.window.clear();
118 self.sorted.clear();
119 self.scratch.clear();
120 }
121
122 #[inline]
123 fn warmup_period(&self) -> usize {
124 self.period
125 }
126
127 #[inline]
128 fn is_ready(&self) -> bool {
129 self.window.len() == self.period
130 }
131
132 #[inline]
133 fn name(&self) -> &'static str {
134 "MedianAbsoluteDeviation"
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::traits::BatchExt;
142 use approx::assert_relative_eq;
143
144 #[test]
145 fn rejects_zero_period() {
146 assert!(matches!(
147 MedianAbsoluteDeviation::new(0),
148 Err(Error::PeriodZero)
149 ));
150 }
151
152 #[test]
153 fn accessors_and_metadata() {
154 let m = MedianAbsoluteDeviation::new(14).unwrap();
155 assert_eq!(m.period(), 14);
156 assert_eq!(m.warmup_period(), 14);
157 assert_eq!(m.name(), "MedianAbsoluteDeviation");
158 }
159
160 #[test]
161 fn reference_value() {
162 let mut m = MedianAbsoluteDeviation::new(7).unwrap();
165 let out = m.batch(&[1.0, 1.0, 2.0, 2.0, 4.0, 6.0, 9.0]);
166 assert_relative_eq!(out[6].unwrap(), 1.0, epsilon = 1e-12);
167 }
168
169 #[test]
170 fn constant_series_yields_zero() {
171 let mut m = MedianAbsoluteDeviation::new(5).unwrap();
172 for v in m.batch(&[42.0; 20]).into_iter().flatten() {
173 assert_relative_eq!(v, 0.0, epsilon = 1e-12);
174 }
175 }
176
177 #[test]
178 fn ignores_single_extreme_outlier() {
179 let mut m = MedianAbsoluteDeviation::new(10).unwrap();
183 let mut prices = vec![5.0; 9];
184 prices.push(1_000.0);
185 let last = m.batch(&prices).into_iter().flatten().last().unwrap();
186 assert_relative_eq!(last, 0.0, epsilon = 1e-12);
187 }
188
189 #[test]
190 fn reset_clears_state() {
191 let mut m = MedianAbsoluteDeviation::new(5).unwrap();
192 m.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
193 assert!(m.is_ready());
194 m.reset();
195 assert!(!m.is_ready());
196 assert_eq!(m.update(1.0), None);
197 }
198
199 #[test]
200 fn batch_equals_streaming() {
201 let prices: Vec<f64> = (0..60)
202 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
203 .collect();
204 let batch = MedianAbsoluteDeviation::new(14).unwrap().batch(&prices);
205 let mut b = MedianAbsoluteDeviation::new(14).unwrap();
206 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
207 assert_eq!(batch, streamed);
208 }
209}