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