Skip to main content

wickra_core/indicators/
initial_balance.rs

1//! Initial Balance (IB): the high / low established over the first N bars of
2//! a session.
3//!
4//! Tracks the running session high and session low across the first `period`
5//! candles received since construction or [`InitialBalance::reset`]. Once the
6//! `period`th candle has been ingested the value is frozen and every
7//! subsequent call to [`Indicator::update`] returns the same locked
8//! [`InitialBalanceOutput`] until the caller invokes `reset()` at the start of
9//! a new session.
10
11use crate::error::{Error, Result};
12use crate::ohlcv::Candle;
13use crate::traits::Indicator;
14
15/// Initial Balance output: the high / low of the first N bars of a session.
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct InitialBalanceOutput {
18    /// Session-opening high established over the IB window.
19    pub high: f64,
20    /// Session-opening low established over the IB window.
21    pub low: f64,
22}
23
24/// Session Initial Balance (first N bars).
25///
26/// `period` defaults to **12** — the canonical one-hour IB on 5-minute bars
27/// for U.S. equities. Callers MUST invoke [`Indicator::reset`] at every new
28/// session boundary; otherwise the IB locks after the first `period` bars and
29/// stays fixed for the entire lifetime of the instance.
30///
31/// # Example
32///
33/// ```
34/// use wickra_core::{Candle, InitialBalance, Indicator};
35///
36/// let mut ib = InitialBalance::new(3).unwrap();
37/// let bars = [
38///     Candle::new(100.0, 102.0, 99.0, 101.0, 10.0, 0).unwrap(),
39///     Candle::new(101.0, 103.0, 100.0, 102.0, 10.0, 1).unwrap(),
40///     Candle::new(102.0, 104.0, 101.0, 103.0, 10.0, 2).unwrap(),
41///     // Locked after period bars — subsequent bars do not modify IB.
42///     Candle::new(103.0, 120.0, 80.0, 105.0, 10.0, 3).unwrap(),
43/// ];
44/// for b in bars {
45///     ib.update(b);
46/// }
47/// let v = ib.value().unwrap();
48/// assert_eq!(v.high, 104.0);
49/// assert_eq!(v.low, 99.0);
50/// ```
51#[derive(Debug, Clone)]
52pub struct InitialBalance {
53    period: usize,
54    bars_seen: usize,
55    high: f64,
56    low: f64,
57    locked: bool,
58}
59
60impl InitialBalance {
61    /// Construct an Initial Balance indicator with the given window length.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`Error::PeriodZero`] if `period == 0`.
66    pub fn new(period: usize) -> Result<Self> {
67        if period == 0 {
68            return Err(Error::PeriodZero);
69        }
70        Ok(Self {
71            period,
72            bars_seen: 0,
73            high: f64::NEG_INFINITY,
74            low: f64::INFINITY,
75            locked: false,
76        })
77    }
78
79    /// Classic 12-bar Initial Balance.
80    pub fn classic() -> Self {
81        Self::new(12).expect("classic IB period is valid")
82    }
83
84    /// Configured period.
85    pub const fn period(&self) -> usize {
86        self.period
87    }
88
89    /// Most recent output if at least one bar has been seen.
90    pub fn value(&self) -> Option<InitialBalanceOutput> {
91        if self.bars_seen == 0 {
92            None
93        } else {
94            Some(InitialBalanceOutput {
95                high: self.high,
96                low: self.low,
97            })
98        }
99    }
100
101    /// True once `period` bars have been ingested and the IB is locked.
102    pub const fn is_locked(&self) -> bool {
103        self.locked
104    }
105}
106
107impl Indicator for InitialBalance {
108    type Input = Candle;
109    type Output = InitialBalanceOutput;
110
111    fn update(&mut self, candle: Candle) -> Option<InitialBalanceOutput> {
112        if self.locked {
113            return Some(InitialBalanceOutput {
114                high: self.high,
115                low: self.low,
116            });
117        }
118        if candle.high > self.high {
119            self.high = candle.high;
120        }
121        if candle.low < self.low {
122            self.low = candle.low;
123        }
124        self.bars_seen += 1;
125        if self.bars_seen >= self.period {
126            self.locked = true;
127        }
128        Some(InitialBalanceOutput {
129            high: self.high,
130            low: self.low,
131        })
132    }
133
134    fn reset(&mut self) {
135        self.bars_seen = 0;
136        self.high = f64::NEG_INFINITY;
137        self.low = f64::INFINITY;
138        self.locked = false;
139    }
140
141    fn warmup_period(&self) -> usize {
142        1
143    }
144
145    fn is_ready(&self) -> bool {
146        self.bars_seen > 0
147    }
148
149    fn name(&self) -> &'static str {
150        "InitialBalance"
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::traits::BatchExt;
158    use approx::assert_relative_eq;
159
160    fn c(high: f64, low: f64, ts: i64) -> Candle {
161        // open / close pinned inside [low, high] so the candle validates.
162        let mid = f64::midpoint(high, low);
163        Candle::new(mid, high, low, mid, 10.0, ts).unwrap()
164    }
165
166    #[test]
167    fn rejects_zero_period() {
168        assert!(matches!(InitialBalance::new(0), Err(Error::PeriodZero)));
169    }
170
171    #[test]
172    fn accessors_and_metadata() {
173        let mut ib = InitialBalance::new(12).unwrap();
174        assert_eq!(ib.period(), 12);
175        assert_eq!(ib.name(), "InitialBalance");
176        assert_eq!(ib.warmup_period(), 1);
177        assert!(ib.value().is_none());
178        assert!(!ib.is_locked());
179        // After the first bar, value() returns Some with that bar's H/L.
180        ib.update(c(102.0, 100.0, 0));
181        let v = ib.value().unwrap();
182        assert_relative_eq!(v.high, 102.0);
183        assert_relative_eq!(v.low, 100.0);
184    }
185
186    #[test]
187    fn classic_is_constructible() {
188        let ib = InitialBalance::classic();
189        assert_eq!(ib.period(), 12);
190    }
191
192    #[test]
193    fn tracks_high_low_during_window() {
194        let mut ib = InitialBalance::new(3).unwrap();
195        let o1 = ib.update(c(102.0, 100.0, 0)).unwrap();
196        assert_relative_eq!(o1.high, 102.0);
197        assert_relative_eq!(o1.low, 100.0);
198        let o2 = ib.update(c(105.0, 99.0, 1)).unwrap();
199        assert_relative_eq!(o2.high, 105.0);
200        assert_relative_eq!(o2.low, 99.0);
201        let o3 = ib.update(c(103.0, 99.5, 2)).unwrap();
202        assert_relative_eq!(o3.high, 105.0);
203        assert_relative_eq!(o3.low, 99.0);
204        assert!(ib.is_locked());
205    }
206
207    #[test]
208    fn locks_after_period_and_ignores_subsequent_bars() {
209        let mut ib = InitialBalance::new(2).unwrap();
210        ib.update(c(102.0, 100.0, 0));
211        ib.update(c(103.0, 101.0, 1));
212        assert!(ib.is_locked());
213        // Wide bar after lock must not modify the IB.
214        let after = ib.update(c(200.0, 50.0, 2)).unwrap();
215        assert_relative_eq!(after.high, 103.0);
216        assert_relative_eq!(after.low, 100.0);
217    }
218
219    #[test]
220    fn reset_unlocks_and_clears_state() {
221        let mut ib = InitialBalance::new(2).unwrap();
222        ib.update(c(102.0, 100.0, 0));
223        ib.update(c(103.0, 101.0, 1));
224        assert!(ib.is_locked());
225        ib.reset();
226        assert!(!ib.is_locked());
227        assert!(!ib.is_ready());
228        // After reset the next session's first bar drives the IB anew.
229        let o = ib.update(c(50.0, 49.0, 2)).unwrap();
230        assert_relative_eq!(o.high, 50.0);
231        assert_relative_eq!(o.low, 49.0);
232    }
233
234    #[test]
235    fn batch_equals_streaming() {
236        let candles: Vec<Candle> = (0..20)
237            .map(|i| c(100.0 + i as f64, 99.0 + i as f64 * 0.5, i))
238            .collect();
239        let mut a = InitialBalance::new(5).unwrap();
240        let mut b = InitialBalance::new(5).unwrap();
241        assert_eq!(
242            a.batch(&candles),
243            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
244        );
245    }
246
247    #[test]
248    fn is_ready_after_first_bar() {
249        let mut ib = InitialBalance::new(5).unwrap();
250        assert!(!ib.is_ready());
251        ib.update(c(101.0, 99.0, 0));
252        assert!(ib.is_ready());
253    }
254}