Skip to main content

wickra_core/indicators/
balance_of_power.rs

1//! Balance of Power.
2
3use crate::ohlcv::Candle;
4use crate::traits::Indicator;
5
6/// Balance of Power — where the close settled within the bar's range relative
7/// to the open.
8///
9/// ```text
10/// BOP = (close − open) / (high − low)
11/// ```
12///
13/// The result lives in `[−1, +1]`: `+1` is a bar that opened on its low and
14/// closed on its high (buyers in full control), `−1` the mirror image. It is
15/// a stateless per-bar reading — a quick gauge of intrabar conviction. A
16/// zero-range bar carries no information and yields `0`.
17///
18/// # Example
19///
20/// ```
21/// use wickra_core::{Candle, Indicator, BalanceOfPower};
22///
23/// let mut indicator = BalanceOfPower::new();
24/// let mut last = None;
25/// for i in 0..80 {
26///     let base = 100.0 + f64::from(i);
27///     let candle =
28///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
29///     last = indicator.update(candle);
30/// }
31/// assert!(last.is_some());
32/// ```
33#[derive(Debug, Clone, Default)]
34pub struct BalanceOfPower {
35    has_emitted: bool,
36}
37
38impl BalanceOfPower {
39    /// Construct a new Balance of Power transform.
40    pub const fn new() -> Self {
41        Self { has_emitted: false }
42    }
43}
44
45impl Indicator for BalanceOfPower {
46    type Input = Candle;
47    type Output = f64;
48
49    fn update(&mut self, candle: Candle) -> Option<f64> {
50        self.has_emitted = true;
51        let range = candle.high - candle.low;
52        let bop = if range == 0.0 {
53            // A zero-range bar carries no directional information.
54            0.0
55        } else {
56            (candle.close - candle.open) / range
57        };
58        Some(bop)
59    }
60
61    fn reset(&mut self) {
62        self.has_emitted = false;
63    }
64
65    fn warmup_period(&self) -> usize {
66        1
67    }
68
69    fn is_ready(&self) -> bool {
70        self.has_emitted
71    }
72
73    fn name(&self) -> &'static str {
74        "BalanceOfPower"
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::traits::BatchExt;
82    use approx::assert_relative_eq;
83
84    fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
85        Candle::new(open, high, low, close, 1.0, ts).unwrap()
86    }
87
88    #[test]
89    fn reference_value() {
90        // (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
91        let mut bop = BalanceOfPower::new();
92        assert_relative_eq!(
93            bop.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(),
94            0.5,
95            epsilon = 1e-12
96        );
97    }
98
99    #[test]
100    fn close_on_high_after_open_on_low_is_plus_one() {
101        let mut bop = BalanceOfPower::new();
102        // open == low, close == high -> BOP = +1.
103        assert_relative_eq!(
104            bop.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(),
105            1.0,
106            epsilon = 1e-12
107        );
108    }
109
110    #[test]
111    fn stays_within_unit_range() {
112        let candles: Vec<Candle> = (0..100)
113            .map(|i| {
114                let mid = 100.0 + (i as f64 * 0.2).sin() * 8.0;
115                let close = mid + (i as f64 * 0.5).cos() * 2.0;
116                candle(mid, mid + 3.0, mid - 3.0, close, i)
117            })
118            .collect();
119        let mut bop = BalanceOfPower::new();
120        for v in bop.batch(&candles).into_iter().flatten() {
121            assert!((-1.0..=1.0).contains(&v), "BOP {v} outside [-1, 1]");
122        }
123    }
124
125    #[test]
126    fn zero_range_bar_yields_zero() {
127        let mut bop = BalanceOfPower::new();
128        assert_relative_eq!(
129            bop.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
130            0.0,
131            epsilon = 1e-12
132        );
133    }
134
135    /// Cover the Indicator-impl `name` body (73-75).
136    #[test]
137    fn name_metadata() {
138        let bop = BalanceOfPower::new();
139        assert_eq!(bop.name(), "BalanceOfPower");
140    }
141
142    #[test]
143    fn emits_from_first_candle() {
144        let mut bop = BalanceOfPower::new();
145        assert_eq!(bop.warmup_period(), 1);
146        assert!(!bop.is_ready());
147        assert!(bop.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
148        assert!(bop.is_ready());
149    }
150
151    #[test]
152    fn reset_clears_state() {
153        let mut bop = BalanceOfPower::new();
154        bop.update(candle(10.0, 11.0, 9.0, 10.0, 0));
155        assert!(bop.is_ready());
156        bop.reset();
157        assert!(!bop.is_ready());
158    }
159
160    #[test]
161    fn batch_equals_streaming() {
162        let candles: Vec<Candle> = (0..40)
163            .map(|i| {
164                let base = 100.0 + i as f64;
165                candle(base, base + 2.0, base - 2.0, base + 1.0, i)
166            })
167            .collect();
168        let mut a = BalanceOfPower::new();
169        let mut b = BalanceOfPower::new();
170        assert_eq!(
171            a.batch(&candles),
172            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
173        );
174    }
175}