wickra_core/indicators/
median_ma.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::sorted_window;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
34pub struct MedianMa {
35 period: usize,
36 window: VecDeque<f64>,
37 scratch: Vec<f64>,
40 last: Option<f64>,
42}
43
44impl MedianMa {
45 pub fn new(period: usize) -> Result<Self> {
51 if period == 0 {
52 return Err(Error::PeriodZero);
53 }
54 if period > crate::error::MAX_PERIOD {
55 return Err(Error::InvalidPeriod {
56 message: crate::error::PERIOD_ABOVE_MAX,
57 });
58 }
59 Ok(Self {
60 period,
61 window: VecDeque::with_capacity(period),
62 scratch: Vec::with_capacity(period),
63 last: None,
64 })
65 }
66
67 pub const fn period(&self) -> usize {
69 self.period
70 }
71
72 pub const fn value(&self) -> Option<f64> {
77 self.last
78 }
79
80 fn recompute(&mut self) {
82 if self.window.len() != self.period {
83 self.last = None;
84 return;
85 }
86 let mid = self.period / 2;
87 self.last = Some(if self.period % 2 == 1 {
88 self.scratch[mid]
89 } else {
90 f64::midpoint(self.scratch[mid - 1], self.scratch[mid])
91 });
92 }
93}
94
95impl Indicator for MedianMa {
96 type Input = f64;
97 type Output = f64;
98
99 #[inline]
100 fn update(&mut self, input: f64) -> Option<f64> {
101 if !input.is_finite() {
102 return None;
103 }
104 if self.window.len() == self.period {
105 let oldest = self.window.pop_front().expect("window is full");
106 sorted_window::remove(&mut self.scratch, oldest);
107 }
108 self.window.push_back(input);
109 sorted_window::insert(&mut self.scratch, input);
110 self.recompute();
111 self.last
112 }
113
114 fn reset(&mut self) {
115 self.window.clear();
116 self.scratch.clear();
117 self.last = None;
118 }
119
120 #[inline]
121 fn warmup_period(&self) -> usize {
122 self.period
123 }
124
125 #[inline]
126 fn is_ready(&self) -> bool {
127 self.window.len() == self.period
128 }
129
130 #[inline]
131 fn name(&self) -> &'static str {
132 "MedianMA"
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::traits::BatchExt;
140 use approx::assert_relative_eq;
141
142 #[test]
143 fn new_rejects_zero_period() {
144 assert!(matches!(MedianMa::new(0), Err(Error::PeriodZero)));
145 }
146
147 #[test]
150 fn accessors_and_metadata() {
151 let mma = MedianMa::new(7).unwrap();
152 assert_eq!(mma.period(), 7);
153 assert_eq!(mma.warmup_period(), 7);
154 assert_eq!(mma.name(), "MedianMA");
155 }
156
157 #[test]
158 fn warmup_returns_none_then_odd_median() {
159 let mut mma = MedianMa::new(3).unwrap();
160 assert_eq!(mma.update(5.0), None);
161 assert_eq!(mma.update(1.0), None);
162 assert_relative_eq!(mma.update(3.0).unwrap(), 3.0, epsilon = 1e-12);
164 }
165
166 #[test]
167 fn even_period_averages_two_central_values() {
168 let mut mma = MedianMa::new(4).unwrap();
170 let v = mma.batch(&[1.0, 2.0, 3.0, 4.0]);
171 assert_relative_eq!(v[3].unwrap(), 2.5, epsilon = 1e-12);
172 }
173
174 #[test]
175 fn robust_to_single_outlier() {
176 let mut mma = MedianMa::new(3).unwrap();
179 let v = mma.batch(&[10.0, 11.0, 9999.0]);
180 assert_relative_eq!(v[2].unwrap(), 11.0, epsilon = 1e-12);
181 }
182
183 #[test]
184 fn period_one_is_pass_through() {
185 let mut mma = MedianMa::new(1).unwrap();
186 assert_relative_eq!(mma.update(5.5).unwrap(), 5.5, epsilon = 1e-12);
187 assert_relative_eq!(mma.update(7.5).unwrap(), 7.5, epsilon = 1e-12);
188 }
189
190 #[test]
191 fn slides_window_correctly() {
192 let mut mma = MedianMa::new(3).unwrap();
194 let v = mma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
195 assert_relative_eq!(v[2].unwrap(), 2.0, epsilon = 1e-12);
196 assert_relative_eq!(v[3].unwrap(), 3.0, epsilon = 1e-12);
197 assert_relative_eq!(v[4].unwrap(), 4.0, epsilon = 1e-12);
198 }
199
200 #[test]
201 fn reset_clears_state() {
202 let mut mma = MedianMa::new(4).unwrap();
203 mma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
204 assert!(mma.is_ready());
205 mma.reset();
206 assert!(!mma.is_ready());
207 assert_eq!(mma.update(10.0), None);
208 }
209
210 #[test]
211 fn batch_equals_streaming() {
212 let prices: Vec<f64> = (1..=20).map(|i| (f64::from(i) * 0.7).sin() * 5.0).collect();
213 let mut a = MedianMa::new(5).unwrap();
214 let mut b = MedianMa::new(5).unwrap();
215 assert_eq!(
216 a.batch(&prices),
217 prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
218 );
219 }
220
221 #[test]
222 fn ignores_non_finite_input_but_keeps_state() {
223 let mut mma = MedianMa::new(3).unwrap();
224 mma.update(5.0);
225 mma.update(1.0);
226 let _ready = mma
227 .update(3.0)
228 .expect("MedianMA(3) ready after three inputs");
229 assert_eq!(mma.update(f64::NAN), None);
230 assert_eq!(mma.update(f64::INFINITY), None);
231 assert_relative_eq!(mma.update(8.0).unwrap(), 3.0, epsilon = 1e-12);
233 }
234}