Skip to main content

wickra_core/indicators/
bomar_bands.rs

1//! Bomar Bands — adaptive percentage bands that contain a target fraction of
2//! recent price.
3
4use std::collections::VecDeque;
5
6use crate::error::{Error, Result};
7use crate::indicators::rolling_quantile::quantile_sorted;
8use crate::indicators::sorted_window;
9use crate::traits::Indicator;
10
11/// Bomar Bands output.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct BomarBandsOutput {
14    /// Upper band: `middle + |middle| · p`.
15    pub upper: f64,
16    /// Middle line: the simple moving average over the window.
17    pub middle: f64,
18    /// Lower band: `middle − |middle| · p`.
19    pub lower: f64,
20}
21
22/// Bomar Bands: percentage bands whose width adapts so that a fixed `coverage`
23/// fraction of recent closes falls inside them.
24///
25/// The Bomar Bands predate Bollinger Bands; John Bollinger cites them as an
26/// inspiration — percentage bands around a moving average, with the percentage
27/// tuned so a fixed share (classically ~85%) of price stayed within. Wickra
28/// realises that idea deterministically: the half-width is the `coverage`
29/// quantile of the relative deviations from the midline, so by construction
30/// `coverage` of the window's closes lie inside the bands.
31///
32/// ```text
33/// middle = SMA(close, period)
34/// dev_i  = | close_i / middle − 1 |          // relative distance from midline
35/// p      = coverage-quantile of { dev_i }     // type-7 interpolation
36/// upper  = middle + |middle| · p
37/// lower  = middle − |middle| · p
38/// ```
39///
40/// Unlike the fixed-percentage [`MaEnvelope`](crate::MaEnvelope), the offset
41/// here is data-driven: the bands widen in turbulent regimes and tighten in
42/// quiet ones without a volatility input. Unlike Bollinger Bands, the width is
43/// an order statistic of the actual deviations rather than a multiple of the
44/// standard deviation, so it is unaffected by the shape of the tails beyond the
45/// `coverage` rank. When the midline is zero the relative deviation is
46/// undefined and the bands collapse onto the midline.
47///
48/// # Example
49///
50/// ```
51/// use wickra_core::{BomarBands, Indicator};
52///
53/// let mut indicator = BomarBands::new(20, 0.85).unwrap();
54/// let mut last = None;
55/// for i in 0..40 {
56///     last = indicator.update(100.0 + f64::from(i % 7));
57/// }
58/// assert!(last.is_some());
59/// ```
60#[derive(Debug, Clone)]
61pub struct BomarBands {
62    period: usize,
63    coverage: f64,
64    window: VecDeque<f64>,
65    /// The window's values in `total_cmp` order, kept sorted as it slides
66    /// (from a period of [`MERGE_FROM`]; empty below it).
67    sorted: Vec<f64>,
68    /// The relative deviations from the mean, in ascending order.
69    scratch: Vec<f64>,
70}
71
72/// The period from which the sorted deviations are merged from a window kept
73/// sorted rather than sorted afresh: below it, sorting a short slice is
74/// cheaper than keeping the window in order and merging two runs. Both give
75/// the same sorted sequence, bit for bit.
76const MERGE_FROM: usize = 32;
77
78impl BomarBands {
79    /// Construct new Bomar Bands.
80    ///
81    /// `coverage` is the target fraction of closes to contain, in `(0.0, 1.0]`.
82    ///
83    /// # Errors
84    /// Returns [`Error::PeriodZero`] if `period == 0`, or
85    /// [`Error::InvalidParameter`] if `coverage` is not a finite value in
86    /// `(0.0, 1.0]`.
87    pub fn new(period: usize, coverage: f64) -> Result<Self> {
88        if period == 0 {
89            return Err(Error::PeriodZero);
90        }
91        if period > crate::error::MAX_PERIOD {
92            return Err(Error::InvalidPeriod {
93                message: crate::error::PERIOD_ABOVE_MAX,
94            });
95        }
96        if !coverage.is_finite() || coverage <= 0.0 || coverage > 1.0 {
97            return Err(Error::InvalidParameter {
98                message: "bomar bands coverage must be a finite value in (0.0, 1.0]",
99            });
100        }
101        Ok(Self {
102            period,
103            coverage,
104            window: VecDeque::with_capacity(period),
105            sorted: Vec::with_capacity(period),
106            scratch: Vec::with_capacity(period),
107        })
108    }
109
110    /// Configured period.
111    pub const fn period(&self) -> usize {
112        self.period
113    }
114
115    /// Configured coverage fraction.
116    pub const fn coverage(&self) -> f64 {
117        self.coverage
118    }
119}
120
121impl Indicator for BomarBands {
122    type Input = f64;
123    type Output = BomarBandsOutput;
124
125    #[inline]
126    fn update(&mut self, value: f64) -> Option<BomarBandsOutput> {
127        if !value.is_finite() {
128            return None;
129        }
130        let merge = self.period >= MERGE_FROM;
131        if self.window.len() == self.period {
132            let oldest = self.window.pop_front().expect("window is full");
133            if merge {
134                sorted_window::remove(&mut self.sorted, oldest);
135            }
136        }
137        self.window.push_back(value);
138        if merge {
139            sorted_window::insert(&mut self.sorted, value);
140        }
141        if self.window.len() < self.period {
142            return None;
143        }
144        let sum: f64 = self.window.iter().sum();
145        let middle = sum / (self.period as f64);
146        let denom = middle.abs();
147
148        if denom == 0.0 {
149            self.scratch.clear();
150            self.scratch.resize(self.period, 0.0);
151        } else if merge {
152            // `|(v - middle) / denom|` falls towards the mean and rises past
153            // it, so the sorted deviations come from merging the two runs.
154            sorted_window::distances(
155                &self.sorted,
156                middle,
157                |v| ((v - middle) / denom).abs(),
158                &mut self.scratch,
159            );
160        } else {
161            self.scratch.clear();
162            let devs = self.window.iter().map(|&v| ((v - middle) / denom).abs());
163            self.scratch.extend(devs);
164            self.scratch.sort_by(f64::total_cmp);
165        }
166        let p = quantile_sorted(&self.scratch, self.coverage);
167        let offset = denom * p;
168
169        Some(BomarBandsOutput {
170            upper: middle + offset,
171            middle,
172            lower: middle - offset,
173        })
174    }
175
176    fn reset(&mut self) {
177        self.window.clear();
178        self.sorted.clear();
179        self.scratch.clear();
180    }
181
182    #[inline]
183    fn warmup_period(&self) -> usize {
184        self.period
185    }
186
187    #[inline]
188    fn is_ready(&self) -> bool {
189        self.window.len() == self.period
190    }
191
192    #[inline]
193    fn name(&self) -> &'static str {
194        "BomarBands"
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::traits::BatchExt;
202    use approx::assert_relative_eq;
203
204    /// Bands from a freshly sorted copy of `window`, as each update used to
205    /// compute them.
206    fn from_scratch(window: &[f64], coverage: f64) -> [f64; 3] {
207        let middle = window.iter().sum::<f64>() / window.len() as f64;
208        let denom = middle.abs();
209        let mut devs: Vec<f64> = window
210            .iter()
211            .map(|&v| {
212                if denom == 0.0 {
213                    0.0
214                } else {
215                    ((v - middle) / denom).abs()
216                }
217            })
218            .collect();
219        devs.sort_by(f64::total_cmp);
220        let offset = denom * quantile_sorted(&devs, coverage);
221        [middle + offset, middle, middle - offset]
222    }
223
224    #[test]
225    fn short_and_long_periods_match_a_fresh_sort() {
226        let prices: Vec<f64> = (0..400)
227            .map(|i| {
228                let t = f64::from(i);
229                100.0 + ((t * 0.07).sin() * 4.0).round() / 2.0 + (t * 0.31).cos()
230            })
231            .collect();
232        for period in [5, MERGE_FROM - 1, MERGE_FROM, 60] {
233            let mut bands = BomarBands::new(period, 0.85).unwrap();
234            for (i, &price) in prices.iter().enumerate() {
235                let got = bands.update(price);
236                if i + 1 >= period {
237                    let o = got.unwrap();
238                    let want = from_scratch(&prices[i + 1 - period..=i], 0.85);
239                    assert_eq!(
240                        [o.upper.to_bits(), o.middle.to_bits(), o.lower.to_bits()],
241                        want.map(f64::to_bits),
242                        "period {period} at {i}"
243                    );
244                }
245            }
246        }
247    }
248
249    #[test]
250    fn rejects_zero_period() {
251        assert!(matches!(BomarBands::new(0, 0.85), Err(Error::PeriodZero)));
252        assert!(BomarBands::new(1, 0.85).is_ok());
253    }
254
255    #[test]
256    fn rejects_out_of_range_coverage() {
257        assert!(matches!(
258            BomarBands::new(20, 0.0),
259            Err(Error::InvalidParameter { .. })
260        ));
261        assert!(matches!(
262            BomarBands::new(20, 1.1),
263            Err(Error::InvalidParameter { .. })
264        ));
265        assert!(matches!(
266            BomarBands::new(20, -0.5),
267            Err(Error::InvalidParameter { .. })
268        ));
269        assert!(matches!(
270            BomarBands::new(20, f64::NAN),
271            Err(Error::InvalidParameter { .. })
272        ));
273    }
274
275    #[test]
276    fn accessors_and_metadata() {
277        let bb = BomarBands::new(20, 0.85).unwrap();
278        assert_eq!(bb.period(), 20);
279        assert_relative_eq!(bb.coverage(), 0.85, epsilon = 1e-12);
280        assert_eq!(bb.warmup_period(), 20);
281        assert_eq!(bb.name(), "BomarBands");
282        assert!(!bb.is_ready());
283    }
284
285    #[test]
286    fn warms_up_then_emits() {
287        let mut bb = BomarBands::new(4, 0.85).unwrap();
288        assert!(bb.update(100.0).is_none());
289        assert!(bb.update(102.0).is_none());
290        assert!(bb.update(98.0).is_none());
291        assert!(bb.update(104.0).is_some());
292        assert!(bb.is_ready());
293    }
294
295    #[test]
296    fn known_bands() {
297        // mean=101; |dev| = {1,1,3,3}/101; coverage 0.85 quantile -> 3/101.
298        // offset = 101 * 3/101 = 3 -> upper 104, lower 98.
299        let mut bb = BomarBands::new(4, 0.85).unwrap();
300        let out = bb.batch(&[100.0, 102.0, 98.0, 104.0]);
301        let last = out[3].unwrap();
302        assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
303        assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
304        assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
305    }
306
307    #[test]
308    fn zero_midline_collapses_bands() {
309        // Window mean exactly zero -> relative deviation undefined -> collapse.
310        let mut bb = BomarBands::new(2, 0.85).unwrap();
311        let out = bb.batch(&[3.0, -3.0]);
312        let last = out[1].unwrap();
313        assert_relative_eq!(last.middle, 0.0, epsilon = 1e-12);
314        assert_relative_eq!(last.upper, 0.0, epsilon = 1e-12);
315        assert_relative_eq!(last.lower, 0.0, epsilon = 1e-12);
316    }
317
318    #[test]
319    fn rolling_window_evicts_oldest() {
320        // Eight values through a period-4 window: only the last four survive,
321        // reproducing the `known_bands` window.
322        let mut bb = BomarBands::new(4, 0.85).unwrap();
323        let out = bb.batch(&[50.0, 50.0, 50.0, 50.0, 100.0, 102.0, 98.0, 104.0]);
324        let last = out[7].unwrap();
325        assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
326        assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
327        assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
328    }
329
330    #[test]
331    fn reset_clears_state() {
332        let mut bb = BomarBands::new(4, 0.85).unwrap();
333        for v in [100.0, 102.0, 98.0, 104.0] {
334            bb.update(v);
335        }
336        assert!(bb.is_ready());
337        bb.reset();
338        assert!(!bb.is_ready());
339        assert!(bb.update(100.0).is_none());
340    }
341}