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