wickra_core/indicators/
rolling_iqr.rs1use 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#[derive(Debug, Clone)]
41pub struct RollingIqr {
42 period: usize,
43 window: VecDeque<f64>,
44 scratch: Vec<f64>,
47}
48
49impl RollingIqr {
50 pub fn new(period: usize) -> Result<Self> {
55 if period == 0 {
56 return Err(Error::PeriodZero);
57 }
58 if period > crate::error::MAX_PERIOD {
59 return Err(Error::InvalidPeriod {
60 message: crate::error::PERIOD_ABOVE_MAX,
61 });
62 }
63 Ok(Self {
64 period,
65 window: VecDeque::with_capacity(period),
66 scratch: Vec::with_capacity(period),
67 })
68 }
69
70 pub const fn period(&self) -> usize {
72 self.period
73 }
74}
75
76impl Indicator for RollingIqr {
77 type Input = f64;
78 type Output = f64;
79
80 #[inline]
81 fn update(&mut self, value: f64) -> Option<f64> {
82 if !value.is_finite() {
83 return None;
84 }
85 if self.window.len() == self.period {
86 let oldest = self.window.pop_front().expect("window is full");
87 sorted_window::remove(&mut self.scratch, oldest);
88 }
89 self.window.push_back(value);
90 sorted_window::insert(&mut self.scratch, value);
91 if self.window.len() < self.period {
92 return None;
93 }
94 let q1 = quantile_sorted(&self.scratch, 0.25);
95 let q3 = quantile_sorted(&self.scratch, 0.75);
96 Some(q3 - q1)
97 }
98
99 fn reset(&mut self) {
100 self.window.clear();
101 self.scratch.clear();
102 }
103
104 #[inline]
105 fn warmup_period(&self) -> usize {
106 self.period
107 }
108
109 #[inline]
110 fn is_ready(&self) -> bool {
111 self.window.len() == self.period
112 }
113
114 #[inline]
115 fn name(&self) -> &'static str {
116 "RollingIqr"
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use crate::traits::BatchExt;
124 use approx::assert_relative_eq;
125
126 #[test]
127 fn rejects_zero_period() {
128 assert!(matches!(RollingIqr::new(0), Err(Error::PeriodZero)));
129 }
130
131 #[test]
132 fn accessors_and_metadata() {
133 let iqr = RollingIqr::new(14).unwrap();
134 assert_eq!(iqr.period(), 14);
135 assert_eq!(iqr.warmup_period(), 14);
136 assert_eq!(iqr.name(), "RollingIqr");
137 assert!(!iqr.is_ready());
138 }
139
140 #[test]
141 fn reference_value() {
142 let mut iqr = RollingIqr::new(5).unwrap();
145 let out = iqr.batch(&[50.0, 40.0, 30.0, 20.0, 10.0]);
146 assert_relative_eq!(out[4].unwrap(), 20.0, epsilon = 1e-12);
147 }
148
149 #[test]
150 fn constant_series_yields_zero() {
151 let mut iqr = RollingIqr::new(8).unwrap();
152 for v in iqr.batch(&[42.0; 20]).into_iter().flatten() {
153 assert_relative_eq!(v, 0.0, epsilon = 1e-12);
154 }
155 }
156
157 #[test]
158 fn output_is_non_negative() {
159 let mut iqr = RollingIqr::new(20).unwrap();
160 let prices: Vec<f64> = (1..=200)
161 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
162 .collect();
163 for v in iqr.batch(&prices).into_iter().flatten() {
164 assert!(v >= 0.0, "IQR must be non-negative, got {v}");
165 }
166 }
167
168 #[test]
169 fn ignores_single_extreme_outlier() {
170 let mut iqr = RollingIqr::new(20).unwrap();
173 let mut prices = vec![5.0; 19];
174 prices.push(10_000.0);
175 let last = iqr.batch(&prices).into_iter().flatten().last().unwrap();
176 assert!(last < 1.0, "spike leaked into IQR: {last}");
177 }
178
179 #[test]
180 fn reset_clears_state() {
181 let mut iqr = RollingIqr::new(5).unwrap();
182 iqr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
183 assert!(iqr.is_ready());
184 iqr.reset();
185 assert!(!iqr.is_ready());
186 assert_eq!(iqr.update(1.0), None);
187 }
188
189 #[test]
190 fn batch_equals_streaming() {
191 let prices: Vec<f64> = (0..60)
192 .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
193 .collect();
194 let batch = RollingIqr::new(14).unwrap().batch(&prices);
195 let mut b = RollingIqr::new(14).unwrap();
196 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
197 assert_eq!(batch, streamed);
198 }
199}