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