Skip to main content

kestrel_chartkit/indicator/
bop.rs

1use super::smoothing::Sma;
2use super::{Indicator, IndicatorAlert, IndicatorOutput};
3use crate::model::Bar;
4
5/// Balance of Power: `SMA(period)((close - open) / (high - low))`, the range floored at `1e-8`,
6/// clamped to `-1..=1`; on bars with `open == close` it is `0`.
7///
8/// First output: with the `period`-th bar. [`Indicator::reset`] clears the average.
9#[derive(Debug, Clone)]
10pub struct BalanceOfPowerEngine {
11    period: usize,
12    sma: Sma,
13}
14
15impl BalanceOfPowerEngine {
16    pub fn new(period: usize) -> Self {
17        Self {
18            period: period.max(1),
19            sma: Sma::new(period),
20        }
21    }
22}
23
24impl Indicator for BalanceOfPowerEngine {
25    fn name(&self) -> &str {
26        "bop"
27    }
28
29    fn warmup_period(&self) -> usize {
30        self.period
31    }
32
33    fn reset(&mut self) {
34        self.sma.reset();
35    }
36
37    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
38        let range = (bar.high - bar.low).max(1e-8);
39        let raw_bop = (bar.close - bar.open) / range;
40        let bop_val = self.sma.update(raw_bop)?;
41        Some(IndicatorOutput::new(bop_val.clamp(-1.0, 1.0)))
42    }
43
44    fn alerts(&self) -> Vec<IndicatorAlert> {
45        Vec::new()
46    }
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52
53    #[test]
54    fn test_bop_bounds() {
55        let mut bop = BalanceOfPowerEngine::new(14);
56        let mut out = None;
57        for i in 0..20 {
58            let b = Bar::new(i, 100.0, 105.0, 95.0, 104.0, 1000.0);
59            out = bop.on_bar(&b);
60        }
61        assert!(out.is_some());
62        let val = out.unwrap().value;
63        assert!((-1.0..=1.0).contains(&val));
64    }
65}