Skip to main content

wickra_core/indicators/
bollinger.rs

1//! Bollinger Bands.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Bollinger Bands output.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct BollingerOutput {
11    /// Upper band: `middle + multiplier * stddev`.
12    pub upper: f64,
13    /// Middle band: SMA over the window.
14    pub middle: f64,
15    /// Lower band: `middle − multiplier * stddev`.
16    pub lower: f64,
17    /// Sample standard deviation (denominator `period`, population stddev) used to build
18    /// the bands. Reported separately because some callers compute their own bands.
19    pub stddev: f64,
20}
21
22/// Bollinger Bands with SMA middle band and population standard deviation envelopes.
23///
24/// Standard parameters are `period = 20`, `multiplier = 2.0`. Bollinger's original
25/// publication uses population (not sample) standard deviation, which matches every
26/// reference implementation (TA-Lib, pandas-ta, etc.).
27///
28/// The running `sum` and `sum_sq` are reseeded from the live window every
29/// `16 · period` updates to cap floating-point drift on long streams. This is
30/// amortised O(1), preserves bit-equivalence with the previous behaviour on
31/// inputs that did not drift, and is particularly important for `sum_sq`,
32/// where catastrophic cancellation between large add/subtract pairs can drive
33/// the computed variance negative (the `.max(0.0)` clamp below is the
34/// safety-net for the rare cases where the reseed has not happened yet).
35///
36/// # Example
37///
38/// ```
39/// use wickra_core::{Indicator, BollingerBands};
40///
41/// let mut indicator = BollingerBands::new(5, 2.0).unwrap();
42/// let mut last = None;
43/// for i in 0..80 {
44///     last = indicator.update(100.0 + f64::from(i));
45/// }
46/// assert!(last.is_some());
47/// ```
48#[derive(Debug, Clone)]
49pub struct BollingerBands {
50    period: usize,
51    multiplier: f64,
52    window: VecDeque<f64>,
53    sum: f64,
54    sum_sq: f64,
55    /// Number of finite updates since the running sums were last reseeded
56    /// from the live window. See [`RECOMPUTE_EVERY`] below.
57    updates_since_recompute: usize,
58}
59
60/// How often (in finite updates) the incremental `sum` / `sum_sq` are reseeded
61/// from the live window. The multiplier `16` keeps the amortised cost flat and
62/// caps any cancellation drift to roughly `16 · period · ULP · max(|x|²)` —
63/// negligible on real-world price scales.
64const RECOMPUTE_EVERY: usize = 16;
65
66impl BollingerBands {
67    /// Construct a new Bollinger Bands indicator.
68    ///
69    /// # Errors
70    ///
71    /// Returns [`Error::PeriodZero`] for `period == 0` and
72    /// [`Error::NonPositiveMultiplier`] for `multiplier <= 0`.
73    pub fn new(period: usize, multiplier: f64) -> Result<Self> {
74        if period == 0 {
75            return Err(Error::PeriodZero);
76        }
77        if !multiplier.is_finite() || multiplier <= 0.0 {
78            return Err(Error::NonPositiveMultiplier);
79        }
80        Ok(Self {
81            period,
82            multiplier,
83            window: VecDeque::with_capacity(period),
84            sum: 0.0,
85            sum_sq: 0.0,
86            updates_since_recompute: 0,
87        })
88    }
89
90    /// Classic configuration: `period = 20`, `multiplier = 2.0`.
91    pub fn classic() -> Self {
92        Self::new(20, 2.0).expect("classic Bollinger parameters are valid")
93    }
94
95    /// Configured period.
96    pub const fn period(&self) -> usize {
97        self.period
98    }
99
100    /// Configured multiplier.
101    pub const fn multiplier(&self) -> f64 {
102        self.multiplier
103    }
104
105    fn current(&self) -> Option<BollingerOutput> {
106        if self.window.len() != self.period {
107            return None;
108        }
109        let n = self.period as f64;
110        let mean = self.sum / n;
111        // Population variance: E[x^2] - (E[x])^2. Clamp small negative values that arise
112        // from catastrophic cancellation on near-constant inputs.
113        let var = (self.sum_sq / n - mean * mean).max(0.0);
114        let stddev = var.sqrt();
115        Some(BollingerOutput {
116            upper: mean + self.multiplier * stddev,
117            middle: mean,
118            lower: mean - self.multiplier * stddev,
119            stddev,
120        })
121    }
122}
123
124impl Indicator for BollingerBands {
125    type Input = f64;
126    type Output = BollingerOutput;
127
128    fn update(&mut self, input: f64) -> Option<BollingerOutput> {
129        if !input.is_finite() {
130            return self.current();
131        }
132        if self.window.len() == self.period {
133            let old = self.window.pop_front().expect("non-empty");
134            self.sum -= old;
135            self.sum_sq -= old * old;
136        }
137        self.window.push_back(input);
138        self.sum += input;
139        self.sum_sq += input * input;
140        self.updates_since_recompute += 1;
141        if self.updates_since_recompute >= RECOMPUTE_EVERY * self.period {
142            self.sum = self.window.iter().copied().sum();
143            self.sum_sq = self.window.iter().copied().map(|x| x * x).sum();
144            self.updates_since_recompute = 0;
145        }
146        self.current()
147    }
148
149    fn reset(&mut self) {
150        self.window.clear();
151        self.sum = 0.0;
152        self.sum_sq = 0.0;
153        self.updates_since_recompute = 0;
154    }
155
156    fn warmup_period(&self) -> usize {
157        self.period
158    }
159
160    fn is_ready(&self) -> bool {
161        self.window.len() == self.period
162    }
163
164    fn name(&self) -> &'static str {
165        "BollingerBands"
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::traits::BatchExt;
173    use approx::assert_relative_eq;
174
175    fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput {
176        assert!(
177            prices.len() >= period,
178            "naive requires at least `period` prices"
179        );
180        let w = &prices[prices.len() - period..];
181        let mean = w.iter().sum::<f64>() / period as f64;
182        let var = w.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
183        let s = var.sqrt();
184        BollingerOutput {
185            upper: mean + mult * s,
186            middle: mean,
187            lower: mean - mult * s,
188            stddev: s,
189        }
190    }
191
192    #[test]
193    fn rejects_zero_period() {
194        assert!(matches!(
195            BollingerBands::new(0, 2.0),
196            Err(Error::PeriodZero)
197        ));
198    }
199
200    #[test]
201    fn rejects_non_positive_multiplier() {
202        assert!(matches!(
203            BollingerBands::new(20, 0.0),
204            Err(Error::NonPositiveMultiplier)
205        ));
206        assert!(matches!(
207            BollingerBands::new(20, -1.0),
208            Err(Error::NonPositiveMultiplier)
209        ));
210        assert!(matches!(
211            BollingerBands::new(20, f64::NAN),
212            Err(Error::NonPositiveMultiplier)
213        ));
214    }
215
216    /// Cover the convenience constructor `BollingerBands::classic()` plus the
217    /// const accessors `period` / `multiplier` and the Indicator-impl
218    /// metadata methods `warmup_period` / `name`. Existing tests never
219    /// invoked `classic()` (every test passed explicit parameters to
220    /// `new`) and never queried any of the four getters.
221    #[test]
222    fn classic_and_accessors_and_metadata() {
223        let bb = BollingerBands::classic();
224        assert_eq!(bb.period(), 20);
225        assert_relative_eq!(bb.multiplier(), 2.0, epsilon = 1e-12);
226        assert_eq!(bb.warmup_period(), 20);
227        assert_eq!(bb.name(), "BollingerBands");
228    }
229
230    #[test]
231    fn warmup_returns_none() {
232        let mut bb = BollingerBands::new(5, 2.0).unwrap();
233        for v in [1.0, 2.0, 3.0, 4.0] {
234            assert!(bb.update(v).is_none());
235        }
236        assert!(bb.update(5.0).is_some());
237    }
238
239    #[test]
240    fn constant_series_yields_zero_stddev() {
241        let mut bb = BollingerBands::new(10, 2.0).unwrap();
242        let out = bb.batch(&[5.0_f64; 30]);
243        let last = out.iter().rev().flatten().next().unwrap();
244        assert_relative_eq!(last.middle, 5.0, epsilon = 1e-12);
245        assert_relative_eq!(last.stddev, 0.0, epsilon = 1e-12);
246        assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
247        assert_relative_eq!(last.lower, 5.0, epsilon = 1e-12);
248    }
249
250    #[test]
251    fn matches_naive_definition() {
252        let prices: Vec<f64> = (1..=60)
253            .map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
254            .collect();
255        let mut bb = BollingerBands::new(20, 2.0).unwrap();
256        let out = bb.batch(&prices);
257        for i in 19..prices.len() {
258            let got = out[i].unwrap();
259            let want = naive(&prices[..=i], 20, 2.0);
260            assert_relative_eq!(got.middle, want.middle, epsilon = 1e-9);
261            assert_relative_eq!(got.stddev, want.stddev, epsilon = 1e-9);
262            assert_relative_eq!(got.upper, want.upper, epsilon = 1e-9);
263            assert_relative_eq!(got.lower, want.lower, epsilon = 1e-9);
264        }
265    }
266
267    #[test]
268    fn upper_above_middle_above_lower() {
269        let prices: Vec<f64> = (1..=100).map(f64::from).collect();
270        let mut bb = BollingerBands::new(20, 2.0).unwrap();
271        for o in bb.batch(&prices).into_iter().flatten() {
272            assert!(o.upper >= o.middle);
273            assert!(o.middle >= o.lower);
274        }
275    }
276
277    #[test]
278    fn batch_equals_streaming() {
279        let prices: Vec<f64> = (1..=50).map(|i| f64::from(i) * 0.7).collect();
280        let mut a = BollingerBands::new(10, 2.0).unwrap();
281        let mut b = BollingerBands::new(10, 2.0).unwrap();
282        assert_eq!(
283            a.batch(&prices),
284            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
285        );
286    }
287
288    #[test]
289    fn reset_clears_state() {
290        let mut bb = BollingerBands::new(5, 2.0).unwrap();
291        bb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
292        assert!(bb.is_ready());
293        bb.reset();
294        assert!(!bb.is_ready());
295    }
296
297    /// Long-running stability check. After several recompute cycles the
298    /// reported Bollinger bands must still equal a fresh from-scratch
299    /// computation over the live window — even on inputs designed to cause
300    /// catastrophic cancellation in the `sum_sq` accumulator (alternating
301    /// between two very different magnitudes).
302    #[test]
303    fn long_stream_drift_stays_bounded() {
304        let period = 20;
305        let mult = 2.0;
306        let mut bb = BollingerBands::new(period, mult).unwrap();
307        let mut window: VecDeque<f64> = VecDeque::with_capacity(period);
308        // Forces the periodic reseed to fire 5+ times.
309        let n_updates = 16 * period * 5;
310        let mut last = None;
311        for i in 0..n_updates {
312            let v = if i % 2 == 0 { 1e6 } else { 1.0 };
313            last = bb.update(v);
314            if window.len() == period {
315                window.pop_front();
316            }
317            window.push_back(v);
318        }
319        let scratch = naive(&window.iter().copied().collect::<Vec<_>>(), period, mult);
320        let got = last.expect("warmed up");
321        assert!(
322            (got.middle - scratch.middle).abs() < 1e-3,
323            "middle drift: got={}, scratch={}",
324            got.middle,
325            scratch.middle,
326        );
327        assert!(
328            (got.stddev - scratch.stddev).abs() < 1e-3,
329            "stddev drift: got={}, scratch={}",
330            got.stddev,
331            scratch.stddev,
332        );
333    }
334
335    #[test]
336    fn ignores_non_finite_input() {
337        let mut bb = BollingerBands::new(5, 2.0).unwrap();
338        let ready = bb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
339        let last = ready.last().unwrap().unwrap();
340        // Non-finite inputs return the current bands without mutating the window.
341        assert_eq!(bb.update(f64::NAN).unwrap(), last);
342        assert_eq!(bb.update(f64::INFINITY).unwrap(), last);
343        // The window still holds 1..=5, so a real input slides it to 2..=6.
344        let after = bb.update(6.0).unwrap();
345        assert_relative_eq!(
346            after.middle,
347            (2.0 + 3.0 + 4.0 + 5.0 + 6.0) / 5.0,
348            epsilon = 1e-12
349        );
350    }
351}