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        if period > crate::error::MAX_PERIOD {
77            return Err(Error::InvalidPeriod {
78                message: crate::error::PERIOD_ABOVE_MAX,
79            });
80        }
81        Ok(Self {
82            period,
83            bars_seen: 0,
84            high: f64::NEG_INFINITY,
85            low: f64::INFINITY,
86            last_close: 0.0,
87            locked: false,
88            last: None,
89        })
90    }
91
92    /// Classic 6-bar Opening Range.
93    pub fn classic() -> Self {
94        Self::new(6).expect("classic OR period is valid")
95    }
96
97    /// Configured period.
98    pub const fn period(&self) -> usize {
99        self.period
100    }
101
102    /// Most recent output if at least one bar has been seen.
103    pub const fn value(&self) -> Option<OpeningRangeOutput> {
104        self.last
105    }
106
107    /// True once `period` bars have been ingested and the OR is locked.
108    pub const fn is_locked(&self) -> bool {
109        self.locked
110    }
111
112    fn snapshot(&self) -> OpeningRangeOutput {
113        let mid = f64::midpoint(self.high, self.low);
114        OpeningRangeOutput {
115            high: self.high,
116            low: self.low,
117            breakout_distance: self.last_close - mid,
118        }
119    }
120}
121
122impl Indicator for OpeningRange {
123    type Input = Candle;
124    type Output = OpeningRangeOutput;
125
126    #[inline]
127    fn update(&mut self, candle: Candle) -> Option<OpeningRangeOutput> {
128        if !self.locked {
129            if candle.high > self.high {
130                self.high = candle.high;
131            }
132            if candle.low < self.low {
133                self.low = candle.low;
134            }
135            self.bars_seen += 1;
136            if self.bars_seen >= self.period {
137                self.locked = true;
138            }
139        }
140        self.last_close = candle.close;
141        let out = self.snapshot();
142        self.last = Some(out);
143        Some(out)
144    }
145
146    fn reset(&mut self) {
147        self.bars_seen = 0;
148        self.high = f64::NEG_INFINITY;
149        self.low = f64::INFINITY;
150        self.last_close = 0.0;
151        self.locked = false;
152        self.last = None;
153    }
154
155    #[inline]
156    fn warmup_period(&self) -> usize {
157        1
158    }
159
160    #[inline]
161    fn is_ready(&self) -> bool {
162        self.bars_seen > 0
163    }
164
165    #[inline]
166    fn name(&self) -> &'static str {
167        "OpeningRange"
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::traits::BatchExt;
175    use approx::assert_relative_eq;
176
177    fn c(high: f64, low: f64, close: f64, ts: i64) -> Candle {
178        let open = f64::midpoint(high, low);
179        Candle::new(open, high, low, close, 10.0, ts).unwrap()
180    }
181
182    #[test]
183    fn rejects_zero_period() {
184        assert!(matches!(OpeningRange::new(0), Err(Error::PeriodZero)));
185    }
186
187    #[test]
188    fn accessors_and_metadata() {
189        let or = OpeningRange::new(6).unwrap();
190        assert_eq!(or.period(), 6);
191        assert_eq!(or.name(), "OpeningRange");
192        assert_eq!(or.warmup_period(), 1);
193        assert!(or.value().is_none());
194        assert!(!or.is_locked());
195    }
196
197    #[test]
198    fn classic_is_constructible() {
199        let or = OpeningRange::classic();
200        assert_eq!(or.period(), 6);
201    }
202
203    #[test]
204    fn tracks_range_during_window() {
205        let mut or = OpeningRange::new(3).unwrap();
206        let o1 = or.update(c(102.0, 100.0, 101.0, 0)).unwrap();
207        assert_relative_eq!(o1.high, 102.0);
208        assert_relative_eq!(o1.low, 100.0);
209        // close 101 vs mid 101 → breakout 0.
210        assert_relative_eq!(o1.breakout_distance, 0.0, epsilon = 1e-12);
211        let o2 = or.update(c(105.0, 99.0, 104.0, 1)).unwrap();
212        assert_relative_eq!(o2.high, 105.0);
213        assert_relative_eq!(o2.low, 99.0);
214        // close 104 vs mid 102 → breakout 2.
215        assert_relative_eq!(o2.breakout_distance, 2.0, epsilon = 1e-12);
216    }
217
218    #[test]
219    fn locks_after_period_and_breakout_reflects_close_minus_mid() {
220        let mut or = OpeningRange::new(2).unwrap();
221        or.update(c(102.0, 100.0, 101.0, 0));
222        or.update(c(103.0, 101.0, 102.0, 1));
223        assert!(or.is_locked());
224        // OR locked at high 103, low 100, mid 101.5.
225        // Bar 2: wide candle ignored for high/low; close 105 -> breakout 3.5.
226        let after = or.update(c(200.0, 50.0, 105.0, 2)).unwrap();
227        assert_relative_eq!(after.high, 103.0);
228        assert_relative_eq!(after.low, 100.0);
229        assert_relative_eq!(after.breakout_distance, 3.5, epsilon = 1e-12);
230    }
231
232    #[test]
233    fn breakout_distance_is_negative_below_range() {
234        let mut or = OpeningRange::new(2).unwrap();
235        or.update(c(102.0, 100.0, 101.0, 0));
236        or.update(c(103.0, 101.0, 102.0, 1));
237        // mid 101.5, close 90 -> -11.5.
238        let out = or.update(c(110.0, 89.0, 90.0, 2)).unwrap();
239        assert_relative_eq!(out.breakout_distance, -11.5, epsilon = 1e-12);
240    }
241
242    #[test]
243    fn reset_unlocks_and_clears_state() {
244        let mut or = OpeningRange::new(2).unwrap();
245        or.update(c(102.0, 100.0, 101.0, 0));
246        or.update(c(103.0, 101.0, 102.0, 1));
247        assert!(or.is_locked());
248        or.reset();
249        assert!(!or.is_locked());
250        assert!(!or.is_ready());
251        let o = or.update(c(50.0, 49.0, 49.5, 2)).unwrap();
252        assert_relative_eq!(o.high, 50.0);
253        assert_relative_eq!(o.low, 49.0);
254    }
255
256    #[test]
257    fn batch_equals_streaming() {
258        let candles: Vec<Candle> = (0..20)
259            .map(|i| {
260                let base = 100.0 + i as f64 * 0.25;
261                c(base + 1.0, base - 1.0, base, i)
262            })
263            .collect();
264        let mut a = OpeningRange::new(5).unwrap();
265        let mut b = OpeningRange::new(5).unwrap();
266        assert_eq!(
267            a.batch(&candles),
268            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
269        );
270    }
271
272    #[test]
273    fn is_ready_after_first_bar() {
274        let mut or = OpeningRange::new(5).unwrap();
275        assert!(!or.is_ready());
276        or.update(c(101.0, 99.0, 100.0, 0));
277        assert!(or.is_ready());
278    }
279}