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 period > crate::error::MAX_PERIOD {
61            return Err(Error::InvalidPeriod {
62                message: crate::error::PERIOD_ABOVE_MAX,
63            });
64        }
65        if !quantile.is_finite() || !(0.0..=1.0).contains(&quantile) {
66            return Err(Error::InvalidParameter {
67                message: "rolling quantile must be a finite value in [0.0, 1.0]",
68            });
69        }
70        Ok(Self {
71            period,
72            quantile,
73            window: VecDeque::with_capacity(period),
74            scratch: Vec::with_capacity(period),
75        })
76    }
77
78    /// Configured period.
79    pub const fn period(&self) -> usize {
80        self.period
81    }
82
83    /// Configured quantile in `[0.0, 1.0]`.
84    pub const fn quantile(&self) -> f64 {
85        self.quantile
86    }
87}
88
89/// Linearly-interpolated quantile of a sorted, non-empty slice (type-7).
90pub(crate) fn quantile_sorted(sorted: &[f64], quantile: f64) -> f64 {
91    let n = sorted.len();
92    if n == 1 {
93        return sorted[0];
94    }
95    let h = (n - 1) as f64 * quantile;
96    let lower = h.floor();
97    let idx = lower as usize;
98    // `idx <= n - 1`: when `quantile == 1.0`, `h == n - 1` and `idx == n - 1`,
99    // so the interpolation neighbour would be out of bounds — return the top.
100    if idx >= n - 1 {
101        return sorted[n - 1];
102    }
103    let frac = h - lower;
104    sorted[idx] + frac * (sorted[idx + 1] - sorted[idx])
105}
106
107impl Indicator for RollingQuantile {
108    type Input = f64;
109    type Output = f64;
110
111    #[inline]
112    fn update(&mut self, value: f64) -> Option<f64> {
113        if !value.is_finite() {
114            return None;
115        }
116        if self.window.len() == self.period {
117            self.window.pop_front();
118        }
119        self.window.push_back(value);
120        if self.window.len() < self.period {
121            return None;
122        }
123        self.scratch.clear();
124        self.scratch.extend(self.window.iter().copied());
125        self.scratch.sort_by(f64::total_cmp);
126        Some(quantile_sorted(&self.scratch, self.quantile))
127    }
128
129    fn reset(&mut self) {
130        self.window.clear();
131        self.scratch.clear();
132    }
133
134    #[inline]
135    fn warmup_period(&self) -> usize {
136        self.period
137    }
138
139    #[inline]
140    fn is_ready(&self) -> bool {
141        self.window.len() == self.period
142    }
143
144    #[inline]
145    fn name(&self) -> &'static str {
146        "RollingQuantile"
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::traits::BatchExt;
154    use approx::assert_relative_eq;
155
156    #[test]
157    fn rejects_zero_period() {
158        assert!(matches!(
159            RollingQuantile::new(0, 0.5),
160            Err(Error::PeriodZero)
161        ));
162    }
163
164    #[test]
165    fn rejects_out_of_range_quantile() {
166        assert!(matches!(
167            RollingQuantile::new(5, -0.1),
168            Err(Error::InvalidParameter { .. })
169        ));
170        assert!(matches!(
171            RollingQuantile::new(5, 1.1),
172            Err(Error::InvalidParameter { .. })
173        ));
174        assert!(matches!(
175            RollingQuantile::new(5, f64::NAN),
176            Err(Error::InvalidParameter { .. })
177        ));
178    }
179
180    #[test]
181    fn accessors_and_metadata() {
182        let q = RollingQuantile::new(14, 0.25).unwrap();
183        assert_eq!(q.period(), 14);
184        assert_relative_eq!(q.quantile(), 0.25, epsilon = 1e-12);
185        assert_eq!(q.warmup_period(), 14);
186        assert_eq!(q.name(), "RollingQuantile");
187        assert!(!q.is_ready());
188    }
189
190    #[test]
191    fn median_of_window() {
192        // Window [5, 1, 3, 2, 4] sorted [1,2,3,4,5] → median 3.
193        let mut q = RollingQuantile::new(5, 0.5).unwrap();
194        let out = q.batch(&[5.0, 1.0, 3.0, 2.0, 4.0]);
195        assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
196    }
197
198    #[test]
199    fn min_and_max_quantiles() {
200        let prices = [5.0, 1.0, 3.0, 2.0, 4.0];
201        let lo = RollingQuantile::new(5, 0.0).unwrap().batch(&prices)[4].unwrap();
202        let hi = RollingQuantile::new(5, 1.0).unwrap().batch(&prices)[4].unwrap();
203        assert_relative_eq!(lo, 1.0, epsilon = 1e-12);
204        assert_relative_eq!(hi, 5.0, epsilon = 1e-12);
205    }
206
207    #[test]
208    fn interpolated_quantile() {
209        // sorted [10,20,30,40]: q=0.25 → h=(4-1)*0.25=0.75 → 10 + 0.75*(20-10)=17.5.
210        let mut q = RollingQuantile::new(4, 0.25).unwrap();
211        let out = q.batch(&[40.0, 30.0, 20.0, 10.0]);
212        assert_relative_eq!(out[3].unwrap(), 17.5, epsilon = 1e-12);
213    }
214
215    #[test]
216    fn single_period_returns_value() {
217        // period 1: window holds one value; quantile of a singleton is itself.
218        let mut q = RollingQuantile::new(1, 0.3).unwrap();
219        assert_relative_eq!(q.update(7.0).unwrap(), 7.0, epsilon = 1e-12);
220    }
221
222    #[test]
223    fn reset_clears_state() {
224        let mut q = RollingQuantile::new(5, 0.5).unwrap();
225        q.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
226        assert!(q.is_ready());
227        q.reset();
228        assert!(!q.is_ready());
229        assert_eq!(q.update(1.0), None);
230    }
231
232    #[test]
233    fn batch_equals_streaming() {
234        let prices: Vec<f64> = (0..60)
235            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
236            .collect();
237        let batch = RollingQuantile::new(14, 0.75).unwrap().batch(&prices);
238        let mut b = RollingQuantile::new(14, 0.75).unwrap();
239        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
240        assert_eq!(batch, streamed);
241    }
242}