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        if period > crate::error::MAX_PERIOD {
71            return Err(Error::InvalidPeriod {
72                message: crate::error::PERIOD_ABOVE_MAX,
73            });
74        }
75        Ok(Self {
76            period,
77            bars_seen: 0,
78            high: f64::NEG_INFINITY,
79            low: f64::INFINITY,
80            locked: false,
81        })
82    }
83
84    /// Classic 12-bar Initial Balance.
85    pub fn classic() -> Self {
86        Self::new(12).expect("classic IB period is valid")
87    }
88
89    /// Configured period.
90    pub const fn period(&self) -> usize {
91        self.period
92    }
93
94    /// Most recent output if at least one bar has been seen.
95    pub fn value(&self) -> Option<InitialBalanceOutput> {
96        if self.bars_seen == 0 {
97            None
98        } else {
99            Some(InitialBalanceOutput {
100                high: self.high,
101                low: self.low,
102            })
103        }
104    }
105
106    /// True once `period` bars have been ingested and the IB is locked.
107    pub const fn is_locked(&self) -> bool {
108        self.locked
109    }
110}
111
112impl Indicator for InitialBalance {
113    type Input = Candle;
114    type Output = InitialBalanceOutput;
115
116    #[inline]
117    fn update(&mut self, candle: Candle) -> Option<InitialBalanceOutput> {
118        if self.locked {
119            return Some(InitialBalanceOutput {
120                high: self.high,
121                low: self.low,
122            });
123        }
124        if candle.high > self.high {
125            self.high = candle.high;
126        }
127        if candle.low < self.low {
128            self.low = candle.low;
129        }
130        self.bars_seen += 1;
131        if self.bars_seen >= self.period {
132            self.locked = true;
133        }
134        Some(InitialBalanceOutput {
135            high: self.high,
136            low: self.low,
137        })
138    }
139
140    fn reset(&mut self) {
141        self.bars_seen = 0;
142        self.high = f64::NEG_INFINITY;
143        self.low = f64::INFINITY;
144        self.locked = false;
145    }
146
147    #[inline]
148    fn warmup_period(&self) -> usize {
149        1
150    }
151
152    #[inline]
153    fn is_ready(&self) -> bool {
154        self.bars_seen > 0
155    }
156
157    #[inline]
158    fn name(&self) -> &'static str {
159        "InitialBalance"
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::traits::BatchExt;
167    use approx::assert_relative_eq;
168
169    fn c(high: f64, low: f64, ts: i64) -> Candle {
170        // open / close pinned inside [low, high] so the candle validates.
171        let mid = f64::midpoint(high, low);
172        Candle::new(mid, high, low, mid, 10.0, ts).unwrap()
173    }
174
175    #[test]
176    fn rejects_zero_period() {
177        assert!(matches!(InitialBalance::new(0), Err(Error::PeriodZero)));
178    }
179
180    #[test]
181    fn accessors_and_metadata() {
182        let mut ib = InitialBalance::new(12).unwrap();
183        assert_eq!(ib.period(), 12);
184        assert_eq!(ib.name(), "InitialBalance");
185        assert_eq!(ib.warmup_period(), 1);
186        assert!(ib.value().is_none());
187        assert!(!ib.is_locked());
188        // After the first bar, value() returns Some with that bar's H/L.
189        ib.update(c(102.0, 100.0, 0));
190        let v = ib.value().unwrap();
191        assert_relative_eq!(v.high, 102.0);
192        assert_relative_eq!(v.low, 100.0);
193    }
194
195    #[test]
196    fn classic_is_constructible() {
197        let ib = InitialBalance::classic();
198        assert_eq!(ib.period(), 12);
199    }
200
201    #[test]
202    fn tracks_high_low_during_window() {
203        let mut ib = InitialBalance::new(3).unwrap();
204        let o1 = ib.update(c(102.0, 100.0, 0)).unwrap();
205        assert_relative_eq!(o1.high, 102.0);
206        assert_relative_eq!(o1.low, 100.0);
207        let o2 = ib.update(c(105.0, 99.0, 1)).unwrap();
208        assert_relative_eq!(o2.high, 105.0);
209        assert_relative_eq!(o2.low, 99.0);
210        let o3 = ib.update(c(103.0, 99.5, 2)).unwrap();
211        assert_relative_eq!(o3.high, 105.0);
212        assert_relative_eq!(o3.low, 99.0);
213        assert!(ib.is_locked());
214    }
215
216    #[test]
217    fn locks_after_period_and_ignores_subsequent_bars() {
218        let mut ib = InitialBalance::new(2).unwrap();
219        ib.update(c(102.0, 100.0, 0));
220        ib.update(c(103.0, 101.0, 1));
221        assert!(ib.is_locked());
222        // Wide bar after lock must not modify the IB.
223        let after = ib.update(c(200.0, 50.0, 2)).unwrap();
224        assert_relative_eq!(after.high, 103.0);
225        assert_relative_eq!(after.low, 100.0);
226    }
227
228    #[test]
229    fn reset_unlocks_and_clears_state() {
230        let mut ib = InitialBalance::new(2).unwrap();
231        ib.update(c(102.0, 100.0, 0));
232        ib.update(c(103.0, 101.0, 1));
233        assert!(ib.is_locked());
234        ib.reset();
235        assert!(!ib.is_locked());
236        assert!(!ib.is_ready());
237        // After reset the next session's first bar drives the IB anew.
238        let o = ib.update(c(50.0, 49.0, 2)).unwrap();
239        assert_relative_eq!(o.high, 50.0);
240        assert_relative_eq!(o.low, 49.0);
241    }
242
243    #[test]
244    fn batch_equals_streaming() {
245        let candles: Vec<Candle> = (0..20)
246            .map(|i| c(100.0 + i as f64, 99.0 + i as f64 * 0.5, i))
247            .collect();
248        let mut a = InitialBalance::new(5).unwrap();
249        let mut b = InitialBalance::new(5).unwrap();
250        assert_eq!(
251            a.batch(&candles),
252            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
253        );
254    }
255
256    #[test]
257    fn is_ready_after_first_bar() {
258        let mut ib = InitialBalance::new(5).unwrap();
259        assert!(!ib.is_ready());
260        ib.update(c(101.0, 99.0, 0));
261        assert!(ib.is_ready());
262    }
263}