Skip to main content

quantwave_core/indicators/
renko.rs

1//! Renko bar construction (close-based, Drozda et al. 2024 Definition 1).
2//!
3//! A Renko brick spans a fixed height `ΔP` (the "box"); a new brick is created
4//! only when the latest *close* leaves the current brick's `[min, max]` band, so
5//! small moves are filtered out. This follows the formal definition in:
6//!
7//!   Drozda, Cavojsky, Sebes (2024), "On Suitability of Renko Charts for
8//!   Algorithmic Trading". Def. 1: the first brick `R⁰` is anchored at the first
9//!   close `p₀`; a close above `p_max` creates an up-brick `[p_max, p_max + ΔP]`
10//!   and a close below `p_min` creates a down-brick `[p_min − ΔP, p_min]`
11//!   (adjacent, non-retracing); several bricks may be created for one close.
12//!   `ΔP` may vary per brick, so an ATR-derived box is the natural default.
13//!
14//! A high/low-based variant (symmetric threshold `|P(u) − P(τ)| = H`) is used for
15//! pairs trading in Bogomolov (2013), "Pairs trading based on statistical
16//! variability of the spread process", Quantitative Finance 13(9): 1411–1430;
17//! this module implements the close-based Drozda definition.
18//!
19//! Two surfaces kept in parity (see the proptest):
20//! - streaming: [`RenkoBuilder::next`] pushes one close, returns 0+ new bricks;
21//! - batch: [`renko_batch`] folds the builder over a close slice.
22
23/// One Renko brick. `direction` is +1 (up) or -1 (down); `open`/`close` are the
24/// brick's price boundaries `box_size` apart, in the direction of travel.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct RenkoBrick {
27    pub open: f64,
28    pub close: f64,
29    pub direction: i8,
30}
31
32/// Stateful close-based Renko builder (Drozda et al. 2024, Def. 1) with a fixed
33/// box size.
34#[derive(Debug, Clone)]
35pub struct RenkoBuilder {
36    box_size: f64,
37    /// First close, used to anchor the first brick (Def. 1: `R⁰` at `p₀`).
38    anchor: f64,
39    anchored: bool,
40    /// Current brick band `[min, max]`; valid once `started`.
41    min: f64,
42    max: f64,
43    started: bool,
44}
45
46impl RenkoBuilder {
47    /// Create a builder. `box_size` (`ΔP`) must be strictly positive.
48    pub fn new(box_size: f64) -> Self {
49        Self {
50            box_size,
51            anchor: 0.0,
52            anchored: false,
53            min: 0.0,
54            max: 0.0,
55            started: false,
56        }
57    }
58
59    /// Push one close; return any bricks created by it (0+).
60    pub fn next(&mut self, close: f64) -> Vec<RenkoBrick> {
61        let mut out = Vec::new();
62        if self.box_size <= 0.0 || !close.is_finite() {
63            return out;
64        }
65        if !self.anchored {
66            self.anchor = close;
67            self.anchored = true;
68            return out;
69        }
70        if !self.started {
71            // First brick forms after a full box move from p₀, either direction.
72            if close >= self.anchor + self.box_size {
73                let mut bottom = self.anchor;
74                while close >= bottom + self.box_size {
75                    out.push(RenkoBrick {
76                        open: bottom,
77                        close: bottom + self.box_size,
78                        direction: 1,
79                    });
80                    bottom += self.box_size;
81                }
82                self.min = bottom - self.box_size;
83                self.max = bottom;
84                self.started = true;
85            } else if close <= self.anchor - self.box_size {
86                let mut top = self.anchor;
87                while close <= top - self.box_size {
88                    out.push(RenkoBrick {
89                        open: top,
90                        close: top - self.box_size,
91                        direction: -1,
92                    });
93                    top -= self.box_size;
94                }
95                self.max = top + self.box_size;
96                self.min = top;
97                self.started = true;
98            }
99            return out;
100        }
101        // Continuation / reversal via Def. 1 formulas on the current band.
102        while close > self.max {
103            out.push(RenkoBrick {
104                open: self.max,
105                close: self.max + self.box_size,
106                direction: 1,
107            });
108            self.min = self.max;
109            self.max += self.box_size;
110        }
111        while close < self.min {
112            out.push(RenkoBrick {
113                open: self.min,
114                close: self.min - self.box_size,
115                direction: -1,
116            });
117            self.max = self.min;
118            self.min -= self.box_size;
119        }
120        out
121    }
122}
123
124/// Batch Renko: fold [`RenkoBuilder`] over a close slice, returning all bricks.
125pub fn renko_batch(closes: &[f64], box_size: f64) -> Vec<RenkoBrick> {
126    let mut b = RenkoBuilder::new(box_size);
127    let mut out = Vec::new();
128    for &c in closes {
129        out.extend(b.next(c));
130    }
131    out
132}
133
134/// ATR-box Renko: box size is `multiplier * atr`, a single representative
135/// volatility value (Drozda et al. use ATR as the default box). Per Def. 1 the
136/// box may vary per brick; a fixed box per run keeps boundaries reproducible.
137pub fn renko_atr_batch(closes: &[f64], atr: f64, multiplier: f64) -> Vec<RenkoBrick> {
138    renko_batch(closes, atr * multiplier)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use proptest::prelude::*;
145
146    #[test]
147    fn gold_uptrend() {
148        // box=1, anchor p₀=10. Climb to 13 → up bricks [10,11],[11,12],[12,13].
149        let bricks = renko_batch(&[10.0, 10.5, 11.0, 12.4, 13.0], 1.0);
150        assert_eq!(bricks.len(), 3);
151        for (i, b) in bricks.iter().enumerate() {
152            assert_eq!(b.direction, 1);
153            assert_eq!(b.open, 10.0 + i as f64);
154            assert_eq!(b.close, 11.0 + i as f64);
155        }
156    }
157
158    #[test]
159    fn gold_reversal_is_non_retracing() {
160        // Up to 12 → bricks [10,11],[11,12] (band min=11). Reversal: close 10 < 11
161        // → down brick [10,11]; close 9 < 10 → down brick [9,10]. Def. 1 places
162        // reversal bricks adjacent below the band — it does NOT retrace [11,12].
163        let bricks = renko_batch(&[10.0, 12.0, 11.5, 10.0, 9.0], 1.0);
164        let dirs: Vec<i8> = bricks.iter().map(|b| b.direction).collect();
165        assert_eq!(dirs, vec![1, 1, -1, -1]);
166        assert_eq!(bricks[2].open, 11.0);
167        assert_eq!(bricks[2].close, 10.0);
168        assert_eq!(bricks.last().unwrap().close, 9.0);
169    }
170
171    #[test]
172    fn no_brick_within_box() {
173        assert!(renko_batch(&[10.0, 10.9, 10.1, 10.99], 1.0).is_empty());
174    }
175
176    #[test]
177    fn multi_brick_on_gap() {
178        // A single close far above p₀ creates several bricks at once.
179        let bricks = renko_batch(&[10.0, 13.0], 1.0);
180        assert_eq!(bricks.len(), 3);
181        assert!(bricks.iter().all(|b| b.direction == 1));
182    }
183
184    proptest! {
185        // Streaming (one at a time) must equal batch (fold).
186        #[test]
187        fn streaming_equals_batch(
188            closes in prop::collection::vec(0.0f64..1000.0, 1..200),
189            box_size in 0.5f64..50.0,
190        ) {
191            let batch = renko_batch(&closes, box_size);
192            let mut b = RenkoBuilder::new(box_size);
193            let mut streamed = Vec::new();
194            for &c in &closes {
195                streamed.extend(b.next(c));
196            }
197            prop_assert_eq!(streamed, batch);
198        }
199
200        // Every brick spans exactly one box in its stated direction.
201        #[test]
202        fn brick_spans_one_box(
203            closes in prop::collection::vec(0.0f64..1000.0, 1..200),
204            box_size in 0.5f64..50.0,
205        ) {
206            for brick in renko_batch(&closes, box_size) {
207                let span = brick.close - brick.open;
208                prop_assert!((span.abs() - box_size).abs() < 1e-9);
209                prop_assert_eq!(span.signum() as i8, brick.direction);
210            }
211        }
212
213        // Def. 1 anchors to p₀: every boundary lies on the p₀ + k·box grid.
214        #[test]
215        fn boundaries_on_p0_grid(
216            closes in prop::collection::vec(0.0f64..1000.0, 1..200),
217            box_size in 0.5f64..50.0,
218        ) {
219            let p0 = closes[0];
220            for brick in renko_batch(&closes, box_size) {
221                for edge in [brick.open, brick.close] {
222                    let k = (edge - p0) / box_size;
223                    prop_assert!((k - k.round()).abs() < 1e-6);
224                }
225            }
226        }
227    }
228}