Skip to main content

wickra_core/indicators/
rolling_quantile.rs

1//! Rolling Quantile over a trailing window.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// The `quantile`-th quantile of the last `period` values, with linear
9/// interpolation between order statistics.
10///
11/// ```text
12/// h        = (period − 1) · quantile
13/// lower    = ⌊h⌋
14/// result   = sorted[lower] + (h − lower) · (sorted[lower + 1] − sorted[lower])
15/// ```
16///
17/// This is the type-7 / NumPy-default `quantile` definition: `quantile = 0.0`
18/// returns the window minimum, `0.5` the median, `1.0` the maximum, and
19/// fractional values interpolate linearly between the bracketing order
20/// statistics. Rolling quantiles are the building block for distribution-aware
21/// thresholds — a price sitting above its rolling 90th-percentile, a volatility
22/// regime split at the 25th/75th percentiles, robust band edges that ignore the
23/// tails.
24///
25/// Each `update` is O(period log period): the window is copied into a scratch
26/// buffer and sorted with total ordering (NaN-safe).
27///
28/// # Example
29///
30/// ```
31/// use wickra_core::{Indicator, RollingQuantile};
32///
33/// // Rolling median of the last 5 values.
34/// let mut indicator = RollingQuantile::new(5, 0.5).unwrap();
35/// let out = indicator.update(1.0);
36/// assert!(out.is_none()); // warming up
37/// ```
38#[derive(Debug, Clone)]
39pub struct RollingQuantile {
40    period: usize,
41    quantile: f64,
42    window: VecDeque<f64>,
43    /// Reusable scratch buffer to avoid allocating per `update`.
44    scratch: Vec<f64>,
45}
46
47impl RollingQuantile {
48    /// Construct a new rolling quantile.
49    ///
50    /// `quantile` selects the order statistic in `[0.0, 1.0]`.
51    ///
52    /// # Errors
53    /// Returns [`Error::PeriodZero`] if `period == 0`, or
54    /// [`Error::InvalidParameter`] if `quantile` is not a finite value in
55    /// `[0.0, 1.0]`.
56    pub fn new(period: usize, quantile: f64) -> Result<Self> {
57        if period == 0 {
58            return Err(Error::PeriodZero);
59        }
60        if !quantile.is_finite() || !(0.0..=1.0).contains(&quantile) {
61            return Err(Error::InvalidParameter {
62                message: "rolling quantile must be a finite value in [0.0, 1.0]",
63            });
64        }
65        Ok(Self {
66            period,
67            quantile,
68            window: VecDeque::with_capacity(period),
69            scratch: Vec::with_capacity(period),
70        })
71    }
72
73    /// Configured period.
74    pub const fn period(&self) -> usize {
75        self.period
76    }
77
78    /// Configured quantile in `[0.0, 1.0]`.
79    pub const fn quantile(&self) -> f64 {
80        self.quantile
81    }
82}
83
84/// Linearly-interpolated quantile of a sorted, non-empty slice (type-7).
85pub(crate) fn quantile_sorted(sorted: &[f64], quantile: f64) -> f64 {
86    let n = sorted.len();
87    if n == 1 {
88        return sorted[0];
89    }
90    let h = (n - 1) as f64 * quantile;
91    let lower = h.floor();
92    let idx = lower as usize;
93    // `idx <= n - 1`: when `quantile == 1.0`, `h == n - 1` and `idx == n - 1`,
94    // so the interpolation neighbour would be out of bounds — return the top.
95    if idx >= n - 1 {
96        return sorted[n - 1];
97    }
98    let frac = h - lower;
99    sorted[idx] + frac * (sorted[idx + 1] - sorted[idx])
100}
101
102impl Indicator for RollingQuantile {
103    type Input = f64;
104    type Output = f64;
105
106    fn update(&mut self, value: f64) -> Option<f64> {
107        if !value.is_finite() {
108            return None;
109        }
110        if self.window.len() == self.period {
111            self.window.pop_front();
112        }
113        self.window.push_back(value);
114        if self.window.len() < self.period {
115            return None;
116        }
117        self.scratch.clear();
118        self.scratch.extend(self.window.iter().copied());
119        self.scratch.sort_by(f64::total_cmp);
120        Some(quantile_sorted(&self.scratch, self.quantile))
121    }
122
123    fn reset(&mut self) {
124        self.window.clear();
125        self.scratch.clear();
126    }
127
128    fn warmup_period(&self) -> usize {
129        self.period
130    }
131
132    fn is_ready(&self) -> bool {
133        self.window.len() == self.period
134    }
135
136    fn name(&self) -> &'static str {
137        "RollingQuantile"
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::traits::BatchExt;
145    use approx::assert_relative_eq;
146
147    #[test]
148    fn rejects_zero_period() {
149        assert!(matches!(
150            RollingQuantile::new(0, 0.5),
151            Err(Error::PeriodZero)
152        ));
153    }
154
155    #[test]
156    fn rejects_out_of_range_quantile() {
157        assert!(matches!(
158            RollingQuantile::new(5, -0.1),
159            Err(Error::InvalidParameter { .. })
160        ));
161        assert!(matches!(
162            RollingQuantile::new(5, 1.1),
163            Err(Error::InvalidParameter { .. })
164        ));
165        assert!(matches!(
166            RollingQuantile::new(5, f64::NAN),
167            Err(Error::InvalidParameter { .. })
168        ));
169    }
170
171    #[test]
172    fn accessors_and_metadata() {
173        let q = RollingQuantile::new(14, 0.25).unwrap();
174        assert_eq!(q.period(), 14);
175        assert_relative_eq!(q.quantile(), 0.25, epsilon = 1e-12);
176        assert_eq!(q.warmup_period(), 14);
177        assert_eq!(q.name(), "RollingQuantile");
178        assert!(!q.is_ready());
179    }
180
181    #[test]
182    fn median_of_window() {
183        // Window [5, 1, 3, 2, 4] sorted [1,2,3,4,5] → median 3.
184        let mut q = RollingQuantile::new(5, 0.5).unwrap();
185        let out = q.batch(&[5.0, 1.0, 3.0, 2.0, 4.0]);
186        assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
187    }
188
189    #[test]
190    fn min_and_max_quantiles() {
191        let prices = [5.0, 1.0, 3.0, 2.0, 4.0];
192        let lo = RollingQuantile::new(5, 0.0).unwrap().batch(&prices)[4].unwrap();
193        let hi = RollingQuantile::new(5, 1.0).unwrap().batch(&prices)[4].unwrap();
194        assert_relative_eq!(lo, 1.0, epsilon = 1e-12);
195        assert_relative_eq!(hi, 5.0, epsilon = 1e-12);
196    }
197
198    #[test]
199    fn interpolated_quantile() {
200        // sorted [10,20,30,40]: q=0.25 → h=(4-1)*0.25=0.75 → 10 + 0.75*(20-10)=17.5.
201        let mut q = RollingQuantile::new(4, 0.25).unwrap();
202        let out = q.batch(&[40.0, 30.0, 20.0, 10.0]);
203        assert_relative_eq!(out[3].unwrap(), 17.5, epsilon = 1e-12);
204    }
205
206    #[test]
207    fn single_period_returns_value() {
208        // period 1: window holds one value; quantile of a singleton is itself.
209        let mut q = RollingQuantile::new(1, 0.3).unwrap();
210        assert_relative_eq!(q.update(7.0).unwrap(), 7.0, epsilon = 1e-12);
211    }
212
213    #[test]
214    fn reset_clears_state() {
215        let mut q = RollingQuantile::new(5, 0.5).unwrap();
216        q.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
217        assert!(q.is_ready());
218        q.reset();
219        assert!(!q.is_ready());
220        assert_eq!(q.update(1.0), None);
221    }
222
223    #[test]
224    fn batch_equals_streaming() {
225        let prices: Vec<f64> = (0..60)
226            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
227            .collect();
228        let batch = RollingQuantile::new(14, 0.75).unwrap().batch(&prices);
229        let mut b = RollingQuantile::new(14, 0.75).unwrap();
230        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
231        assert_eq!(batch, streamed);
232    }
233}