Skip to main content

wickra_core/indicators/
rolling_iqr.rs

1//! Rolling Interquartile Range (IQR) over a trailing window.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_quantile::quantile_sorted;
7use crate::traits::Indicator;
8
9/// Interquartile Range of the last `period` values: `Q3 − Q1`.
10///
11/// ```text
12/// IQR = quantile(0.75) − quantile(0.25)
13/// ```
14///
15/// The IQR is the width of the central 50% of the window — the spread between
16/// the third and first quartiles. It is a robust dispersion measure: unlike the
17/// standard deviation it ignores the extreme tails entirely, so a single spike
18/// barely moves it. That makes it the natural scale for outlier rules (the
19/// classic *Tukey fence* flags points more than `1.5 · IQR` beyond a quartile)
20/// and for volatility-regime splits that must not be dominated by one shock.
21///
22/// Both quartiles use the type-7 / NumPy-default linearly-interpolated
23/// definition, identical to [`RollingQuantile`](crate::RollingQuantile). Each
24/// `update` is O(period log period): the window is copied into a scratch buffer
25/// and sorted once.
26///
27/// # Example
28///
29/// ```
30/// use wickra_core::{Indicator, RollingIqr};
31///
32/// let mut indicator = RollingIqr::new(20).unwrap();
33/// let mut last = None;
34/// for i in 0..40 {
35///     last = indicator.update(100.0 + f64::from(i));
36/// }
37/// assert!(last.is_some());
38/// ```
39#[derive(Debug, Clone)]
40pub struct RollingIqr {
41    period: usize,
42    window: VecDeque<f64>,
43    /// Reusable scratch buffer to avoid allocating per `update`.
44    scratch: Vec<f64>,
45}
46
47impl RollingIqr {
48    /// Construct a new rolling IQR with the given period.
49    ///
50    /// # Errors
51    /// Returns [`Error::PeriodZero`] if `period == 0`.
52    pub fn new(period: usize) -> Result<Self> {
53        if period == 0 {
54            return Err(Error::PeriodZero);
55        }
56        if period > crate::error::MAX_PERIOD {
57            return Err(Error::InvalidPeriod {
58                message: crate::error::PERIOD_ABOVE_MAX,
59            });
60        }
61        Ok(Self {
62            period,
63            window: VecDeque::with_capacity(period),
64            scratch: Vec::with_capacity(period),
65        })
66    }
67
68    /// Configured period.
69    pub const fn period(&self) -> usize {
70        self.period
71    }
72}
73
74impl Indicator for RollingIqr {
75    type Input = f64;
76    type Output = f64;
77
78    #[inline]
79    fn update(&mut self, value: f64) -> Option<f64> {
80        if !value.is_finite() {
81            return None;
82        }
83        if self.window.len() == self.period {
84            self.window.pop_front();
85        }
86        self.window.push_back(value);
87        if self.window.len() < self.period {
88            return None;
89        }
90        self.scratch.clear();
91        self.scratch.extend(self.window.iter().copied());
92        self.scratch.sort_by(f64::total_cmp);
93        let q1 = quantile_sorted(&self.scratch, 0.25);
94        let q3 = quantile_sorted(&self.scratch, 0.75);
95        Some(q3 - q1)
96    }
97
98    fn reset(&mut self) {
99        self.window.clear();
100        self.scratch.clear();
101    }
102
103    #[inline]
104    fn warmup_period(&self) -> usize {
105        self.period
106    }
107
108    #[inline]
109    fn is_ready(&self) -> bool {
110        self.window.len() == self.period
111    }
112
113    #[inline]
114    fn name(&self) -> &'static str {
115        "RollingIqr"
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::traits::BatchExt;
123    use approx::assert_relative_eq;
124
125    #[test]
126    fn rejects_zero_period() {
127        assert!(matches!(RollingIqr::new(0), Err(Error::PeriodZero)));
128    }
129
130    #[test]
131    fn accessors_and_metadata() {
132        let iqr = RollingIqr::new(14).unwrap();
133        assert_eq!(iqr.period(), 14);
134        assert_eq!(iqr.warmup_period(), 14);
135        assert_eq!(iqr.name(), "RollingIqr");
136        assert!(!iqr.is_ready());
137    }
138
139    #[test]
140    fn reference_value() {
141        // sorted [10,20,30,40,50]: Q1 = q(0.25)= 10 + (4*0.25)*(...)= h=1.0 →20,
142        // Q3 = q(0.75): h = 4*0.75 = 3.0 → 40. IQR = 40 - 20 = 20.
143        let mut iqr = RollingIqr::new(5).unwrap();
144        let out = iqr.batch(&[50.0, 40.0, 30.0, 20.0, 10.0]);
145        assert_relative_eq!(out[4].unwrap(), 20.0, epsilon = 1e-12);
146    }
147
148    #[test]
149    fn constant_series_yields_zero() {
150        let mut iqr = RollingIqr::new(8).unwrap();
151        for v in iqr.batch(&[42.0; 20]).into_iter().flatten() {
152            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
153        }
154    }
155
156    #[test]
157    fn output_is_non_negative() {
158        let mut iqr = RollingIqr::new(20).unwrap();
159        let prices: Vec<f64> = (1..=200)
160            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 12.0)
161            .collect();
162        for v in iqr.batch(&prices).into_iter().flatten() {
163            assert!(v >= 0.0, "IQR must be non-negative, got {v}");
164        }
165    }
166
167    #[test]
168    fn ignores_single_extreme_outlier() {
169        // 19 tightly-clustered values plus one huge spike: the central 50%
170        // is unaffected, so the IQR stays small (well below the spike scale).
171        let mut iqr = RollingIqr::new(20).unwrap();
172        let mut prices = vec![5.0; 19];
173        prices.push(10_000.0);
174        let last = iqr.batch(&prices).into_iter().flatten().last().unwrap();
175        assert!(last < 1.0, "spike leaked into IQR: {last}");
176    }
177
178    #[test]
179    fn reset_clears_state() {
180        let mut iqr = RollingIqr::new(5).unwrap();
181        iqr.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
182        assert!(iqr.is_ready());
183        iqr.reset();
184        assert!(!iqr.is_ready());
185        assert_eq!(iqr.update(1.0), None);
186    }
187
188    #[test]
189    fn batch_equals_streaming() {
190        let prices: Vec<f64> = (0..60)
191            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
192            .collect();
193        let batch = RollingIqr::new(14).unwrap().batch(&prices);
194        let mut b = RollingIqr::new(14).unwrap();
195        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
196        assert_eq!(batch, streamed);
197    }
198}