Skip to main content

ggplot_rs/stat/
renko.rs

1//! `StatRenko` / `StatPointFigure`: time-independent price charts built
2//! from a box size. Prices are read from `y` in data order (time order).
3//!
4//! * Renko: a new brick of height `box_size` whenever the price closes a
5//!   full box above the last brick's top or below its bottom (so a
6//!   reversal needs a two-box move). Output: one row per brick with `x`
7//!   (brick number), `ymin`/`ymax`, `y` (= ymax) and `direction`
8//!   (`"up"`/`"down"`).
9//! * Point & figure: columns of X (rising) and O (falling) boxes; a column
10//!   extends while the price moves on by whole boxes and reverses after a
11//!   `reversal`-box move against it. Output: one row per box with `x`
12//!   (column number), `y` (box centre), `ymin`/`ymax` and `mark`
13//!   (`"X"`/`"O"`).
14
15use crate::aes::Aesthetic;
16use crate::data::{DataFrame, Value};
17use crate::scale::ScaleSet;
18
19use super::Stat;
20
21/// A Renko brick: `(low, high, up)`.
22type Brick = (f64, f64, bool);
23
24/// Renko bricks `(low, high, up)` for `prices` with box size `b`.
25pub fn renko_bricks(prices: &[f64], b: f64) -> Vec<(f64, f64, bool)> {
26    let mut out: Vec<(f64, f64, bool)> = Vec::new();
27    let Some(&first) = prices.first() else {
28        return out;
29    };
30    if b.is_nan() || b <= 0.0 {
31        return out;
32    }
33    let (mut lo, mut hi) = (first, first);
34    for &p in &prices[1..] {
35        while p >= hi + b {
36            out.push((hi, hi + b, true));
37            lo = hi;
38            hi += b;
39        }
40        while p <= lo - b {
41            out.push((lo - b, lo, false));
42            hi = lo;
43            lo -= b;
44        }
45    }
46    out
47}
48
49/// Point-and-figure columns: `(column, box low, is_x)` per box, for box
50/// size `b` and a `reversal`-box reversal.
51pub fn point_figure_boxes(prices: &[f64], b: f64, reversal: usize) -> Vec<(usize, f64, bool)> {
52    let mut out = Vec::new();
53    let Some(&first) = prices.first() else {
54        return out;
55    };
56    if b.is_nan() || b <= 0.0 {
57        return out;
58    }
59    let k = reversal.max(1) as f64;
60    // Work in whole boxes from a floor-aligned base.
61    let base = (first / b).floor() * b;
62    let level = |p: f64| ((p - base) / b).floor();
63    // (direction up?, low box, high box) of the current column.
64    let mut col: Option<(bool, f64, f64)> = None;
65    let mut idx = 0usize;
66    let start = level(first);
67    let push = |out: &mut Vec<(usize, f64, bool)>, idx: usize, from: f64, to: f64, up: bool| {
68        let mut l = from;
69        while l <= to {
70            out.push((idx, base + l * b, up));
71            l += 1.0;
72        }
73    };
74    for &p in &prices[1..] {
75        let lv = level(p);
76        match col {
77            None => {
78                if lv >= start + 1.0 {
79                    col = Some((true, start, lv));
80                } else if lv <= start - 1.0 {
81                    col = Some((false, lv, start));
82                }
83            }
84            Some((true, low, high)) => {
85                if lv > high {
86                    col = Some((true, low, lv));
87                } else if lv <= high - k {
88                    push(&mut out, idx, low, high, true);
89                    idx += 1;
90                    col = Some((false, lv, high - 1.0));
91                }
92            }
93            Some((false, low, high)) => {
94                if lv < low {
95                    col = Some((false, lv, high));
96                } else if lv >= low + k {
97                    push(&mut out, idx, low, high, false);
98                    idx += 1;
99                    col = Some((true, low + 1.0, lv));
100                }
101            }
102        }
103    }
104    if let Some((up, low, high)) = col {
105        push(&mut out, idx, low, high, up);
106    }
107    out
108}
109
110fn prices(data: &DataFrame) -> Vec<f64> {
111    data.column("y")
112        .map(|c| {
113            c.iter()
114                .filter_map(|v| v.as_f64())
115                .filter(|v| v.is_finite())
116                .collect()
117        })
118        .unwrap_or_default()
119}
120
121/// Renko stat behind `geom_renko`.
122#[derive(Clone, Debug)]
123pub struct StatRenko {
124    pub box_size: f64,
125}
126
127impl Stat for StatRenko {
128    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
129        let bricks = renko_bricks(&prices(data), self.box_size);
130        let f = |g: &dyn Fn(usize, &Brick) -> f64| {
131            bricks
132                .iter()
133                .enumerate()
134                .map(|(i, b)| Value::Float(g(i, b)))
135                .collect::<Vec<_>>()
136        };
137        let mut out = DataFrame::new();
138        out.add_column("x".into(), f(&|i, _| i as f64 + 1.0));
139        out.add_column("ymin".into(), f(&|_, b| b.0));
140        out.add_column("ymax".into(), f(&|_, b| b.1));
141        out.add_column("y".into(), f(&|_, b| b.1));
142        out.add_column(
143            "direction".into(),
144            bricks
145                .iter()
146                .map(|b| Value::Str(if b.2 { "up" } else { "down" }.into()))
147                .collect(),
148        );
149        out
150    }
151    fn required_aes(&self) -> Vec<Aesthetic> {
152        vec![Aesthetic::Y]
153    }
154    fn panelwise(&self) -> bool {
155        true
156    }
157    fn name(&self) -> &str {
158        "renko"
159    }
160}
161
162/// Point-and-figure stat behind `geom_point_figure`.
163#[derive(Clone, Debug)]
164pub struct StatPointFigure {
165    pub box_size: f64,
166    pub reversal: usize,
167}
168
169impl Stat for StatPointFigure {
170    fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
171        let boxes = point_figure_boxes(&prices(data), self.box_size, self.reversal);
172        let b = self.box_size;
173        let mut out = DataFrame::new();
174        out.add_column(
175            "x".into(),
176            boxes
177                .iter()
178                .map(|x| Value::Float(x.0 as f64 + 1.0))
179                .collect(),
180        );
181        out.add_column(
182            "y".into(),
183            boxes.iter().map(|x| Value::Float(x.1 + b / 2.0)).collect(),
184        );
185        out.add_column(
186            "ymin".into(),
187            boxes.iter().map(|x| Value::Float(x.1)).collect(),
188        );
189        out.add_column(
190            "ymax".into(),
191            boxes.iter().map(|x| Value::Float(x.1 + b)).collect(),
192        );
193        out.add_column(
194            "mark".into(),
195            boxes
196                .iter()
197                .map(|x| Value::Str(if x.2 { "X" } else { "O" }.into()))
198                .collect(),
199        );
200        out
201    }
202    fn required_aes(&self) -> Vec<Aesthetic> {
203        vec![Aesthetic::Y]
204    }
205    fn panelwise(&self) -> bool {
206        true
207    }
208    fn name(&self) -> &str {
209        "point_figure"
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn renko_needs_a_full_box_and_two_to_reverse() {
219        let b = renko_bricks(&[10.0, 10.9, 11.0, 12.5, 11.2, 10.0, 9.0], 1.0);
220        // 11 → [10,11]; 12.5 → [11,12]; 11.2 no; 10 → [10,11] down; 9 → [9,10].
221        assert_eq!(
222            b,
223            vec![
224                (10.0, 11.0, true),
225                (11.0, 12.0, true),
226                (10.0, 11.0, false),
227                (9.0, 10.0, false)
228            ]
229        );
230        assert!(renko_bricks(&[1.0, 2.0], 0.0).is_empty());
231    }
232
233    #[test]
234    fn point_figure_columns_reverse_after_k_boxes() {
235        // Box 1, reversal 3: up to 15, back to 12 (3 boxes) → O column, up to 16.
236        let p = point_figure_boxes(&[10.0, 12.0, 15.0, 14.0, 12.0, 11.0, 14.0, 16.0], 1.0, 3);
237        let col = |c: usize| {
238            p.iter()
239                .filter(|b| b.0 == c)
240                .map(|b| (b.1, b.2))
241                .collect::<Vec<_>>()
242        };
243        assert_eq!(col(0).len(), 6, "X from 10 to 15");
244        assert!(col(0).iter().all(|b| b.1));
245        assert_eq!(
246            col(1),
247            vec![(11.0, false), (12.0, false), (13.0, false), (14.0, false)]
248        );
249        assert!(col(2).iter().all(|b| b.1) && col(2).last().unwrap().0 == 16.0);
250    }
251}