Skip to main content

egui_charts/model/
range_bar.rs

1//! Range-bar chart transformation.
2//!
3//! Build bars with a configured price range rather than a fixed time interval.
4//! Use [`to_range_bars_from_ticks`] with executions, or
5//! [`to_range_bars_from_ohlc`] for an approximation from existing bars. The newest
6//! output bar can be incomplete.
7
8use super::PriceBuilder;
9use super::Tick;
10use super::bar::Bar;
11use chrono::{DateTime, Utc};
12
13/// Configuration for range-bar construction.
14///
15/// The `range_size` sets the fixed price range per bar. It can also be
16/// derived from ATR via [`with_atr`](Self::with_atr).
17#[derive(Debug, Clone)]
18pub struct RangeBarConfig {
19    /// Fixed range size in price units
20    pub range_size: f64,
21    /// Whether to use ATR for range size
22    pub use_atr: bool,
23    /// ATR period if using ATR-based ranges
24    pub atr_period: usize,
25    /// ATR multiplier
26    pub atr_multiplier: f64,
27}
28
29impl Default for RangeBarConfig {
30    fn default() -> Self {
31        Self {
32            range_size: 10.0,
33            use_atr: false,
34            atr_period: 14,
35            atr_multiplier: 1.0,
36        }
37    }
38}
39
40impl RangeBarConfig {
41    /// Create a range-bar config with an explicit range size in price units.
42    pub fn new(range_size: f64) -> Self {
43        Self {
44            range_size,
45            ..Default::default()
46        }
47    }
48
49    /// Derive the range size from ATR instead of a fixed value.
50    pub fn with_atr(mut self, period: usize, multiplier: f64) -> Self {
51        self.use_atr = true;
52        self.atr_period = period;
53        self.atr_multiplier = multiplier;
54        self
55    }
56}
57
58/// A single range bar with fixed price range and variable time span.
59#[derive(Debug, Clone)]
60pub struct RangeBar {
61    /// Open price
62    pub open: f64,
63    /// High price
64    pub high: f64,
65    /// Low price
66    pub low: f64,
67    /// Close price
68    pub close: f64,
69    /// Total volume
70    pub volume: f64,
71    /// Ts when bar started
72    pub start_time: DateTime<Utc>,
73    /// Ts when bar completed
74    pub end_time: DateTime<Utc>,
75    /// Range size used for this bar
76    pub range_size: f64,
77    /// Number of ticks in this bar
78    pub tick_cnt: usize,
79}
80
81impl RangeBar {
82    /// Check if this is a bullish (up) bar
83    pub fn is_bullish(&self) -> bool {
84        self.close >= self.open
85    }
86
87    /// Check if this is a bearish (down) bar
88    pub fn is_bearish(&self) -> bool {
89        self.close < self.open
90    }
91
92    /// Get the range of this bar
93    pub fn range(&self) -> f64 {
94        self.high - self.low
95    }
96
97    /// Get the body size (absolute)
98    pub fn body(&self) -> f64 {
99        (self.close - self.open).abs()
100    }
101
102    /// Convert to standard Bar format
103    pub fn to_bar(&self) -> Bar {
104        Bar {
105            time: self.end_time,
106            open: self.open,
107            high: self.high,
108            low: self.low,
109            close: self.close,
110            volume: self.volume,
111            colors: None,
112            payload: None,
113        }
114    }
115}
116
117/// What a chart's range bars are built from.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum RangeBarSource {
120    /// The trades the feed supplied, in order, with their timestamps and volume.
121    Executions,
122    /// The open, high, low and close of the chart's bars: an approximation, used while the chart
123    /// holds no trades.
124    OhlcApproximation,
125}
126
127/// Build range bars from trades (exact construction). A trade's size that would make its bar's
128/// volume overflow is left out.
129///
130/// # Safety Limits
131/// - At most the newest 5,000 range bars
132pub fn to_range_bars_from_ticks(ticks: &[Tick], config: &RangeBarConfig) -> Vec<RangeBar> {
133    let mut builder = RangeBarBuilder::new(config.range_size);
134    for tick in ticks {
135        builder.push_tick(tick);
136    }
137    builder.elements()
138}
139
140/// Convert OHLC bars to range bars (approximation). A bar's volume that would make its range bar's
141/// volume overflow is left out.
142///
143/// Returns no bars when the range size is not a positive finite number.
144///
145/// # Safety Limits
146/// - When the bars move by more than two million range sizes, the range bars are built from the
147///   latest bar after which they move at most that much
148/// - At most the newest 5,000 range bars
149pub fn to_range_bars_from_ohlc(data: &[Bar], config: &RangeBarConfig) -> Vec<RangeBar> {
150    // The range size, from ATR when the config asks for it
151    let range_size = if config.use_atr && data.len() >= config.atr_period {
152        calculate_atr(data, config.atr_period) * config.atr_multiplier
153    } else {
154        config.range_size
155    };
156    if data.is_empty() || !(range_size > 0.0 && range_size.is_finite()) {
157        return Vec::new();
158    }
159    let mut builder = RangeBarBuilder::new(range_size);
160    for bar in data[work_start(data, range_size)..]
161        .iter()
162        .filter(|bar| !bar.is_whitespace())
163    {
164        builder.push(bar);
165    }
166    builder.elements()
167}
168
169pub(crate) const MAX_BARS: usize = 5_000;
170
171/// Builds range bars from OHLC bars one at a time; `range_size` must be a positive finite
172/// number. The range bar in progress is kept apart from the closed bars and added by `elements`.
173#[derive(Clone)]
174pub(crate) struct RangeBarBuilder {
175    range_size: f64,
176    /// The range bar in progress; `None` until the first bar.
177    curr_bar: Option<RangeBar>,
178    bars: Vec<RangeBar>,
179}
180
181impl RangeBarBuilder {
182    pub(crate) fn new(range_size: f64) -> Self {
183        Self {
184            range_size,
185            curr_bar: None,
186            bars: Vec::new(),
187        }
188    }
189
190    /// Adds one trade, after the bars or trades already added. A trade that crosses several
191    /// ranges closes one bar per range; a range of 0 or less closes none. A trade closes at most
192    /// 100 bars, which bounds the work for one bad print; raise it if gaps that large must chart
193    /// in full. The bar that follows a close holds the trade only when it lies beyond the close.
194    pub(crate) fn push_tick(&mut self, tick: &Tick) {
195        let range_size = self.range_size;
196        let Some(mut curr_bar) = self.curr_bar.take() else {
197            self.curr_bar = Some(RangeBar {
198                open: tick.price,
199                high: tick.price,
200                low: tick.price,
201                close: tick.price,
202                volume: add_volume(0.0, tick.size),
203                start_time: tick.ts,
204                end_time: tick.ts,
205                range_size,
206                tick_cnt: 1,
207            });
208            return;
209        };
210        curr_bar.high = curr_bar.high.max(tick.price);
211        curr_bar.low = curr_bar.low.min(tick.price);
212        curr_bar.close = tick.price;
213        curr_bar.volume = add_volume(curr_bar.volume, tick.size);
214        curr_bar.end_time = tick.ts;
215        curr_bar.tick_cnt += 1;
216
217        const MAX_CLOSES: usize = 100;
218        let mut closes = 0;
219        while curr_bar.range() >= range_size && range_size > 0.0 && closes < MAX_CLOSES {
220            closes += 1;
221            if tick.price >= curr_bar.open {
222                curr_bar.close = curr_bar.low + range_size;
223                curr_bar.high = curr_bar.close;
224            } else {
225                curr_bar.close = curr_bar.high - range_size;
226                curr_bar.low = curr_bar.close;
227            }
228            self.bars.push(curr_bar.clone());
229            if self.bars.len() == 2 * MAX_BARS {
230                self.bars.drain(..MAX_BARS);
231            }
232
233            // Start the next bar where the last one ended
234            curr_bar = RangeBar {
235                open: curr_bar.close,
236                high: tick.price.max(curr_bar.close),
237                low: tick.price.min(curr_bar.close),
238                close: tick.price,
239                volume: 0.0,
240                start_time: tick.ts,
241                end_time: tick.ts,
242                range_size,
243                tick_cnt: usize::from(tick.price != curr_bar.close),
244            };
245        }
246        self.curr_bar = Some(curr_bar);
247    }
248}
249
250impl PriceBuilder for RangeBarBuilder {
251    type Element = RangeBar;
252
253    fn push(&mut self, bar: &Bar) {
254        let range_size = self.range_size;
255        let Some(mut curr_bar) = self.curr_bar.take() else {
256            self.curr_bar = Some(RangeBar {
257                open: bar.open,
258                high: bar.high,
259                low: bar.low,
260                close: bar.close,
261                volume: add_volume(0.0, bar.volume),
262                start_time: bar.time,
263                end_time: bar.time,
264                range_size,
265                tick_cnt: 1,
266            });
267            return;
268        };
269
270        // Simulate processing this bar's range
271        let prices = [bar.open, bar.high, bar.low, bar.close];
272
273        for &price in &prices {
274            curr_bar.high = curr_bar.high.max(price);
275            curr_bar.low = curr_bar.low.min(price);
276
277            while curr_bar.range() >= range_size {
278                // Complete the bar
279                if curr_bar.high - curr_bar.open >= range_size {
280                    // A size below the precision of the price moves nothing
281                    if curr_bar.low + range_size == curr_bar.low {
282                        break;
283                    }
284                    // Closed up
285                    curr_bar.close = curr_bar.low + range_size;
286                    curr_bar.high = curr_bar.close;
287                } else {
288                    if curr_bar.high - range_size == curr_bar.high {
289                        break;
290                    }
291                    // Closed down
292                    curr_bar.close = curr_bar.high - range_size;
293                    curr_bar.low = curr_bar.close;
294                }
295
296                curr_bar.end_time = bar.time;
297                self.bars.push(curr_bar.clone());
298
299                // Bound memory by dropping the oldest bars; `curr_bar` never reads them.
300                if self.bars.len() == 2 * MAX_BARS {
301                    self.bars.drain(..MAX_BARS);
302                }
303
304                // Start new bar
305                curr_bar = RangeBar {
306                    open: curr_bar.close,
307                    high: price.max(curr_bar.close),
308                    low: price.min(curr_bar.close),
309                    close: price,
310                    volume: 0.0,
311                    start_time: bar.time,
312                    end_time: bar.time,
313                    range_size,
314                    tick_cnt: 0,
315                };
316            }
317        }
318
319        curr_bar.close = bar.close;
320        curr_bar.volume = add_volume(curr_bar.volume, bar.volume);
321        curr_bar.end_time = bar.time;
322        curr_bar.tick_cnt += 1;
323        self.curr_bar = Some(curr_bar);
324    }
325
326    fn elements(&self) -> Vec<RangeBar> {
327        let mut bars = self.bars.clone();
328        // The incomplete bar, if it has any ticks
329        bars.extend(self.curr_bar.clone().filter(|b| b.tick_cnt > 0));
330        bars.drain(..bars.len().saturating_sub(MAX_BARS));
331        bars
332    }
333}
334
335/// `volume` plus `added`, or `volume` alone when the sum is not finite: two valid volumes can add
336/// up to infinity.
337fn add_volume(volume: f64, added: f64) -> f64 {
338    let sum = volume + added;
339    if sum.is_finite() { sum } else { volume }
340}
341
342/// Index of the bar to build from so that the range bars move at most `MAX_WORK` range sizes.
343/// Bar `i` costs its path through open, high, low and close in sizes plus 4; the returned bar
344/// only seeds the state, and its own range is split at the next bar's first price.
345pub(crate) fn work_start(bars: &[Bar], size: f64) -> usize {
346    let mut total = 0.0;
347    let mut priced = bars
348        .iter()
349        .enumerate()
350        .rev()
351        .filter(|(_, bar)| !bar.is_whitespace());
352    let Some((mut i, mut b)) = priced.next() else {
353        return 0;
354    };
355    for (previous_i, prev) in priced {
356        total += (((b.open - prev.close).abs()
357            + (b.high - b.open).abs()
358            + (b.high - b.low)
359            + (b.close - b.low).abs())
360            / size)
361            .max(0.0)
362            + 4.0;
363        if total + (prev.high - prev.low) / size > super::MAX_WORK {
364            return i;
365        }
366        (i, b) = (previous_i, prev);
367    }
368    0
369}
370
371/// Calculate ATR for range size
372pub(super) fn calculate_atr(data: &[Bar], period: usize) -> f64 {
373    if data.len() < 2 {
374        return 1.0;
375    }
376
377    let mut tr_values = Vec::new();
378
379    for i in 1..data.len() {
380        let high = data[i].high;
381        let low = data[i].low;
382        let prev_close = data[i - 1].close;
383
384        let tr = (high - low)
385            .max((high - prev_close).abs())
386            .max((low - prev_close).abs());
387        tr_values.push(tr);
388    }
389
390    if tr_values.len() < period {
391        return tr_values.iter().sum::<f64>() / tr_values.len() as f64;
392    }
393
394    let sum: f64 = tr_values.iter().rev().take(period).sum();
395    sum / period as f64
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn range_bars_from_ticks_follow_every_tick() {
404        // (range, tick prices, (open, high, low, close) of each bar)
405        type Ohlc = (f64, f64, f64, f64);
406        let cases: [(f64, &[f64], &[Ohlc]); 4] = [
407            (
408                10.0,
409                &[100.0, 150.0],
410                &[
411                    (100.0, 110.0, 100.0, 110.0),
412                    (110.0, 120.0, 110.0, 120.0),
413                    (120.0, 130.0, 120.0, 130.0),
414                    (130.0, 140.0, 130.0, 140.0),
415                    (140.0, 150.0, 140.0, 150.0),
416                ],
417            ),
418            (
419                10.0,
420                &[100.0, 105.0, 112.0],
421                &[(100.0, 110.0, 100.0, 110.0), (110.0, 112.0, 110.0, 112.0)],
422            ),
423            // A last tick exactly on a boundary adds no zero-range bar with the previous bar's time.
424            (10.0, &[100.0, 110.0], &[(100.0, 110.0, 100.0, 110.0)]),
425            // A range of 0 closes nothing and still terminates.
426            (0.0, &[100.0, 0.0], &[(100.0, 100.0, 0.0, 0.0)]),
427        ];
428        for (range, prices, expected) in cases {
429            let ticks: Vec<Tick> = prices
430                .iter()
431                .enumerate()
432                .map(|(i, &price)| {
433                    Tick::new(
434                        DateTime::from_timestamp(i as i64, 0).unwrap(),
435                        price,
436                        1.0,
437                        None,
438                    )
439                })
440                .collect();
441            let bars: Vec<_> = to_range_bars_from_ticks(&ticks, &RangeBarConfig::new(range))
442                .iter()
443                .map(|b| (b.open, b.high, b.low, b.close))
444                .collect();
445            assert_eq!(bars, expected, "{prices:?} range {range}");
446        }
447    }
448
449    #[test]
450    fn test_range_bars_from_ohlc() {
451        let first = DateTime::UNIX_EPOCH;
452        let next = first + chrono::Duration::minutes(1);
453        let data = [
454            Bar::new(first, 100.0, 100.0, 100.0, 100.0, 10.0),
455            Bar::new(next, 112.0, 112.0, 112.0, 112.0, 20.0),
456        ];
457        // A 12-point advance closes two five-point ranges and leaves a two-point tail.
458        let bars = to_range_bars_from_ohlc(&data, &RangeBarConfig::new(5.0));
459        let actual: Vec<_> = bars
460            .iter()
461            .map(|b| {
462                (
463                    b.open,
464                    b.high,
465                    b.low,
466                    b.close,
467                    b.volume,
468                    b.start_time,
469                    b.end_time,
470                )
471            })
472            .collect();
473        assert_eq!(
474            actual,
475            [
476                (100.0, 105.0, 100.0, 105.0, 10.0, first, next),
477                (105.0, 110.0, 105.0, 110.0, 0.0, next, next),
478                (110.0, 112.0, 110.0, 112.0, 20.0, next, next),
479            ]
480        );
481    }
482
483    /// Guards the volume scale: an infinite first volume, or two valid volumes near `f64::MAX`,
484    /// must not give a range bar an infinite volume, from ticks or from bars.
485    #[test]
486    fn a_volume_that_would_overflow_is_left_out() {
487        let ts = Utc::now();
488        let tick = |volume| Tick::new(ts, 100.0, volume, None);
489        let bar = |volume| Bar::new(ts, 100.0, 100.0, 100.0, 100.0, volume);
490        let volumes = [f64::INFINITY, f64::MAX, f64::MAX];
491        let config = RangeBarConfig::new(5.0);
492        let from_ticks = to_range_bars_from_ticks(&volumes.map(tick), &config);
493        let from_bars = to_range_bars_from_ohlc(&volumes.map(bar), &config);
494        let volumes = |bars: Vec<RangeBar>| Vec::from_iter(bars.iter().map(|bar| bar.volume));
495        assert_eq!(volumes(from_ticks), [f64::MAX]);
496        assert_eq!(volumes(from_bars), [f64::MAX]);
497    }
498
499    #[test]
500    fn test_range_bar_properties() {
501        let bar = RangeBar {
502            open: 100.0,
503            high: 105.0,
504            low: 100.0,
505            close: 105.0,
506            volume: 1000.0,
507            start_time: Utc::now(),
508            end_time: Utc::now(),
509            range_size: 5.0,
510            tick_cnt: 10,
511        };
512
513        assert!(bar.is_bullish());
514        assert!(!bar.is_bearish());
515        assert!((bar.range() - 5.0).abs() < 0.01);
516        assert!((bar.body() - 5.0).abs() < 0.01);
517    }
518}