Skip to main content

wickra_core/indicators/
spread_bollinger_bands.rs

1//! Bollinger bands on the spread of two series, for pairs mean-reversion trading.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Output of [`SpreadBollingerBands`].
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct SpreadBollingerBandsOutput {
11    /// Middle band: the rolling mean of the spread.
12    pub middle: f64,
13    /// Upper band: `middle + num_std · σ`.
14    pub upper: f64,
15    /// Lower band: `middle − num_std · σ`.
16    pub lower: f64,
17    /// `%b`: where the current spread sits across the band, `(s − lower) /
18    /// (upper − lower)`. `0` is the lower band, `1` the upper, `0.5` the middle.
19    /// Reported as `0.5` when the band has zero width (a flat spread).
20    pub percent_b: f64,
21}
22
23/// Bollinger bands on the spread `a − b` of two series.
24///
25/// Each `update` takes one `(a, b)` price pair and forms the spread
26/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads it builds a
27/// classic Bollinger envelope:
28///
29/// ```text
30/// middle = mean(s)        σ = stddev(s)
31/// upper  = middle + num_std · σ
32/// lower  = middle − num_std · σ
33/// %b     = (s_now − lower) / (upper − lower)
34/// ```
35///
36/// Applied to a spread rather than a price, the bands are a ready-made pairs
37/// mean-reversion signal: the spread riding the **upper** band is stretched
38/// rich (a short-the-spread setup), the **lower** band stretched cheap, and a
39/// return to the **middle** is the exit. `%b` compresses the location into one
40/// number for thresholding. The spread is the raw difference `a − b`, so feed
41/// already-comparable legs (e.g. a hedged pair, two yields, or log prices); pair
42/// this with [`crate::BetaNeutralSpread`] when the legs need a hedge ratio first.
43///
44/// A flat spread yields a zero-width band; `%b` is then reported as the neutral
45/// `0.5`. Each `update` is `O(1)`: the mean and variance come from two running
46/// sums maintained as the window slides.
47///
48/// # Example
49///
50/// ```
51/// use wickra_core::{Indicator, SpreadBollingerBands};
52///
53/// let mut bb = SpreadBollingerBands::new(20, 2.0).unwrap();
54/// let mut last = None;
55/// for t in 0..60 {
56///     let b = 100.0 + f64::from(t);
57///     let a = b + 2.0 * (f64::from(t) * 0.5).sin();
58///     last = bb.update((a, b));
59/// }
60/// let out = last.unwrap();
61/// assert!(out.lower <= out.middle && out.middle <= out.upper);
62/// ```
63#[derive(Debug, Clone)]
64pub struct SpreadBollingerBands {
65    period: usize,
66    num_std: f64,
67    window: VecDeque<f64>,
68    sum: f64,
69    sum_sq: f64,
70}
71
72impl SpreadBollingerBands {
73    /// Construct new spread Bollinger bands.
74    ///
75    /// `period` is the look-back window; `num_std` is the band width in standard
76    /// deviations.
77    ///
78    /// # Errors
79    /// Returns [`Error::InvalidPeriod`] if `period < 2`, or
80    /// [`Error::InvalidParameter`] if `num_std` is not strictly positive (and
81    /// finite).
82    pub fn new(period: usize, num_std: f64) -> Result<Self> {
83        if period < 2 {
84            return Err(Error::InvalidPeriod {
85                message: "spread bollinger bands needs period >= 2",
86            });
87        }
88        if !num_std.is_finite() || num_std <= 0.0 {
89            return Err(Error::InvalidParameter {
90                message: "spread bollinger bands needs num_std > 0",
91            });
92        }
93        Ok(Self {
94            period,
95            num_std,
96            window: VecDeque::with_capacity(period),
97            sum: 0.0,
98            sum_sq: 0.0,
99        })
100    }
101
102    /// Configured look-back window.
103    pub const fn period(&self) -> usize {
104        self.period
105    }
106
107    /// Configured band width in standard deviations.
108    pub const fn num_std(&self) -> f64 {
109        self.num_std
110    }
111}
112
113impl Indicator for SpreadBollingerBands {
114    type Input = (f64, f64);
115    type Output = SpreadBollingerBandsOutput;
116
117    fn update(&mut self, input: (f64, f64)) -> Option<SpreadBollingerBandsOutput> {
118        let (a, b) = input;
119        if !a.is_finite() || !b.is_finite() {
120            return None;
121        }
122        let spread = a - b;
123        if self.window.len() == self.period {
124            let old = self.window.pop_front().expect("non-empty");
125            self.sum -= old;
126            self.sum_sq -= old * old;
127        }
128        self.window.push_back(spread);
129        self.sum += spread;
130        self.sum_sq += spread * spread;
131        if self.window.len() < self.period {
132            return None;
133        }
134        let n = self.period as f64;
135        let middle = self.sum / n;
136        let variance = (self.sum_sq / n - middle * middle).max(0.0);
137        let sigma = variance.sqrt();
138        let half_width = self.num_std * sigma;
139        let upper = middle + half_width;
140        let lower = middle - half_width;
141        let percent_b = if half_width == 0.0 {
142            0.5
143        } else {
144            (spread - lower) / (upper - lower)
145        };
146        Some(SpreadBollingerBandsOutput {
147            middle,
148            upper,
149            lower,
150            percent_b,
151        })
152    }
153
154    fn reset(&mut self) {
155        self.window.clear();
156        self.sum = 0.0;
157        self.sum_sq = 0.0;
158    }
159
160    fn warmup_period(&self) -> usize {
161        self.period
162    }
163
164    fn is_ready(&self) -> bool {
165        self.window.len() == self.period
166    }
167
168    fn name(&self) -> &'static str {
169        "SpreadBollingerBands"
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::traits::BatchExt;
177    use approx::assert_relative_eq;
178
179    #[test]
180    fn rejects_bad_parameters() {
181        assert!(SpreadBollingerBands::new(1, 2.0).is_err());
182        assert!(SpreadBollingerBands::new(20, 0.0).is_err());
183        assert!(SpreadBollingerBands::new(20, -1.0).is_err());
184        assert!(SpreadBollingerBands::new(20, f64::NAN).is_err());
185        assert!(SpreadBollingerBands::new(2, 2.0).is_ok());
186    }
187
188    #[test]
189    fn accessors_and_metadata() {
190        let bb = SpreadBollingerBands::new(20, 2.5).unwrap();
191        assert_eq!(bb.period(), 20);
192        assert_eq!(bb.num_std(), 2.5);
193        assert_eq!(bb.warmup_period(), 20);
194        assert_eq!(bb.name(), "SpreadBollingerBands");
195        assert!(!bb.is_ready());
196    }
197
198    #[test]
199    fn warmup_returns_none() {
200        let mut bb = SpreadBollingerBands::new(3, 2.0).unwrap();
201        assert_eq!(bb.update((1.0, 0.0)), None);
202        assert_eq!(bb.update((2.0, 0.0)), None);
203        assert!(bb.update((3.0, 0.0)).is_some());
204        assert!(bb.is_ready());
205    }
206
207    #[test]
208    fn hand_computed_value() {
209        // Spreads 1,2,3,4 (b = 0), period 4, num_std 2:
210        //   mean = 2.5, σ = √1.25, upper = 2.5 + 2√1.25, lower = 2.5 − 2√1.25,
211        //   %b at s = 4 ⇒ 0.8354102.
212        let pairs = [(1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0)];
213        let out = SpreadBollingerBands::new(4, 2.0)
214            .unwrap()
215            .batch(&pairs)
216            .into_iter()
217            .flatten()
218            .last()
219            .unwrap();
220        assert_relative_eq!(out.middle, 2.5, epsilon = 1e-9);
221        assert_relative_eq!(out.upper, 4.736_067_977_499_79, epsilon = 1e-9);
222        assert_relative_eq!(out.lower, 0.263_932_022_500_21, epsilon = 1e-9);
223        assert_relative_eq!(out.percent_b, 0.835_410_196_624_97, epsilon = 1e-9);
224    }
225
226    #[test]
227    fn flat_spread_collapses_band() {
228        // a − b constant ⇒ σ = 0 ⇒ upper = middle = lower, %b = 0.5.
229        let pairs: Vec<(f64, f64)> = (0..10)
230            .map(|t| (5.0 + f64::from(t), f64::from(t)))
231            .collect();
232        let out = SpreadBollingerBands::new(5, 2.0)
233            .unwrap()
234            .batch(&pairs)
235            .into_iter()
236            .flatten()
237            .last()
238            .unwrap();
239        assert_relative_eq!(out.upper, out.middle, epsilon = 1e-12);
240        assert_relative_eq!(out.lower, out.middle, epsilon = 1e-12);
241        assert_relative_eq!(out.percent_b, 0.5, epsilon = 1e-12);
242    }
243
244    #[test]
245    fn bands_are_ordered() {
246        let pairs: Vec<(f64, f64)> = (0..80)
247            .map(|t| {
248                let b = 100.0 + f64::from(t);
249                (b + 3.0 * (f64::from(t) * 0.4).sin(), b)
250            })
251            .collect();
252        let mut bb = SpreadBollingerBands::new(20, 2.0).unwrap();
253        for out in bb.batch(&pairs).into_iter().flatten() {
254            assert!(out.lower <= out.middle && out.middle <= out.upper);
255        }
256    }
257
258    #[test]
259    fn reset_clears_state() {
260        let mut bb = SpreadBollingerBands::new(4, 2.0).unwrap();
261        bb.batch(&[(1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0), (5.0, 0.0)]);
262        assert!(bb.is_ready());
263        bb.reset();
264        assert!(!bb.is_ready());
265        assert_eq!(bb.update((1.0, 0.0)), None);
266    }
267
268    #[test]
269    fn batch_equals_streaming() {
270        let pairs: Vec<(f64, f64)> = (0..60)
271            .map(|t| {
272                let b = 30.0 + 0.7 * f64::from(t);
273                (b + (f64::from(t) * 0.4).sin() * 1.5, b)
274            })
275            .collect();
276        let batch = SpreadBollingerBands::new(15, 2.0).unwrap().batch(&pairs);
277        let mut bb = SpreadBollingerBands::new(15, 2.0).unwrap();
278        let streamed: Vec<_> = pairs.iter().map(|p| bb.update(*p)).collect();
279        assert_eq!(batch, streamed);
280    }
281}