Skip to main content

quantwave_core/indicators/
range_bars.rs

1//! Range bar construction (price → constant-range OHLC bars).
2//!
3//! A range bar closes as soon as its high-low span reaches `range_size`, then a
4//! new bar opens at the price that triggered the close. Like Renko this discards
5//! time; unlike Renko it keeps full OHLC per bar.
6//!
7//! Two surfaces kept in parity (see the proptest):
8//! - streaming: [`RangeBarBuilder::next`] pushes one price, returns a bar when
9//!   the current bar's span completes (else `None`);
10//! - batch: [`range_bars_batch`] folds the builder over a price slice.
11
12/// One completed range bar. Its `high - low` is `>= range_size`.
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct RangeBar {
15    pub open: f64,
16    pub high: f64,
17    pub low: f64,
18    pub close: f64,
19}
20
21/// Stateful constant-range bar builder.
22#[derive(Debug, Clone)]
23pub struct RangeBarBuilder {
24    range_size: f64,
25    open: f64,
26    high: f64,
27    low: f64,
28    initialized: bool,
29}
30
31impl RangeBarBuilder {
32    /// Create a builder. `range_size` must be strictly positive.
33    pub fn new(range_size: f64) -> Self {
34        Self {
35            range_size,
36            open: 0.0,
37            high: 0.0,
38            low: 0.0,
39            initialized: false,
40        }
41    }
42
43    /// Push one price; return a completed bar if this price closed the current
44    /// bar's span, else `None`.
45    pub fn next(&mut self, price: f64) -> Option<RangeBar> {
46        if self.range_size <= 0.0 || !price.is_finite() {
47            return None;
48        }
49        if !self.initialized {
50            self.open = price;
51            self.high = price;
52            self.low = price;
53            self.initialized = true;
54            return None;
55        }
56        if price > self.high {
57            self.high = price;
58        }
59        if price < self.low {
60            self.low = price;
61        }
62        if self.high - self.low >= self.range_size {
63            let bar = RangeBar {
64                open: self.open,
65                high: self.high,
66                low: self.low,
67                close: price,
68            };
69            // New bar opens at the triggering price.
70            self.open = price;
71            self.high = price;
72            self.low = price;
73            return Some(bar);
74        }
75        None
76    }
77}
78
79/// Batch range bars: fold [`RangeBarBuilder`] over a price slice.
80pub fn range_bars_batch(prices: &[f64], range_size: f64) -> Vec<RangeBar> {
81    let mut b = RangeBarBuilder::new(range_size);
82    let mut out = Vec::new();
83    for &p in prices {
84        if let Some(bar) = b.next(p) {
85            out.push(bar);
86        }
87    }
88    out
89}
90
91/// ATR-range bars: range size is `multiplier * atr` (a single representative ATR).
92pub fn range_bars_atr_batch(prices: &[f64], atr: f64, multiplier: f64) -> Vec<RangeBar> {
93    range_bars_batch(prices, atr * multiplier)
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use proptest::prelude::*;
100
101    #[test]
102    fn gold_single_bar() {
103        // Open 10, climbs to 12 (span 2 >= range 2) → one bar closing at 12.
104        let bars = range_bars_batch(&[10.0, 10.5, 11.0, 12.0], 2.0);
105        assert_eq!(bars.len(), 1);
106        assert_eq!(bars[0].open, 10.0);
107        assert_eq!(bars[0].close, 12.0);
108        assert_eq!(bars[0].high, 12.0);
109        assert_eq!(bars[0].low, 10.0);
110    }
111
112    #[test]
113    fn gold_span_includes_both_directions() {
114        // 10 up to 11, down to 9 → span 11-9=2 completes at 9.
115        let bars = range_bars_batch(&[10.0, 11.0, 9.0], 2.0);
116        assert_eq!(bars.len(), 1);
117        assert_eq!(bars[0].high, 11.0);
118        assert_eq!(bars[0].low, 9.0);
119        assert_eq!(bars[0].close, 9.0);
120    }
121
122    #[test]
123    fn no_bar_within_range() {
124        assert!(range_bars_batch(&[10.0, 10.9, 10.1, 10.5], 2.0).is_empty());
125    }
126
127    proptest! {
128        // Streaming (one at a time) must equal batch (fold).
129        #[test]
130        fn streaming_equals_batch(
131            prices in prop::collection::vec(0.0f64..1000.0, 1..200),
132            range_size in 0.5f64..50.0,
133        ) {
134            let batch = range_bars_batch(&prices, range_size);
135            let mut b = RangeBarBuilder::new(range_size);
136            let mut streamed = Vec::new();
137            for &p in &prices {
138                if let Some(bar) = b.next(p) {
139                    streamed.push(bar);
140                }
141            }
142            prop_assert_eq!(streamed, batch);
143        }
144
145        // Every completed bar's span is at least range_size, with consistent OHLC.
146        #[test]
147        fn bar_span_at_least_range(
148            prices in prop::collection::vec(0.0f64..1000.0, 1..200),
149            range_size in 0.5f64..50.0,
150        ) {
151            for bar in range_bars_batch(&prices, range_size) {
152                prop_assert!(bar.high - bar.low >= range_size - 1e-9);
153                prop_assert!(bar.high >= bar.open && bar.high >= bar.close);
154                prop_assert!(bar.low <= bar.open && bar.low <= bar.close);
155            }
156        }
157    }
158}