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