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    fn update(&mut self, input: f64) -> Option<f64> {
74        let o = self.bands.update(input)?;
75        let bandwidth = if o.middle == 0.0 {
76            // Undefined against a zero middle band.
77            0.0
78        } else {
79            (o.upper - o.lower) / o.middle
80        };
81        self.last = Some(bandwidth);
82        Some(bandwidth)
83    }
84
85    fn reset(&mut self) {
86        self.bands.reset();
87        self.last = None;
88    }
89
90    fn warmup_period(&self) -> usize {
91        self.bands.warmup_period()
92    }
93
94    fn is_ready(&self) -> bool {
95        self.last.is_some()
96    }
97
98    fn name(&self) -> &'static str {
99        "BollingerBandwidth"
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::traits::BatchExt;
107    use approx::assert_relative_eq;
108
109    #[test]
110    fn new_rejects_invalid_parameters() {
111        assert!(BollingerBandwidth::new(0, 2.0).is_err());
112        assert!(BollingerBandwidth::new(20, 0.0).is_err());
113        assert!(BollingerBandwidth::new(20, -1.0).is_err());
114    }
115
116    /// Cover the public const accessors `period`, `multiplier`, `value` and
117    /// the Indicator-impl `warmup_period` + `name` methods. None of the
118    /// pre-existing tests inspected the metadata surface — they only fed
119    /// numeric updates and asserted on the bandwidth values, leaving the
120    /// five getter bodies (lines 54-66, 90-92, 98-100) untouched.
121    #[test]
122    fn accessors_and_metadata() {
123        let mut bbw = BollingerBandwidth::new(20, 2.0).unwrap();
124        assert_eq!(bbw.period(), 20);
125        assert_relative_eq!(bbw.multiplier(), 2.0, epsilon = 1e-12);
126        // value() before warmup must be the literal None branch of self.last.
127        assert_eq!(bbw.value(), None);
128        assert_eq!(bbw.warmup_period(), 20);
129        assert_eq!(bbw.name(), "BollingerBandwidth");
130        // Drive past warmup so value() exercises the Some branch as well.
131        for i in 1..=20 {
132            bbw.update(f64::from(i));
133        }
134        assert!(bbw.value().is_some());
135    }
136
137    #[test]
138    fn constant_series_yields_zero() {
139        // Flat prices: the bands collapse onto the middle, so width is 0.
140        let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
141        let out = bbw.batch(&[100.0; 20]);
142        for v in out.iter().skip(4).flatten() {
143            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
144        }
145    }
146
147    /// Cover the defensive `o.middle == 0.0` branch in `update` (line 77).
148    /// All other tests use price levels ≈100, so the rolling SMA is always
149    /// strictly positive and the zero-middle fallback is unreachable. Feed
150    /// a symmetric series whose 5-bar mean is exactly 0 to force the branch
151    /// and assert the indicator yields exactly 0.0 (rather than inf/nan).
152    #[test]
153    fn zero_middle_band_yields_zero_bandwidth() {
154        let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
155        // sum(-2, -1, 0, 1, 2) = 0 exactly in IEEE-754, so the SMA middle
156        // lands on exactly 0.0 at the fifth input. Stddev > 0, so absent
157        // the guard the next line would divide by zero.
158        let out = bbw.batch(&[-2.0, -1.0, 0.0, 1.0, 2.0]);
159        assert_eq!(out[..4], [None, None, None, None]);
160        let v = out[4].expect("warmed up");
161        assert_eq!(v, 0.0, "zero-middle fallback must emit exactly 0.0");
162    }
163
164    #[test]
165    fn matches_bands_definition() {
166        // Bandwidth must equal (upper - lower) / middle from BollingerBands.
167        let prices: Vec<f64> = (1..=60)
168            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
169            .collect();
170        let bbw_out = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
171        let bands_out = BollingerBands::new(20, 2.0).unwrap().batch(&prices);
172        for (i, (w, b)) in bbw_out.iter().zip(bands_out.iter()).enumerate() {
173            // Same warmup period on both — emission shape must agree at every index.
174            assert_eq!(w.is_some(), b.is_some(), "warmup mismatch at index {i}");
175            if let (Some(wv), Some(bv)) = (w, b) {
176                assert_relative_eq!(*wv, (bv.upper - bv.lower) / bv.middle, epsilon = 1e-12);
177            }
178        }
179    }
180
181    #[test]
182    fn output_is_non_negative() {
183        let mut bbw = BollingerBandwidth::new(20, 2.0).unwrap();
184        let prices: Vec<f64> = (1..=120)
185            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 12.0)
186            .collect();
187        for v in bbw.batch(&prices).into_iter().flatten() {
188            assert!(v >= 0.0, "bandwidth must be non-negative, got {v}");
189        }
190    }
191
192    #[test]
193    fn reset_clears_state() {
194        let mut bbw = BollingerBandwidth::new(5, 2.0).unwrap();
195        bbw.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
196        assert!(bbw.is_ready());
197        bbw.reset();
198        assert!(!bbw.is_ready());
199        assert_eq!(bbw.update(1.0), None);
200    }
201
202    #[test]
203    fn batch_equals_streaming() {
204        let prices: Vec<f64> = (1..=80)
205            .map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 7.0)
206            .collect();
207        let batch = BollingerBandwidth::new(20, 2.0).unwrap().batch(&prices);
208        let mut b = BollingerBandwidth::new(20, 2.0).unwrap();
209        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
210        assert_eq!(batch, streamed);
211    }
212}