Skip to main content

quantwave_core/indicators/
session_levels.rs

1//! Session Levels and Intraday Time Indicators.
2//!
3//! This module provides streaming indicators anchored to trading sessions:
4//! - [`SessionVwap`]: Volume Weighted Average Price with ±1σ, ±2σ, and ±3σ standard deviation bands.
5//! - [`OpeningRangeBreakout`]: Toby Crabel's Opening Range Breakout (ORB) with Fibonacci/expansion targets.
6//!
7//! Both indicators implement the [`Next`] trait and reset accumulation counters whenever
8//! `is_session_start == true`.
9
10use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
11use crate::traits::Next;
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15/// Errors that can occur when configuring session indicators.
16#[derive(Debug, Clone, PartialEq, Eq, Error)]
17pub enum SessionLevelsError {
18    /// Opening range bars must be at least 1.
19    #[error("range_bars must be at least 1, got {0}")]
20    InvalidRangeBars(usize),
21}
22
23// ============================================================================
24// 1. Session VWAP with Standard Deviation Bands
25// ============================================================================
26
27/// Standard deviation band identifier for Session VWAP.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29pub enum SessionVwapBand {
30    /// First standard deviation band (±1.0σ).
31    Sigma1,
32    /// Second standard deviation band (±2.0σ).
33    Sigma2,
34    /// Third standard deviation band (±3.0σ).
35    Sigma3,
36}
37
38impl SessionVwapBand {
39    /// Returns the standard deviation multiplier for this band.
40    #[inline]
41    #[must_use]
42    pub const fn multiplier(self) -> f64 {
43        match self {
44            Self::Sigma1 => 1.0,
45            Self::Sigma2 => 2.0,
46            Self::Sigma3 => 3.0,
47        }
48    }
49}
50
51/// Output state of the Session VWAP indicator.
52#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
53pub struct SessionVwapState {
54    /// Volume Weighted Average Price for the session.
55    pub vwap: f64,
56    /// Upper +1.0 standard deviation band (`vwap + 1.0 * std_dev`).
57    pub upper_1: f64,
58    /// Lower -1.0 standard deviation band (`vwap - 1.0 * std_dev`).
59    pub lower_1: f64,
60    /// Upper +2.0 standard deviation band (`vwap + 2.0 * std_dev`).
61    pub upper_2: f64,
62    /// Lower -2.0 standard deviation band (`vwap - 2.0 * std_dev`).
63    pub lower_2: f64,
64    /// Upper +3.0 standard deviation band (`vwap + 3.0 * std_dev`).
65    pub upper_3: f64,
66    /// Lower -3.0 standard deviation band (`vwap - 3.0 * std_dev`).
67    pub lower_3: f64,
68    /// Session volume-weighted standard deviation: $\sqrt{\frac{\sum V \cdot (TP - VWAP)^2}{\sum V}}$.
69    pub std_dev: f64,
70    /// Cumulative volume accumulated in the current session.
71    pub cumulative_volume: f64,
72    /// 0-indexed count of bars processed in the current session.
73    pub bar_index: usize,
74}
75
76impl SessionVwapState {
77    /// Returns the upper band value for the specified sigma multiplier.
78    #[inline]
79    #[must_use]
80    pub fn upper(&self, band: SessionVwapBand) -> f64 {
81        match band {
82            SessionVwapBand::Sigma1 => self.upper_1,
83            SessionVwapBand::Sigma2 => self.upper_2,
84            SessionVwapBand::Sigma3 => self.upper_3,
85        }
86    }
87
88    /// Returns the lower band value for the specified sigma multiplier.
89    #[inline]
90    #[must_use]
91    pub fn lower(&self, band: SessionVwapBand) -> f64 {
92        match band {
93            SessionVwapBand::Sigma1 => self.lower_1,
94            SessionVwapBand::Sigma2 => self.lower_2,
95            SessionVwapBand::Sigma3 => self.lower_3,
96        }
97    }
98
99    /// Returns the `(upper, lower)` band envelope pair for the specified sigma multiplier.
100    #[inline]
101    #[must_use]
102    pub fn band(&self, band: SessionVwapBand) -> (f64, f64) {
103        (self.upper(band), self.lower(band))
104    }
105}
106
107/// Input tuple wrapper for [`SessionVwap`].
108#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
109pub struct SessionVwapInput {
110    /// Bar high price.
111    pub high: f64,
112    /// Bar low price.
113    pub low: f64,
114    /// Bar close price.
115    pub close: f64,
116    /// Bar traded volume.
117    pub volume: f64,
118    /// Flag indicating whether this bar begins a new trading session.
119    pub is_session_start: bool,
120}
121
122impl From<(f64, f64, f64, f64, bool)> for SessionVwapInput {
123    #[inline]
124    fn from((high, low, close, volume, is_session_start): (f64, f64, f64, f64, bool)) -> Self {
125        Self {
126            high,
127            low,
128            close,
129            volume,
130            is_session_start,
131        }
132    }
133}
134
135impl From<SessionVwapInput> for (f64, f64, f64, f64, bool) {
136    #[inline]
137    fn from(input: SessionVwapInput) -> Self {
138        (
139            input.high,
140            input.low,
141            input.close,
142            input.volume,
143            input.is_session_start,
144        )
145    }
146}
147
148/// Session Volume Weighted Average Price (VWAP) with Standard Deviation Bands.
149///
150/// Accumulates volume-weighted typical price across intraday bars and resets
151/// whenever `is_session_start` is true.
152///
153/// Typical Price:
154/// $$TP = \frac{\text{High} + \text{Low} + \text{Close}}{3}$$
155///
156/// Running VWAP:
157/// $$VWAP = \frac{\sum (TP \cdot Volume)}{\sum Volume}$$
158///
159/// Running Volume-Weighted Standard Deviation:
160/// $$\sigma = \sqrt{\frac{\sum Volume \cdot (TP - VWAP)^2}{\sum Volume}}$$
161///
162/// Standard Deviation Bands:
163/// $$\text{Upper}_k = VWAP + k \cdot \sigma, \quad \text{Lower}_k = VWAP - k \cdot \sigma \quad (k \in \{1, 2, 3\})$$
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
165pub struct SessionVwap {
166    cumulative_volume: f64,
167    vwap: f64,
168    m2: f64,
169    bar_index: usize,
170    initialized: bool,
171}
172
173impl Default for SessionVwap {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179impl SessionVwap {
180    /// Creates a new `SessionVwap` state machine.
181    #[must_use]
182    pub fn new() -> Self {
183        Self {
184            cumulative_volume: 0.0,
185            vwap: 0.0,
186            m2: 0.0,
187            bar_index: 0,
188            initialized: false,
189        }
190    }
191
192    /// Explicitly resets the session accumulation counters.
193    pub fn reset(&mut self) {
194        self.cumulative_volume = 0.0;
195        self.vwap = 0.0;
196        self.m2 = 0.0;
197        self.bar_index = 0;
198        self.initialized = false;
199    }
200
201    /// Returns the total volume accumulated in the current session.
202    #[inline]
203    #[must_use]
204    pub fn cumulative_volume(&self) -> f64 {
205        self.cumulative_volume
206    }
207
208    /// Returns the current VWAP level for the session.
209    #[inline]
210    #[must_use]
211    pub fn current_vwap(&self) -> f64 {
212        self.vwap
213    }
214
215    /// Returns the 0-indexed count of bars processed in the current session.
216    #[inline]
217    #[must_use]
218    pub fn current_bar_index(&self) -> usize {
219        self.bar_index
220    }
221
222    #[inline]
223    fn update_internal(
224        &mut self,
225        high: f64,
226        low: f64,
227        close: f64,
228        volume: f64,
229        is_session_start: bool,
230    ) -> SessionVwapState {
231        let tp = (high + low + close) / 3.0;
232        let vol = if volume > 0.0 { volume } else { 0.0 };
233
234        if !self.initialized || is_session_start {
235            self.cumulative_volume = vol;
236            self.vwap = tp;
237            self.m2 = 0.0;
238            self.bar_index = 0;
239            self.initialized = true;
240
241            SessionVwapState {
242                vwap: tp,
243                upper_1: tp,
244                lower_1: tp,
245                upper_2: tp,
246                lower_2: tp,
247                upper_3: tp,
248                lower_3: tp,
249                std_dev: 0.0,
250                cumulative_volume: vol,
251                bar_index: 0,
252            }
253        } else {
254            self.bar_index += 1;
255
256            if vol > 0.0 {
257                let old_vol = self.cumulative_volume;
258                self.cumulative_volume += vol;
259                if self.cumulative_volume > 0.0 {
260                    let delta = tp - self.vwap;
261                    let r = delta * (vol / self.cumulative_volume);
262                    self.vwap += r;
263                    self.m2 += old_vol * delta * r;
264                }
265            }
266
267            let variance = if self.cumulative_volume > 0.0 {
268                (self.m2 / self.cumulative_volume).max(0.0)
269            } else {
270                0.0
271            };
272            let std_dev = variance.sqrt();
273
274            SessionVwapState {
275                vwap: self.vwap,
276                upper_1: self.vwap + 1.0 * std_dev,
277                lower_1: self.vwap - 1.0 * std_dev,
278                upper_2: self.vwap + 2.0 * std_dev,
279                lower_2: self.vwap - 2.0 * std_dev,
280                upper_3: self.vwap + 3.0 * std_dev,
281                lower_3: self.vwap - 3.0 * std_dev,
282                std_dev,
283                cumulative_volume: self.cumulative_volume,
284                bar_index: self.bar_index,
285            }
286        }
287    }
288}
289
290impl Next<(f64, f64, f64, f64, bool)> for SessionVwap {
291    type Output = SessionVwapState;
292
293    #[inline]
294    fn next(
295        &mut self,
296        (high, low, close, volume, is_session_start): (f64, f64, f64, f64, bool),
297    ) -> Self::Output {
298        self.update_internal(high, low, close, volume, is_session_start)
299    }
300}
301
302impl Next<SessionVwapInput> for SessionVwap {
303    type Output = SessionVwapState;
304
305    #[inline]
306    fn next(&mut self, input: SessionVwapInput) -> Self::Output {
307        self.update_internal(
308            input.high,
309            input.low,
310            input.close,
311            input.volume,
312            input.is_session_start,
313        )
314    }
315}
316
317// ============================================================================
318// 2. Opening Range Breakout (ORB)
319// ============================================================================
320
321/// Lifecycle phase of an Opening Range Breakout session.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
323pub enum OrbPhase {
324    /// Opening range window is forming; highs and lows are actively accumulated.
325    Forming,
326    /// Opening range window is locked; breakout triggers and targets are active.
327    Active,
328    /// Session is closed; no further breakouts are monitored.
329    Closed,
330}
331
332impl OrbPhase {
333    /// Returns `true` if the opening range is currently forming.
334    #[inline]
335    #[must_use]
336    pub const fn is_forming(self) -> bool {
337        matches!(self, Self::Forming)
338    }
339
340    /// Returns `true` if the opening range is active and monitoring breakouts.
341    #[inline]
342    #[must_use]
343    pub const fn is_active(self) -> bool {
344        matches!(self, Self::Active)
345    }
346
347    /// Returns `true` if the session has closed.
348    #[inline]
349    #[must_use]
350    pub const fn is_closed(self) -> bool {
351        matches!(self, Self::Closed)
352    }
353}
354
355/// Breakout status relative to the opening range.
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
357pub enum OrbBreakout {
358    /// Price is within the opening range or range is still forming / closed.
359    None,
360    /// Bar closed strictly above the opening range high.
361    BullishBreakout,
362    /// Bar closed strictly below the opening range low.
363    BearishBreakout,
364}
365
366impl OrbBreakout {
367    /// Returns `true` if any directional breakout occurred.
368    #[inline]
369    #[must_use]
370    pub const fn is_breakout(self) -> bool {
371        matches!(self, Self::BullishBreakout | Self::BearishBreakout)
372    }
373
374    /// Returns `true` if a bullish breakout occurred.
375    #[inline]
376    #[must_use]
377    pub const fn is_bullish(self) -> bool {
378        matches!(self, Self::BullishBreakout)
379    }
380
381    /// Returns `true` if a bearish breakout occurred.
382    #[inline]
383    #[must_use]
384    pub const fn is_bearish(self) -> bool {
385        matches!(self, Self::BearishBreakout)
386    }
387}
388
389/// Output state of the Opening Range Breakout (ORB) indicator.
390#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
391pub struct OrbState {
392    /// Current lifecycle phase of the session.
393    pub phase: OrbPhase,
394    /// Breakout status evaluated on the current bar close.
395    pub breakout: OrbBreakout,
396    /// Established or in-progress high of the opening range.
397    pub range_high: f64,
398    /// Established or in-progress low of the opening range.
399    pub range_low: f64,
400    /// Height of the opening range (`range_high - range_low`).
401    pub range_size: f64,
402    /// Bullish extension target 1 (`range_high + 0.5 * range_size`).
403    pub target_1_bull: f64,
404    /// Bearish extension target 1 (`range_low - 0.5 * range_size`).
405    pub target_1_bear: f64,
406    /// Bullish extension target 2 (`range_high + 1.0 * range_size`).
407    pub target_2_bull: f64,
408    /// Bearish extension target 2 (`range_low - 1.0 * range_size`).
409    pub target_2_bear: f64,
410    /// 0-indexed count of bars processed in the current session.
411    pub bar_index: usize,
412}
413
414impl OrbState {
415    /// Returns `true` if the bar closed with a breakout in either direction.
416    #[inline]
417    #[must_use]
418    pub const fn is_breakout(&self) -> bool {
419        self.breakout.is_breakout()
420    }
421
422    /// Returns `true` if target 1 was reached or exceeded by the provided price.
423    #[inline]
424    #[must_use]
425    pub fn target_1_reached(&self, price: f64) -> bool {
426        price >= self.target_1_bull || price <= self.target_1_bear
427    }
428
429    /// Returns `true` if target 2 was reached or exceeded by the provided price.
430    #[inline]
431    #[must_use]
432    pub fn target_2_reached(&self, price: f64) -> bool {
433        price >= self.target_2_bull || price <= self.target_2_bear
434    }
435}
436
437/// Input tuple wrapper for [`OpeningRangeBreakout`].
438#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
439pub struct OrbInput {
440    /// Bar high price.
441    pub high: f64,
442    /// Bar low price.
443    pub low: f64,
444    /// Bar close price.
445    pub close: f64,
446    /// Flag indicating whether this bar begins a new trading session.
447    pub is_session_start: bool,
448}
449
450impl From<(f64, f64, f64, bool)> for OrbInput {
451    #[inline]
452    fn from((high, low, close, is_session_start): (f64, f64, f64, bool)) -> Self {
453        Self {
454            high,
455            low,
456            close,
457            is_session_start,
458        }
459    }
460}
461
462impl From<OrbInput> for (f64, f64, f64, bool) {
463    #[inline]
464    fn from(input: OrbInput) -> Self {
465        (input.high, input.low, input.close, input.is_session_start)
466    }
467}
468
469/// Opening Range Breakout (ORB) indicator.
470///
471/// Tracks the high and low established over the first `range_bars` bars of each session
472/// and generates directional breakout signals with 0.5x and 1.0x range extension targets.
473#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
474pub struct OpeningRangeBreakout {
475    range_bars: usize,
476    max_session_bars: Option<usize>,
477    range_high: f64,
478    range_low: f64,
479    bars_in_session: usize,
480    phase: OrbPhase,
481    breakout: OrbBreakout,
482    initialized: bool,
483}
484
485impl Default for OpeningRangeBreakout {
486    fn default() -> Self {
487        Self::new(15)
488    }
489}
490
491impl OpeningRangeBreakout {
492    /// Creates a new `OpeningRangeBreakout` with the specified number of opening bars.
493    ///
494    /// If `range_bars == 0`, it defaults to 1.
495    #[must_use]
496    pub fn new(range_bars: usize) -> Self {
497        Self {
498            range_bars: range_bars.max(1),
499            max_session_bars: None,
500            range_high: 0.0,
501            range_low: 0.0,
502            bars_in_session: 0,
503            phase: OrbPhase::Forming,
504            breakout: OrbBreakout::None,
505            initialized: false,
506        }
507    }
508
509    /// Try to create a new `OpeningRangeBreakout`, returning an error if `range_bars == 0`.
510    pub fn try_new(range_bars: usize) -> Result<Self, SessionLevelsError> {
511        if range_bars == 0 {
512            return Err(SessionLevelsError::InvalidRangeBars(0));
513        }
514        Ok(Self::new(range_bars))
515    }
516
517    /// Sets an optional session bar cap after which the indicator transitions to [`OrbPhase::Closed`].
518    #[must_use]
519    pub fn with_max_session_bars(mut self, max_bars: usize) -> Self {
520        self.max_session_bars = Some(max_bars);
521        self
522    }
523
524    /// Number of bars comprising the opening range window.
525    #[inline]
526    #[must_use]
527    pub fn range_bars(&self) -> usize {
528        self.range_bars
529    }
530
531    /// Optional session bar cap.
532    #[inline]
533    #[must_use]
534    pub fn max_session_bars(&self) -> Option<usize> {
535        self.max_session_bars
536    }
537
538    /// Explicitly resets the indicator state.
539    pub fn reset(&mut self) {
540        self.range_high = 0.0;
541        self.range_low = 0.0;
542        self.bars_in_session = 0;
543        self.phase = OrbPhase::Forming;
544        self.breakout = OrbBreakout::None;
545        self.initialized = false;
546    }
547
548    /// Manually closes the active session.
549    pub fn close_session(&mut self) {
550        self.phase = OrbPhase::Closed;
551        self.breakout = OrbBreakout::None;
552    }
553
554    #[inline]
555    fn update_internal(
556        &mut self,
557        high: f64,
558        low: f64,
559        close: f64,
560        is_session_start: bool,
561    ) -> OrbState {
562        let h = high.max(low);
563        let l = high.min(low);
564
565        if !self.initialized || is_session_start {
566            self.bars_in_session = 0;
567            self.range_high = h;
568            self.range_low = l;
569            self.phase = OrbPhase::Forming;
570            self.breakout = OrbBreakout::None;
571            self.initialized = true;
572
573            let range_size = (self.range_high - self.range_low).max(0.0);
574            let state = OrbState {
575                phase: OrbPhase::Forming,
576                breakout: OrbBreakout::None,
577                range_high: self.range_high,
578                range_low: self.range_low,
579                range_size,
580                target_1_bull: self.range_high + 0.5 * range_size,
581                target_1_bear: self.range_low - 0.5 * range_size,
582                target_2_bull: self.range_high + 1.0 * range_size,
583                target_2_bear: self.range_low - 1.0 * range_size,
584                bar_index: 0,
585            };
586            self.bars_in_session = 1;
587            state
588        } else {
589            let current_bar = self.bars_in_session;
590            self.bars_in_session += 1;
591
592            if let Some(max_bars) = self.max_session_bars
593                && current_bar >= max_bars
594            {
595                self.phase = OrbPhase::Closed;
596                self.breakout = OrbBreakout::None;
597                let range_size = (self.range_high - self.range_low).max(0.0);
598                return OrbState {
599                    phase: OrbPhase::Closed,
600                    breakout: OrbBreakout::None,
601                    range_high: self.range_high,
602                    range_low: self.range_low,
603                    range_size,
604                    target_1_bull: self.range_high + 0.5 * range_size,
605                    target_1_bear: self.range_low - 0.5 * range_size,
606                    target_2_bull: self.range_high + 1.0 * range_size,
607                    target_2_bear: self.range_low - 1.0 * range_size,
608                    bar_index: current_bar,
609                };
610            }
611
612            if current_bar < self.range_bars {
613                self.range_high = self.range_high.max(h);
614                self.range_low = self.range_low.min(l);
615                self.phase = OrbPhase::Forming;
616                self.breakout = OrbBreakout::None;
617            } else {
618                self.phase = OrbPhase::Active;
619                if close > self.range_high {
620                    self.breakout = OrbBreakout::BullishBreakout;
621                } else if close < self.range_low {
622                    self.breakout = OrbBreakout::BearishBreakout;
623                } else {
624                    self.breakout = OrbBreakout::None;
625                }
626            }
627
628            let range_size = (self.range_high - self.range_low).max(0.0);
629            OrbState {
630                phase: self.phase,
631                breakout: self.breakout,
632                range_high: self.range_high,
633                range_low: self.range_low,
634                range_size,
635                target_1_bull: self.range_high + 0.5 * range_size,
636                target_1_bear: self.range_low - 0.5 * range_size,
637                target_2_bull: self.range_high + 1.0 * range_size,
638                target_2_bear: self.range_low - 1.0 * range_size,
639                bar_index: current_bar,
640            }
641        }
642    }
643}
644
645impl Next<(f64, f64, f64, bool)> for OpeningRangeBreakout {
646    type Output = OrbState;
647
648    #[inline]
649    fn next(
650        &mut self,
651        (high, low, close, is_session_start): (f64, f64, f64, bool),
652    ) -> Self::Output {
653        self.update_internal(high, low, close, is_session_start)
654    }
655}
656
657impl Next<OrbInput> for OpeningRangeBreakout {
658    type Output = OrbState;
659
660    #[inline]
661    fn next(&mut self, input: OrbInput) -> Self::Output {
662        self.update_internal(input.high, input.low, input.close, input.is_session_start)
663    }
664}
665
666// ============================================================================
667// 3. Metadata Constants
668// ============================================================================
669
670/// Metadata definition for Session VWAP.
671pub const SESSION_VWAP_METADATA: IndicatorMetadata = IndicatorMetadata {
672    name: "Session VWAP with Standard Deviation Bands",
673    description: "Session-anchored Volume Weighted Average Price (VWAP) with three standard deviation volatility bands, resetting at session boundaries.",
674    usage: "Use as an intraday fair value benchmark. The core VWAP serves as the primary institutional execution level, while Sigma 1, 2, and 3 standard deviation bands identify statistical mean reversion boundaries and dynamic support/resistance.",
675    keywords: &[
676        "vwap",
677        "session",
678        "intraday",
679        "volume",
680        "bands",
681        "standard-deviation",
682        "mean-reversion",
683    ],
684    ehlers_summary: "Volume Weighted Average Price calculates the cumulative typical price weighted by volume across a trading session. Incorporating standard deviation bands provides dynamic volatility envelopes that highlight statistical value containment and institutional liquidation extremes. — StockCharts / Investopedia",
685    params: &[],
686    formula_source: "https://chartschool.stockcharts.com/table-of-contents/technical-indicators-and-overlays/technical-overlays/vwap-standard-deviation-bands",
687    formula_latex: r#"
688\[
689TP_t = \frac{H_t + L_t + C_t}{3} \\
690VWAP_t = \frac{\sum_{i=1}^t TP_i \cdot V_i}{\sum_{i=1}^t V_i} \\
691\sigma_t = \sqrt{\frac{\sum_{i=1}^t V_i \cdot (TP_i - VWAP_t)^2}{\sum_{i=1}^t V_i}} \\
692\text{Upper}_k = VWAP_t + k \cdot \sigma_t, \quad \text{Lower}_k = VWAP_t - k \cdot \sigma_t \quad (k \in \{1, 2, 3\})
693\]
694"#,
695    gold_standard_file: "session_vwap.json",
696    category: "Volume",
697};
698
699/// Metadata definition for Opening Range Breakout (ORB).
700pub const ORB_METADATA: IndicatorMetadata = IndicatorMetadata {
701    name: "Opening Range Breakout",
702    description: "Tracks the high and low over the opening range bars and detects directional breakouts with extension targets.",
703    usage: "Identifies early-session market commitment and momentum expansion. When price breaks out of the established opening range, traders enter in the direction of the break targeting 0.5x and 1.0x range extensions.",
704    keywords: &[
705        "orb",
706        "opening-range",
707        "breakout",
708        "session",
709        "intraday",
710        "toby-crabel",
711        "trend",
712    ],
713    ehlers_summary: "Pioneered by Toby Crabel in 'Day Trading with Short Term Price Patterns' (1990), Opening Range Breakout establishes support and resistance during the initial minutes of trading. A decisive breakout beyond the opening boundary often signals directional conviction for the remainder of the trading day. — Toby Crabel / TradingView",
714    params: &[ParamDef {
715        name: "range_bars",
716        default: "15",
717        description: "Number of opening bars defining the range window",
718    }],
719    formula_source: "https://www.tradingview.com/support/solutions/43000696950-opening-range-breakout-orb/",
720    formula_latex: r#"
721\[
722\text{Range High} = \max_{i \in [1, N]}(H_i), \quad \text{Range Low} = \min_{i \in [1, N]}(L_i) \\
723\text{Range Size} = \text{Range High} - \text{Range Low} \\
724\text{Target}_{1,\text{bull}} = \text{Range High} + 0.5 \cdot \text{Range Size}, \quad \text{Target}_{1,\text{bear}} = \text{Range Low} - 0.5 \cdot \text{Range Size} \\
725\text{Target}_{2,\text{bull}} = \text{Range High} + 1.0 \cdot \text{Range Size}, \quad \text{Target}_{2,\text{bear}} = \text{Range Low} - 1.0 \cdot \text{Range Size}
726\]
727"#,
728    gold_standard_file: "orb.json",
729    category: "Price Action",
730};
731
732// ============================================================================
733// 4. Unit Tests and Verification
734// ============================================================================
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use approx::assert_relative_eq;
740    use proptest::prelude::*;
741
742    #[test]
743    fn test_session_vwap_single_bar() {
744        let mut vwap = SessionVwap::new();
745        // H = 105.0, L = 95.0, C = 100.0 => TP = 100.0, V = 50.0, is_session_start = true
746        let state = vwap.next((105.0, 95.0, 100.0, 50.0, true));
747
748        assert_relative_eq!(state.vwap, 100.0);
749        assert_relative_eq!(state.std_dev, 0.0);
750        assert_relative_eq!(state.upper_1, 100.0);
751        assert_relative_eq!(state.lower_1, 100.0);
752        assert_relative_eq!(state.upper_2, 100.0);
753        assert_relative_eq!(state.lower_2, 100.0);
754        assert_relative_eq!(state.upper_3, 100.0);
755        assert_relative_eq!(state.lower_3, 100.0);
756        assert_relative_eq!(state.cumulative_volume, 50.0);
757        assert_eq!(state.bar_index, 0);
758    }
759
760    #[test]
761    fn test_session_vwap_multi_bar_accumulation() {
762        let mut vwap = SessionVwap::new();
763
764        // Bar 0: TP = 100.0, V = 100.0
765        let s0 = vwap.next((105.0, 95.0, 100.0, 100.0, true));
766        assert_relative_eq!(s0.vwap, 100.0);
767        assert_relative_eq!(s0.std_dev, 0.0);
768        assert_eq!(s0.bar_index, 0);
769
770        // Bar 1: TP = 110.0, V = 100.0
771        // Expected VWAP = (100 * 100 + 110 * 100) / 200 = 105.0
772        // Expected Variance = [100 * (100 - 105)^2 + 100 * (110 - 105)^2] / 200
773        //                   = [100 * 25 + 100 * 25] / 200 = 5000 / 200 = 25.0
774        // Expected StdDev = sqrt(25.0) = 5.0
775        let s1 = vwap.next((115.0, 105.0, 110.0, 100.0, false));
776        assert_relative_eq!(s1.vwap, 105.0);
777        assert_relative_eq!(s1.std_dev, 5.0);
778        assert_relative_eq!(s1.upper_1, 110.0);
779        assert_relative_eq!(s1.lower_1, 100.0);
780        assert_relative_eq!(s1.upper_2, 115.0);
781        assert_relative_eq!(s1.lower_2, 95.0);
782        assert_relative_eq!(s1.upper_3, 120.0);
783        assert_relative_eq!(s1.lower_3, 90.0);
784        assert_relative_eq!(s1.cumulative_volume, 200.0);
785        assert_eq!(s1.bar_index, 1);
786
787        // Verify band accessor methods
788        assert_relative_eq!(s1.upper(SessionVwapBand::Sigma1), 110.0);
789        assert_relative_eq!(s1.lower(SessionVwapBand::Sigma1), 100.0);
790        assert_eq!(s1.band(SessionVwapBand::Sigma2), (115.0, 95.0));
791
792        // Bar 2: TP = 120.0, V = 200.0
793        // Expected Cumulative Volume = 400.0
794        // Expected VWAP = (100*100 + 110*100 + 120*200) / 400 = (10000 + 11000 + 24000) / 400 = 45000 / 400 = 112.5
795        // Expected Variance:
796        //   100 * (100 - 112.5)^2 = 100 * 156.25 = 15625
797        //   100 * (110 - 112.5)^2 = 100 * 6.25   = 625
798        //   200 * (120 - 112.5)^2 = 200 * 56.25  = 11250
799        //   Sum = 27500 / 400 = 68.75
800        // Expected StdDev = sqrt(68.75) = 8.2915619758885
801        let s2 = vwap.next((125.0, 115.0, 120.0, 200.0, false));
802        assert_relative_eq!(s2.vwap, 112.5);
803        assert_relative_eq!(s2.std_dev, 68.75_f64.sqrt(), epsilon = 1e-10);
804        assert_relative_eq!(s2.upper_1, 112.5 + 68.75_f64.sqrt(), epsilon = 1e-10);
805        assert_relative_eq!(s2.lower_1, 112.5 - 68.75_f64.sqrt(), epsilon = 1e-10);
806        assert_relative_eq!(s2.cumulative_volume, 400.0);
807        assert_eq!(s2.bar_index, 2);
808    }
809
810    #[test]
811    fn test_session_vwap_session_reset() {
812        let mut vwap = SessionVwap::new();
813
814        vwap.next((105.0, 95.0, 100.0, 100.0, true));
815        vwap.next((115.0, 105.0, 110.0, 100.0, false));
816
817        // New session starts: counters must reset cleanly
818        let reset_state = vwap.next((205.0, 195.0, 200.0, 50.0, true));
819        assert_relative_eq!(reset_state.vwap, 200.0);
820        assert_relative_eq!(reset_state.std_dev, 0.0);
821        assert_relative_eq!(reset_state.cumulative_volume, 50.0);
822        assert_eq!(reset_state.bar_index, 0);
823
824        // Second bar in new session
825        let next_state = vwap.next((215.0, 205.0, 210.0, 50.0, false));
826        assert_relative_eq!(next_state.vwap, 205.0);
827        assert_relative_eq!(next_state.std_dev, 5.0);
828        assert_relative_eq!(next_state.cumulative_volume, 100.0);
829        assert_eq!(next_state.bar_index, 1);
830    }
831
832    #[test]
833    fn test_session_vwap_zero_volume_handling() {
834        let mut vwap = SessionVwap::new();
835
836        // Session start with zero volume
837        let s0 = vwap.next((105.0, 95.0, 100.0, 0.0, true));
838        assert_relative_eq!(s0.vwap, 100.0);
839        assert_relative_eq!(s0.std_dev, 0.0);
840        assert_relative_eq!(s0.cumulative_volume, 0.0);
841
842        // Next bar also zero volume
843        let s1 = vwap.next((108.0, 98.0, 103.0, 0.0, false));
844        assert_relative_eq!(s1.vwap, 100.0);
845        assert_relative_eq!(s1.std_dev, 0.0);
846        assert_relative_eq!(s1.cumulative_volume, 0.0);
847    }
848
849    #[test]
850    fn test_orb_range_formation_and_phase_transition() {
851        let mut orb = OpeningRangeBreakout::new(3);
852
853        // Bar 0 (Session Start): H = 105, L = 95, C = 100
854        let s0 = orb.next((105.0, 95.0, 100.0, true));
855        assert_eq!(s0.phase, OrbPhase::Forming);
856        assert_eq!(s0.breakout, OrbBreakout::None);
857        assert_relative_eq!(s0.range_high, 105.0);
858        assert_relative_eq!(s0.range_low, 95.0);
859        assert_relative_eq!(s0.range_size, 10.0);
860        assert_relative_eq!(s0.target_1_bull, 110.0);
861        assert_relative_eq!(s0.target_1_bear, 90.0);
862        assert_relative_eq!(s0.target_2_bull, 115.0);
863        assert_relative_eq!(s0.target_2_bear, 85.0);
864        assert_eq!(s0.bar_index, 0);
865
866        // Bar 1: H = 108, L = 94, C = 102
867        let s1 = orb.next((108.0, 94.0, 102.0, false));
868        assert_eq!(s1.phase, OrbPhase::Forming);
869        assert_eq!(s1.breakout, OrbBreakout::None);
870        assert_relative_eq!(s1.range_high, 108.0);
871        assert_relative_eq!(s1.range_low, 94.0);
872        assert_relative_eq!(s1.range_size, 14.0);
873        assert_eq!(s1.bar_index, 1);
874
875        // Bar 2: H = 107, L = 96, C = 101 (inside existing bounds)
876        let s2 = orb.next((107.0, 96.0, 101.0, false));
877        assert_eq!(s2.phase, OrbPhase::Forming);
878        assert_eq!(s2.breakout, OrbBreakout::None);
879        assert_relative_eq!(s2.range_high, 108.0);
880        assert_relative_eq!(s2.range_low, 94.0);
881        assert_eq!(s2.bar_index, 2);
882
883        // Bar 3: Range complete -> transitions to Active!
884        // H = 106, L = 98, C = 105 (inside range)
885        let s3 = orb.next((106.0, 98.0, 105.0, false));
886        assert_eq!(s3.phase, OrbPhase::Active);
887        assert_eq!(s3.breakout, OrbBreakout::None);
888        assert_relative_eq!(s3.range_high, 108.0); // Frozen!
889        assert_relative_eq!(s3.range_low, 94.0); // Frozen!
890        assert_relative_eq!(s3.range_size, 14.0);
891        assert_relative_eq!(s3.target_1_bull, 115.0);
892        assert_relative_eq!(s3.target_1_bear, 87.0);
893        assert_relative_eq!(s3.target_2_bull, 122.0);
894        assert_relative_eq!(s3.target_2_bear, 80.0);
895        assert_eq!(s3.bar_index, 3);
896    }
897
898    #[test]
899    fn test_orb_bullish_and_bearish_breakouts() {
900        let mut orb = OpeningRangeBreakout::new(2);
901
902        // Form 2-bar opening range: High = 110, Low = 90
903        orb.next((105.0, 95.0, 100.0, true));
904        orb.next((110.0, 90.0, 102.0, false));
905
906        // Bar 2: Active, close = 112 > 110 => BullishBreakout
907        let s_bull = orb.next((115.0, 108.0, 112.0, false));
908        assert_eq!(s_bull.phase, OrbPhase::Active);
909        assert_eq!(s_bull.breakout, OrbBreakout::BullishBreakout);
910        assert!(s_bull.is_breakout());
911        assert!(s_bull.breakout.is_bullish());
912        assert!(!s_bull.breakout.is_bearish());
913
914        // Target checking
915        assert_relative_eq!(s_bull.range_size, 20.0);
916        assert_relative_eq!(s_bull.target_1_bull, 120.0);
917        assert!(!s_bull.target_1_reached(112.0));
918        assert!(s_bull.target_1_reached(120.0));
919        assert!(s_bull.target_1_reached(125.0));
920
921        // Bar 3: Active, close = 88 < 90 => BearishBreakout
922        let s_bear = orb.next((95.0, 85.0, 88.0, false));
923        assert_eq!(s_bear.phase, OrbPhase::Active);
924        assert_eq!(s_bear.breakout, OrbBreakout::BearishBreakout);
925        assert!(s_bear.is_breakout());
926        assert!(s_bear.breakout.is_bearish());
927        assert!(!s_bear.breakout.is_bullish());
928
929        // Bar 4: Active, close = 100 (inside 90..110) => None
930        let s_none = orb.next((102.0, 98.0, 100.0, false));
931        assert_eq!(s_none.phase, OrbPhase::Active);
932        assert_eq!(s_none.breakout, OrbBreakout::None);
933        assert!(!s_none.is_breakout());
934    }
935
936    #[test]
937    fn test_orb_session_reset() {
938        let mut orb = OpeningRangeBreakout::new(2);
939
940        orb.next((105.0, 95.0, 100.0, true));
941        orb.next((110.0, 90.0, 102.0, false));
942        let active = orb.next((115.0, 105.0, 112.0, false));
943        assert_eq!(active.phase, OrbPhase::Active);
944
945        // New session begins
946        let reset_state = orb.next((205.0, 195.0, 200.0, true));
947        assert_eq!(reset_state.phase, OrbPhase::Forming);
948        assert_eq!(reset_state.breakout, OrbBreakout::None);
949        assert_relative_eq!(reset_state.range_high, 205.0);
950        assert_relative_eq!(reset_state.range_low, 195.0);
951        assert_relative_eq!(reset_state.range_size, 10.0);
952        assert_eq!(reset_state.bar_index, 0);
953    }
954
955    #[test]
956    fn test_orb_max_session_bars_closed() {
957        let mut orb = OpeningRangeBreakout::new(2).with_max_session_bars(4);
958
959        orb.next((105.0, 95.0, 100.0, true)); // bar 0
960        orb.next((108.0, 94.0, 102.0, false)); // bar 1
961        let s2 = orb.next((112.0, 106.0, 111.0, false)); // bar 2: Active
962        assert_eq!(s2.phase, OrbPhase::Active);
963        let s3 = orb.next((115.0, 107.0, 114.0, false)); // bar 3: Active
964        assert_eq!(s3.phase, OrbPhase::Active);
965
966        // bar 4: Reached max session bars (4) -> Closed
967        let s4 = orb.next((120.0, 110.0, 118.0, false));
968        assert_eq!(s4.phase, OrbPhase::Closed);
969        assert_eq!(s4.breakout, OrbBreakout::None);
970        assert!(s4.phase.is_closed());
971    }
972
973    #[test]
974    fn test_orb_try_new_invalid_range_bars() {
975        assert_eq!(
976            OpeningRangeBreakout::try_new(0),
977            Err(SessionLevelsError::InvalidRangeBars(0))
978        );
979        assert!(OpeningRangeBreakout::try_new(5).is_ok());
980    }
981
982    #[test]
983    fn test_metadata_consistency() {
984        assert_eq!(
985            SESSION_VWAP_METADATA.name,
986            "Session VWAP with Standard Deviation Bands"
987        );
988        assert_eq!(
989            SESSION_VWAP_METADATA.gold_standard_file,
990            "session_vwap.json"
991        );
992        assert_eq!(SESSION_VWAP_METADATA.category, "Volume");
993
994        assert_eq!(ORB_METADATA.name, "Opening Range Breakout");
995        assert_eq!(ORB_METADATA.gold_standard_file, "orb.json");
996        assert_eq!(ORB_METADATA.category, "Price Action");
997    }
998
999    // ========================================================================
1000    // Property-Based Tests (Streaming vs Batch Parity)
1001    // ========================================================================
1002
1003    fn session_vwap_batch(data: &[(f64, f64, f64, f64, bool)]) -> Vec<SessionVwapState> {
1004        let mut results = Vec::with_capacity(data.len());
1005        let mut session_bars: Vec<(f64, f64)> = Vec::new(); // (TP, Volume)
1006        let mut bar_index = 0;
1007
1008        for &(high, low, close, volume, is_session_start) in data {
1009            let tp = (high + low + close) / 3.0;
1010            let vol = if volume > 0.0 { volume } else { 0.0 };
1011
1012            if session_bars.is_empty() || is_session_start {
1013                session_bars.clear();
1014                bar_index = 0;
1015            } else {
1016                bar_index += 1;
1017            }
1018
1019            session_bars.push((tp, vol));
1020
1021            let total_vol: f64 = session_bars.iter().map(|&(_, v)| v).sum();
1022            let vwap = if total_vol > 0.0 {
1023                session_bars.iter().map(|&(p, v)| p * v).sum::<f64>() / total_vol
1024            } else {
1025                tp
1026            };
1027
1028            let variance = if total_vol > 0.0 {
1029                let sum_sq_diff: f64 = session_bars
1030                    .iter()
1031                    .map(|&(p, v)| v * (p - vwap).powi(2))
1032                    .sum();
1033                (sum_sq_diff / total_vol).max(0.0)
1034            } else {
1035                0.0
1036            };
1037            let std_dev = variance.sqrt();
1038
1039            results.push(SessionVwapState {
1040                vwap,
1041                upper_1: vwap + 1.0 * std_dev,
1042                lower_1: vwap - 1.0 * std_dev,
1043                upper_2: vwap + 2.0 * std_dev,
1044                lower_2: vwap - 2.0 * std_dev,
1045                upper_3: vwap + 3.0 * std_dev,
1046                lower_3: vwap - 3.0 * std_dev,
1047                std_dev,
1048                cumulative_volume: total_vol,
1049                bar_index,
1050            });
1051        }
1052
1053        results
1054    }
1055
1056    fn orb_batch(data: &[(f64, f64, f64, bool)], range_bars: usize) -> Vec<OrbState> {
1057        let range_bars = range_bars.max(1);
1058        let mut results = Vec::with_capacity(data.len());
1059        let mut session_highs: Vec<f64> = Vec::new();
1060        let mut session_lows: Vec<f64> = Vec::new();
1061
1062        for &(high, low, close, is_session_start) in data {
1063            let h = high.max(low);
1064            let l = high.min(low);
1065
1066            if session_highs.is_empty() || is_session_start {
1067                session_highs.clear();
1068                session_lows.clear();
1069            }
1070
1071            let bar_idx = session_highs.len();
1072            session_highs.push(h);
1073            session_lows.push(l);
1074
1075            let forming_window = (bar_idx + 1).min(range_bars);
1076            let range_high = session_highs[..forming_window]
1077                .iter()
1078                .copied()
1079                .fold(f64::NEG_INFINITY, f64::max);
1080            let range_low = session_lows[..forming_window]
1081                .iter()
1082                .copied()
1083                .fold(f64::INFINITY, f64::min);
1084            let range_size = (range_high - range_low).max(0.0);
1085
1086            let phase = if bar_idx < range_bars {
1087                OrbPhase::Forming
1088            } else {
1089                OrbPhase::Active
1090            };
1091
1092            let breakout = if phase == OrbPhase::Active {
1093                if close > range_high {
1094                    OrbBreakout::BullishBreakout
1095                } else if close < range_low {
1096                    OrbBreakout::BearishBreakout
1097                } else {
1098                    OrbBreakout::None
1099                }
1100            } else {
1101                OrbBreakout::None
1102            };
1103
1104            results.push(OrbState {
1105                phase,
1106                breakout,
1107                range_high,
1108                range_low,
1109                range_size,
1110                target_1_bull: range_high + 0.5 * range_size,
1111                target_1_bear: range_low - 0.5 * range_size,
1112                target_2_bull: range_high + 1.0 * range_size,
1113                target_2_bear: range_low - 1.0 * range_size,
1114                bar_index: bar_idx,
1115            });
1116        }
1117
1118        results
1119    }
1120
1121    proptest! {
1122        #[test]
1123        fn test_session_vwap_parity(
1124            prices in prop::collection::vec((10.0..500.0f64, 10.0..500.0f64, 10.0..500.0f64, 1.0..1000.0f64, any::<bool>()), 1..100)
1125        ) {
1126            let mut sanitized_input: Vec<(f64, f64, f64, f64, bool)> = Vec::with_capacity(prices.len());
1127            for (i, (h_raw, l_raw, c_raw, vol, is_start)) in prices.into_iter().enumerate() {
1128                let high: f64 = h_raw.max(l_raw).max(c_raw);
1129                let low: f64 = h_raw.min(l_raw).min(c_raw);
1130                let is_session_start = if i == 0 { true } else { is_start };
1131                sanitized_input.push((high, low, c_raw, vol, is_session_start));
1132            }
1133
1134            let mut indicator = SessionVwap::new();
1135            let mut streaming_results = Vec::with_capacity(sanitized_input.len());
1136            for &val in &sanitized_input {
1137                streaming_results.push(indicator.next(val));
1138            }
1139
1140            let batch_results = session_vwap_batch(&sanitized_input);
1141
1142            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
1143                assert_relative_eq!(s.vwap, b.vwap, epsilon = 1e-8);
1144                assert_relative_eq!(s.std_dev, b.std_dev, epsilon = 1e-8);
1145                assert_relative_eq!(s.upper_1, b.upper_1, epsilon = 1e-8);
1146                assert_relative_eq!(s.lower_1, b.lower_1, epsilon = 1e-8);
1147                assert_relative_eq!(s.upper_2, b.upper_2, epsilon = 1e-8);
1148                assert_relative_eq!(s.lower_2, b.lower_2, epsilon = 1e-8);
1149                assert_relative_eq!(s.upper_3, b.upper_3, epsilon = 1e-8);
1150                assert_relative_eq!(s.lower_3, b.lower_3, epsilon = 1e-8);
1151                assert_relative_eq!(s.cumulative_volume, b.cumulative_volume, epsilon = 1e-8);
1152                assert_eq!(s.bar_index, b.bar_index);
1153            }
1154        }
1155
1156        #[test]
1157        fn test_orb_parity(
1158            bars in prop::collection::vec((10.0..500.0f64, 10.0..500.0f64, 10.0..500.0f64, any::<bool>()), 1..100),
1159            range_bars in 1..20usize,
1160        ) {
1161            let mut sanitized_input: Vec<(f64, f64, f64, bool)> = Vec::with_capacity(bars.len());
1162            for (i, (h_raw, l_raw, c_raw, is_start)) in bars.into_iter().enumerate() {
1163                let high: f64 = h_raw.max(l_raw).max(c_raw);
1164                let low: f64 = h_raw.min(l_raw).min(c_raw);
1165                let is_session_start = if i == 0 { true } else { is_start };
1166                sanitized_input.push((high, low, c_raw, is_session_start));
1167            }
1168
1169            let mut indicator = OpeningRangeBreakout::new(range_bars);
1170            let mut streaming_results = Vec::with_capacity(sanitized_input.len());
1171            for &val in &sanitized_input {
1172                streaming_results.push(indicator.next(val));
1173            }
1174
1175            let batch_results = orb_batch(&sanitized_input, range_bars);
1176
1177            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
1178                assert_eq!(s.phase, b.phase);
1179                assert_eq!(s.breakout, b.breakout);
1180                assert_relative_eq!(s.range_high, b.range_high, epsilon = 1e-10);
1181                assert_relative_eq!(s.range_low, b.range_low, epsilon = 1e-10);
1182                assert_relative_eq!(s.range_size, b.range_size, epsilon = 1e-10);
1183                assert_relative_eq!(s.target_1_bull, b.target_1_bull, epsilon = 1e-10);
1184                assert_relative_eq!(s.target_1_bear, b.target_1_bear, epsilon = 1e-10);
1185                assert_relative_eq!(s.target_2_bull, b.target_2_bull, epsilon = 1e-10);
1186                assert_relative_eq!(s.target_2_bear, b.target_2_bear, epsilon = 1e-10);
1187                assert_eq!(s.bar_index, b.bar_index);
1188            }
1189        }
1190    }
1191}