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 self.window.len() == self.period {
108            self.window.pop_front();
109        }
110        self.window.push_back(value);
111        if self.window.len() < self.period {
112            return None;
113        }
114        self.scratch.clear();
115        self.scratch.extend(self.window.iter().copied());
116        self.scratch.sort_by(f64::total_cmp);
117        Some(quantile_sorted(&self.scratch, self.quantile))
118    }
119
120    fn reset(&mut self) {
121        self.window.clear();
122        self.scratch.clear();
123    }
124
125    fn warmup_period(&self) -> usize {
126        self.period
127    }
128
129    fn is_ready(&self) -> bool {
130        self.window.len() == self.period
131    }
132
133    fn name(&self) -> &'static str {
134        "RollingQuantile"
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::traits::BatchExt;
142    use approx::assert_relative_eq;
143
144    #[test]
145    fn rejects_zero_period() {
146        assert!(matches!(
147            RollingQuantile::new(0, 0.5),
148            Err(Error::PeriodZero)
149        ));
150    }
151
152    #[test]
153    fn rejects_out_of_range_quantile() {
154        assert!(matches!(
155            RollingQuantile::new(5, -0.1),
156            Err(Error::InvalidParameter { .. })
157        ));
158        assert!(matches!(
159            RollingQuantile::new(5, 1.1),
160            Err(Error::InvalidParameter { .. })
161        ));
162        assert!(matches!(
163            RollingQuantile::new(5, f64::NAN),
164            Err(Error::InvalidParameter { .. })
165        ));
166    }
167
168    #[test]
169    fn accessors_and_metadata() {
170        let q = RollingQuantile::new(14, 0.25).unwrap();
171        assert_eq!(q.period(), 14);
172        assert_relative_eq!(q.quantile(), 0.25, epsilon = 1e-12);
173        assert_eq!(q.warmup_period(), 14);
174        assert_eq!(q.name(), "RollingQuantile");
175        assert!(!q.is_ready());
176    }
177
178    #[test]
179    fn median_of_window() {
180        // Window [5, 1, 3, 2, 4] sorted [1,2,3,4,5] → median 3.
181        let mut q = RollingQuantile::new(5, 0.5).unwrap();
182        let out = q.batch(&[5.0, 1.0, 3.0, 2.0, 4.0]);
183        assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
184    }
185
186    #[test]
187    fn min_and_max_quantiles() {
188        let prices = [5.0, 1.0, 3.0, 2.0, 4.0];
189        let lo = RollingQuantile::new(5, 0.0).unwrap().batch(&prices)[4].unwrap();
190        let hi = RollingQuantile::new(5, 1.0).unwrap().batch(&prices)[4].unwrap();
191        assert_relative_eq!(lo, 1.0, epsilon = 1e-12);
192        assert_relative_eq!(hi, 5.0, epsilon = 1e-12);
193    }
194
195    #[test]
196    fn interpolated_quantile() {
197        // sorted [10,20,30,40]: q=0.25 → h=(4-1)*0.25=0.75 → 10 + 0.75*(20-10)=17.5.
198        let mut q = RollingQuantile::new(4, 0.25).unwrap();
199        let out = q.batch(&[40.0, 30.0, 20.0, 10.0]);
200        assert_relative_eq!(out[3].unwrap(), 17.5, epsilon = 1e-12);
201    }
202
203    #[test]
204    fn single_period_returns_value() {
205        // period 1: window holds one value; quantile of a singleton is itself.
206        let mut q = RollingQuantile::new(1, 0.3).unwrap();
207        assert_relative_eq!(q.update(7.0).unwrap(), 7.0, epsilon = 1e-12);
208    }
209
210    #[test]
211    fn reset_clears_state() {
212        let mut q = RollingQuantile::new(5, 0.5).unwrap();
213        q.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
214        assert!(q.is_ready());
215        q.reset();
216        assert!(!q.is_ready());
217        assert_eq!(q.update(1.0), None);
218    }
219
220    #[test]
221    fn batch_equals_streaming() {
222        let prices: Vec<f64> = (0..60)
223            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
224            .collect();
225        let batch = RollingQuantile::new(14, 0.75).unwrap().batch(&prices);
226        let mut b = RollingQuantile::new(14, 0.75).unwrap();
227        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
228        assert_eq!(batch, streamed);
229    }
230}