Skip to main content

egui_charts/studies/builtin/
percent_b.rs

1use super::clamp_param;
2use crate::model::Bar;
3use crate::studies::palette;
4use crate::studies::stepper::update_window;
5use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
6use egui::Color32;
7
8/// Percent B (%B)
9/// Shows where price is relative to Bollinger Bands
10/// 0 = at lower band, 1 = at upper band
11#[derive(Clone)]
12pub struct PercentB {
13    /// Period for Bollinger Bands (typically 20)
14    period: usize,
15    /// Standard deviation multiplier (typically 2.0)
16    multiplier: f64,
17    values: IndicatorValues,
18    color: Color32,
19    visible: bool,
20}
21
22impl PercentB {
23    /// `multiplier` is clamped into 0 to `f64::MAX`, NaN to 0.
24    pub fn new(period: usize, multiplier: f64) -> Self {
25        Self {
26            period: period.max(1),
27            multiplier: clamp_param(multiplier, 0.0, f64::MAX),
28            values: IndicatorValues::new(),
29            color: palette::BLUE,
30            visible: true,
31        }
32    }
33
34    /// Set the plot color.
35    pub fn with_color(mut self, color: Color32) -> Self {
36        self.color = color;
37        self
38    }
39}
40
41impl Default for PercentB {
42    fn default() -> Self {
43        Self::new(20, 2.0)
44    }
45}
46
47impl PercentB {
48    fn compute(&self, data: &[Bar]) -> IndicatorValues {
49        let mut values = IndicatorValues::new();
50
51        if data.len() < self.period {
52            for _ in 0..data.len() {
53                values.push(IndicatorValue::None);
54            }
55            return values;
56        }
57
58        for i in 0..data.len() {
59            if i < self.period - 1 {
60                values.push(IndicatorValue::None);
61            } else {
62                let window = &data[i + 1 - self.period..=i];
63
64                // Calculate SMA
65                let sma: f64 = window.iter().map(|b| b.close).sum::<f64>() / self.period as f64;
66
67                // Calculate standard deviation
68                let variance: f64 = window.iter().map(|b| (b.close - sma).powi(2)).sum::<f64>()
69                    / self.period as f64;
70                let std_dev = variance.sqrt();
71
72                // Calculate bands
73                let upper = sma + self.multiplier * std_dev;
74                let lower = sma - self.multiplier * std_dev;
75
76                // Calculate %B
77                let band_width = upper - lower;
78                if band_width == 0.0 {
79                    values.push(IndicatorValue::Single(0.5));
80                } else {
81                    let percent_b = (data[i].close - lower) / band_width;
82                    values.push(IndicatorValue::Single(percent_b));
83                }
84            }
85        }
86        values
87    }
88}
89
90impl Indicator for PercentB {
91    fn source(&self) -> Option<crate::studies::StudySource> {
92        Some(crate::studies::StudySource::Price(
93            crate::model::PriceSource::Close,
94        ))
95    }
96
97    crate::studies::inputs::study_inputs!(self;
98        period: length("Period", 1, usize::MAX),
99        multiplier: number("Multiplier", 0.0, f64::MAX)
100    ; {});
101
102    fn name(&self) -> &str {
103        "%B"
104    }
105
106    fn desc(&self) -> &str {
107        "Percent B - Price position within Bollinger Bands"
108    }
109
110    fn calculate(&mut self, data: &[Bar]) {
111        self.update(data, 0);
112    }
113
114    fn update(&mut self, data: &[Bar], from: usize) {
115        let mut values = std::mem::take(&mut self.values);
116        update_window(
117            &mut values,
118            data,
119            from,
120            |_data, from| from.saturating_sub(self.period - 1),
121            |data| self.compute(data),
122        );
123        self.values = values;
124    }
125
126    fn is_incremental(&self) -> bool {
127        true
128    }
129
130    fn values(&self) -> &IndicatorValues {
131        &self.values
132    }
133
134    fn colors(&self) -> Vec<Color32> {
135        vec![self.color]
136    }
137
138    fn set_colors(&mut self, colors: Vec<Color32>) {
139        if !colors.is_empty() {
140            self.color = colors[0];
141        }
142    }
143
144    fn is_overlay(&self) -> bool {
145        false
146    }
147
148    fn is_visible(&self) -> bool {
149        self.visible
150    }
151
152    fn set_visible(&mut self, visible: bool) {
153        self.visible = visible;
154    }
155
156    fn clone_box(&self) -> Box<dyn Indicator> {
157        Box::new(self.clone())
158    }
159
160    fn line_names(&self) -> Vec<String> {
161        vec![format!("%B({},{})", self.period, self.multiplier)]
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::studies::test_support::make_bar;
169
170    #[test]
171    fn test_percent_b() {
172        let mut pb = PercentB::new(5, 2.0);
173
174        // Constant prices = no volatility = %B should be ~0.5
175        let data: Vec<Bar> = (0..10).map(|_| make_bar(100.0)).collect();
176
177        pb.calculate(&data);
178
179        let rows = pb.values.to_rows();
180        let IndicatorValue::Single(v) = rows.last().unwrap() else {
181            panic!("no value: {:?}", pb.values.to_rows().last().unwrap());
182        };
183        // Constant prices collapse the bands to a point: %B is the middle of the band
184        assert_eq!(*v, 0.5);
185    }
186}