wickra_core/indicators/
rolling_quantile.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
39pub struct RollingQuantile {
40 period: usize,
41 quantile: f64,
42 window: VecDeque<f64>,
43 scratch: Vec<f64>,
45}
46
47impl RollingQuantile {
48 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 pub const fn period(&self) -> usize {
80 self.period
81 }
82
83 pub const fn quantile(&self) -> f64 {
85 self.quantile
86 }
87}
88
89pub(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 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 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 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 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}