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 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        // mean=101; |dev| = {1,1,3,3}/101; coverage 0.85 quantile -> 3/101.
213        // offset = 101 * 3/101 = 3 -> upper 104, lower 98.
214        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        // Window mean exactly zero -> relative deviation undefined -> collapse.
225        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        // Eight values through a period-4 window: only the last four survive,
236        // reproducing the `known_bands` window.
237        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}