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