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