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