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        Ok(Self {
57            period,
58            window: VecDeque::with_capacity(period),
59            scratch: Vec::with_capacity(period),
60        })
61    }
62
63    /// Configured period.
64    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        // sorted [10,20,30,40,50]: Q1 = q(0.25)= 10 + (4*0.25)*(...)= h=1.0 →20,
133        // Q3 = q(0.75): h = 4*0.75 = 3.0 → 40. IQR = 40 - 20 = 20.
134        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        // 19 tightly-clustered values plus one huge spike: the central 50%
161        // is unaffected, so the IQR stays small (well below the spike scale).
162        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}