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 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    /// Configured period.
99    pub const fn period(&self) -> usize {
100        self.period
101    }
102
103    /// Configured coverage fraction.
104    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        // mean=101; |dev| = {1,1,3,3}/101; coverage 0.85 quantile -> 3/101.
225        // offset = 101 * 3/101 = 3 -> upper 104, lower 98.
226        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        // Window mean exactly zero -> relative deviation undefined -> collapse.
237        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        // Eight values through a period-4 window: only the last four survive,
248        // reproducing the `known_bands` window.
249        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}