Skip to main content

wickra_core/indicators/
quartile_bands.rs

1//! Quartile Bands — rolling 25th / 50th / 75th percentile envelope.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_quantile::quantile_sorted;
7use crate::traits::Indicator;
8
9/// Quartile Bands output.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct QuartileBandsOutput {
12    /// Upper band: the rolling third quartile (75th percentile, `Q3`).
13    pub upper: f64,
14    /// Middle line: the rolling median (50th percentile, `Q2`).
15    pub middle: f64,
16    /// Lower band: the rolling first quartile (25th percentile, `Q1`).
17    pub lower: f64,
18}
19
20/// Quartile Bands: a distribution-based envelope drawn at the rolling quartiles.
21///
22/// ```text
23/// lower  = Q1  = 25th percentile of the last `period` values
24/// middle = Q2  = 50th percentile (median)
25/// upper  = Q3  = 75th percentile
26/// ```
27///
28/// Quantiles use the type-7 (`NumPy`/`R-7`) linear interpolation shared with
29/// [`RollingQuantile`](crate::RollingQuantile). Where Bollinger Bands assume an
30/// approximately normal distribution and size the envelope by the mean and
31/// standard deviation, Quartile Bands are fully **non-parametric**: the band
32/// edges are order statistics, so a single outlier shifts at most one rank
33/// rather than inflating the whole width, and the inter-quartile span between
34/// the bands is exactly the [`RollingIqr`](crate::RollingIqr). The middle line
35/// is the robust median rather than the mean, so it is unmoved by spikes.
36///
37/// # Example
38///
39/// ```
40/// use wickra_core::{Indicator, QuartileBands};
41///
42/// let mut indicator = QuartileBands::new(20).unwrap();
43/// let mut last = None;
44/// for i in 0..40 {
45///     last = indicator.update(100.0 + f64::from(i));
46/// }
47/// assert!(last.is_some());
48/// ```
49#[derive(Debug, Clone)]
50pub struct QuartileBands {
51    period: usize,
52    window: VecDeque<f64>,
53    scratch: Vec<f64>,
54}
55
56impl QuartileBands {
57    /// Construct new Quartile Bands.
58    ///
59    /// # Errors
60    /// Returns [`Error::PeriodZero`] if `period == 0`.
61    pub fn new(period: usize) -> Result<Self> {
62        if period == 0 {
63            return Err(Error::PeriodZero);
64        }
65        Ok(Self {
66            period,
67            window: VecDeque::with_capacity(period),
68            scratch: Vec::with_capacity(period),
69        })
70    }
71
72    /// Configured period.
73    pub const fn period(&self) -> usize {
74        self.period
75    }
76}
77
78impl Indicator for QuartileBands {
79    type Input = f64;
80    type Output = QuartileBandsOutput;
81
82    fn update(&mut self, value: f64) -> Option<QuartileBandsOutput> {
83        if !value.is_finite() {
84            return None;
85        }
86        if self.window.len() == self.period {
87            self.window.pop_front();
88        }
89        self.window.push_back(value);
90        if self.window.len() < self.period {
91            return None;
92        }
93        self.scratch.clear();
94        self.scratch.extend(self.window.iter().copied());
95        self.scratch.sort_by(f64::total_cmp);
96        Some(QuartileBandsOutput {
97            upper: quantile_sorted(&self.scratch, 0.75),
98            middle: quantile_sorted(&self.scratch, 0.5),
99            lower: quantile_sorted(&self.scratch, 0.25),
100        })
101    }
102
103    fn reset(&mut self) {
104        self.window.clear();
105        self.scratch.clear();
106    }
107
108    fn warmup_period(&self) -> usize {
109        self.period
110    }
111
112    fn is_ready(&self) -> bool {
113        self.window.len() == self.period
114    }
115
116    fn name(&self) -> &'static str {
117        "QuartileBands"
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::traits::BatchExt;
125    use approx::assert_relative_eq;
126
127    #[test]
128    fn rejects_zero_period() {
129        assert!(matches!(QuartileBands::new(0), Err(Error::PeriodZero)));
130        assert!(QuartileBands::new(1).is_ok());
131    }
132
133    #[test]
134    fn accessors_and_metadata() {
135        let qb = QuartileBands::new(20).unwrap();
136        assert_eq!(qb.period(), 20);
137        assert_eq!(qb.warmup_period(), 20);
138        assert_eq!(qb.name(), "QuartileBands");
139        assert!(!qb.is_ready());
140    }
141
142    #[test]
143    fn warms_up_then_emits() {
144        let mut qb = QuartileBands::new(4).unwrap();
145        assert!(qb.update(10.0).is_none());
146        assert!(qb.update(20.0).is_none());
147        assert!(qb.update(30.0).is_none());
148        assert!(qb.update(40.0).is_some());
149        assert!(qb.is_ready());
150    }
151
152    #[test]
153    fn known_quartiles() {
154        // sorted [10,20,30,40]:
155        //   Q1 h=(4-1)*0.25=0.75 -> 10 + 0.75*10 = 17.5
156        //   Q2 h=1.5            -> 20 + 0.5*10  = 25.0
157        //   Q3 h=2.25           -> 30 + 0.25*10 = 32.5
158        let mut qb = QuartileBands::new(4).unwrap();
159        let out = qb.batch(&[40.0, 30.0, 20.0, 10.0]);
160        let last = out[3].unwrap();
161        assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
162        assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
163        assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
164    }
165
166    #[test]
167    fn median_robust_to_outlier() {
168        // A single spike shifts the mean a lot but the median by at most one rank.
169        let mut qb = QuartileBands::new(5).unwrap();
170        let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 1000.0]);
171        assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12);
172    }
173
174    #[test]
175    fn rolling_window_evicts_oldest() {
176        // Eight values through a period-4 window: only the last four survive,
177        // reproducing the `known_quartiles` window.
178        let mut qb = QuartileBands::new(4).unwrap();
179        let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 40.0, 30.0, 20.0, 10.0]);
180        let last = out[7].unwrap();
181        assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
182        assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
183        assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
184    }
185
186    #[test]
187    fn reset_clears_state() {
188        let mut qb = QuartileBands::new(4).unwrap();
189        for v in [10.0, 20.0, 30.0, 40.0] {
190            qb.update(v);
191        }
192        assert!(qb.is_ready());
193        qb.reset();
194        assert!(!qb.is_ready());
195        assert!(qb.update(10.0).is_none());
196    }
197}