wickra_core/indicators/
bomar_bands.rs1use std::collections::VecDeque;
5
6use crate::error::{Error, Result};
7use crate::indicators::rolling_quantile::quantile_sorted;
8use crate::traits::Indicator;
9
10#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct BomarBandsOutput {
13 pub upper: f64,
15 pub middle: f64,
17 pub lower: f64,
19}
20
21#[derive(Debug, Clone)]
60pub struct BomarBands {
61 period: usize,
62 coverage: f64,
63 window: VecDeque<f64>,
64 scratch: Vec<f64>,
65}
66
67impl BomarBands {
68 pub fn new(period: usize, coverage: f64) -> Result<Self> {
77 if period == 0 {
78 return Err(Error::PeriodZero);
79 }
80 if period > crate::error::MAX_PERIOD {
81 return Err(Error::InvalidPeriod {
82 message: crate::error::PERIOD_ABOVE_MAX,
83 });
84 }
85 if !coverage.is_finite() || coverage <= 0.0 || coverage > 1.0 {
86 return Err(Error::InvalidParameter {
87 message: "bomar bands coverage must be a finite value in (0.0, 1.0]",
88 });
89 }
90 Ok(Self {
91 period,
92 coverage,
93 window: VecDeque::with_capacity(period),
94 scratch: Vec::with_capacity(period),
95 })
96 }
97
98 pub const fn period(&self) -> usize {
100 self.period
101 }
102
103 pub const fn coverage(&self) -> f64 {
105 self.coverage
106 }
107}
108
109impl Indicator for BomarBands {
110 type Input = f64;
111 type Output = BomarBandsOutput;
112
113 #[inline]
114 fn update(&mut self, value: f64) -> Option<BomarBandsOutput> {
115 if !value.is_finite() {
116 return None;
117 }
118 if self.window.len() == self.period {
119 self.window.pop_front();
120 }
121 self.window.push_back(value);
122 if self.window.len() < self.period {
123 return None;
124 }
125 let sum: f64 = self.window.iter().sum();
126 let middle = sum / (self.period as f64);
127 let denom = middle.abs();
128
129 self.scratch.clear();
130 for &v in &self.window {
131 let dev = if denom == 0.0 {
132 0.0
133 } else {
134 ((v - middle) / denom).abs()
135 };
136 self.scratch.push(dev);
137 }
138 self.scratch.sort_by(f64::total_cmp);
139 let p = quantile_sorted(&self.scratch, self.coverage);
140 let offset = denom * p;
141
142 Some(BomarBandsOutput {
143 upper: middle + offset,
144 middle,
145 lower: middle - offset,
146 })
147 }
148
149 fn reset(&mut self) {
150 self.window.clear();
151 self.scratch.clear();
152 }
153
154 #[inline]
155 fn warmup_period(&self) -> usize {
156 self.period
157 }
158
159 #[inline]
160 fn is_ready(&self) -> bool {
161 self.window.len() == self.period
162 }
163
164 #[inline]
165 fn name(&self) -> &'static str {
166 "BomarBands"
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::traits::BatchExt;
174 use approx::assert_relative_eq;
175
176 #[test]
177 fn rejects_zero_period() {
178 assert!(matches!(BomarBands::new(0, 0.85), Err(Error::PeriodZero)));
179 assert!(BomarBands::new(1, 0.85).is_ok());
180 }
181
182 #[test]
183 fn rejects_out_of_range_coverage() {
184 assert!(matches!(
185 BomarBands::new(20, 0.0),
186 Err(Error::InvalidParameter { .. })
187 ));
188 assert!(matches!(
189 BomarBands::new(20, 1.1),
190 Err(Error::InvalidParameter { .. })
191 ));
192 assert!(matches!(
193 BomarBands::new(20, -0.5),
194 Err(Error::InvalidParameter { .. })
195 ));
196 assert!(matches!(
197 BomarBands::new(20, f64::NAN),
198 Err(Error::InvalidParameter { .. })
199 ));
200 }
201
202 #[test]
203 fn accessors_and_metadata() {
204 let bb = BomarBands::new(20, 0.85).unwrap();
205 assert_eq!(bb.period(), 20);
206 assert_relative_eq!(bb.coverage(), 0.85, epsilon = 1e-12);
207 assert_eq!(bb.warmup_period(), 20);
208 assert_eq!(bb.name(), "BomarBands");
209 assert!(!bb.is_ready());
210 }
211
212 #[test]
213 fn warms_up_then_emits() {
214 let mut bb = BomarBands::new(4, 0.85).unwrap();
215 assert!(bb.update(100.0).is_none());
216 assert!(bb.update(102.0).is_none());
217 assert!(bb.update(98.0).is_none());
218 assert!(bb.update(104.0).is_some());
219 assert!(bb.is_ready());
220 }
221
222 #[test]
223 fn known_bands() {
224 let mut bb = BomarBands::new(4, 0.85).unwrap();
227 let out = bb.batch(&[100.0, 102.0, 98.0, 104.0]);
228 let last = out[3].unwrap();
229 assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
230 assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
231 assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
232 }
233
234 #[test]
235 fn zero_midline_collapses_bands() {
236 let mut bb = BomarBands::new(2, 0.85).unwrap();
238 let out = bb.batch(&[3.0, -3.0]);
239 let last = out[1].unwrap();
240 assert_relative_eq!(last.middle, 0.0, epsilon = 1e-12);
241 assert_relative_eq!(last.upper, 0.0, epsilon = 1e-12);
242 assert_relative_eq!(last.lower, 0.0, epsilon = 1e-12);
243 }
244
245 #[test]
246 fn rolling_window_evicts_oldest() {
247 let mut bb = BomarBands::new(4, 0.85).unwrap();
250 let out = bb.batch(&[50.0, 50.0, 50.0, 50.0, 100.0, 102.0, 98.0, 104.0]);
251 let last = out[7].unwrap();
252 assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
253 assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
254 assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
255 }
256
257 #[test]
258 fn reset_clears_state() {
259 let mut bb = BomarBands::new(4, 0.85).unwrap();
260 for v in [100.0, 102.0, 98.0, 104.0] {
261 bb.update(v);
262 }
263 assert!(bb.is_ready());
264 bb.reset();
265 assert!(!bb.is_ready());
266 assert!(bb.update(100.0).is_none());
267 }
268}