Skip to main content

wickra_core/indicators/
opening_range.rs

1//! Opening Range (OR): high / low of the first N session bars plus the
2//! current bar's breakout distance from the range midpoint.
3//!
4//! Conceptually identical to [`crate::InitialBalance`] but with two
5//! differences: the default window is shorter (6 = 30 min on 5-minute bars)
6//! and the output carries a third field, `breakout_distance`, which is the
7//! signed distance from the current candle's close to the range midpoint —
8//! positive for breakouts above the OR, negative for breakdowns. Callers
9//! MUST invoke [`Indicator::reset`] at every new session boundary to start
10//! a fresh OR.
11
12use crate::error::{Error, Result};
13use crate::ohlcv::Candle;
14use crate::traits::Indicator;
15
16/// Opening Range output: high, low and breakout distance from the OR midpoint.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct OpeningRangeOutput {
19    /// Session-opening high established over the OR window.
20    pub high: f64,
21    /// Session-opening low established over the OR window.
22    pub low: f64,
23    /// Current bar's close minus the OR midpoint. Positive once price
24    /// trades above the range mid, negative below.
25    pub breakout_distance: f64,
26}
27
28/// Session Opening Range (first N bars + breakout distance).
29///
30/// `period` defaults to **6** — the canonical 30-minute opening range on
31/// 5-minute bars. Callers MUST invoke [`Indicator::reset`] at session
32/// boundaries; otherwise the OR locks after the first `period` bars and
33/// stays fixed for the remainder of the instance's life.
34///
35/// # Example
36///
37/// ```
38/// use wickra_core::{Candle, Indicator, OpeningRange};
39///
40/// let mut or = OpeningRange::new(2).unwrap();
41/// let bars = [
42///     Candle::new(100.0, 102.0, 99.0, 101.0, 10.0, 0).unwrap(),
43///     Candle::new(101.0, 103.0, 100.0, 102.0, 10.0, 1).unwrap(),
44///     // Now locked — breakout distance reflects close - (high + low) / 2.
45///     Candle::new(102.0, 110.0, 102.0, 105.0, 10.0, 2).unwrap(),
46/// ];
47/// for b in bars {
48///     or.update(b);
49/// }
50/// let v = or.value().unwrap();
51/// assert_eq!(v.high, 103.0);
52/// assert_eq!(v.low, 99.0);
53/// assert_eq!(v.breakout_distance, 105.0 - (103.0 + 99.0) / 2.0);
54/// ```
55#[derive(Debug, Clone)]
56pub struct OpeningRange {
57    period: usize,
58    bars_seen: usize,
59    high: f64,
60    low: f64,
61    last_close: f64,
62    locked: bool,
63    last: Option<OpeningRangeOutput>,
64}
65
66impl OpeningRange {
67    /// Construct an Opening Range indicator with the given window length.
68    ///
69    /// # Errors
70    ///
71    /// Returns [`Error::PeriodZero`] if `period == 0`.
72    pub fn new(period: usize) -> Result<Self> {
73        if period == 0 {
74            return Err(Error::PeriodZero);
75        }
76        Ok(Self {
77            period,
78            bars_seen: 0,
79            high: f64::NEG_INFINITY,
80            low: f64::INFINITY,
81            last_close: 0.0,
82            locked: false,
83            last: None,
84        })
85    }
86
87    /// Classic 6-bar Opening Range.
88    pub fn classic() -> Self {
89        Self::new(6).expect("classic OR period is valid")
90    }
91
92    /// Configured period.
93    pub const fn period(&self) -> usize {
94        self.period
95    }
96
97    /// Most recent output if at least one bar has been seen.
98    pub const fn value(&self) -> Option<OpeningRangeOutput> {
99        self.last
100    }
101
102    /// True once `period` bars have been ingested and the OR is locked.
103    pub const fn is_locked(&self) -> bool {
104        self.locked
105    }
106
107    fn snapshot(&self) -> OpeningRangeOutput {
108        let mid = f64::midpoint(self.high, self.low);
109        OpeningRangeOutput {
110            high: self.high,
111            low: self.low,
112            breakout_distance: self.last_close - mid,
113        }
114    }
115}
116
117impl Indicator for OpeningRange {
118    type Input = Candle;
119    type Output = OpeningRangeOutput;
120
121    fn update(&mut self, candle: Candle) -> Option<OpeningRangeOutput> {
122        if !self.locked {
123            if candle.high > self.high {
124                self.high = candle.high;
125            }
126            if candle.low < self.low {
127                self.low = candle.low;
128            }
129            self.bars_seen += 1;
130            if self.bars_seen >= self.period {
131                self.locked = true;
132            }
133        }
134        self.last_close = candle.close;
135        let out = self.snapshot();
136        self.last = Some(out);
137        Some(out)
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.last_close = 0.0;
145        self.locked = false;
146        self.last = None;
147    }
148
149    fn warmup_period(&self) -> usize {
150        1
151    }
152
153    fn is_ready(&self) -> bool {
154        self.bars_seen > 0
155    }
156
157    fn name(&self) -> &'static str {
158        "OpeningRange"
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::traits::BatchExt;
166    use approx::assert_relative_eq;
167
168    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
169        let open = f64::midpoint(high, low);
170        Candle::new(open, high, low, close, 10.0, ts).unwrap()
171    }
172
173    #[test]
174    fn rejects_zero_period() {
175        assert!(matches!(OpeningRange::new(0), Err(Error::PeriodZero)));
176    }
177
178    #[test]
179    fn accessors_and_metadata() {
180        let or = OpeningRange::new(6).unwrap();
181        assert_eq!(or.period(), 6);
182        assert_eq!(or.name(), "OpeningRange");
183        assert_eq!(or.warmup_period(), 1);
184        assert!(or.value().is_none());
185        assert!(!or.is_locked());
186    }
187
188    #[test]
189    fn classic_is_constructible() {
190        let or = OpeningRange::classic();
191        assert_eq!(or.period(), 6);
192    }
193
194    #[test]
195    fn tracks_range_during_window() {
196        let mut or = OpeningRange::new(3).unwrap();
197        let o1 = or.update(c(102.0, 100.0, 101.0, 0)).unwrap();
198        assert_relative_eq!(o1.high, 102.0);
199        assert_relative_eq!(o1.low, 100.0);
200        // close 101 vs mid 101 → breakout 0.
201        assert_relative_eq!(o1.breakout_distance, 0.0, epsilon = 1e-12);
202        let o2 = or.update(c(105.0, 99.0, 104.0, 1)).unwrap();
203        assert_relative_eq!(o2.high, 105.0);
204        assert_relative_eq!(o2.low, 99.0);
205        // close 104 vs mid 102 → breakout 2.
206        assert_relative_eq!(o2.breakout_distance, 2.0, epsilon = 1e-12);
207    }
208
209    #[test]
210    fn locks_after_period_and_breakout_reflects_close_minus_mid() {
211        let mut or = OpeningRange::new(2).unwrap();
212        or.update(c(102.0, 100.0, 101.0, 0));
213        or.update(c(103.0, 101.0, 102.0, 1));
214        assert!(or.is_locked());
215        // OR locked at high 103, low 100, mid 101.5.
216        // Bar 2: wide candle ignored for high/low; close 105 -> breakout 3.5.
217        let after = or.update(c(200.0, 50.0, 105.0, 2)).unwrap();
218        assert_relative_eq!(after.high, 103.0);
219        assert_relative_eq!(after.low, 100.0);
220        assert_relative_eq!(after.breakout_distance, 3.5, epsilon = 1e-12);
221    }
222
223    #[test]
224    fn breakout_distance_is_negative_below_range() {
225        let mut or = OpeningRange::new(2).unwrap();
226        or.update(c(102.0, 100.0, 101.0, 0));
227        or.update(c(103.0, 101.0, 102.0, 1));
228        // mid 101.5, close 90 -> -11.5.
229        let out = or.update(c(110.0, 89.0, 90.0, 2)).unwrap();
230        assert_relative_eq!(out.breakout_distance, -11.5, epsilon = 1e-12);
231    }
232
233    #[test]
234    fn reset_unlocks_and_clears_state() {
235        let mut or = OpeningRange::new(2).unwrap();
236        or.update(c(102.0, 100.0, 101.0, 0));
237        or.update(c(103.0, 101.0, 102.0, 1));
238        assert!(or.is_locked());
239        or.reset();
240        assert!(!or.is_locked());
241        assert!(!or.is_ready());
242        let o = or.update(c(50.0, 49.0, 49.5, 2)).unwrap();
243        assert_relative_eq!(o.high, 50.0);
244        assert_relative_eq!(o.low, 49.0);
245    }
246
247    #[test]
248    fn batch_equals_streaming() {
249        let candles: Vec<Candle> = (0..20)
250            .map(|i| {
251                let base = 100.0 + i as f64 * 0.25;
252                c(base + 1.0, base - 1.0, base, i)
253            })
254            .collect();
255        let mut a = OpeningRange::new(5).unwrap();
256        let mut b = OpeningRange::new(5).unwrap();
257        assert_eq!(
258            a.batch(&candles),
259            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
260        );
261    }
262
263    #[test]
264    fn is_ready_after_first_bar() {
265        let mut or = OpeningRange::new(5).unwrap();
266        assert!(!or.is_ready());
267        or.update(c(101.0, 99.0, 100.0, 0));
268        assert!(or.is_ready());
269    }
270}