Skip to main content

kestrel_chartkit/indicator/
bollinger.rs

1use std::collections::{HashMap, VecDeque};
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6use crate::model::Bar;
7
8use super::{Indicator, IndicatorAlert, IndicatorOutput};
9
10/// Which divisor the band standard deviation uses.
11///
12/// The window is the full population of the lookback in one reading and a sample drawn from an
13/// unobserved wider distribution in the other; neither is a correction of the other, so the
14/// choice belongs to the caller.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
17pub enum VarianceConvention {
18    /// Divisor `N`. The default, and the historical behaviour of this indicator.
19    #[default]
20    Population,
21    /// Divisor `N - 1` (Bessel-corrected), which requires `N >= 2`. At `N = 20` this widens the
22    /// distance between basis and band by `sqrt(20/19)`, roughly 2.6% — the band values
23    /// themselves do not scale by that factor, since the basis is unaffected.
24    Sample,
25}
26
27/// Bollinger Bands over the closing price.
28///
29/// Basis is the SMA of the last `len` closes; the bands sit `mult` standard deviations away,
30/// computed from the same window around that basis (`sum((x - basis)^2) / divisor`, with the
31/// divisor chosen by [`VarianceConvention`]). The centred form is kept deliberately rather than
32/// `E[x^2] - E[x]^2`, which loses precision when small fluctuations ride on a large price level.
33///
34/// Per-bar outputs, all derived from the selected bands: `value`/`extra["basis"]`,
35/// `extra["upper"]`, `extra["lower"]`, `extra["bandwidth"]` (`(upper - lower) / basis`, 0 for a
36/// zero basis) and `extra["percent_b"]` (`(close - lower) / (upper - lower)`, 0.5 for a
37/// degenerate band). The touch alerts compare the close against the same bands.
38///
39/// First output: with the `len`-th bar. [`Indicator::reset`] clears the window, so the next
40/// series starts deterministically.
41#[derive(Debug, Clone)]
42pub struct BollingerBands {
43    len: usize,
44    mult: f64,
45    variance: VarianceConvention,
46    window: VecDeque<f64>,
47    sum: f64,
48
49    alerts: BollingerAlerts,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Default)]
53pub struct BollingerAlerts {
54    pub lower_touch: bool,
55    pub upper_touch: bool,
56    pub percent_b: f64,
57}
58
59impl BollingerBands {
60    pub fn new(len: usize, mult: f64) -> Self {
61        Self {
62            len,
63            mult,
64            variance: VarianceConvention::Population,
65            window: VecDeque::with_capacity(len),
66            sum: 0.0,
67            alerts: BollingerAlerts::default(),
68        }
69    }
70
71    pub fn with_defaults() -> Self {
72        Self::new(20, 2.0)
73    }
74
75    /// Selects the standard-deviation divisor; see [`VarianceConvention`].
76    ///
77    /// Additive to the existing constructors, which keep the population divisor. Panics on
78    /// `Sample` with `len < 2`, where `N - 1` is not a divisor; the registry rejects that
79    /// combination with an error instead of panicking.
80    pub fn with_variance(mut self, variance: VarianceConvention) -> Self {
81        assert!(
82            variance != VarianceConvention::Sample || self.len >= 2,
83            "sample variance requires len >= 2, got {}",
84            self.len
85        );
86        self.variance = variance;
87        self
88    }
89
90    pub fn variance(&self) -> VarianceConvention {
91        self.variance
92    }
93}
94
95impl Indicator for BollingerBands {
96    fn name(&self) -> &str {
97        "bollinger"
98    }
99
100    fn warmup_period(&self) -> usize {
101        self.len
102    }
103
104    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
105        self.alerts = BollingerAlerts::default();
106        let close = bar.close;
107
108        self.window.push_back(close);
109        self.sum += close;
110
111        if self.window.len() > self.len {
112            self.sum -= self.window.pop_front().unwrap();
113        }
114
115        if self.window.len() < self.len {
116            return None;
117        }
118
119        let basis = self.sum / self.len as f64;
120        let divisor = match self.variance {
121            VarianceConvention::Population => self.len as f64,
122            // `with_variance`/the registry rule out `len < 2` in this mode.
123            VarianceConvention::Sample => (self.len - 1) as f64,
124        };
125        let variance = self
126            .window
127            .iter()
128            .map(|val| {
129                let diff = val - basis;
130                diff * diff
131            })
132            .sum::<f64>()
133            / divisor;
134
135        let std_dev = variance.sqrt();
136        let upper = basis + self.mult * std_dev;
137        let lower = basis - self.mult * std_dev;
138
139        let width = if basis != 0.0 {
140            (upper - lower) / basis
141        } else {
142            0.0
143        };
144
145        let pct_b = if upper != lower {
146            (close - lower) / (upper - lower)
147        } else {
148            0.5
149        };
150
151        self.alerts.lower_touch = close <= lower;
152        self.alerts.upper_touch = close >= upper;
153        self.alerts.percent_b = pct_b;
154
155        let mut extra = HashMap::new();
156        extra.insert("basis".to_string(), basis);
157        extra.insert("upper".to_string(), upper);
158        extra.insert("lower".to_string(), lower);
159        extra.insert("bandwidth".to_string(), width);
160        extra.insert("percent_b".to_string(), pct_b);
161
162        Some(IndicatorOutput::with_extra(basis, extra))
163    }
164
165    fn reset(&mut self) {
166        self.window.clear();
167        self.sum = 0.0;
168        self.alerts = BollingerAlerts::default();
169    }
170
171    fn alerts(&self) -> Vec<IndicatorAlert> {
172        let a = self.alerts;
173        let mut out = Vec::new();
174        if a.lower_touch {
175            out.push(IndicatorAlert {
176                kind: "lower_touch".to_string(),
177                note: "BOLLINGER · TOUCHED LOWER BAND".to_string(),
178                strength: 1.0,
179            });
180        }
181        if a.upper_touch {
182            out.push(IndicatorAlert {
183                kind: "upper_touch".to_string(),
184                note: "BOLLINGER · TOUCHED UPPER BAND".to_string(),
185                strength: 1.0,
186            });
187        }
188        out
189    }
190}