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::traits::Indicator;
9
10/// Bomar Bands output.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct BomarBandsOutput {
13    /// Upper band: `middle + |middle| · p`.
14    pub upper: f64,
15    /// Middle line: the simple moving average over the window.
16    pub middle: f64,
17    /// Lower band: `middle − |middle| · p`.
18    pub lower: f64,
19}
20
21/// Bomar Bands: percentage bands whose width adapts so that a fixed `coverage`
22/// fraction of recent closes falls inside them.
23///
24/// The Bomar Bands predate Bollinger Bands; John Bollinger cites them as an
25/// inspiration — percentage bands around a moving average, with the percentage
26/// tuned so a fixed share (classically ~85%) of price stayed within. Wickra
27/// realises that idea deterministically: the half-width is the `coverage`
28/// quantile of the relative deviations from the midline, so by construction
29/// `coverage` of the window's closes lie inside the bands.
30///
31/// ```text
32/// middle = SMA(close, period)
33/// dev_i  = | close_i / middle − 1 |          // relative distance from midline
34/// p      = coverage-quantile of { dev_i }     // type-7 interpolation
35/// upper  = middle + |middle| · p
36/// lower  = middle − |middle| · p
37/// ```
38///
39/// Unlike the fixed-percentage [`MaEnvelope`](crate::MaEnvelope), the offset
40/// here is data-driven: the bands widen in turbulent regimes and tighten in
41/// quiet ones without a volatility input. Unlike Bollinger Bands, the width is
42/// an order statistic of the actual deviations rather than a multiple of the
43/// standard deviation, so it is unaffected by the shape of the tails beyond the
44/// `coverage` rank. When the midline is zero the relative deviation is
45/// undefined and the bands collapse onto the midline.
46///
47/// # Example
48///
49/// ```
50/// use wickra_core::{BomarBands, Indicator};
51///
52/// let mut indicator = BomarBands::new(20, 0.85).unwrap();
53/// let mut last = None;
54/// for i in 0..40 {
55///     last = indicator.update(100.0 + f64::from(i % 7));
56/// }
57/// assert!(last.is_some());
58/// ```
59#[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    /// Construct new Bomar Bands.
69    ///
70    /// `coverage` is the target fraction of closes to contain, in `(0.0, 1.0]`.
71    ///
72    /// # Errors
73    /// Returns [`Error::PeriodZero`] if `period == 0`, or
74    /// [`Error::InvalidParameter`] if `coverage` is not a finite value in
75    /// `(0.0, 1.0]`.
76    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    /// Configured period.
94    pub const fn period(&self) -> usize {
95        self.period
96    }
97
98    /// Configured coverage fraction.
99    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        // mean=101; |dev| = {1,1,3,3}/101; coverage 0.85 quantile -> 3/101.
216        // offset = 101 * 3/101 = 3 -> upper 104, lower 98.
217        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        // Window mean exactly zero -> relative deviation undefined -> collapse.
228        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        // Eight values through a period-4 window: only the last four survive,
239        // reproducing the `known_bands` window.
240        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}