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