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    #[inline]
50    fn update(&mut self, candle: Candle) -> Option<f64> {
51        self.has_emitted = true;
52        let range = candle.high - candle.low;
53        let bop = if range == 0.0 {
54            // A zero-range bar carries no directional information.
55            0.0
56        } else {
57            (candle.close - candle.open) / range
58        };
59        Some(bop)
60    }
61
62    fn reset(&mut self) {
63        self.has_emitted = false;
64    }
65
66    #[inline]
67    fn warmup_period(&self) -> usize {
68        1
69    }
70
71    #[inline]
72    fn is_ready(&self) -> bool {
73        self.has_emitted
74    }
75
76    #[inline]
77    fn name(&self) -> &'static str {
78        "BalanceOfPower"
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::traits::BatchExt;
86    use approx::assert_relative_eq;
87
88    fn candle(open: f64, high: f64, low: f64, close: f64, ts: i64) -> Candle {
89        Candle::new(open, high, low, close, 1.0, ts).unwrap()
90    }
91
92    #[test]
93    fn reference_value() {
94        // (close - open) / (high - low) = (12 - 10) / (14 - 10) = 0.5.
95        let mut bop = BalanceOfPower::new();
96        assert_relative_eq!(
97            bop.update(candle(10.0, 14.0, 10.0, 12.0, 0)).unwrap(),
98            0.5,
99            epsilon = 1e-12
100        );
101    }
102
103    #[test]
104    fn close_on_high_after_open_on_low_is_plus_one() {
105        let mut bop = BalanceOfPower::new();
106        // open == low, close == high -> BOP = +1.
107        assert_relative_eq!(
108            bop.update(candle(9.0, 11.0, 9.0, 11.0, 0)).unwrap(),
109            1.0,
110            epsilon = 1e-12
111        );
112    }
113
114    #[test]
115    fn stays_within_unit_range() {
116        let candles: Vec<Candle> = (0..100)
117            .map(|i| {
118                let mid = 100.0 + (i as f64 * 0.2).sin() * 8.0;
119                let close = mid + (i as f64 * 0.5).cos() * 2.0;
120                candle(mid, mid + 3.0, mid - 3.0, close, i)
121            })
122            .collect();
123        let mut bop = BalanceOfPower::new();
124        for v in bop.batch(&candles).into_iter().flatten() {
125            assert!((-1.0..=1.0).contains(&v), "BOP {v} outside [-1, 1]");
126        }
127    }
128
129    #[test]
130    fn zero_range_bar_yields_zero() {
131        let mut bop = BalanceOfPower::new();
132        assert_relative_eq!(
133            bop.update(candle(10.0, 10.0, 10.0, 10.0, 0)).unwrap(),
134            0.0,
135            epsilon = 1e-12
136        );
137    }
138
139    /// Cover the Indicator-impl `name` body (73-75).
140    #[test]
141    fn name_metadata() {
142        let bop = BalanceOfPower::new();
143        assert_eq!(bop.name(), "BalanceOfPower");
144    }
145
146    #[test]
147    fn emits_from_first_candle() {
148        let mut bop = BalanceOfPower::new();
149        assert_eq!(bop.warmup_period(), 1);
150        assert!(!bop.is_ready());
151        assert!(bop.update(candle(10.0, 11.0, 9.0, 10.0, 0)).is_some());
152        assert!(bop.is_ready());
153    }
154
155    #[test]
156    fn reset_clears_state() {
157        let mut bop = BalanceOfPower::new();
158        bop.update(candle(10.0, 11.0, 9.0, 10.0, 0));
159        assert!(bop.is_ready());
160        bop.reset();
161        assert!(!bop.is_ready());
162    }
163
164    #[test]
165    fn batch_equals_streaming() {
166        let candles: Vec<Candle> = (0..40)
167            .map(|i| {
168                let base = 100.0 + i as f64;
169                candle(base, base + 2.0, base - 2.0, base + 1.0, i)
170            })
171            .collect();
172        let mut a = BalanceOfPower::new();
173        let mut b = BalanceOfPower::new();
174        assert_eq!(
175            a.batch(&candles),
176            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
177        );
178    }
179}