Skip to main content

wickra_core/indicators/
range_bars.rs

1//! Range bar builder — fixed price-range bars with no reversal penalty.
2
3use crate::error::{Error, Result};
4use crate::ohlcv::Candle;
5use crate::traits::BarBuilder;
6
7/// One completed range bar.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct RangeBar {
10    /// Price at the bar's origin edge.
11    pub open: f64,
12    /// Price at the bar's far edge (`open ± range`).
13    pub close: f64,
14    /// `+1` for an up bar, `-1` for a down bar.
15    pub direction: i8,
16}
17
18/// Range bar builder using a fixed price increment on close prices.
19///
20/// A range bar completes every time price travels a fixed `range` from the current
21/// anchor, in *either* direction. This is the key difference from
22/// [`RenkoBars`](crate::RenkoBars): Renko imposes a `2 * box_size` penalty to
23/// reverse direction, so it filters out small oscillations; range bars have **no
24/// reversal penalty** — a move of exactly `range` against the trend prints a bar
25/// immediately. Range bars therefore track every leg of price movement, while Renko
26/// smooths them.
27///
28/// Construction rules:
29///
30/// - The first candle seeds the anchor and prints no bar.
31/// - Each subsequent candle prints one bar for every `range` of close movement away
32///   from the anchor; a candle that gaps several ranges prints them all in one
33///   [`BarBuilder::update`] call.
34/// - Bars are aligned to the `range` grid relative to the seed price.
35///
36/// # Example
37///
38/// ```
39/// use wickra_core::{BarBuilder, Candle, RangeBars};
40///
41/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
42/// let mut bars = RangeBars::new(1.0).unwrap();
43/// assert!(bars.update(flat(10.0)).is_empty()); // seed
44/// let up = bars.update(flat(12.0)); // +2 ranges
45/// assert_eq!(up.len(), 2);
46/// let down = bars.update(flat(11.0)); // -1 range, no penalty
47/// assert_eq!(down.len(), 1);
48/// ```
49#[derive(Debug, Clone)]
50pub struct RangeBars {
51    range: f64,
52    anchor: Option<f64>,
53}
54
55impl RangeBars {
56    /// Construct a range-bar builder with the given price increment.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`Error::InvalidPeriod`] if `range` is not finite and positive.
61    pub fn new(range: f64) -> Result<Self> {
62        if !range.is_finite() || range <= 0.0 {
63            return Err(Error::InvalidPeriod {
64                message: "range must be finite and positive",
65            });
66        }
67        Ok(Self {
68            range,
69            anchor: None,
70        })
71    }
72
73    /// Configured price range.
74    pub const fn range(&self) -> f64 {
75        self.range
76    }
77
78    /// Current anchor level (the close of the last completed bar, or the seed
79    /// price before any bar has formed).
80    pub const fn anchor(&self) -> Option<f64> {
81        self.anchor
82    }
83}
84
85impl BarBuilder for RangeBars {
86    type Bar = RangeBar;
87
88    #[inline]
89    fn update(&mut self, candle: Candle) -> Vec<RangeBar> {
90        let close = candle.close;
91        let Some(mut anchor) = self.anchor else {
92            self.anchor = Some(close);
93            return Vec::new();
94        };
95        let range = self.range;
96        let mut bars = Vec::new();
97        while close >= anchor + range {
98            bars.push(RangeBar {
99                open: anchor,
100                close: anchor + range,
101                direction: 1,
102            });
103            anchor += range;
104        }
105        while close <= anchor - range {
106            bars.push(RangeBar {
107                open: anchor,
108                close: anchor - range,
109                direction: -1,
110            });
111            anchor -= range;
112        }
113        self.anchor = Some(anchor);
114        bars
115    }
116
117    fn reset(&mut self) {
118        self.anchor = None;
119    }
120
121    #[inline]
122    fn name(&self) -> &'static str {
123        "RangeBars"
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use approx::assert_relative_eq;
131
132    fn flat(price: f64) -> Candle {
133        Candle::new(price, price, price, price, 1.0, 0).unwrap()
134    }
135
136    #[test]
137    fn rejects_invalid_range() {
138        assert!(matches!(
139            RangeBars::new(0.0),
140            Err(Error::InvalidPeriod { .. })
141        ));
142        assert!(matches!(
143            RangeBars::new(-1.0),
144            Err(Error::InvalidPeriod { .. })
145        ));
146        assert!(matches!(
147            RangeBars::new(f64::NAN),
148            Err(Error::InvalidPeriod { .. })
149        ));
150    }
151
152    #[test]
153    fn accessors_and_metadata() {
154        let bars = RangeBars::new(2.5).unwrap();
155        assert_eq!(bars.name(), "RangeBars");
156        assert_relative_eq!(bars.range(), 2.5, epsilon = 1e-12);
157        assert_eq!(bars.anchor(), None);
158    }
159
160    #[test]
161    fn first_candle_seeds_without_bar() {
162        let mut bars = RangeBars::new(1.0).unwrap();
163        assert!(bars.update(flat(10.0)).is_empty());
164        assert_eq!(bars.anchor(), Some(10.0));
165    }
166
167    #[test]
168    fn up_move_prints_aligned_bars() {
169        let mut bars = RangeBars::new(1.0).unwrap();
170        bars.update(flat(10.0));
171        let up = bars.update(flat(13.0));
172        assert_eq!(up.len(), 3);
173        assert_relative_eq!(up[0].open, 10.0, epsilon = 1e-12);
174        assert_relative_eq!(up[2].close, 13.0, epsilon = 1e-12);
175        assert!(up.iter().all(|b| b.direction == 1));
176        assert_eq!(bars.anchor(), Some(13.0));
177    }
178
179    #[test]
180    fn down_move_prints_aligned_bars() {
181        let mut bars = RangeBars::new(1.0).unwrap();
182        bars.update(flat(10.0));
183        let down = bars.update(flat(7.0));
184        assert_eq!(down.len(), 3);
185        assert!(down.iter().all(|b| b.direction == -1));
186        assert_relative_eq!(down[2].close, 7.0, epsilon = 1e-12);
187    }
188
189    #[test]
190    fn reversal_needs_only_one_range() {
191        // Unlike Renko, a single-range move against the trend prints immediately.
192        let mut bars = RangeBars::new(1.0).unwrap();
193        bars.update(flat(10.0));
194        bars.update(flat(12.0)); // anchor 12, up
195        let down = bars.update(flat(11.0)); // drop of exactly one range
196        assert_eq!(down.len(), 1);
197        assert_eq!(down[0].direction, -1);
198        assert_relative_eq!(down[0].close, 11.0, epsilon = 1e-12);
199        assert_eq!(bars.anchor(), Some(11.0));
200    }
201
202    #[test]
203    fn small_move_prints_nothing() {
204        let mut bars = RangeBars::new(1.0).unwrap();
205        bars.update(flat(10.0));
206        assert!(bars.update(flat(10.5)).is_empty());
207        assert_eq!(bars.anchor(), Some(10.0));
208    }
209
210    #[test]
211    fn reset_clears_state() {
212        let mut bars = RangeBars::new(1.0).unwrap();
213        bars.update(flat(10.0));
214        bars.update(flat(13.0));
215        bars.reset();
216        assert_eq!(bars.anchor(), None);
217        assert!(bars.update(flat(50.0)).is_empty());
218        assert_eq!(bars.anchor(), Some(50.0));
219    }
220
221    #[test]
222    fn batch_concatenates_completed_bars() {
223        let mut bars = RangeBars::new(1.0).unwrap();
224        let candles = [flat(10.0), flat(12.0), flat(13.0)];
225        let out = bars.batch(&candles);
226        assert_eq!(out.len(), 3);
227        assert!(out.iter().all(|b| b.direction == 1));
228    }
229}