Skip to main content

ggplot_rs/geom/
candlestick.rs

1//! Financial OHLC charts: `geom_candlestick` (body + wick; filled or
2//! hollow), `geom_ohlc` (bar with open/close ticks), `geom_hlc` (bar with a
3//! close tick only) and `geom_heikin_ashi` (candles over Heikin-Ashi
4//! values). Map `x` plus the `open`/`high`/`low`/`close` aesthetics; the y
5//! scale trains on the high–low range.
6
7use crate::aes::Aesthetic;
8use crate::coord::Coord;
9use crate::data::{DataFrame, Value};
10use crate::position::identity::PositionIdentity;
11use crate::position::Position;
12use crate::render::backend::{DrawBackend, LineStyle, Linetype, RectStyle};
13use crate::render::RenderError;
14use crate::scale::ScaleSet;
15use crate::stat::identity::StatIdentity;
16use crate::stat::Stat;
17use crate::theme::Theme;
18
19use super::{Geom, GeomParams};
20
21/// How candle bodies encode direction.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
23pub enum CandleStyle {
24    /// Solid bodies, coloured `up` when close ≥ open, else `down`.
25    #[default]
26    Filled,
27    /// Hollow candles: the body is hollow when close ≥ open and filled
28    /// otherwise; the colour is `up` when close ≥ the previous close.
29    Hollow,
30}
31
32/// Candlestick chart: a wick from low to high and a body from open to close,
33/// coloured `up` when close ≥ open and `down` otherwise.
34pub struct GeomCandlestick {
35    /// Filled (default) or hollow candles.
36    pub style: CandleStyle,
37    /// Colour of rising periods (close ≥ open).
38    pub up: (u8, u8, u8),
39    /// Colour of falling periods (close < open).
40    pub down: (u8, u8, u8),
41    /// Body width as a fraction of the x spacing (category slot / resolution).
42    pub width: f64,
43    pub line_width: f64,
44    pub alpha: f64,
45}
46
47impl Default for GeomCandlestick {
48    fn default() -> Self {
49        GeomCandlestick {
50            style: CandleStyle::Filled,
51            up: (0x0c, 0xa6, 0x78),
52            down: (0xe0, 0x31, 0x31),
53            width: 0.6,
54            line_width: 1.0,
55            alpha: 1.0,
56        }
57    }
58}
59
60/// OHLC bar chart: a vertical high–low bar with a left tick at the open and a
61/// right tick at the close, coloured `up`/`down` like [`GeomCandlestick`].
62pub struct GeomOhlc {
63    /// Draw the left open tick; `false` gives an HLC bar (no `open` needed).
64    pub open_tick: bool,
65    pub up: (u8, u8, u8),
66    pub down: (u8, u8, u8),
67    /// Tick span as a fraction of the x spacing.
68    pub width: f64,
69    pub line_width: f64,
70    pub alpha: f64,
71}
72
73impl Default for GeomOhlc {
74    fn default() -> Self {
75        GeomOhlc {
76            open_tick: true,
77            up: (0x0c, 0xa6, 0x78),
78            down: (0xe0, 0x31, 0x31),
79            width: 0.6,
80            line_width: 1.4,
81            alpha: 1.0,
82        }
83    }
84}
85
86fn ohlc_required() -> Vec<Aesthetic> {
87    vec![
88        Aesthetic::X,
89        Aesthetic::Open,
90        Aesthetic::High,
91        Aesthetic::Low,
92        Aesthetic::Close,
93    ]
94}
95
96/// The y scale trains on `[low, high]`: expose them as `ymin`/`ymax`.
97fn ohlc_setup(data: &mut DataFrame) {
98    for (src, dst) in [("low", "ymin"), ("high", "ymax")] {
99        if !data.has_column(dst) {
100            if let Some(c) = data.column(src) {
101                let c = c.to_vec();
102                data.add_column(dst.to_string(), c);
103            }
104        }
105    }
106}
107
108/// One period, mapped to normalized panel coordinates.
109struct Period {
110    nx: f64,
111    open: f64,
112    high: f64,
113    low: f64,
114    close: f64,
115    up: bool,
116    /// Close ≥ the previous period's close (the first period: close ≥ open).
117    up_from_prev: bool,
118}
119
120/// Shared walk over the rows: maps x/ohlc, sets hover metadata, and calls
121/// `draw(period, half_width, backend)` for each complete row.
122fn for_each_period(
123    data: &DataFrame,
124    scales: &ScaleSet,
125    width: f64,
126    backend: &mut dyn DrawBackend,
127    mut draw: impl FnMut(&Period, f64, &mut dyn DrawBackend) -> Result<(), RenderError>,
128) -> Result<(), RenderError> {
129    let col = |n: &str| {
130        data.column(n)
131            .ok_or_else(|| RenderError::MissingAesthetic(n.into()))
132    };
133    let (x_col, h_col, l_col, c_col) = (col("x")?, col("high")?, col("low")?, col("close")?);
134    // Without `open` (HLC bars) a period opens at its close.
135    let o_col = data.column("open").unwrap_or(c_col);
136    let x_scale = scales.get(&Aesthetic::X);
137    let y_scale = scales.get(&Aesthetic::Y);
138    let my = |v: f64| y_scale.map(|s| s.map(&Value::Float(v))).unwrap_or(0.0);
139    let half = if x_scale.map(|s| s.is_discrete()).unwrap_or(false) {
140        let n = x_scale.map(|s| s.breaks().len()).unwrap_or(1).max(1) as f64;
141        width / n / 2.0
142    } else {
143        super::continuous_bar_half_width(
144            x_col.iter().filter_map(|v| x_scale.map(|s| s.map(v))),
145            width,
146            0.01,
147        )
148    };
149    // Walk in x order so each period can compare with the previous close.
150    // A dodge (a discrete group mapped, #404) shifts and narrows the bars.
151    let x_mapper = super::support::XMapper::new(data, scales);
152    let nx_of = |i: usize| {
153        if x_scale.is_some() {
154            x_mapper.map(&x_col[i], i)
155        } else {
156            0.5
157        }
158    };
159    let mut order: Vec<usize> = (0..data.nrows()).collect();
160    order.sort_by(|&a, &b| nx_of(a).total_cmp(&nx_of(b)));
161    let mut prev_close: Option<f64> = None;
162    for i in order {
163        let (Some(o), Some(h), Some(l), Some(c)) = (
164            o_col[i].as_f64(),
165            h_col[i].as_f64(),
166            l_col[i].as_f64(),
167            c_col[i].as_f64(),
168        ) else {
169            continue;
170        };
171        let p = Period {
172            nx: nx_of(i),
173            open: my(o),
174            high: my(h),
175            low: my(l),
176            close: my(c),
177            up: c >= o,
178            up_from_prev: c >= prev_close.unwrap_or(o),
179        };
180        prev_close = Some(c);
181        let x = super::tip_value(&x_col[i]);
182        let f = |v: &Value| super::tip_value(v);
183        let tip = if data.has_column("open") {
184            format!(
185                "{x} — O {} H {} L {} C {}",
186                f(&o_col[i]),
187                f(&h_col[i]),
188                f(&l_col[i]),
189                f(&c_col[i])
190            )
191        } else {
192            format!(
193                "{x} — H {} L {} C {}",
194                f(&h_col[i]),
195                f(&l_col[i]),
196                f(&c_col[i])
197            )
198        };
199        let series =
200            super::series_key(data, i).or_else(|| Some(if p.up { "up" } else { "down" }.into()));
201        super::set_mark(
202            backend,
203            Some(tip),
204            Some(x),
205            series,
206            super::raw_value(&c_col[i]),
207        );
208        super::set_mark_formatted(
209            backend,
210            scales,
211            Some(&x_col[i]),
212            &Aesthetic::Y,
213            Some(&c_col[i]),
214        );
215        draw(&p, half / x_mapper.slot_n(i), backend)?;
216    }
217    super::clear_mark(backend);
218    Ok(())
219}
220
221impl Geom for GeomCandlestick {
222    fn draw(
223        &self,
224        data: &DataFrame,
225        coord: &dyn Coord,
226        scales: &ScaleSet,
227        _theme: &Theme,
228        backend: &mut dyn DrawBackend,
229    ) -> Result<(), RenderError> {
230        let area = backend.plot_area();
231        for_each_period(data, scales, self.width, backend, |p, half, b| {
232            let rising = match self.style {
233                CandleStyle::Filled => p.up,
234                CandleStyle::Hollow => p.up_from_prev,
235            };
236            let color = if rising { self.up } else { self.down };
237            let wick = [
238                coord.transform((p.nx, p.low), &area),
239                coord.transform((p.nx, p.high), &area),
240            ];
241            b.draw_line(
242                &wick,
243                &LineStyle {
244                    color,
245                    alpha: self.alpha,
246                    width: self.line_width,
247                    linetype: Linetype::Solid,
248                    ..Default::default()
249                },
250            )?;
251            let (x0, y0) = coord.transform((p.nx - half, p.open.max(p.close)), &area);
252            let (x1, y1) = coord.transform((p.nx + half, p.open.min(p.close)), &area);
253            // A doji (open == close) still shows a 1px body.
254            let (top, bottom) = (y0.min(y1), y0.max(y1).max(y0.min(y1) + 1.0));
255            // Hollow style: rising bodies (close ≥ open) are outlined only.
256            let hollow = self.style == CandleStyle::Hollow && p.up;
257            b.draw_rect(
258                (x0.min(x1), top),
259                (x0.max(x1), bottom),
260                &RectStyle {
261                    fill: if hollow {
262                        Some((255, 255, 255))
263                    } else {
264                        Some(color)
265                    },
266                    stroke: Some(color),
267                    stroke_width: if hollow {
268                        self.line_width
269                    } else {
270                        self.line_width * 0.5
271                    },
272                    alpha: self.alpha,
273                    clip: true,
274                    corner_radius: 0.0,
275                },
276            )
277        })
278    }
279
280    fn required_aes(&self) -> Vec<Aesthetic> {
281        ohlc_required()
282    }
283    fn default_stat(&self) -> Box<dyn Stat> {
284        Box::new(StatIdentity)
285    }
286    fn default_position(&self) -> Box<dyn Position> {
287        Box::new(PositionIdentity)
288    }
289    fn default_params(&self) -> GeomParams {
290        GeomParams::default()
291    }
292    fn name(&self) -> &str {
293        "candlestick"
294    }
295    fn setup_data(&self, data: &mut DataFrame, _warnings: &mut Vec<String>) {
296        ohlc_setup(data);
297    }
298}
299
300impl GeomOhlc {
301    /// HLC bars: a high–low bar with a close tick, no open.
302    pub fn hlc() -> Self {
303        GeomOhlc {
304            open_tick: false,
305            ..Default::default()
306        }
307    }
308}
309
310impl Geom for GeomOhlc {
311    fn draw(
312        &self,
313        data: &DataFrame,
314        coord: &dyn Coord,
315        scales: &ScaleSet,
316        _theme: &Theme,
317        backend: &mut dyn DrawBackend,
318    ) -> Result<(), RenderError> {
319        let area = backend.plot_area();
320        for_each_period(data, scales, self.width, backend, |p, half, b| {
321            let style = LineStyle {
322                color: if p.up { self.up } else { self.down },
323                alpha: self.alpha,
324                width: self.line_width,
325                linetype: Linetype::Solid,
326                ..Default::default()
327            };
328            let t = |x: f64, y: f64| coord.transform((x, y), &area);
329            b.draw_line(&[t(p.nx, p.low), t(p.nx, p.high)], &style)?;
330            if self.open_tick {
331                b.draw_line(&[t(p.nx - half, p.open), t(p.nx, p.open)], &style)?;
332            }
333            b.draw_line(&[t(p.nx, p.close), t(p.nx + half, p.close)], &style)
334        })
335    }
336
337    fn required_aes(&self) -> Vec<Aesthetic> {
338        let mut req = ohlc_required();
339        if !self.open_tick {
340            req.retain(|a| *a != Aesthetic::Open);
341        }
342        req
343    }
344    fn default_stat(&self) -> Box<dyn Stat> {
345        Box::new(StatIdentity)
346    }
347    fn default_position(&self) -> Box<dyn Position> {
348        Box::new(PositionIdentity)
349    }
350    fn default_params(&self) -> GeomParams {
351        GeomParams::default()
352    }
353    fn name(&self) -> &str {
354        if self.open_tick {
355            "ohlc"
356        } else {
357            "hlc"
358        }
359    }
360    fn setup_data(&self, data: &mut DataFrame, _warnings: &mut Vec<String>) {
361        ohlc_setup(data);
362    }
363}