Skip to main content

wickra_core/indicators/
bollinger.rs

1//! Bollinger Bands.
2
3use crate::error::{Error, Result};
4use crate::indicators::rolling_moments::ShiftedMoments;
5use crate::traits::Indicator;
6
7/// Bollinger Bands output.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct BollingerOutput {
10    /// Upper band: `middle + multiplier * stddev`.
11    pub upper: f64,
12    /// Middle band: SMA over the window.
13    pub middle: f64,
14    /// Lower band: `middle − multiplier * stddev`.
15    pub lower: f64,
16    /// Sample standard deviation (denominator `period`, population stddev) used to build
17    /// the bands. Reported separately because some callers compute their own bands.
18    pub stddev: f64,
19}
20
21/// Bollinger Bands with SMA middle band and population standard deviation envelopes.
22///
23/// Standard parameters are `period = 20`, `multiplier = 2.0`. Bollinger's original
24/// publication uses population (not sample) standard deviation, which matches every
25/// reference implementation (TA-Lib, pandas-ta, etc.).
26///
27/// The running `sum` and `sum_sq` are reseeded from the live window every
28/// `16 · period` updates to cap floating-point drift on long streams. This is
29/// amortised O(1), preserves bit-equivalence with the previous behaviour on
30/// inputs that did not drift, and is particularly important for `sum_sq`,
31/// where catastrophic cancellation between large add/subtract pairs can drive
32/// the computed variance negative (the `.max(0.0)` clamp below is the
33/// safety-net for the rare cases where the reseed has not happened yet).
34///
35/// # Example
36///
37/// ```
38/// use wickra_core::{Indicator, BollingerBands};
39///
40/// let mut indicator = BollingerBands::new(5, 2.0).unwrap();
41/// let mut last = None;
42/// for i in 0..80 {
43///     last = indicator.update(100.0 + f64::from(i));
44/// }
45/// assert!(last.is_some());
46/// ```
47#[derive(Debug, Clone)]
48pub struct BollingerBands {
49    period: usize,
50    multiplier: f64,
51    /// Fixed-capacity ring buffer of the last `period` finite inputs. A flat
52    /// `Box<[f64]>` with a manual write cursor beats `VecDeque` on this hot path.
53    buf: Box<[f64]>,
54    /// Index of the next slot to write — also the oldest element once full.
55    head: usize,
56    /// Number of slots filled, saturating at `period`.
57    count: usize,
58    /// Rolling first and second moments, accumulated around a reference point
59    /// inside the window. See `ShiftedMoments` for why the textbook
60    /// `E[x²] − E[x]²` form is not usable on raw price levels.
61    moments: ShiftedMoments,
62}
63
64impl BollingerBands {
65    /// Construct a new Bollinger Bands indicator.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`Error::PeriodZero`] for `period == 0` and
70    /// [`Error::NonPositiveMultiplier`] for `multiplier <= 0`.
71    pub fn new(period: usize, multiplier: f64) -> Result<Self> {
72        if period == 0 {
73            return Err(Error::PeriodZero);
74        }
75        if period > crate::error::MAX_PERIOD {
76            return Err(Error::InvalidPeriod {
77                message: crate::error::PERIOD_ABOVE_MAX,
78            });
79        }
80        if !multiplier.is_finite() || multiplier <= 0.0 {
81            return Err(Error::NonPositiveMultiplier);
82        }
83        Ok(Self {
84            period,
85            multiplier,
86            buf: vec![0.0; period].into_boxed_slice(),
87            head: 0,
88            count: 0,
89            moments: ShiftedMoments::new(),
90        })
91    }
92
93    /// Classic configuration: `period = 20`, `multiplier = 2.0`.
94    pub fn classic() -> Self {
95        Self::new(20, 2.0).expect("classic Bollinger parameters are valid")
96    }
97
98    /// Configured period.
99    pub const fn period(&self) -> usize {
100        self.period
101    }
102
103    /// Configured multiplier.
104    pub const fn multiplier(&self) -> f64 {
105        self.multiplier
106    }
107
108    /// Vectorized flat batch for bindings: returns `n * 4` values laid out as
109    /// `[upper, middle, lower, stddev]` per input row, warmup rows all `NaN`.
110    ///
111    /// For a fresh, all-finite slice it inlines `update`'s rolling `sum`/`sum_sq`
112    /// and drift-reseed, writing the four band values directly instead of an
113    /// `Option<BollingerOutput>` per element. Same add/subtract order, same reseed
114    /// cadence, same variance/`sqrt` math — so it is *bit-for-bit* equal to
115    /// replaying `update`, including the long-stream drift bound. Any other state,
116    /// or a non-finite element, defers to the exact `update` replay.
117    ///
118    /// This is a *separate* entry point from the trait [`batch`](crate::BatchExt::batch),
119    /// which returns `Vec<Option<BollingerOutput>>`; only the bindings, which want
120    /// a flat `f64` buffer, call this.
121    pub fn batch_bands(&mut self, inputs: &[f64]) -> Vec<f64> {
122        let p = self.period;
123        let n = inputs.len();
124        // `count == 0` is the only pristine state: the reseed counter can only
125        // be non-zero once a value has been pushed, so it adds nothing here.
126        if self.count != 0 || !inputs.iter().all(|x| x.is_finite()) {
127            // Slow path: exact replay of `update` into the flat layout.
128            let mut out = vec![f64::NAN; n * 4];
129            for (i, &x) in inputs.iter().enumerate() {
130                if let Some(o) = self.update(x) {
131                    out[i * 4] = o.upper;
132                    out[i * 4 + 1] = o.middle;
133                    out[i * 4 + 2] = o.lower;
134                    out[i * 4 + 3] = o.stddev;
135                }
136            }
137            return out;
138        }
139
140        let mult = self.multiplier;
141        // Pre-sized output: warmup rows stay NaN, ready rows are written in place
142        // by index — no per-row `push` length/capacity check.
143        let mut out = vec![f64::NAN; n * 4];
144        for (i, &x) in inputs.iter().enumerate() {
145            if self.count == p {
146                self.moments.evict(self.buf[self.head]);
147                self.buf[self.head] = x;
148                self.moments.push(x);
149            } else {
150                self.buf[self.head] = x;
151                self.moments.push(x);
152                self.count += 1;
153            }
154            self.head += 1;
155            if self.head == p {
156                self.head = 0;
157            }
158            if self.moments.needs_reseed(p) {
159                let (older, newer) = if self.count == p {
160                    (&self.buf[self.head..], &self.buf[..self.head])
161                } else {
162                    (&self.buf[..self.count], &self.buf[..0])
163                };
164                self.moments.reseed(older.iter().chain(newer).copied());
165            }
166            if self.count == p {
167                let mean = self.moments.mean(p);
168                let stddev = self.moments.std_dev(p);
169                let band = mult * stddev;
170                out[i * 4] = mean + band;
171                out[i * 4 + 1] = mean;
172                out[i * 4 + 2] = mean - band;
173                out[i * 4 + 3] = stddev;
174            }
175        }
176        out
177    }
178
179    fn current(&self) -> Option<BollingerOutput> {
180        if self.count != self.period {
181            return None;
182        }
183        let mean = self.moments.mean(self.period);
184        let stddev = self.moments.std_dev(self.period);
185        Some(BollingerOutput {
186            upper: mean + self.multiplier * stddev,
187            middle: mean,
188            lower: mean - self.multiplier * stddev,
189            stddev,
190        })
191    }
192}
193
194impl Indicator for BollingerBands {
195    type Input = f64;
196    type Output = BollingerOutput;
197
198    #[inline]
199    fn update(&mut self, input: f64) -> Option<BollingerOutput> {
200        if !input.is_finite() {
201            return None;
202        }
203        if self.count == self.period {
204            self.moments.evict(self.buf[self.head]);
205            self.buf[self.head] = input;
206            self.moments.push(input);
207        } else {
208            self.buf[self.head] = input;
209            self.moments.push(input);
210            self.count += 1;
211        }
212        self.head += 1;
213        if self.head == self.period {
214            self.head = 0;
215        }
216        if self.moments.needs_reseed(self.period) {
217            // Reseed in chronological order (oldest at `head`) so the accumulator
218            // matches a fresh from-scratch pass and the reference point is
219            // re-anchored on the live window.
220            let (older, newer) = if self.count == self.period {
221                (&self.buf[self.head..], &self.buf[..self.head])
222            } else {
223                (&self.buf[..self.count], &self.buf[..0])
224            };
225            self.moments.reseed(older.iter().chain(newer).copied());
226        }
227        self.current()
228    }
229
230    fn reset(&mut self) {
231        self.head = 0;
232        self.count = 0;
233        self.moments.reset();
234    }
235
236    #[inline]
237    fn warmup_period(&self) -> usize {
238        self.period
239    }
240
241    #[inline]
242    fn is_ready(&self) -> bool {
243        self.count == self.period
244    }
245
246    #[inline]
247    fn name(&self) -> &'static str {
248        "BollingerBands"
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::traits::BatchExt;
256    use approx::assert_relative_eq;
257    use std::collections::VecDeque;
258
259    fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput {
260        assert!(
261            prices.len() >= period,
262            "naive requires at least `period` prices"
263        );
264        let w = &prices[prices.len() - period..];
265        let mean = w.iter().sum::<f64>() / period as f64;
266        let var = w.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
267        let s = var.sqrt();
268        BollingerOutput {
269            upper: mean + mult * s,
270            middle: mean,
271            lower: mean - mult * s,
272            stddev: s,
273        }
274    }
275
276    #[test]
277    fn rejects_zero_period() {
278        assert!(matches!(
279            BollingerBands::new(0, 2.0),
280            Err(Error::PeriodZero)
281        ));
282    }
283
284    #[test]
285    fn rejects_non_positive_multiplier() {
286        assert!(matches!(
287            BollingerBands::new(20, 0.0),
288            Err(Error::NonPositiveMultiplier)
289        ));
290        assert!(matches!(
291            BollingerBands::new(20, -1.0),
292            Err(Error::NonPositiveMultiplier)
293        ));
294        assert!(matches!(
295            BollingerBands::new(20, f64::NAN),
296            Err(Error::NonPositiveMultiplier)
297        ));
298    }
299
300    /// Cover the convenience constructor `BollingerBands::classic()` plus the
301    /// const accessors `period` / `multiplier` and the Indicator-impl
302    /// metadata methods `warmup_period` / `name`. Existing tests never
303    /// invoked `classic()` (every test passed explicit parameters to
304    /// `new`) and never queried any of the four getters.
305    #[test]
306    fn classic_and_accessors_and_metadata() {
307        let bb = BollingerBands::classic();
308        assert_eq!(bb.period(), 20);
309        assert_relative_eq!(bb.multiplier(), 2.0, epsilon = 1e-12);
310        assert_eq!(bb.warmup_period(), 20);
311        assert_eq!(bb.name(), "BollingerBands");
312    }
313
314    #[test]
315    fn warmup_returns_none() {
316        let mut bb = BollingerBands::new(5, 2.0).unwrap();
317        for v in [1.0, 2.0, 3.0, 4.0] {
318            assert!(bb.update(v).is_none());
319        }
320        assert!(bb.update(5.0).is_some());
321    }
322
323    /// The band width is a standard deviation, so it inherits the accumulator's
324    /// numerics. With the textbook `E[x²] − E[x]²` form this drifted by 4.3e-06
325    /// at a price level of 1e5 and collapsed to exactly zero at 1e8 — bands of
326    /// zero width, and a permanent squeeze reading downstream.
327    #[test]
328    fn bands_stay_accurate_when_the_level_dwarfs_the_spread() {
329        for level in [1.0e2_f64, 1.0e5, 1.0e8] {
330            let prices: Vec<f64> = (0..60)
331                .map(|i| level + (f64::from(i) * 0.7).sin())
332                .collect();
333            let mut bb = BollingerBands::new(20, 2.0).unwrap();
334            let mut got = 0.0;
335            for price in &prices {
336                if let Some(o) = bb.update(*price) {
337                    got = o.stddev;
338                }
339            }
340            let window = &prices[40..];
341            let n = window.len() as f64;
342            let mean = window.iter().sum::<f64>() / n;
343            let want = (window.iter().map(|x| (x - mean) * (x - mean)).sum::<f64>() / n).sqrt();
344            assert_relative_eq!(got, want, max_relative = 1e-9);
345        }
346    }
347
348    #[test]
349    fn constant_series_yields_zero_stddev() {
350        let mut bb = BollingerBands::new(10, 2.0).unwrap();
351        let out = bb.batch(&[5.0_f64; 30]);
352        let last = out.iter().rev().flatten().next().unwrap();
353        assert_relative_eq!(last.middle, 5.0, epsilon = 1e-12);
354        assert_relative_eq!(last.stddev, 0.0, epsilon = 1e-12);
355        assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
356        assert_relative_eq!(last.lower, 5.0, epsilon = 1e-12);
357    }
358
359    #[test]
360    fn matches_naive_definition() {
361        let prices: Vec<f64> = (1..=60)
362            .map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
363            .collect();
364        let mut bb = BollingerBands::new(20, 2.0).unwrap();
365        let out = bb.batch(&prices);
366        for i in 19..prices.len() {
367            let got = out[i].unwrap();
368            let want = naive(&prices[..=i], 20, 2.0);
369            assert_relative_eq!(got.middle, want.middle, epsilon = 1e-9);
370            assert_relative_eq!(got.stddev, want.stddev, epsilon = 1e-9);
371            assert_relative_eq!(got.upper, want.upper, epsilon = 1e-9);
372            assert_relative_eq!(got.lower, want.lower, epsilon = 1e-9);
373        }
374    }
375
376    #[test]
377    fn upper_above_middle_above_lower() {
378        let prices: Vec<f64> = (1..=100).map(f64::from).collect();
379        let mut bb = BollingerBands::new(20, 2.0).unwrap();
380        for o in bb.batch(&prices).into_iter().flatten() {
381            assert!(o.upper >= o.middle);
382            assert!(o.middle >= o.lower);
383        }
384    }
385
386    #[test]
387    fn batch_equals_streaming() {
388        let prices: Vec<f64> = (1..=50).map(|i| f64::from(i) * 0.7).collect();
389        let mut a = BollingerBands::new(10, 2.0).unwrap();
390        let mut b = BollingerBands::new(10, 2.0).unwrap();
391        assert_eq!(
392            a.batch(&prices),
393            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
394        );
395    }
396
397    #[test]
398    fn reset_clears_state() {
399        let mut bb = BollingerBands::new(5, 2.0).unwrap();
400        bb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
401        assert!(bb.is_ready());
402        bb.reset();
403        assert!(!bb.is_ready());
404    }
405
406    /// Long-running stability check. After several recompute cycles the
407    /// reported Bollinger bands must still equal a fresh from-scratch
408    /// computation over the live window — even on inputs designed to cause
409    /// catastrophic cancellation in the `sum_sq` accumulator (alternating
410    /// between two very different magnitudes).
411    #[test]
412    fn long_stream_drift_stays_bounded() {
413        let period = 20;
414        let mult = 2.0;
415        let mut bb = BollingerBands::new(period, mult).unwrap();
416        let mut window: VecDeque<f64> = VecDeque::with_capacity(period);
417        // Forces the periodic reseed to fire 5+ times.
418        let n_updates = 16 * period * 5;
419        let mut last = None;
420        for i in 0..n_updates {
421            let v = if i % 2 == 0 { 1e6 } else { 1.0 };
422            last = bb.update(v);
423            if window.len() == period {
424                window.pop_front();
425            }
426            window.push_back(v);
427        }
428        let scratch = naive(&window.iter().copied().collect::<Vec<_>>(), period, mult);
429        let got = last.expect("warmed up");
430        assert!(
431            (got.middle - scratch.middle).abs() < 1e-3,
432            "middle drift: got={}, scratch={}",
433            got.middle,
434            scratch.middle,
435        );
436        assert!(
437            (got.stddev - scratch.stddev).abs() < 1e-3,
438            "stddev drift: got={}, scratch={}",
439            got.stddev,
440            scratch.stddev,
441        );
442    }
443
444    fn bits_eq(a: &[f64], b: &[f64]) -> bool {
445        a.len() == b.len()
446            && a.iter()
447                .zip(b)
448                .all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
449    }
450
451    /// Flat `n*4` `[upper, middle, lower, stddev]` replay of `update`.
452    fn bb_replay(period: usize, mult: f64, series: &[f64]) -> Vec<f64> {
453        let mut bb = BollingerBands::new(period, mult).unwrap();
454        let mut out = Vec::with_capacity(series.len() * 4);
455        for &x in series {
456            match bb.update(x) {
457                Some(o) => out.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]),
458                None => out.extend_from_slice(&[f64::NAN; 4]),
459            }
460        }
461        out
462    }
463
464    #[test]
465    fn batch_bands_fast_path_is_bit_identical_with_reseed() {
466        // > 16*period inputs so the drift-reseed branch fires inside batch_bands.
467        let series: Vec<f64> = (0..500)
468            .map(|i| (f64::from(i) * 0.2).sin() * 10.0 + 50.0)
469            .collect();
470        let mut bb = BollingerBands::new(20, 2.0).unwrap();
471        let got = bb.batch_bands(&series);
472        assert!(bits_eq(&got, &bb_replay(20, 2.0, &series)));
473        // State continues identically.
474        let mut ref_bb = BollingerBands::new(20, 2.0).unwrap();
475        for &x in &series {
476            ref_bb.update(x);
477        }
478        assert_eq!(bb.update(55.0), ref_bb.update(55.0));
479    }
480
481    #[test]
482    fn batch_bands_falls_back_on_non_finite() {
483        let series = [1.0, 2.0, 3.0, f64::NAN, 5.0, 6.0, 7.0];
484        let mut bb = BollingerBands::new(3, 2.0).unwrap();
485        assert!(bits_eq(
486            &bb.batch_bands(&series),
487            &bb_replay(3, 2.0, &series)
488        ));
489    }
490
491    #[test]
492    fn batch_bands_falls_back_when_not_fresh() {
493        let mut bb = BollingerBands::new(3, 2.0).unwrap();
494        bb.update(99.0);
495        let series = [1.0, 2.0, 3.0, 4.0];
496        let mut ref_bb = BollingerBands::new(3, 2.0).unwrap();
497        ref_bb.update(99.0);
498        let mut want = Vec::new();
499        for &x in &series {
500            match ref_bb.update(x) {
501                Some(o) => want.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]),
502                None => want.extend_from_slice(&[f64::NAN; 4]),
503            }
504        }
505        assert!(bits_eq(&bb.batch_bands(&series), &want));
506    }
507
508    #[test]
509    fn batch_bands_sub_period_slice_is_all_nan() {
510        let series = [1.0, 2.0, 3.0];
511        let mut bb = BollingerBands::new(10, 2.0).unwrap();
512        let got = bb.batch_bands(&series);
513        assert!(bits_eq(&got, &bb_replay(10, 2.0, &series)));
514        assert!(got.iter().all(|x| x.is_nan()) && got.len() == 12);
515    }
516
517    #[test]
518    fn ignores_non_finite_input() {
519        let mut bb = BollingerBands::new(5, 2.0).unwrap();
520        bb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
521        // A non-finite input has no value and does not mutate the window.
522        assert_eq!(bb.update(f64::NAN), None);
523        assert_eq!(bb.update(f64::INFINITY), None);
524        // The window still holds 1..=5, so a real input slides it to 2..=6.
525        let after = bb.update(6.0).unwrap();
526        assert_relative_eq!(
527            after.middle,
528            (2.0 + 3.0 + 4.0 + 5.0 + 6.0) / 5.0,
529            epsilon = 1e-12
530        );
531    }
532}