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    /// Allocates the result and fills it through
112    /// [`batch_bands_into`](Self::batch_bands_into). This is a *separate* entry
113    /// point from the trait [`batch`](crate::BatchExt::batch), which returns
114    /// `Vec<Option<BollingerOutput>>`.
115    pub fn batch_bands(&mut self, inputs: &[f64]) -> Vec<f64> {
116        let mut out = vec![0.0; inputs.len() * 4];
117        self.batch_bands_into(inputs, &mut out);
118        out
119    }
120
121    /// [`batch_bands`](Self::batch_bands) into a caller-owned buffer of
122    /// `inputs.len() * 4` values, overwriting every cell.
123    ///
124    /// For a fresh, all-finite slice it inlines `update`'s rolling moments and
125    /// drift-reseed on local copies of the ring cursor and the accumulator —
126    /// the state is written back once at the end rather than through `self` on
127    /// every element — and writes the four band values straight into the row.
128    /// Same add/subtract order, same reseed cadence, same variance/`sqrt` math,
129    /// so it is *bit-for-bit* equal to replaying `update`, including the
130    /// long-stream drift bound. Any other state, or a non-finite element,
131    /// defers to the exact `update` replay.
132    ///
133    /// # Panics
134    ///
135    /// Panics if `out.len() != inputs.len() * 4`.
136    pub fn batch_bands_into(&mut self, inputs: &[f64], out: &mut [f64]) {
137        assert_eq!(
138            out.len(),
139            inputs.len() * 4,
140            "batch_bands output must hold four values per input"
141        );
142        // `count == 0` is the only pristine state: the reseed counter can only
143        // be non-zero once a value has been pushed, so it adds nothing here.
144        if self.count != 0 || !inputs.iter().all(|x| x.is_finite()) {
145            for (row, &x) in out.chunks_exact_mut(4).zip(inputs) {
146                match self.update(x) {
147                    Some(o) => row.copy_from_slice(&[o.upper, o.middle, o.lower, o.stddev]),
148                    None => row.fill(f64::NAN),
149                }
150            }
151            return;
152        }
153
154        let p = self.period;
155        let mult = self.multiplier;
156        let mut moments = self.moments.clone();
157        let (mut head, mut count) = (self.head, self.count);
158        let buf = &mut self.buf[..];
159        for (row, &x) in out.chunks_exact_mut(4).zip(inputs) {
160            if count == p {
161                moments.evict(buf[head]);
162                buf[head] = x;
163                moments.push(x);
164            } else {
165                buf[head] = x;
166                moments.push(x);
167                count += 1;
168            }
169            head += 1;
170            if head == p {
171                head = 0;
172            }
173            // A reseed is due every `p` pushes and the first `p` pushes fill
174            // the window, so it only ever runs on a full one: oldest at `head`.
175            if moments.needs_reseed(p) {
176                debug_assert_eq!(count, p);
177                let (newer, older) = buf.split_at(head);
178                moments.reseed(older.iter().chain(newer).copied());
179            }
180            if count == p {
181                let mean = moments.mean(p);
182                let stddev = moments.std_dev(p);
183                let band = mult * stddev;
184                row.copy_from_slice(&[mean + band, mean, mean - band, stddev]);
185            } else {
186                row.fill(f64::NAN);
187            }
188        }
189        self.moments = moments;
190        self.head = head;
191        self.count = count;
192    }
193
194    /// Opt-in fast variant of [`batch_bands_into`](Self::batch_bands_into):
195    /// the window's shifted first and second power sums run as SIMD
196    /// window-sum scans, re-anchored every `16 · period` values like the exact
197    /// accumulator, and the bands are finished lane-parallel. Every value agrees
198    /// with the exact batch to within a few units in the last place; warmup rows
199    /// and length are identical, and the result is the same on every platform.
200    /// Only a fresh indicator over finite values within `1e100`, at least one
201    /// window long, takes the kernel; anything else is the exact batch.
202    /// Bollinger bands only remember their last `period` inputs, so afterwards
203    /// the state is rebuilt exactly by replaying them.
204    ///
205    /// # Panics
206    ///
207    /// Panics if `out.len() != inputs.len() * 4`.
208    pub fn batch_bands_fast_into(&mut self, inputs: &[f64], out: &mut [f64]) {
209        assert_eq!(
210            out.len(),
211            inputs.len() * 4,
212            "batch_bands output must hold four values per input"
213        );
214        let p = self.period;
215        if self.count != 0 || inputs.len() < p || !crate::fast::in_range(inputs) {
216            self.batch_bands_into(inputs, out);
217            return;
218        }
219        crate::fast::with_scratch(crate::fast::power_scratch_len(2, p), |scratch| {
220            wickra_simd::dispatch(crate::fast::BollingerFast {
221                x: inputs,
222                period: p,
223                multiplier: self.multiplier,
224                scratch,
225                out,
226                _borrow: std::marker::PhantomData,
227            });
228        });
229        crate::fast::replay_tail(self, &inputs[inputs.len() - p..]);
230    }
231
232    /// [`batch_bands_fast_into`](Self::batch_bands_fast_into) into a fresh
233    /// vector.
234    pub fn batch_bands_fast(&mut self, inputs: &[f64]) -> Vec<f64> {
235        let mut out = vec![0.0; inputs.len() * 4];
236        self.batch_bands_fast_into(inputs, &mut out);
237        out
238    }
239
240    fn current(&self) -> Option<BollingerOutput> {
241        if self.count != self.period {
242            return None;
243        }
244        let mean = self.moments.mean(self.period);
245        let stddev = self.moments.std_dev(self.period);
246        Some(BollingerOutput {
247            upper: mean + self.multiplier * stddev,
248            middle: mean,
249            lower: mean - self.multiplier * stddev,
250            stddev,
251        })
252    }
253}
254
255impl Indicator for BollingerBands {
256    type Input = f64;
257    type Output = BollingerOutput;
258
259    #[inline]
260    fn update(&mut self, input: f64) -> Option<BollingerOutput> {
261        if !input.is_finite() {
262            return None;
263        }
264        if self.count == self.period {
265            self.moments.evict(self.buf[self.head]);
266            self.buf[self.head] = input;
267            self.moments.push(input);
268        } else {
269            self.buf[self.head] = input;
270            self.moments.push(input);
271            self.count += 1;
272        }
273        self.head += 1;
274        if self.head == self.period {
275            self.head = 0;
276        }
277        if self.moments.needs_reseed(self.period) {
278            // Reseed in chronological order (oldest at `head`) so the accumulator
279            // matches a fresh from-scratch pass and the reference point is
280            // re-anchored on the live window. A reseed is due every `period`
281            // pushes and the first `period` pushes fill the window, so the
282            // window is always full here.
283            debug_assert_eq!(self.count, self.period);
284            let (newer, older) = self.buf.split_at(self.head);
285            self.moments.reseed(older.iter().chain(newer).copied());
286        }
287        self.current()
288    }
289
290    fn reset(&mut self) {
291        self.head = 0;
292        self.count = 0;
293        self.moments.reset();
294    }
295
296    #[inline]
297    fn warmup_period(&self) -> usize {
298        self.period
299    }
300
301    #[inline]
302    fn is_ready(&self) -> bool {
303        self.count == self.period
304    }
305
306    #[inline]
307    fn name(&self) -> &'static str {
308        "BollingerBands"
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::traits::BatchExt;
316    use approx::assert_relative_eq;
317    use std::collections::VecDeque;
318
319    fn naive(prices: &[f64], period: usize, mult: f64) -> BollingerOutput {
320        assert!(
321            prices.len() >= period,
322            "naive requires at least `period` prices"
323        );
324        let w = &prices[prices.len() - period..];
325        let mean = w.iter().sum::<f64>() / period as f64;
326        let var = w.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / period as f64;
327        let s = var.sqrt();
328        BollingerOutput {
329            upper: mean + mult * s,
330            middle: mean,
331            lower: mean - mult * s,
332            stddev: s,
333        }
334    }
335
336    #[test]
337    fn rejects_zero_period() {
338        assert!(matches!(
339            BollingerBands::new(0, 2.0),
340            Err(Error::PeriodZero)
341        ));
342    }
343
344    #[test]
345    fn rejects_non_positive_multiplier() {
346        assert!(matches!(
347            BollingerBands::new(20, 0.0),
348            Err(Error::NonPositiveMultiplier)
349        ));
350        assert!(matches!(
351            BollingerBands::new(20, -1.0),
352            Err(Error::NonPositiveMultiplier)
353        ));
354        assert!(matches!(
355            BollingerBands::new(20, f64::NAN),
356            Err(Error::NonPositiveMultiplier)
357        ));
358    }
359
360    /// Cover the convenience constructor `BollingerBands::classic()` plus the
361    /// const accessors `period` / `multiplier` and the Indicator-impl
362    /// metadata methods `warmup_period` / `name`. Existing tests never
363    /// invoked `classic()` (every test passed explicit parameters to
364    /// `new`) and never queried any of the four getters.
365    #[test]
366    fn classic_and_accessors_and_metadata() {
367        let bb = BollingerBands::classic();
368        assert_eq!(bb.period(), 20);
369        assert_relative_eq!(bb.multiplier(), 2.0, epsilon = 1e-12);
370        assert_eq!(bb.warmup_period(), 20);
371        assert_eq!(bb.name(), "BollingerBands");
372    }
373
374    #[test]
375    fn warmup_returns_none() {
376        let mut bb = BollingerBands::new(5, 2.0).unwrap();
377        for v in [1.0, 2.0, 3.0, 4.0] {
378            assert!(bb.update(v).is_none());
379        }
380        assert!(bb.update(5.0).is_some());
381    }
382
383    /// The band width is a standard deviation, so it inherits the accumulator's
384    /// numerics. With the textbook `E[x²] − E[x]²` form this drifted by 4.3e-06
385    /// at a price level of 1e5 and collapsed to exactly zero at 1e8 — bands of
386    /// zero width, and a permanent squeeze reading downstream.
387    #[test]
388    fn bands_stay_accurate_when_the_level_dwarfs_the_spread() {
389        for level in [1.0e2_f64, 1.0e5, 1.0e8] {
390            let prices: Vec<f64> = (0..60)
391                .map(|i| level + (f64::from(i) * 0.7).sin())
392                .collect();
393            let mut bb = BollingerBands::new(20, 2.0).unwrap();
394            let mut got = 0.0;
395            for price in &prices {
396                if let Some(o) = bb.update(*price) {
397                    got = o.stddev;
398                }
399            }
400            let window = &prices[40..];
401            let n = window.len() as f64;
402            let mean = window.iter().sum::<f64>() / n;
403            let want = (window.iter().map(|x| (x - mean) * (x - mean)).sum::<f64>() / n).sqrt();
404            assert_relative_eq!(got, want, max_relative = 1e-9);
405        }
406    }
407
408    #[test]
409    fn constant_series_yields_zero_stddev() {
410        let mut bb = BollingerBands::new(10, 2.0).unwrap();
411        let out = bb.batch(&[5.0_f64; 30]);
412        let last = out.iter().rev().flatten().next().unwrap();
413        assert_relative_eq!(last.middle, 5.0, epsilon = 1e-12);
414        assert_relative_eq!(last.stddev, 0.0, epsilon = 1e-12);
415        assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
416        assert_relative_eq!(last.lower, 5.0, epsilon = 1e-12);
417    }
418
419    #[test]
420    fn matches_naive_definition() {
421        let prices: Vec<f64> = (1..=60)
422            .map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
423            .collect();
424        let mut bb = BollingerBands::new(20, 2.0).unwrap();
425        let out = bb.batch(&prices);
426        for i in 19..prices.len() {
427            let got = out[i].unwrap();
428            let want = naive(&prices[..=i], 20, 2.0);
429            assert_relative_eq!(got.middle, want.middle, epsilon = 1e-9);
430            assert_relative_eq!(got.stddev, want.stddev, epsilon = 1e-9);
431            assert_relative_eq!(got.upper, want.upper, epsilon = 1e-9);
432            assert_relative_eq!(got.lower, want.lower, epsilon = 1e-9);
433        }
434    }
435
436    #[test]
437    fn upper_above_middle_above_lower() {
438        let prices: Vec<f64> = (1..=100).map(f64::from).collect();
439        let mut bb = BollingerBands::new(20, 2.0).unwrap();
440        for o in bb.batch(&prices).into_iter().flatten() {
441            assert!(o.upper >= o.middle);
442            assert!(o.middle >= o.lower);
443        }
444    }
445
446    #[test]
447    fn batch_equals_streaming() {
448        let prices: Vec<f64> = (1..=50).map(|i| f64::from(i) * 0.7).collect();
449        let mut a = BollingerBands::new(10, 2.0).unwrap();
450        let mut b = BollingerBands::new(10, 2.0).unwrap();
451        assert_eq!(
452            a.batch(&prices),
453            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
454        );
455    }
456
457    #[test]
458    fn reset_clears_state() {
459        let mut bb = BollingerBands::new(5, 2.0).unwrap();
460        bb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
461        assert!(bb.is_ready());
462        bb.reset();
463        assert!(!bb.is_ready());
464    }
465
466    /// Long-running stability check. After several recompute cycles the
467    /// reported Bollinger bands must still equal a fresh from-scratch
468    /// computation over the live window — even on inputs designed to cause
469    /// catastrophic cancellation in the `sum_sq` accumulator (alternating
470    /// between two very different magnitudes).
471    #[test]
472    fn long_stream_drift_stays_bounded() {
473        let period = 20;
474        let mult = 2.0;
475        let mut bb = BollingerBands::new(period, mult).unwrap();
476        let mut window: VecDeque<f64> = VecDeque::with_capacity(period);
477        // Forces the periodic reseed to fire 5+ times.
478        let n_updates = 16 * period * 5;
479        let mut last = None;
480        for i in 0..n_updates {
481            let v = if i % 2 == 0 { 1e6 } else { 1.0 };
482            last = bb.update(v);
483            if window.len() == period {
484                window.pop_front();
485            }
486            window.push_back(v);
487        }
488        let scratch = naive(&window.iter().copied().collect::<Vec<_>>(), period, mult);
489        let got = last.expect("warmed up");
490        assert!(
491            (got.middle - scratch.middle).abs() < 1e-3,
492            "middle drift: got={}, scratch={}",
493            got.middle,
494            scratch.middle,
495        );
496        assert!(
497            (got.stddev - scratch.stddev).abs() < 1e-3,
498            "stddev drift: got={}, scratch={}",
499            got.stddev,
500            scratch.stddev,
501        );
502    }
503
504    fn bits_eq(a: &[f64], b: &[f64]) -> bool {
505        a.len() == b.len()
506            && a.iter()
507                .zip(b)
508                .all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
509    }
510
511    /// Flat `n*4` `[upper, middle, lower, stddev]` replay of `update`.
512    fn bb_replay(period: usize, mult: f64, series: &[f64]) -> Vec<f64> {
513        let mut bb = BollingerBands::new(period, mult).unwrap();
514        let mut out = Vec::with_capacity(series.len() * 4);
515        for &x in series {
516            match bb.update(x) {
517                Some(o) => out.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]),
518                None => out.extend_from_slice(&[f64::NAN; 4]),
519            }
520        }
521        out
522    }
523
524    /// Into a buffer that already holds values, every cell is overwritten:
525    /// warmup rows with `NaN`, the rest with the replay's values, bit for bit.
526    #[test]
527    fn batch_bands_into_overwrites_a_dirty_buffer() {
528        let series: Vec<f64> = (0..400)
529            .map(|i| (f64::from(i) * 0.17).sin() * 6.0 + f64::from(i % 5) + 90.0)
530            .collect();
531        let mut out = vec![5.5; series.len() * 4];
532        BollingerBands::new(20, 2.0)
533            .unwrap()
534            .batch_bands_into(&series, &mut out);
535        let want = bb_replay(20, 2.0, &series);
536        let bits = |v: &[f64]| v.iter().map(|x| x.to_bits()).collect::<Vec<_>>();
537        assert_eq!(bits(&out), bits(&want));
538    }
539
540    #[test]
541    #[should_panic(expected = "batch_bands output must hold four values per input")]
542    fn batch_bands_into_rejects_a_short_buffer() {
543        let mut out = vec![0.0; 7];
544        BollingerBands::new(3, 2.0)
545            .unwrap()
546            .batch_bands_into(&[1.0, 2.0], &mut out);
547    }
548
549    #[test]
550    fn batch_bands_fast_path_is_bit_identical_with_reseed() {
551        // > 16*period inputs so the drift-reseed branch fires inside batch_bands.
552        let series: Vec<f64> = (0..500)
553            .map(|i| (f64::from(i) * 0.2).sin() * 10.0 + 50.0)
554            .collect();
555        let mut bb = BollingerBands::new(20, 2.0).unwrap();
556        let got = bb.batch_bands(&series);
557        assert!(bits_eq(&got, &bb_replay(20, 2.0, &series)));
558        // State continues identically.
559        let mut ref_bb = BollingerBands::new(20, 2.0).unwrap();
560        for &x in &series {
561            ref_bb.update(x);
562        }
563        assert_eq!(bb.update(55.0), ref_bb.update(55.0));
564    }
565
566    #[test]
567    fn batch_bands_falls_back_on_non_finite() {
568        let series = [1.0, 2.0, 3.0, f64::NAN, 5.0, 6.0, 7.0];
569        let mut bb = BollingerBands::new(3, 2.0).unwrap();
570        assert!(bits_eq(
571            &bb.batch_bands(&series),
572            &bb_replay(3, 2.0, &series)
573        ));
574    }
575
576    #[test]
577    fn batch_bands_falls_back_when_not_fresh() {
578        let mut bb = BollingerBands::new(3, 2.0).unwrap();
579        bb.update(99.0);
580        let series = [1.0, 2.0, 3.0, 4.0];
581        let mut ref_bb = BollingerBands::new(3, 2.0).unwrap();
582        ref_bb.update(99.0);
583        let mut want = Vec::new();
584        for &x in &series {
585            match ref_bb.update(x) {
586                Some(o) => want.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]),
587                None => want.extend_from_slice(&[f64::NAN; 4]),
588            }
589        }
590        assert!(bits_eq(&bb.batch_bands(&series), &want));
591    }
592
593    #[test]
594    fn batch_bands_sub_period_slice_is_all_nan() {
595        let series = [1.0, 2.0, 3.0];
596        let mut bb = BollingerBands::new(10, 2.0).unwrap();
597        let got = bb.batch_bands(&series);
598        assert!(bits_eq(&got, &bb_replay(10, 2.0, &series)));
599        assert!(got.iter().all(|x| x.is_nan()) && got.len() == 12);
600    }
601
602    #[test]
603    fn ignores_non_finite_input() {
604        let mut bb = BollingerBands::new(5, 2.0).unwrap();
605        bb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
606        // A non-finite input has no value and does not mutate the window.
607        assert_eq!(bb.update(f64::NAN), None);
608        assert_eq!(bb.update(f64::INFINITY), None);
609        // The window still holds 1..=5, so a real input slides it to 2..=6.
610        let after = bb.update(6.0).unwrap();
611        assert_relative_eq!(
612            after.middle,
613            (2.0 + 3.0 + 4.0 + 5.0 + 6.0) / 5.0,
614            epsilon = 1e-12
615        );
616    }
617
618    #[test]
619    fn rejects_period_above_maximum() {
620        assert!(matches!(
621            BollingerBands::new(crate::error::MAX_PERIOD + 1, 2.0),
622            Err(Error::InvalidPeriod { .. })
623        ));
624    }
625}