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