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