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::indicators::sorted_window;
8use crate::traits::Indicator;
9
10/// Interquartile Range of the last `period` values: `Q3 − Q1`.
11///
12/// ```text
13/// IQR = quantile(0.75) − quantile(0.25)
14/// ```
15///
16/// The IQR is the width of the central 50% of the window — the spread between
17/// the third and first quartiles. It is a robust dispersion measure: unlike the
18/// standard deviation it ignores the extreme tails entirely, so a single spike
19/// barely moves it. That makes it the natural scale for outlier rules (the
20/// classic *Tukey fence* flags points more than `1.5 · IQR` beyond a quartile)
21/// and for volatility-regime splits that must not be dominated by one shock.
22///
23/// Both quartiles use the type-7 / NumPy-default linearly-interpolated
24/// definition, identical to [`RollingQuantile`](crate::RollingQuantile). Each
25/// `update` is O(period log period): the window is copied into a scratch buffer
26/// and sorted once.
27///
28/// # Example
29///
30/// ```
31/// use wickra_core::{Indicator, RollingIqr};
32///
33/// let mut indicator = RollingIqr::new(20).unwrap();
34/// let mut last = None;
35/// for i in 0..40 {
36///     last = indicator.update(100.0 + f64::from(i));
37/// }
38/// assert!(last.is_some());
39/// ```
40#[derive(Debug, Clone)]
41pub struct RollingIqr {
42    period: usize,
43    window: VecDeque<f64>,
44    /// The window's values in `total_cmp` order, kept sorted as it slides:
45    /// bit for bit what sorting a copy of the window would give.
46    scratch: Vec<f64>,
47}
48
49impl RollingIqr {
50    /// Construct a new rolling IQR with the given period.
51    ///
52    /// # Errors
53    /// Returns [`Error::PeriodZero`] if `period == 0`.
54    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    /// Configured period.
71    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        // sorted [10,20,30,40,50]: Q1 = q(0.25)= 10 + (4*0.25)*(...)= h=1.0 →20,
143        // Q3 = q(0.75): h = 4*0.75 = 3.0 → 40. IQR = 40 - 20 = 20.
144        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        // 19 tightly-clustered values plus one huge spike: the central 50%
171        // is unaffected, so the IQR stays small (well below the spike scale).
172        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}