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    fn update(&mut self, candle: Candle) -> Vec<RangeBar> {
89        let close = candle.close;
90        let Some(mut anchor) = self.anchor else {
91            self.anchor = Some(close);
92            return Vec::new();
93        };
94        let range = self.range;
95        let mut bars = Vec::new();
96        while close >= anchor + range {
97            bars.push(RangeBar {
98                open: anchor,
99                close: anchor + range,
100                direction: 1,
101            });
102            anchor += range;
103        }
104        while close <= anchor - range {
105            bars.push(RangeBar {
106                open: anchor,
107                close: anchor - range,
108                direction: -1,
109            });
110            anchor -= range;
111        }
112        self.anchor = Some(anchor);
113        bars
114    }
115
116    fn reset(&mut self) {
117        self.anchor = None;
118    }
119
120    fn name(&self) -> &'static str {
121        "RangeBars"
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use approx::assert_relative_eq;
129
130    fn flat(price: f64) -> Candle {
131        Candle::new(price, price, price, price, 1.0, 0).unwrap()
132    }
133
134    #[test]
135    fn rejects_invalid_range() {
136        assert!(matches!(
137            RangeBars::new(0.0),
138            Err(Error::InvalidPeriod { .. })
139        ));
140        assert!(matches!(
141            RangeBars::new(-1.0),
142            Err(Error::InvalidPeriod { .. })
143        ));
144        assert!(matches!(
145            RangeBars::new(f64::NAN),
146            Err(Error::InvalidPeriod { .. })
147        ));
148    }
149
150    #[test]
151    fn accessors_and_metadata() {
152        let bars = RangeBars::new(2.5).unwrap();
153        assert_eq!(bars.name(), "RangeBars");
154        assert_relative_eq!(bars.range(), 2.5, epsilon = 1e-12);
155        assert_eq!(bars.anchor(), None);
156    }
157
158    #[test]
159    fn first_candle_seeds_without_bar() {
160        let mut bars = RangeBars::new(1.0).unwrap();
161        assert!(bars.update(flat(10.0)).is_empty());
162        assert_eq!(bars.anchor(), Some(10.0));
163    }
164
165    #[test]
166    fn up_move_prints_aligned_bars() {
167        let mut bars = RangeBars::new(1.0).unwrap();
168        bars.update(flat(10.0));
169        let up = bars.update(flat(13.0));
170        assert_eq!(up.len(), 3);
171        assert_relative_eq!(up[0].open, 10.0, epsilon = 1e-12);
172        assert_relative_eq!(up[2].close, 13.0, epsilon = 1e-12);
173        assert!(up.iter().all(|b| b.direction == 1));
174        assert_eq!(bars.anchor(), Some(13.0));
175    }
176
177    #[test]
178    fn down_move_prints_aligned_bars() {
179        let mut bars = RangeBars::new(1.0).unwrap();
180        bars.update(flat(10.0));
181        let down = bars.update(flat(7.0));
182        assert_eq!(down.len(), 3);
183        assert!(down.iter().all(|b| b.direction == -1));
184        assert_relative_eq!(down[2].close, 7.0, epsilon = 1e-12);
185    }
186
187    #[test]
188    fn reversal_needs_only_one_range() {
189        // Unlike Renko, a single-range move against the trend prints immediately.
190        let mut bars = RangeBars::new(1.0).unwrap();
191        bars.update(flat(10.0));
192        bars.update(flat(12.0)); // anchor 12, up
193        let down = bars.update(flat(11.0)); // drop of exactly one range
194        assert_eq!(down.len(), 1);
195        assert_eq!(down[0].direction, -1);
196        assert_relative_eq!(down[0].close, 11.0, epsilon = 1e-12);
197        assert_eq!(bars.anchor(), Some(11.0));
198    }
199
200    #[test]
201    fn small_move_prints_nothing() {
202        let mut bars = RangeBars::new(1.0).unwrap();
203        bars.update(flat(10.0));
204        assert!(bars.update(flat(10.5)).is_empty());
205        assert_eq!(bars.anchor(), Some(10.0));
206    }
207
208    #[test]
209    fn reset_clears_state() {
210        let mut bars = RangeBars::new(1.0).unwrap();
211        bars.update(flat(10.0));
212        bars.update(flat(13.0));
213        bars.reset();
214        assert_eq!(bars.anchor(), None);
215        assert!(bars.update(flat(50.0)).is_empty());
216        assert_eq!(bars.anchor(), Some(50.0));
217    }
218
219    #[test]
220    fn batch_concatenates_completed_bars() {
221        let mut bars = RangeBars::new(1.0).unwrap();
222        let candles = [flat(10.0), flat(12.0), flat(13.0)];
223        let out = bars.batch(&candles);
224        assert_eq!(out.len(), 3);
225        assert!(out.iter().all(|b| b.direction == 1));
226    }
227}