kestrel_chartkit/indicator/
bollinger.rs1use std::collections::{HashMap, VecDeque};
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6use crate::model::Bar;
7
8use super::{Indicator, IndicatorAlert, IndicatorOutput};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
17pub enum VarianceConvention {
18 #[default]
20 Population,
21 Sample,
25}
26
27#[derive(Debug, Clone)]
42pub struct BollingerBands {
43 len: usize,
44 mult: f64,
45 variance: VarianceConvention,
46 window: VecDeque<f64>,
47 sum: f64,
48
49 alerts: BollingerAlerts,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Default)]
53pub struct BollingerAlerts {
54 pub lower_touch: bool,
55 pub upper_touch: bool,
56 pub percent_b: f64,
57}
58
59impl BollingerBands {
60 pub fn new(len: usize, mult: f64) -> Self {
61 Self {
62 len,
63 mult,
64 variance: VarianceConvention::Population,
65 window: VecDeque::with_capacity(len),
66 sum: 0.0,
67 alerts: BollingerAlerts::default(),
68 }
69 }
70
71 pub fn with_defaults() -> Self {
72 Self::new(20, 2.0)
73 }
74
75 pub fn with_variance(mut self, variance: VarianceConvention) -> Self {
81 assert!(
82 variance != VarianceConvention::Sample || self.len >= 2,
83 "sample variance requires len >= 2, got {}",
84 self.len
85 );
86 self.variance = variance;
87 self
88 }
89
90 pub fn variance(&self) -> VarianceConvention {
91 self.variance
92 }
93}
94
95impl Indicator for BollingerBands {
96 fn name(&self) -> &str {
97 "bollinger"
98 }
99
100 fn warmup_period(&self) -> usize {
101 self.len
102 }
103
104 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
105 self.alerts = BollingerAlerts::default();
106 let close = bar.close;
107
108 self.window.push_back(close);
109 self.sum += close;
110
111 if self.window.len() > self.len {
112 self.sum -= self.window.pop_front().unwrap();
113 }
114
115 if self.window.len() < self.len {
116 return None;
117 }
118
119 let basis = self.sum / self.len as f64;
120 let divisor = match self.variance {
121 VarianceConvention::Population => self.len as f64,
122 VarianceConvention::Sample => (self.len - 1) as f64,
124 };
125 let variance = self
126 .window
127 .iter()
128 .map(|val| {
129 let diff = val - basis;
130 diff * diff
131 })
132 .sum::<f64>()
133 / divisor;
134
135 let std_dev = variance.sqrt();
136 let upper = basis + self.mult * std_dev;
137 let lower = basis - self.mult * std_dev;
138
139 let width = if basis != 0.0 {
140 (upper - lower) / basis
141 } else {
142 0.0
143 };
144
145 let pct_b = if upper != lower {
146 (close - lower) / (upper - lower)
147 } else {
148 0.5
149 };
150
151 self.alerts.lower_touch = close <= lower;
152 self.alerts.upper_touch = close >= upper;
153 self.alerts.percent_b = pct_b;
154
155 let mut extra = HashMap::new();
156 extra.insert("basis".to_string(), basis);
157 extra.insert("upper".to_string(), upper);
158 extra.insert("lower".to_string(), lower);
159 extra.insert("bandwidth".to_string(), width);
160 extra.insert("percent_b".to_string(), pct_b);
161
162 Some(IndicatorOutput::with_extra(basis, extra))
163 }
164
165 fn reset(&mut self) {
166 self.window.clear();
167 self.sum = 0.0;
168 self.alerts = BollingerAlerts::default();
169 }
170
171 fn alerts(&self) -> Vec<IndicatorAlert> {
172 let a = self.alerts;
173 let mut out = Vec::new();
174 if a.lower_touch {
175 out.push(IndicatorAlert {
176 kind: "lower_touch".to_string(),
177 note: "BOLLINGER · TOUCHED LOWER BAND".to_string(),
178 strength: 1.0,
179 });
180 }
181 if a.upper_touch {
182 out.push(IndicatorAlert {
183 kind: "upper_touch".to_string(),
184 note: "BOLLINGER · TOUCHED UPPER BAND".to_string(),
185 strength: 1.0,
186 });
187 }
188 out
189 }
190}