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        let spread = a - b;
120        if self.window.len() == self.period {
121            let old = self.window.pop_front().expect("non-empty");
122            self.sum -= old;
123            self.sum_sq -= old * old;
124        }
125        self.window.push_back(spread);
126        self.sum += spread;
127        self.sum_sq += spread * spread;
128        if self.window.len() < self.period {
129            return None;
130        }
131        let n = self.period as f64;
132        let middle = self.sum / n;
133        let variance = (self.sum_sq / n - middle * middle).max(0.0);
134        let sigma = variance.sqrt();
135        let half_width = self.num_std * sigma;
136        let upper = middle + half_width;
137        let lower = middle - half_width;
138        let percent_b = if half_width == 0.0 {
139            0.5
140        } else {
141            (spread - lower) / (upper - lower)
142        };
143        Some(SpreadBollingerBandsOutput {
144            middle,
145            upper,
146            lower,
147            percent_b,
148        })
149    }
150
151    fn reset(&mut self) {
152        self.window.clear();
153        self.sum = 0.0;
154        self.sum_sq = 0.0;
155    }
156
157    fn warmup_period(&self) -> usize {
158        self.period
159    }
160
161    fn is_ready(&self) -> bool {
162        self.window.len() == self.period
163    }
164
165    fn name(&self) -> &'static str {
166        "SpreadBollingerBands"
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::traits::BatchExt;
174    use approx::assert_relative_eq;
175
176    #[test]
177    fn rejects_bad_parameters() {
178        assert!(SpreadBollingerBands::new(1, 2.0).is_err());
179        assert!(SpreadBollingerBands::new(20, 0.0).is_err());
180        assert!(SpreadBollingerBands::new(20, -1.0).is_err());
181        assert!(SpreadBollingerBands::new(20, f64::NAN).is_err());
182        assert!(SpreadBollingerBands::new(2, 2.0).is_ok());
183    }
184
185    #[test]
186    fn accessors_and_metadata() {
187        let bb = SpreadBollingerBands::new(20, 2.5).unwrap();
188        assert_eq!(bb.period(), 20);
189        assert_eq!(bb.num_std(), 2.5);
190        assert_eq!(bb.warmup_period(), 20);
191        assert_eq!(bb.name(), "SpreadBollingerBands");
192        assert!(!bb.is_ready());
193    }
194
195    #[test]
196    fn warmup_returns_none() {
197        let mut bb = SpreadBollingerBands::new(3, 2.0).unwrap();
198        assert_eq!(bb.update((1.0, 0.0)), None);
199        assert_eq!(bb.update((2.0, 0.0)), None);
200        assert!(bb.update((3.0, 0.0)).is_some());
201        assert!(bb.is_ready());
202    }
203
204    #[test]
205    fn hand_computed_value() {
206        // Spreads 1,2,3,4 (b = 0), period 4, num_std 2:
207        //   mean = 2.5, σ = √1.25, upper = 2.5 + 2√1.25, lower = 2.5 − 2√1.25,
208        //   %b at s = 4 ⇒ 0.8354102.
209        let pairs = [(1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0)];
210        let out = SpreadBollingerBands::new(4, 2.0)
211            .unwrap()
212            .batch(&pairs)
213            .into_iter()
214            .flatten()
215            .last()
216            .unwrap();
217        assert_relative_eq!(out.middle, 2.5, epsilon = 1e-9);
218        assert_relative_eq!(out.upper, 4.736_067_977_499_79, epsilon = 1e-9);
219        assert_relative_eq!(out.lower, 0.263_932_022_500_21, epsilon = 1e-9);
220        assert_relative_eq!(out.percent_b, 0.835_410_196_624_97, epsilon = 1e-9);
221    }
222
223    #[test]
224    fn flat_spread_collapses_band() {
225        // a − b constant ⇒ σ = 0 ⇒ upper = middle = lower, %b = 0.5.
226        let pairs: Vec<(f64, f64)> = (0..10)
227            .map(|t| (5.0 + f64::from(t), f64::from(t)))
228            .collect();
229        let out = SpreadBollingerBands::new(5, 2.0)
230            .unwrap()
231            .batch(&pairs)
232            .into_iter()
233            .flatten()
234            .last()
235            .unwrap();
236        assert_relative_eq!(out.upper, out.middle, epsilon = 1e-12);
237        assert_relative_eq!(out.lower, out.middle, epsilon = 1e-12);
238        assert_relative_eq!(out.percent_b, 0.5, epsilon = 1e-12);
239    }
240
241    #[test]
242    fn bands_are_ordered() {
243        let pairs: Vec<(f64, f64)> = (0..80)
244            .map(|t| {
245                let b = 100.0 + f64::from(t);
246                (b + 3.0 * (f64::from(t) * 0.4).sin(), b)
247            })
248            .collect();
249        let mut bb = SpreadBollingerBands::new(20, 2.0).unwrap();
250        for out in bb.batch(&pairs).into_iter().flatten() {
251            assert!(out.lower <= out.middle && out.middle <= out.upper);
252        }
253    }
254
255    #[test]
256    fn reset_clears_state() {
257        let mut bb = SpreadBollingerBands::new(4, 2.0).unwrap();
258        bb.batch(&[(1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0), (5.0, 0.0)]);
259        assert!(bb.is_ready());
260        bb.reset();
261        assert!(!bb.is_ready());
262        assert_eq!(bb.update((1.0, 0.0)), None);
263    }
264
265    #[test]
266    fn batch_equals_streaming() {
267        let pairs: Vec<(f64, f64)> = (0..60)
268            .map(|t| {
269                let b = 30.0 + 0.7 * f64::from(t);
270                (b + (f64::from(t) * 0.4).sin() * 1.5, b)
271            })
272            .collect();
273        let batch = SpreadBollingerBands::new(15, 2.0).unwrap().batch(&pairs);
274        let mut bb = SpreadBollingerBands::new(15, 2.0).unwrap();
275        let streamed: Vec<_> = pairs.iter().map(|p| bb.update(*p)).collect();
276        assert_eq!(batch, streamed);
277    }
278}