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 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        // sorted [10,20,30,40,50]: Q1 = q(0.25)= 10 + (4*0.25)*(...)= h=1.0 →20,
130        // Q3 = q(0.75): h = 4*0.75 = 3.0 → 40. IQR = 40 - 20 = 20.
131        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        // 19 tightly-clustered values plus one huge spike: the central 50%
158        // is unaffected, so the IQR stays small (well below the spike scale).
159        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}