Skip to main content

wickra_core/indicators/
bollinger_bandwidth.rs

1//! Bollinger Bandwidth.
2
3use crate::error::Result;
4use crate::traits::Indicator;
5
6use super::BollingerBands;
7
8/// Bollinger Bandwidth — the width of the Bollinger Bands relative to the
9/// middle band.
10///
11/// ```text
12/// Bandwidth = (upper − lower) / middle
13/// ```
14///
15/// Because the bands are `middle ± multiplier · stddev`, the bandwidth is
16/// `2 · multiplier · stddev / middle` — a normalised volatility reading. Its
17/// value is the basis of two classic patterns: the **squeeze** (bandwidth at a
18/// multi-month low, signalling a coiled, low-volatility market about to
19/// expand) and the **bulge** (bandwidth at an extreme high).
20///
21/// # Example
22///
23/// ```
24/// use wickra_core::{Indicator, BollingerBandwidth};
25///
26/// let mut indicator = BollingerBandwidth::new(20, 2.0).unwrap();
27/// let mut last = None;
28/// for i in 0..80 {
29///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0);
30/// }
31/// assert!(last.is_some());
32/// ```
33#[derive(Debug, Clone)]
34pub struct BollingerBandwidth {
35    bands: BollingerBands,
36    last: Option<f64>,
37}
38
39impl BollingerBandwidth {
40    /// Construct a new Bollinger Bandwidth indicator.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`crate::Error::PeriodZero`] for `period == 0` and
45    /// [`crate::Error::NonPositiveMultiplier`] for `multiplier <= 0`.
46    pub fn new(period: usize, multiplier: f64) -> Result<Self> {
47        Ok(Self {
48            bands: BollingerBands::new(period, multiplier)?,
49            last: None,
50        })
51    }
52
53    /// Configured period.
54    pub const fn period(&self) -> usize {
55        self.bands.period()
56    }
57
58    /// Configured multiplier.
59    pub const fn multiplier(&self) -> f64 {
60        self.bands.multiplier()
61    }
62
63    /// Current value if available.
64    pub const fn value(&self) -> Option<f64> {
65        self.last
66    }
67}
68
69impl Indicator for BollingerBandwidth {
70    type Input = f64;
71    type Output = f64;
72
73    #[inline]
74    fn update(&mut self, input: f64) -> Option<f64> {
75        let o = self.bands.update(input)?;
76        let bandwidth = if o.middle == 0.0 {
77            // Undefined against a zero middle band.
78            0.0
79        } else {
80            (o.upper - o.lower) / o.middle
81        };
82        self.last = Some(bandwidth);
83        Some(bandwidth)
84    }
85
86    fn reset(&mut self) {
87        self.bands.reset();
88        self.last = None;
89    }
90
91    #[inline]
92    fn warmup_period(&self) -> usize {
93        self.bands.warmup_period()
94    }
95
96    #[inline]
97    fn is_ready(&self) -> bool {
98        self.last.is_some()
99    }
100
101    #[inline]
102    fn name(&self) -> &'static str {
103        "BollingerBandwidth"
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::traits::BatchExt;
111    use approx::assert_relative_eq;
112
113    #[test]
114    fn new_rejects_invalid_parameters() {
115        assert!(BollingerBandwidth::new(0, 2.0).is_err());
116        assert!(BollingerBandwidth::new(20, 0.0).is_err());
117        assert!(BollingerBandwidth::new(20, -1.0).is_err());
118    }
119
120    /// Cover the public const accessors `period`, `multiplier`, `value` and
121    /// the Indicator-impl `warmup_period` + `name` methods. None of the
122    /// pre-existing tests inspected the metadata surface — they only fed
123    /// numeric updates and asserted on the bandwidth values, leaving the
124    /// five getter bodies (lines 54-66, 90-92, 98-100) untouched.
125    #[test]
126    fn accessors_and_metadata() {
127        let mut bbw = BollingerBandwidth::new(20, 2.0).unwrap();
128        assert_eq!(bbw.period(), 20);
129        assert_relative_eq!(bbw.multiplier(), 2.0, epsilon = 1e-12);
130        // value() before warmup must be the literal None branch of self.last.
131        assert_eq!(bbw.value(), None);
132        assert_eq!(bbw.warmup_period(), 20);
133        assert_eq!(bbw.name(), "BollingerBandwidth");
134        // Drive past warmup so value() exercises the Some branch as well.
135        for i in 1..=20 {
136            bbw.update(f64::from(i));
137        }
138        assert!(bbw.value().is_some());
139    }
140
141    #[test]
142    fn constant_series_yields_zero() {
143        // Flat prices: the bands collapse onto the middle, so width is 0.
144        let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
145        let out = bbw.batch(&[100.0; 20]);
146        for v in out.iter().skip(4).flatten() {
147            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
148        }
149    }
150
151    /// Cover the defensive `o.middle == 0.0` branch in `update` (line 77).
152    /// All other tests use price levels ≈100, so the rolling SMA is always
153    /// strictly positive and the zero-middle fallback is unreachable. Feed
154    /// a symmetric series whose 5-bar mean is exactly 0 to force the branch
155    /// and assert the indicator yields exactly 0.0 (rather than inf/nan).
156    #[test]
157    fn zero_middle_band_yields_zero_bandwidth() {
158        let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
159        // sum(-2, -1, 0, 1, 2) = 0 exactly in IEEE-754, so the SMA middle
160        // lands on exactly 0.0 at the fifth input. Stddev > 0, so absent
161        // the guard the next line would divide by zero.
162        let out = bbw.batch(&[-2.0, -1.0, 0.0, 1.0, 2.0]);
163        assert_eq!(out[..4], [None, None, None, None]);
164        let v = out[4].expect("warmed up");
165        assert_eq!(v, 0.0, "zero-middle fallback must emit exactly 0.0");
166    }
167
168    #[test]
169    fn matches_bands_definition() {
170        // Bandwidth must equal (upper - lower) / middle from BollingerBands.
171        let prices: Vec<f64> = (1..=60)
172            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
173            .collect();
174        let bbw_out = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
175        let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
176        for (i, (w, b)) in bbw_out.iter().zip(bands_out.iter()).enumerate() {
177            // Same warmup period on both — emission shape must agree at every index.
178            assert_eq!(w.is_some(), b.is_some(), "warmup mismatch at index {i}");
179            if let (Some(wv), Some(bv)) = (w, b) {
180                assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12);
181            }
182        }
183    }
184
185    #[test]
186    fn output_is_non_negative() {
187        let mut bbw = BollingerBandwidth::new(20, 2.0).unwrap();
188        let prices: Vec<f64> = (1..=120)
189            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 12.0)
190            .collect();
191        for v in bbw.batch(&prices).into_iter().flatten() {
192            assert!(v >= 0.0, "bandwidth must be non-negative, got {v}");
193        }
194    }
195
196    #[test]
197    fn reset_clears_state() {
198        let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
199        bbw.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
200        assert!(bbw.is_ready());
201        bbw.reset();
202        assert!(!bbw.is_ready());
203        assert_eq!(bbw.update(1.0), None);
204    }
205
206    #[test]
207    fn batch_equals_streaming() {
208        let prices: Vec<f64> = (1..=80)
209            .map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0)
210            .collect();
211        let batch = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
212        let mut b = BollingerBandwidth::new(20, 2.0).unwrap();
213        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
214        assert_eq!(batch, streamed);
215    }
216}