Skip to main content

wickra_core/indicators/
double_bollinger.rs

1//! Double Bollinger Bands (Kathy Lien).
2
3use crate::error::{Error, Result};
4use crate::indicators::bollinger::BollingerBands;
5use crate::traits::Indicator;
6
7/// Double Bollinger Bands output: two concentric bands at `k_inner` and
8/// `k_outer` standard deviations around a shared SMA middle.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct DoubleBollingerOutput {
11    /// Outer upper band: `middle + k_outer · stddev`.
12    pub upper_outer: f64,
13    /// Inner upper band: `middle + k_inner · stddev`.
14    pub upper_inner: f64,
15    /// Middle band: SMA over the window.
16    pub middle: f64,
17    /// Inner lower band: `middle − k_inner · stddev`.
18    pub lower_inner: f64,
19    /// Outer lower band: `middle − k_outer · stddev`.
20    pub lower_outer: f64,
21}
22
23/// Double Bollinger Bands: two concentric Bollinger envelopes (Kathy Lien).
24///
25/// ```text
26/// middle      = SMA(period)
27/// sigma       = population stddev over the window
28/// upper_outer = middle + k_outer · sigma          // wide channel (often 2σ)
29/// upper_inner = middle + k_inner · sigma          // narrow channel (often 1σ)
30/// lower_inner = middle − k_inner · sigma
31/// lower_outer = middle − k_outer · sigma
32/// ```
33///
34/// Lien's trading framework partitions price into three zones:
35///
36/// - **Sell zone:** close below `lower_inner`.
37/// - **Neutral zone:** close between `lower_inner` and `upper_inner`.
38/// - **Buy zone:** close above `upper_inner`.
39///
40/// A close beyond the outer band marks an extended move that traders typically
41/// fade or trail. The constructor enforces `k_outer > k_inner` so the outputs
42/// remain monotonically ordered.
43///
44/// # Example
45///
46/// ```
47/// use wickra_core::{DoubleBollinger, Indicator};
48///
49/// let mut indicator = DoubleBollinger::new(20, 1.0, 2.0).unwrap();
50/// let mut last = None;
51/// for i in 0..40 {
52///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 6.0);
53/// }
54/// assert!(last.is_some());
55/// ```
56#[derive(Debug, Clone)]
57pub struct DoubleBollinger {
58    inner: BollingerBands,
59    k_inner: f64,
60    k_outer: f64,
61}
62
63impl DoubleBollinger {
64    /// Construct a new Double Bollinger Bands indicator.
65    ///
66    /// # Errors
67    /// Returns [`Error::PeriodZero`] if `period == 0`,
68    /// [`Error::NonPositiveMultiplier`] if either `k_inner` or `k_outer` is
69    /// non-positive or non-finite, and [`Error::InvalidPeriod`] if
70    /// `k_outer <= k_inner` (the outer band must strictly enclose the inner
71    /// band so the zone-partitioning interpretation holds).
72    pub fn new(period: usize, k_inner: f64, k_outer: f64) -> Result<Self> {
73        if !k_inner.is_finite() || k_inner <= 0.0 || !k_outer.is_finite() || k_outer <= 0.0 {
74            return Err(Error::NonPositiveMultiplier);
75        }
76        if k_outer <= k_inner {
77            return Err(Error::InvalidPeriod {
78                message: "double bollinger requires k_outer > k_inner",
79            });
80        }
81        // Build the inner state on the outer multiplier so the upper/lower
82        // outputs of `BollingerBands::update` already give us the outer band;
83        // the inner band is reconstructed from the same `stddev`.
84        Ok(Self {
85            inner: BollingerBands::new(period, k_outer)?,
86            k_inner,
87            k_outer,
88        })
89    }
90
91    /// Kathy Lien's classic configuration: SMA(20) with `±1σ` and `±2σ` bands.
92    pub fn classic() -> Self {
93        Self::new(20, 1.0, 2.0).expect("classic Double Bollinger parameters are valid")
94    }
95
96    /// Configured `(period, k_inner, k_outer)`.
97    pub const fn parameters(&self) -> (usize, f64, f64) {
98        (self.inner.period(), self.k_inner, self.k_outer)
99    }
100}
101
102impl Indicator for DoubleBollinger {
103    type Input = f64;
104    type Output = DoubleBollingerOutput;
105
106    fn update(&mut self, value: f64) -> Option<DoubleBollingerOutput> {
107        let o = self.inner.update(value)?;
108        Some(DoubleBollingerOutput {
109            upper_outer: o.upper,
110            upper_inner: o.middle + self.k_inner * o.stddev,
111            middle: o.middle,
112            lower_inner: o.middle - self.k_inner * o.stddev,
113            lower_outer: o.lower,
114        })
115    }
116
117    fn reset(&mut self) {
118        self.inner.reset();
119    }
120
121    fn warmup_period(&self) -> usize {
122        self.inner.warmup_period()
123    }
124
125    fn is_ready(&self) -> bool {
126        self.inner.is_ready()
127    }
128
129    fn name(&self) -> &'static str {
130        "DoubleBollinger"
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::traits::BatchExt;
138    use approx::assert_relative_eq;
139
140    #[test]
141    fn rejects_zero_period() {
142        assert!(matches!(
143            DoubleBollinger::new(0, 1.0, 2.0),
144            Err(Error::PeriodZero)
145        ));
146    }
147
148    #[test]
149    fn rejects_non_positive_multiplier() {
150        assert!(matches!(
151            DoubleBollinger::new(20, 0.0, 2.0),
152            Err(Error::NonPositiveMultiplier)
153        ));
154        assert!(matches!(
155            DoubleBollinger::new(20, 1.0, -2.0),
156            Err(Error::NonPositiveMultiplier)
157        ));
158        assert!(matches!(
159            DoubleBollinger::new(20, f64::NAN, 2.0),
160            Err(Error::NonPositiveMultiplier)
161        ));
162    }
163
164    #[test]
165    fn rejects_outer_not_greater_than_inner() {
166        assert!(matches!(
167            DoubleBollinger::new(20, 2.0, 1.0),
168            Err(Error::InvalidPeriod { .. })
169        ));
170        assert!(matches!(
171            DoubleBollinger::new(20, 2.0, 2.0),
172            Err(Error::InvalidPeriod { .. })
173        ));
174    }
175
176    #[test]
177    fn accessors_and_metadata() {
178        let db = DoubleBollinger::classic();
179        let (p, ki, ko) = db.parameters();
180        assert_eq!(p, 20);
181        assert_relative_eq!(ki, 1.0, epsilon = 1e-12);
182        assert_relative_eq!(ko, 2.0, epsilon = 1e-12);
183        assert_eq!(db.warmup_period(), 20);
184        assert_eq!(db.name(), "DoubleBollinger");
185    }
186
187    #[test]
188    fn constant_series_collapses_all_bands() {
189        let mut db = DoubleBollinger::new(10, 1.0, 2.0).unwrap();
190        let last = db
191            .batch(&[5.0_f64; 20])
192            .into_iter()
193            .flatten()
194            .last()
195            .unwrap();
196        assert_relative_eq!(last.middle, 5.0, epsilon = 1e-12);
197        assert_relative_eq!(last.upper_outer, 5.0, epsilon = 1e-12);
198        assert_relative_eq!(last.upper_inner, 5.0, epsilon = 1e-12);
199        assert_relative_eq!(last.lower_inner, 5.0, epsilon = 1e-12);
200        assert_relative_eq!(last.lower_outer, 5.0, epsilon = 1e-12);
201    }
202
203    #[test]
204    fn bands_strictly_ordered_with_dispersion() {
205        let prices: Vec<f64> = (0..80)
206            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
207            .collect();
208        let mut db = DoubleBollinger::classic();
209        for o in db.batch(&prices).into_iter().flatten() {
210            assert!(o.upper_outer >= o.upper_inner);
211            assert!(o.upper_inner >= o.middle);
212            assert!(o.middle >= o.lower_inner);
213            assert!(o.lower_inner >= o.lower_outer);
214        }
215    }
216
217    #[test]
218    fn batch_equals_streaming() {
219        let prices: Vec<f64> = (0..50).map(|i| f64::from(i) * 0.7).collect();
220        let mut a = DoubleBollinger::new(10, 1.0, 2.0).unwrap();
221        let mut b = DoubleBollinger::new(10, 1.0, 2.0).unwrap();
222        assert_eq!(
223            a.batch(&prices),
224            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
225        );
226    }
227
228    #[test]
229    fn reset_clears_state() {
230        let mut db = DoubleBollinger::new(5, 1.0, 2.0).unwrap();
231        db.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
232        assert!(db.is_ready());
233        db.reset();
234        assert!(!db.is_ready());
235        assert_eq!(db.update(1.0), None);
236    }
237
238    /// The inner band must agree with running a separate `BollingerBands` at
239    /// the inner multiplier.
240    #[test]
241    fn inner_band_matches_separate_bollinger() {
242        let prices: Vec<f64> = (0..80)
243            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
244            .collect();
245        let mut db = DoubleBollinger::new(20, 1.0, 2.0).unwrap();
246        let mut bb_inner = BollingerBands::new(20, 1.0).unwrap();
247        let mut bb_outer = BollingerBands::new(20, 2.0).unwrap();
248        for p in &prices {
249            let d = db.update(*p);
250            let i = bb_inner.update(*p);
251            let o = bb_outer.update(*p);
252            if let (Some(d), Some(i), Some(o)) = (d, i, o) {
253                assert_relative_eq!(d.middle, i.middle, epsilon = 1e-9);
254                assert_relative_eq!(d.upper_inner, i.upper, epsilon = 1e-9);
255                assert_relative_eq!(d.lower_inner, i.lower, epsilon = 1e-9);
256                assert_relative_eq!(d.upper_outer, o.upper, epsilon = 1e-9);
257                assert_relative_eq!(d.lower_outer, o.lower, epsilon = 1e-9);
258            }
259        }
260    }
261}