Skip to main content

ggplot_rs/geom/
candlestick.rs

1//! Financial OHLC charts: `geom_candlestick` (body + wick) and `geom_ohlc`
2//! (bar with open/close ticks). Map `x` plus the `open`/`high`/`low`/`close`
3//! aesthetics; the y scale trains on the high–low range.
4
5use crate::aes::Aesthetic;
6use crate::coord::Coord;
7use crate::data::{DataFrame, Value};
8use crate::position::identity::PositionIdentity;
9use crate::position::Position;
10use crate::render::backend::{DrawBackend, LineStyle, Linetype, RectStyle};
11use crate::render::RenderError;
12use crate::scale::ScaleSet;
13use crate::stat::identity::StatIdentity;
14use crate::stat::Stat;
15use crate::theme::Theme;
16
17use super::{Geom, GeomParams};
18
19/// Candlestick chart: a wick from low to high and a body from open to close,
20/// coloured `up` when close ≥ open and `down` otherwise.
21pub struct GeomCandlestick {
22    /// Colour of rising periods (close ≥ open).
23    pub up: (u8, u8, u8),
24    /// Colour of falling periods (close < open).
25    pub down: (u8, u8, u8),
26    /// Body width as a fraction of the x spacing (category slot / resolution).
27    pub width: f64,
28    pub line_width: f64,
29    pub alpha: f64,
30}
31
32impl Default for GeomCandlestick {
33    fn default() -> Self {
34        GeomCandlestick {
35            up: (0x0c, 0xa6, 0x78),
36            down: (0xe0, 0x31, 0x31),
37            width: 0.6,
38            line_width: 1.0,
39            alpha: 1.0,
40        }
41    }
42}
43
44/// OHLC bar chart: a vertical high–low bar with a left tick at the open and a
45/// right tick at the close, coloured `up`/`down` like [`GeomCandlestick`].
46pub struct GeomOhlc {
47    pub up: (u8, u8, u8),
48    pub down: (u8, u8, u8),
49    /// Tick span as a fraction of the x spacing.
50    pub width: f64,
51    pub line_width: f64,
52    pub alpha: f64,
53}
54
55impl Default for GeomOhlc {
56    fn default() -> Self {
57        GeomOhlc {
58            up: (0x0c, 0xa6, 0x78),
59            down: (0xe0, 0x31, 0x31),
60            width: 0.6,
61            line_width: 1.4,
62            alpha: 1.0,
63        }
64    }
65}
66
67fn ohlc_required() -> Vec<Aesthetic> {
68    vec![
69        Aesthetic::X,
70        Aesthetic::Open,
71        Aesthetic::High,
72        Aesthetic::Low,
73        Aesthetic::Close,
74    ]
75}
76
77/// The y scale trains on `[low, high]`: expose them as `ymin`/`ymax`.
78fn ohlc_setup(data: &mut DataFrame) {
79    for (src, dst) in [("low", "ymin"), ("high", "ymax")] {
80        if !data.has_column(dst) {
81            if let Some(c) = data.column(src) {
82                let c = c.to_vec();
83                data.add_column(dst.to_string(), c);
84            }
85        }
86    }
87}
88
89/// One period, mapped to normalized panel coordinates.
90struct Period {
91    nx: f64,
92    open: f64,
93    high: f64,
94    low: f64,
95    close: f64,
96    up: bool,
97}
98
99/// Shared walk over the rows: maps x/ohlc, sets hover metadata, and calls
100/// `draw(period, half_width, backend)` for each complete row.
101fn for_each_period(
102    data: &DataFrame,
103    scales: &ScaleSet,
104    width: f64,
105    backend: &mut dyn DrawBackend,
106    mut draw: impl FnMut(&Period, f64, &mut dyn DrawBackend) -> Result<(), RenderError>,
107) -> Result<(), RenderError> {
108    let col = |n: &str| {
109        data.column(n)
110            .ok_or_else(|| RenderError::MissingAesthetic(n.into()))
111    };
112    let (x_col, o_col, h_col, l_col, c_col) = (
113        col("x")?,
114        col("open")?,
115        col("high")?,
116        col("low")?,
117        col("close")?,
118    );
119    let x_scale = scales.get(&Aesthetic::X);
120    let y_scale = scales.get(&Aesthetic::Y);
121    let my = |v: f64| y_scale.map(|s| s.map(&Value::Float(v))).unwrap_or(0.0);
122    let half = if x_scale.map(|s| s.is_discrete()).unwrap_or(false) {
123        let n = x_scale.map(|s| s.breaks().len()).unwrap_or(1).max(1) as f64;
124        width / n / 2.0
125    } else {
126        super::continuous_bar_half_width(
127            x_col.iter().filter_map(|v| x_scale.map(|s| s.map(v))),
128            width,
129            0.01,
130        )
131    };
132    for i in 0..data.nrows() {
133        let (Some(o), Some(h), Some(l), Some(c)) = (
134            o_col[i].as_f64(),
135            h_col[i].as_f64(),
136            l_col[i].as_f64(),
137            c_col[i].as_f64(),
138        ) else {
139            continue;
140        };
141        let p = Period {
142            nx: x_scale.map(|s| s.map(&x_col[i])).unwrap_or(0.5),
143            open: my(o),
144            high: my(h),
145            low: my(l),
146            close: my(c),
147            up: c >= o,
148        };
149        let x = super::tip_value(&x_col[i]);
150        let f = |v: &Value| super::tip_value(v);
151        let tip = format!(
152            "{x} — O {} H {} L {} C {}",
153            f(&o_col[i]),
154            f(&h_col[i]),
155            f(&l_col[i]),
156            f(&c_col[i])
157        );
158        let series =
159            super::series_key(data, i).or_else(|| Some(if p.up { "up" } else { "down" }.into()));
160        super::set_mark(
161            backend,
162            Some(tip),
163            Some(x),
164            series,
165            super::raw_value(&c_col[i]),
166        );
167        draw(&p, half, backend)?;
168    }
169    super::clear_mark(backend);
170    Ok(())
171}
172
173impl Geom for GeomCandlestick {
174    fn draw(
175        &self,
176        data: &DataFrame,
177        coord: &dyn Coord,
178        scales: &ScaleSet,
179        _theme: &Theme,
180        backend: &mut dyn DrawBackend,
181    ) -> Result<(), RenderError> {
182        let area = backend.plot_area();
183        for_each_period(data, scales, self.width, backend, |p, half, b| {
184            let color = if p.up { self.up } else { self.down };
185            let wick = [
186                coord.transform((p.nx, p.low), &area),
187                coord.transform((p.nx, p.high), &area),
188            ];
189            b.draw_line(
190                &wick,
191                &LineStyle {
192                    color,
193                    alpha: self.alpha,
194                    width: self.line_width,
195                    linetype: Linetype::Solid,
196                },
197            )?;
198            let (x0, y0) = coord.transform((p.nx - half, p.open.max(p.close)), &area);
199            let (x1, y1) = coord.transform((p.nx + half, p.open.min(p.close)), &area);
200            // A doji (open == close) still shows a 1px body.
201            let (top, bottom) = (y0.min(y1), y0.max(y1).max(y0.min(y1) + 1.0));
202            b.draw_rect(
203                (x0.min(x1), top),
204                (x0.max(x1), bottom),
205                &RectStyle {
206                    fill: Some(color),
207                    stroke: Some(color),
208                    stroke_width: self.line_width * 0.5,
209                    alpha: self.alpha,
210                    clip: true,
211                },
212            )
213        })
214    }
215
216    fn required_aes(&self) -> Vec<Aesthetic> {
217        ohlc_required()
218    }
219    fn default_stat(&self) -> Box<dyn Stat> {
220        Box::new(StatIdentity)
221    }
222    fn default_position(&self) -> Box<dyn Position> {
223        Box::new(PositionIdentity)
224    }
225    fn default_params(&self) -> GeomParams {
226        GeomParams::default()
227    }
228    fn name(&self) -> &str {
229        "candlestick"
230    }
231    fn setup_data(&self, data: &mut DataFrame) {
232        ohlc_setup(data);
233    }
234}
235
236impl Geom for GeomOhlc {
237    fn draw(
238        &self,
239        data: &DataFrame,
240        coord: &dyn Coord,
241        scales: &ScaleSet,
242        _theme: &Theme,
243        backend: &mut dyn DrawBackend,
244    ) -> Result<(), RenderError> {
245        let area = backend.plot_area();
246        for_each_period(data, scales, self.width, backend, |p, half, b| {
247            let style = LineStyle {
248                color: if p.up { self.up } else { self.down },
249                alpha: self.alpha,
250                width: self.line_width,
251                linetype: Linetype::Solid,
252            };
253            let t = |x: f64, y: f64| coord.transform((x, y), &area);
254            b.draw_line(&[t(p.nx, p.low), t(p.nx, p.high)], &style)?;
255            b.draw_line(&[t(p.nx - half, p.open), t(p.nx, p.open)], &style)?;
256            b.draw_line(&[t(p.nx, p.close), t(p.nx + half, p.close)], &style)
257        })
258    }
259
260    fn required_aes(&self) -> Vec<Aesthetic> {
261        ohlc_required()
262    }
263    fn default_stat(&self) -> Box<dyn Stat> {
264        Box::new(StatIdentity)
265    }
266    fn default_position(&self) -> Box<dyn Position> {
267        Box::new(PositionIdentity)
268    }
269    fn default_params(&self) -> GeomParams {
270        GeomParams::default()
271    }
272    fn name(&self) -> &str {
273        "ohlc"
274    }
275    fn setup_data(&self, data: &mut DataFrame) {
276        ohlc_setup(data);
277    }
278}