wickra_core/indicators/
quartile_bands.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_quantile::quantile_sorted;
7use crate::indicators::sorted_window;
8use crate::traits::Indicator;
9
10#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct QuartileBandsOutput {
13 pub upper: f64,
15 pub middle: f64,
17 pub lower: f64,
19}
20
21#[derive(Debug, Clone)]
51pub struct QuartileBands {
52 period: usize,
53 window: VecDeque<f64>,
54 scratch: Vec<f64>,
57}
58
59impl QuartileBands {
60 pub fn new(period: usize) -> Result<Self> {
65 if period == 0 {
66 return Err(Error::PeriodZero);
67 }
68 if period > crate::error::MAX_PERIOD {
69 return Err(Error::InvalidPeriod {
70 message: crate::error::PERIOD_ABOVE_MAX,
71 });
72 }
73 Ok(Self {
74 period,
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
86impl Indicator for QuartileBands {
87 type Input = f64;
88 type Output = QuartileBandsOutput;
89
90 #[inline]
91 fn update(&mut self, value: f64) -> Option<QuartileBandsOutput> {
92 if !value.is_finite() {
93 return None;
94 }
95 if self.window.len() == self.period {
96 let oldest = self.window.pop_front().expect("window is full");
97 sorted_window::remove(&mut self.scratch, oldest);
98 }
99 self.window.push_back(value);
100 sorted_window::insert(&mut self.scratch, value);
101 if self.window.len() < self.period {
102 return None;
103 }
104 Some(QuartileBandsOutput {
105 upper: quantile_sorted(&self.scratch, 0.75),
106 middle: quantile_sorted(&self.scratch, 0.5),
107 lower: quantile_sorted(&self.scratch, 0.25),
108 })
109 }
110
111 fn reset(&mut self) {
112 self.window.clear();
113 self.scratch.clear();
114 }
115
116 #[inline]
117 fn warmup_period(&self) -> usize {
118 self.period
119 }
120
121 #[inline]
122 fn is_ready(&self) -> bool {
123 self.window.len() == self.period
124 }
125
126 #[inline]
127 fn name(&self) -> &'static str {
128 "QuartileBands"
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use crate::traits::BatchExt;
136 use approx::assert_relative_eq;
137
138 #[test]
139 fn rejects_zero_period() {
140 assert!(matches!(QuartileBands::new(0), Err(Error::PeriodZero)));
141 assert!(QuartileBands::new(1).is_ok());
142 }
143
144 #[test]
145 fn accessors_and_metadata() {
146 let qb = QuartileBands::new(20).unwrap();
147 assert_eq!(qb.period(), 20);
148 assert_eq!(qb.warmup_period(), 20);
149 assert_eq!(qb.name(), "QuartileBands");
150 assert!(!qb.is_ready());
151 }
152
153 #[test]
154 fn warms_up_then_emits() {
155 let mut qb = QuartileBands::new(4).unwrap();
156 assert!(qb.update(10.0).is_none());
157 assert!(qb.update(20.0).is_none());
158 assert!(qb.update(30.0).is_none());
159 assert!(qb.update(40.0).is_some());
160 assert!(qb.is_ready());
161 }
162
163 #[test]
164 fn known_quartiles() {
165 let mut qb = QuartileBands::new(4).unwrap();
170 let out = qb.batch(&[40.0, 30.0, 20.0, 10.0]);
171 let last = out[3].unwrap();
172 assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
173 assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
174 assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
175 }
176
177 #[test]
178 fn median_robust_to_outlier() {
179 let mut qb = QuartileBands::new(5).unwrap();
181 let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 1000.0]);
182 assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12);
183 }
184
185 #[test]
186 fn rolling_window_evicts_oldest() {
187 let mut qb = QuartileBands::new(4).unwrap();
190 let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 40.0, 30.0, 20.0, 10.0]);
191 let last = out[7].unwrap();
192 assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
193 assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
194 assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
195 }
196
197 #[test]
198 fn reset_clears_state() {
199 let mut qb = QuartileBands::new(4).unwrap();
200 for v in [10.0, 20.0, 30.0, 40.0] {
201 qb.update(v);
202 }
203 assert!(qb.is_ready());
204 qb.reset();
205 assert!(!qb.is_ready());
206 assert!(qb.update(10.0).is_none());
207 }
208}