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