Skip to main content

egui_charts/model/
renko.rs

1//! Renko chart transformation.
2//!
3//! Renko charts filter out time entirely, plotting uniform-sized bricks
4//! that represent a fixed price movement. A brick in the direction of the
5//! trend is drawn when the close moves one [`brick_size`](RenkoConfig::brick_size)
6//! beyond the close of the previous brick. A reversal needs a two-brick move:
7//! the close must move one brick beyond the open of the previous brick, and
8//! the reversal brick starts at that open.
9//!
10//! Use [`to_renko_bricks`] to transform OHLCV bars into Renko bricks.
11
12use super::PriceBasedSource;
13use crate::model::{Bar, PriceBuilder};
14use chrono::{DateTime, Utc};
15
16/// A single Renko brick representing a fixed-size price movement.
17#[derive(Debug, Clone)]
18pub struct RenkoBrick {
19    /// Timestamp of the source bar that completed this brick.
20    pub ts: DateTime<Utc>,
21    /// Opening price (bottom of an Up brick, top of a Down brick).
22    pub open: f64,
23    /// Closing price (top of an Up brick, bottom of a Down brick).
24    pub close: f64,
25    /// Highest source price of this brick, including an upper wick on a down brick.
26    pub high: f64,
27    /// Lowest source price of this brick, including a lower wick on an up brick.
28    pub low: f64,
29    /// Direction of this brick.
30    pub direction: RenkoDirection,
31}
32
33/// Direction of a Renko brick.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum RenkoDirection {
36    /// Bullish brick (price moved up by one brick size).
37    Up,
38    /// Bearish brick (price moved down by one brick size).
39    Down,
40}
41
42impl RenkoBrick {
43    /// Convert to a Bar for rendering compatibility
44    pub fn to_bar(&self) -> Bar {
45        Bar {
46            time: self.ts,
47            open: self.open,
48            high: self.high,
49            low: self.low,
50            close: self.close,
51            volume: 0.0, // Renko doesn't use volume
52            colors: None,
53            payload: None,
54        }
55    }
56}
57
58/// Configuration for the Renko chart transformation.
59///
60/// The brick size can be set directly or derived from ATR via
61/// [`from_atr`](Self::from_atr).
62#[derive(Debug, Clone)]
63pub struct RenkoConfig {
64    /// Size of each brick in price units
65    pub brick_size: f64,
66    /// Price input; high/low gives continuation priority when a bar crosses both thresholds.
67    pub source: PriceBasedSource,
68}
69
70impl Default for RenkoConfig {
71    fn default() -> Self {
72        Self {
73            brick_size: 1.0,
74            source: PriceBasedSource::Close,
75        }
76    }
77}
78
79impl RenkoConfig {
80    /// Create a Renko config with an explicit brick size in price units.
81    pub fn new(brick_size: f64) -> Self {
82        Self {
83            brick_size,
84            source: PriceBasedSource::Close,
85        }
86    }
87
88    /// Calculate brick size from ATR (Avg True Range)
89    pub fn from_atr(bars: &[Bar], period: usize, multiplier: f64) -> Self {
90        Self {
91            brick_size: super::BoxSizeMethod::Atr { period, multiplier }
92                .resolve(0.0, bars)
93                .unwrap_or(f64::NAN),
94            source: PriceBasedSource::Close,
95        }
96    }
97}
98
99/// Transform bars into Renko bricks
100///
101/// The first brick is drawn once a close moves one brick either way from the
102/// first close rounded down to a multiple of the brick size. After that, a
103/// brick continues the trend when a close reaches one brick beyond the last
104/// brick's close, and reverses it when a close reaches one brick beyond the
105/// last brick's open, so a reversal needs a two-brick move.
106///
107/// Returns no bricks when the brick size is not a positive finite number.
108///
109/// # Safety Limits
110/// - When the bars move by more than two million brick sizes, the bricks are built from the latest
111///   bar after which they move at most that much
112/// - At most the newest 10,000 bricks
113pub fn to_renko_bricks(bars: &[Bar], config: &RenkoConfig) -> Vec<RenkoBrick> {
114    let brick_size = config.brick_size;
115    if bars.is_empty() || !(brick_size > 0.0 && brick_size.is_finite()) {
116        return Vec::new();
117    }
118    let mut builder = RenkoBuilder::new(brick_size, config.source);
119    for bar in bars[work_start(bars, brick_size, config.source)..]
120        .iter()
121        .filter(|bar| !bar.is_whitespace())
122    {
123        builder.push(bar);
124    }
125    builder.elements()
126}
127
128pub(crate) const MAX_BRICKS: usize = 10_000;
129
130/// Builds Renko bricks bar by bar; `size` must be a positive finite number.
131#[derive(Clone)]
132pub(crate) struct RenkoBuilder {
133    size: f64,
134    source: PriceBasedSource,
135    extremes: Option<(f64, f64)>,
136    /// Open and close of the last brick, or the start price before the first brick; `None`
137    /// until the first bar.
138    last: Option<(f64, f64)>,
139    bricks: Vec<RenkoBrick>,
140}
141
142impl RenkoBuilder {
143    pub(crate) fn new(size: f64, source: PriceBasedSource) -> Self {
144        Self {
145            size,
146            source,
147            extremes: None,
148            last: None,
149            bricks: Vec::new(),
150        }
151    }
152}
153
154impl PriceBuilder for RenkoBuilder {
155    type Element = RenkoBrick;
156
157    fn push(&mut self, bar: &Bar) {
158        let size = self.size;
159        let Some(mut last) = self.last else {
160            let start = (bar.close / size).floor() * size;
161            self.last = Some((start, start));
162            self.extremes = Some((bar.close, bar.close));
163            return;
164        };
165        let (low, high) = match self.source {
166            PriceBasedSource::Close => (bar.close, bar.close),
167            PriceBasedSource::HighLow => (bar.low, bar.high),
168        };
169        let (prior_low, prior_high) = self.extremes.unwrap_or((bar.close, bar.close));
170        let (mut wick_low, mut wick_high) = (prior_low.min(low), prior_high.max(high));
171        let (open, close) = last;
172        let price = match self.source {
173            PriceBasedSource::Close => bar.close,
174            PriceBasedSource::HighLow if close >= open => {
175                if high >= close + size {
176                    high
177                } else {
178                    low
179                }
180            }
181            PriceBasedSource::HighLow => {
182                if low <= close - size {
183                    low
184                } else {
185                    high
186                }
187            }
188        };
189        loop {
190            let (open, close) = last;
191            let (low, high) = (open.min(close), open.max(close));
192            // Before the first brick both ends are the start, so either direction needs one brick.
193            let (brick_open, brick_close) = if price >= high + size {
194                (high, high + size)
195            } else if price <= low - size {
196                (low, low - size)
197            } else {
198                break;
199            };
200            // A box below the precision of the price moves nothing.
201            if brick_close == brick_open {
202                break;
203            }
204            let up = brick_close > brick_open;
205
206            self.bricks.push(RenkoBrick {
207                ts: bar.time,
208                open: brick_open,
209                close: brick_close,
210                high: if up {
211                    brick_close
212                } else {
213                    wick_high.max(brick_open)
214                },
215                low: if up {
216                    wick_low.min(brick_open)
217                } else {
218                    brick_close
219                },
220                direction: if up {
221                    RenkoDirection::Up
222                } else {
223                    RenkoDirection::Down
224                },
225            });
226            last = (brick_open, brick_close);
227            wick_low = brick_close;
228            wick_high = brick_close;
229
230            // Bound memory by dropping the oldest bricks; `last` never reads them.
231            if self.bricks.len() == 2 * MAX_BRICKS {
232                self.bricks.drain(..MAX_BRICKS);
233            }
234        }
235        self.last = Some(last);
236        // Keep the unfinished move beyond the last brick for the next wick.
237        self.extremes = Some((wick_low.min(price), wick_high.max(price)));
238    }
239
240    fn elements(&self) -> Vec<RenkoBrick> {
241        self.bricks[self.bricks.len().saturating_sub(MAX_BRICKS)..].to_vec()
242    }
243}
244
245/// Index of the bar to build from so that the bricks move at most `MAX_WORK` brick sizes. Bar
246/// `i` costs its close-to-close move in sizes plus 2; the returned bar only seeds the state.
247pub(crate) fn work_start(bars: &[Bar], size: f64, source: PriceBasedSource) -> usize {
248    let mut total = 0.0;
249    let mut priced = bars
250        .iter()
251        .enumerate()
252        .rev()
253        .filter(|(_, bar)| !bar.is_whitespace());
254    let Some((mut i, mut bar)) = priced.next() else {
255        return 0;
256    };
257    for (previous_i, previous) in priced {
258        let movement = match source {
259            PriceBasedSource::Close => (bar.close - previous.close).abs(),
260            PriceBasedSource::HighLow => bar.high.max(previous.high) - bar.low.min(previous.low),
261        };
262        total += (movement / size).max(0.0) + 2.0;
263        if total > super::MAX_WORK {
264            return i;
265        }
266        (i, bar) = (previous_i, previous);
267    }
268    0
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use chrono::Duration;
275    #[test]
276    fn bricks_reverse_only_after_a_two_box_move() {
277        let cases = [
278            (
279                vec![100.0, 103.0, 102.0],
280                vec![(100.0, 101.0), (101.0, 102.0), (102.0, 103.0)],
281            ),
282            (
283                vec![100.0, 103.0, 101.0],
284                vec![
285                    (100.0, 101.0),
286                    (101.0, 102.0),
287                    (102.0, 103.0),
288                    (102.0, 101.0),
289                ],
290            ),
291            (
292                vec![100.0, 103.0, 101.0, 102.0, 103.0],
293                vec![
294                    (100.0, 101.0),
295                    (101.0, 102.0),
296                    (102.0, 103.0),
297                    (102.0, 101.0),
298                    (102.0, 103.0),
299                ],
300            ),
301            (vec![100.0, 98.0], vec![(100.0, 99.0), (99.0, 98.0)]),
302            (vec![100.0, 100.9], vec![]),
303            // At 2^53 one box rounds away above the price but not below it.
304            (vec![9_007_199_254_740_992.0; 2], vec![]),
305        ];
306        let start = Utc::now();
307        for (closes, expected) in &cases {
308            let bars: Vec<Bar> = closes
309                .iter()
310                .enumerate()
311                .map(|(i, &c)| Bar {
312                    time: start + Duration::minutes(i as i64),
313                    open: c,
314                    high: c,
315                    low: c,
316                    close: c,
317                    volume: 0.0,
318                    colors: None,
319                    payload: None,
320                })
321                .collect();
322            let got: Vec<(f64, f64)> = to_renko_bricks(&bars, &RenkoConfig::new(1.0))
323                .iter()
324                .map(|b| (b.open, b.close))
325                .collect();
326            assert_eq!(got, *expected, "closes {closes:?}");
327        }
328    }
329
330    #[test]
331    fn test_renko_brick_to_bar() {
332        let brick = RenkoBrick {
333            ts: Utc::now(),
334            open: 100.0,
335            close: 102.0,
336            high: 102.0,
337            low: 99.5,
338            direction: RenkoDirection::Up,
339        };
340
341        let bar = brick.to_bar();
342        assert_eq!(bar.open, 100.0);
343        assert_eq!(bar.close, 102.0);
344        assert_eq!(bar.high, 102.0);
345        assert_eq!(bar.low, 99.5);
346    }
347}