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