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