Skip to main content

finance_solution/stocks/ta/
bollinger.rs

1//! # Bollinger Bands
2//!
3//! Middle band = SMA(`period`). Upper/lower = middle ± `num_std` × window standard deviation.
4//!
5//! ## Word problem
6//!
7//! > A trader plots Bollinger(20, 2) on daily closes. After 20 days of identical closes at 100,
8//! > where are the bands?
9//!
10//! Expect: middle = 100, upper = lower = 100 (zero width → `%B` is `None`, not a fake 0.5).
11//!
12//! ```
13//! use finance_solution::stocks::ta::{bollinger, BollingerParams};
14//! let c = vec![100.0; 25];
15//! let s = bollinger(&c, BollingerParams::standard()).unwrap();
16//! assert_eq!(s.middle[19], Some(100.0));
17//! assert_eq!(s.upper[19], Some(100.0));
18//! assert_eq!(s.pct_b[19], None);
19//! ```
20//!
21//! ## Stdev: sample vs population
22//!
23//! | Kind | Denominator | Use |
24//! |------|-------------|-----|
25//! | [`StdevKind::Sample`] (default) | `n − 1` | **Recommended** for Bollinger in most finance software |
26//! | [`StdevKind::Population`] | `n` | Matches some charting packages / “full window” definitions |
27//!
28//! Sample is slightly wider for small `n`. Both are available so you are not locked in.
29//!
30//! ```
31//! use finance_solution::stocks::ta::{bollinger, BollingerParams, StdevKind};
32//! let closes: Vec<f64> = (1..=30).map(|x| 100.0 + x as f64).collect();
33//! let sample = bollinger(&closes, BollingerParams::standard()).unwrap();
34//! let pop = bollinger(
35//!     &closes,
36//!     BollingerParams { stdev: StdevKind::Population, ..BollingerParams::standard() },
37//! ).unwrap();
38//! // Same middle (SMA); upper band: sample ≥ population for n>1 when variance > 0
39//! assert!(sample.upper[29].unwrap() >= pop.upper[29].unwrap() - 1e-12);
40//! ```
41//!
42//! ## Quant pattern
43//!
44//! Batch [`bollinger`] / [`ValidatedBollinger::compute`] uses [`BollingerState`] (O(1) Σ/Σx² after warm-up).
45//!
46//! ```
47//! use finance_solution::stocks::ta::{BollingerParams, ValidatedBollinger, BollingerState};
48//!
49//! const BB20_2: BollingerParams = BollingerParams::standard(); // sample stdev
50//! let bb = ValidatedBollinger::new(BB20_2).unwrap();
51//! # let closes: Vec<f64> = (1..=40).map(|x| 100.0 + (x as f64).sin()).collect();
52//! let s = bb.compute(&closes).unwrap();
53//!
54//! // Live:
55//! let mut st = BollingerState::new(BB20_2).unwrap();
56//! let _ = st.push_bars(&closes).unwrap();
57//! ```
58//!
59//! ## Sample solution table
60//!
61//! ```text
62//! period   close  middle   upper   lower     pct_b
63//! ------  ------  ------  ------  ------  --------
64//!     18  100.50     n/a     n/a     n/a       n/a
65//!     19  100.60  100.20  101.10   99.30    0.6667
66//! ```
67//!
68//! ## %B
69//!
70//! `%B = (close − lower) / (upper − lower)` when width > 0; otherwise `None`.
71
72use crate::stocks::ta::common::{opt_cell, StdevKind};
73use crate::stocks::ta::state::BollingerState;
74use crate::util::error::{require_finite, FinanceError, FinanceResult};
75use crate::util::primitives::PeriodLength;
76use crate::{columns_with_strings, print_table_locale_opt};
77
78/// Bollinger parameter pack.
79#[derive(Clone, Copy, Debug, PartialEq)]
80pub struct BollingerParams {
81    pub period: usize,
82    /// Multiplier on window stdev (typically `2.0`).
83    pub num_std: f64,
84    /// Sample (`n−1`) or population (`n`) stdev. Default: sample.
85    pub stdev: StdevKind,
86}
87
88impl BollingerParams {
89    /// `(20, 2.0, Sample)` — common default.
90    pub const fn standard() -> Self {
91        Self {
92            period: 20,
93            num_std: 2.0,
94            stdev: StdevKind::Sample,
95        }
96    }
97
98    /// Sample stdev (same as setting `stdev: Sample`).
99    pub const fn new(period: usize, num_std: f64) -> Self {
100        Self {
101            period,
102            num_std,
103            stdev: StdevKind::Sample,
104        }
105    }
106
107    pub const fn with_stdev(period: usize, num_std: f64, stdev: StdevKind) -> Self {
108        Self {
109            period,
110            num_std,
111            stdev,
112        }
113    }
114}
115
116/// Validated Bollinger config.
117#[derive(Clone, Copy, Debug, PartialEq)]
118pub struct ValidatedBollinger {
119    params: BollingerParams,
120}
121
122impl ValidatedBollinger {
123    pub fn new(params: BollingerParams) -> FinanceResult<Self> {
124        PeriodLength::new(params.period)?;
125        require_finite("num_std", params.num_std)?;
126        if params.num_std < 0.0 {
127            return Err(FinanceError::Unsolvable {
128                message: "Bollinger num_std must be non-negative",
129            });
130        }
131        match params.stdev {
132            StdevKind::Sample if params.period < 2 => {
133                return Err(FinanceError::Unsolvable {
134                    message: "Bollinger sample stdev requires period >= 2",
135                });
136            }
137            StdevKind::Population if params.period < 1 => {
138                return Err(FinanceError::Unsolvable {
139                    message: "Bollinger population stdev requires period >= 1",
140                });
141            }
142            _ => {}
143        }
144        Ok(Self { params })
145    }
146
147    pub fn params(self) -> BollingerParams {
148        self.params
149    }
150
151    pub fn compute(self, closes: &[f64]) -> FinanceResult<BollingerSeries> {
152        bollinger_validated(closes, self)
153    }
154}
155
156/// Middle / upper / lower / %B series.
157#[derive(Clone, Debug, PartialEq)]
158pub struct BollingerSeries {
159    pub middle: Vec<Option<f64>>,
160    pub upper: Vec<Option<f64>>,
161    pub lower: Vec<Option<f64>>,
162    /// `(close - lower) / (upper - lower)` when width > 0.
163    pub pct_b: Vec<Option<f64>>,
164    pub params: BollingerParams,
165}
166
167/// Teaching solution + table.
168#[derive(Clone, Debug)]
169pub struct BollingerSolution {
170    series: BollingerSeries,
171    closes: Vec<f64>,
172    formula: String,
173    symbolic_formula: String,
174}
175
176impl BollingerSolution {
177    pub fn series(&self) -> &BollingerSeries {
178        &self.series
179    }
180    pub fn formula(&self) -> &str {
181        &self.formula
182    }
183    pub fn symbolic_formula(&self) -> &str {
184        &self.symbolic_formula
185    }
186
187    /// # Sample output
188    /// ```text
189    /// period   close  middle   upper   lower   pct_b
190    /// ------  ------  ------  ------  ------  ------
191    ///     19  100.60  100.20  101.10   99.30  0.6667
192    /// ```
193    pub fn print_table(&self) {
194        self.print_table_locale_opt(None, None);
195    }
196
197    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
198        self.print_table_locale_opt(Some(locale), Some(precision));
199    }
200
201    fn print_table_locale_opt(
202        &self,
203        locale: Option<&num_format::Locale>,
204        precision: Option<usize>,
205    ) {
206        let columns = columns_with_strings(&[
207            ("period", "i", true),
208            ("close", "f", true),
209            ("middle", "f", true),
210            ("upper", "f", true),
211            ("lower", "f", true),
212            ("pct_b", "f", true),
213        ]);
214        let data = self
215            .closes
216            .iter()
217            .enumerate()
218            .map(|(i, c)| {
219                vec![
220                    i.to_string(),
221                    c.to_string(),
222                    opt_cell(self.series.middle[i]),
223                    opt_cell(self.series.upper[i]),
224                    opt_cell(self.series.lower[i]),
225                    opt_cell(self.series.pct_b[i]),
226                ]
227            })
228            .collect();
229        print_table_locale_opt(&columns, data, locale, precision);
230    }
231}
232
233pub fn bollinger(closes: &[f64], params: BollingerParams) -> FinanceResult<BollingerSeries> {
234    ValidatedBollinger::new(params)?.compute(closes)
235}
236
237/// Teaching solution with formulas + table.
238///
239/// # Examples
240/// ```
241/// use finance_solution::stocks::ta::{bollinger_solution, BollingerParams, StdevKind};
242/// let closes: Vec<f64> = (1..=30).map(|x| 100.0 + (x as f64) * 0.1).collect();
243/// let sol = bollinger_solution(&closes, BollingerParams::standard()).unwrap();
244/// assert!(sol.formula().contains("20"));
245/// let sol_pop = bollinger_solution(
246///     &closes,
247///     BollingerParams::with_stdev(20, 2.0, StdevKind::Population),
248/// ).unwrap();
249/// assert!(sol_pop.formula().contains("population") || sol_pop.formula().contains("Population")
250///     || sol_pop.symbolic_formula().contains("population"));
251/// ```
252pub fn bollinger_solution(
253    closes: &[f64],
254    params: BollingerParams,
255) -> FinanceResult<BollingerSolution> {
256    let series = bollinger(closes, params)?;
257    let stdev_label = match params.stdev {
258        StdevKind::Sample => "sample_stdev(n-1)",
259        StdevKind::Population => "population_stdev(n)",
260    };
261    let formula = format!(
262        "mid = SMA({}); upper/lower = mid ± {} * {}; %B = (close-lower)/(upper-lower)",
263        params.period, params.num_std, stdev_label
264    );
265    let symbolic = format!(
266        "mid = sma(close,n); band = k * stdev_{:?}(window); upper = mid+band; lower = mid-band",
267        params.stdev
268    );
269    Ok(BollingerSolution {
270        series,
271        closes: closes.to_vec(),
272        formula,
273        symbolic_formula: symbolic,
274    })
275}
276
277fn bollinger_validated(closes: &[f64], v: ValidatedBollinger) -> FinanceResult<BollingerSeries> {
278    let p = v.params;
279    let mut st = BollingerState::new(p)?;
280    let bars = st.push_bars(closes)?;
281    let n = bars.len();
282    let mut middle = vec![None; n];
283    let mut upper = vec![None; n];
284    let mut lower = vec![None; n];
285    let mut pct_b = vec![None; n];
286    for (i, bar) in bars.into_iter().enumerate() {
287        if let Some(b) = bar {
288            middle[i] = Some(b.middle);
289            upper[i] = Some(b.upper);
290            lower[i] = Some(b.lower);
291            pct_b[i] = b.pct_b;
292        }
293    }
294    Ok(BollingerSeries {
295        middle,
296        upper,
297        lower,
298        pct_b,
299        params: p,
300    })
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn constant_series_zero_width() {
309        let c = vec![10.0; 25];
310        let s = bollinger(&c, BollingerParams::standard()).unwrap();
311        assert_eq!(s.middle[19], Some(10.0));
312        assert_eq!(s.upper[19], Some(10.0));
313        assert_eq!(s.pct_b[19], None);
314    }
315
316    #[test]
317    fn pct_b_midpoint_on_middle() {
318        // Price at middle → %B = 0.5 when width > 0
319        let closes: Vec<f64> = (0..40).map(|i| 100.0 + (i as f64 - 20.0) * 0.1).collect();
320        let s = bollinger(&closes, BollingerParams::standard()).unwrap();
321        let i = 30;
322        let mid = s.middle[i].unwrap();
323        let u = s.upper[i].unwrap();
324        let l = s.lower[i].unwrap();
325        // synthetic check of formula at close = mid
326        let pct = (mid - l) / (u - l);
327        assert!((pct - 0.5).abs() < 1e-9);
328    }
329
330    #[test]
331    fn sample_wider_than_population() {
332        let closes: Vec<f64> = (1..=30).map(|x| x as f64).collect();
333        let s = bollinger(&closes, BollingerParams::standard()).unwrap();
334        let p = bollinger(
335            &closes,
336            BollingerParams::with_stdev(20, 2.0, StdevKind::Population),
337        )
338        .unwrap();
339        let su = s.upper[29].unwrap();
340        let pu = p.upper[29].unwrap();
341        assert!(su + 1e-12 >= pu);
342    }
343}