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