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 Ok(Self {
66 period,
67 window: VecDeque::with_capacity(period),
68 scratch: Vec::with_capacity(period),
69 })
70 }
71
72 pub const fn period(&self) -> usize {
74 self.period
75 }
76}
77
78impl Indicator for QuartileBands {
79 type Input = f64;
80 type Output = QuartileBandsOutput;
81
82 fn update(&mut self, value: f64) -> Option<QuartileBandsOutput> {
83 if !value.is_finite() {
84 return None;
85 }
86 if self.window.len() == self.period {
87 self.window.pop_front();
88 }
89 self.window.push_back(value);
90 if self.window.len() < self.period {
91 return None;
92 }
93 self.scratch.clear();
94 self.scratch.extend(self.window.iter().copied());
95 self.scratch.sort_by(f64::total_cmp);
96 Some(QuartileBandsOutput {
97 upper: quantile_sorted(&self.scratch, 0.75),
98 middle: quantile_sorted(&self.scratch, 0.5),
99 lower: quantile_sorted(&self.scratch, 0.25),
100 })
101 }
102
103 fn reset(&mut self) {
104 self.window.clear();
105 self.scratch.clear();
106 }
107
108 fn warmup_period(&self) -> usize {
109 self.period
110 }
111
112 fn is_ready(&self) -> bool {
113 self.window.len() == self.period
114 }
115
116 fn name(&self) -> &'static str {
117 "QuartileBands"
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::traits::BatchExt;
125 use approx::assert_relative_eq;
126
127 #[test]
128 fn rejects_zero_period() {
129 assert!(matches!(QuartileBands::new(0), Err(Error::PeriodZero)));
130 assert!(QuartileBands::new(1).is_ok());
131 }
132
133 #[test]
134 fn accessors_and_metadata() {
135 let qb = QuartileBands::new(20).unwrap();
136 assert_eq!(qb.period(), 20);
137 assert_eq!(qb.warmup_period(), 20);
138 assert_eq!(qb.name(), "QuartileBands");
139 assert!(!qb.is_ready());
140 }
141
142 #[test]
143 fn warms_up_then_emits() {
144 let mut qb = QuartileBands::new(4).unwrap();
145 assert!(qb.update(10.0).is_none());
146 assert!(qb.update(20.0).is_none());
147 assert!(qb.update(30.0).is_none());
148 assert!(qb.update(40.0).is_some());
149 assert!(qb.is_ready());
150 }
151
152 #[test]
153 fn known_quartiles() {
154 let mut qb = QuartileBands::new(4).unwrap();
159 let out = qb.batch(&[40.0, 30.0, 20.0, 10.0]);
160 let last = out[3].unwrap();
161 assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
162 assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
163 assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
164 }
165
166 #[test]
167 fn median_robust_to_outlier() {
168 let mut qb = QuartileBands::new(5).unwrap();
170 let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 1000.0]);
171 assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12);
172 }
173
174 #[test]
175 fn rolling_window_evicts_oldest() {
176 let mut qb = QuartileBands::new(4).unwrap();
179 let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 40.0, 30.0, 20.0, 10.0]);
180 let last = out[7].unwrap();
181 assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
182 assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
183 assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
184 }
185
186 #[test]
187 fn reset_clears_state() {
188 let mut qb = QuartileBands::new(4).unwrap();
189 for v in [10.0, 20.0, 30.0, 40.0] {
190 qb.update(v);
191 }
192 assert!(qb.is_ready());
193 qb.reset();
194 assert!(!qb.is_ready());
195 assert!(qb.update(10.0).is_none());
196 }
197}