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 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        Some(QuartileBandsOutput {
94            upper: quantile_sorted(&self.scratch, 0.75),
95            middle: quantile_sorted(&self.scratch, 0.5),
96            lower: quantile_sorted(&self.scratch, 0.25),
97        })
98    }
99
100    fn reset(&mut self) {
101        self.window.clear();
102        self.scratch.clear();
103    }
104
105    fn warmup_period(&self) -> usize {
106        self.period
107    }
108
109    fn is_ready(&self) -> bool {
110        self.window.len() == self.period
111    }
112
113    fn name(&self) -> &'static str {
114        "QuartileBands"
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::traits::BatchExt;
122    use approx::assert_relative_eq;
123
124    #[test]
125    fn rejects_zero_period() {
126        assert!(matches!(QuartileBands::new(0), Err(Error::PeriodZero)));
127        assert!(QuartileBands::new(1).is_ok());
128    }
129
130    #[test]
131    fn accessors_and_metadata() {
132        let qb = QuartileBands::new(20).unwrap();
133        assert_eq!(qb.period(), 20);
134        assert_eq!(qb.warmup_period(), 20);
135        assert_eq!(qb.name(), "QuartileBands");
136        assert!(!qb.is_ready());
137    }
138
139    #[test]
140    fn warms_up_then_emits() {
141        let mut qb = QuartileBands::new(4).unwrap();
142        assert!(qb.update(10.0).is_none());
143        assert!(qb.update(20.0).is_none());
144        assert!(qb.update(30.0).is_none());
145        assert!(qb.update(40.0).is_some());
146        assert!(qb.is_ready());
147    }
148
149    #[test]
150    fn known_quartiles() {
151        // sorted [10,20,30,40]:
152        //   Q1 h=(4-1)*0.25=0.75 -> 10 + 0.75*10 = 17.5
153        //   Q2 h=1.5            -> 20 + 0.5*10  = 25.0
154        //   Q3 h=2.25           -> 30 + 0.25*10 = 32.5
155        let mut qb = QuartileBands::new(4).unwrap();
156        let out = qb.batch(&[40.0, 30.0, 20.0, 10.0]);
157        let last = out[3].unwrap();
158        assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
159        assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
160        assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
161    }
162
163    #[test]
164    fn median_robust_to_outlier() {
165        // A single spike shifts the mean a lot but the median by at most one rank.
166        let mut qb = QuartileBands::new(5).unwrap();
167        let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 1000.0]);
168        assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12);
169    }
170
171    #[test]
172    fn rolling_window_evicts_oldest() {
173        // Eight values through a period-4 window: only the last four survive,
174        // reproducing the `known_quartiles` window.
175        let mut qb = QuartileBands::new(4).unwrap();
176        let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 40.0, 30.0, 20.0, 10.0]);
177        let last = out[7].unwrap();
178        assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
179        assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
180        assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
181    }
182
183    #[test]
184    fn reset_clears_state() {
185        let mut qb = QuartileBands::new(4).unwrap();
186        for v in [10.0, 20.0, 30.0, 40.0] {
187            qb.update(v);
188        }
189        assert!(qb.is_ready());
190        qb.reset();
191        assert!(!qb.is_ready());
192        assert!(qb.update(10.0).is_none());
193    }
194}