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        if period > crate::error::MAX_PERIOD {
66            return Err(Error::InvalidPeriod {
67                message: crate::error::PERIOD_ABOVE_MAX,
68            });
69        }
70        Ok(Self {
71            period,
72            window: VecDeque::with_capacity(period),
73            scratch: Vec::with_capacity(period),
74        })
75    }
76
77    /// Configured period.
78    pub const fn period(&self) -> usize {
79        self.period
80    }
81}
82
83impl Indicator for QuartileBands {
84    type Input = f64;
85    type Output = QuartileBandsOutput;
86
87    #[inline]
88    fn update(&mut self, value: f64) -> Option<QuartileBandsOutput> {
89        if !value.is_finite() {
90            return None;
91        }
92        if self.window.len() == self.period {
93            self.window.pop_front();
94        }
95        self.window.push_back(value);
96        if self.window.len() < self.period {
97            return None;
98        }
99        self.scratch.clear();
100        self.scratch.extend(self.window.iter().copied());
101        self.scratch.sort_by(f64::total_cmp);
102        Some(QuartileBandsOutput {
103            upper: quantile_sorted(&self.scratch, 0.75),
104            middle: quantile_sorted(&self.scratch, 0.5),
105            lower: quantile_sorted(&self.scratch, 0.25),
106        })
107    }
108
109    fn reset(&mut self) {
110        self.window.clear();
111        self.scratch.clear();
112    }
113
114    #[inline]
115    fn warmup_period(&self) -> usize {
116        self.period
117    }
118
119    #[inline]
120    fn is_ready(&self) -> bool {
121        self.window.len() == self.period
122    }
123
124    #[inline]
125    fn name(&self) -> &'static str {
126        "QuartileBands"
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::traits::BatchExt;
134    use approx::assert_relative_eq;
135
136    #[test]
137    fn rejects_zero_period() {
138        assert!(matches!(QuartileBands::new(0), Err(Error::PeriodZero)));
139        assert!(QuartileBands::new(1).is_ok());
140    }
141
142    #[test]
143    fn accessors_and_metadata() {
144        let qb = QuartileBands::new(20).unwrap();
145        assert_eq!(qb.period(), 20);
146        assert_eq!(qb.warmup_period(), 20);
147        assert_eq!(qb.name(), "QuartileBands");
148        assert!(!qb.is_ready());
149    }
150
151    #[test]
152    fn warms_up_then_emits() {
153        let mut qb = QuartileBands::new(4).unwrap();
154        assert!(qb.update(10.0).is_none());
155        assert!(qb.update(20.0).is_none());
156        assert!(qb.update(30.0).is_none());
157        assert!(qb.update(40.0).is_some());
158        assert!(qb.is_ready());
159    }
160
161    #[test]
162    fn known_quartiles() {
163        // sorted [10,20,30,40]:
164        //   Q1 h=(4-1)*0.25=0.75 -> 10 + 0.75*10 = 17.5
165        //   Q2 h=1.5            -> 20 + 0.5*10  = 25.0
166        //   Q3 h=2.25           -> 30 + 0.25*10 = 32.5
167        let mut qb = QuartileBands::new(4).unwrap();
168        let out = qb.batch(&[40.0, 30.0, 20.0, 10.0]);
169        let last = out[3].unwrap();
170        assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
171        assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
172        assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
173    }
174
175    #[test]
176    fn median_robust_to_outlier() {
177        // A single spike shifts the mean a lot but the median by at most one rank.
178        let mut qb = QuartileBands::new(5).unwrap();
179        let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 1000.0]);
180        assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12);
181    }
182
183    #[test]
184    fn rolling_window_evicts_oldest() {
185        // Eight values through a period-4 window: only the last four survive,
186        // reproducing the `known_quartiles` window.
187        let mut qb = QuartileBands::new(4).unwrap();
188        let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 40.0, 30.0, 20.0, 10.0]);
189        let last = out[7].unwrap();
190        assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
191        assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
192        assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
193    }
194
195    #[test]
196    fn reset_clears_state() {
197        let mut qb = QuartileBands::new(4).unwrap();
198        for v in [10.0, 20.0, 30.0, 40.0] {
199            qb.update(v);
200        }
201        assert!(qb.is_ready());
202        qb.reset();
203        assert!(!qb.is_ready());
204        assert!(qb.update(10.0).is_none());
205    }
206}