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        super::set_mark_formatted(
168            backend,
169            scales,
170            Some(&x_col[i]),
171            &Aesthetic::Y,
172            Some(&c_col[i]),
173        );
174        draw(&p, half, backend)?;
175    }
176    super::clear_mark(backend);
177    Ok(())
178}
179
180impl Geom for GeomCandlestick {
181    fn draw(
182        &self,
183        data: &DataFrame,
184        coord: &dyn Coord,
185        scales: &ScaleSet,
186        _theme: &Theme,
187        backend: &mut dyn DrawBackend,
188    ) -> Result<(), RenderError> {
189        let area = backend.plot_area();
190        for_each_period(data, scales, self.width, backend, |p, half, b| {
191            let color = if p.up { self.up } else { self.down };
192            let wick = [
193                coord.transform((p.nx, p.low), &area),
194                coord.transform((p.nx, p.high), &area),
195            ];
196            b.draw_line(
197                &wick,
198                &LineStyle {
199                    color,
200                    alpha: self.alpha,
201                    width: self.line_width,
202                    linetype: Linetype::Solid,
203                    ..Default::default()
204                },
205            )?;
206            let (x0, y0) = coord.transform((p.nx - half, p.open.max(p.close)), &area);
207            let (x1, y1) = coord.transform((p.nx + half, p.open.min(p.close)), &area);
208            // A doji (open == close) still shows a 1px body.
209            let (top, bottom) = (y0.min(y1), y0.max(y1).max(y0.min(y1) + 1.0));
210            b.draw_rect(
211                (x0.min(x1), top),
212                (x0.max(x1), bottom),
213                &RectStyle {
214                    fill: Some(color),
215                    stroke: Some(color),
216                    stroke_width: self.line_width * 0.5,
217                    alpha: self.alpha,
218                    clip: true,
219                    corner_radius: 0.0,
220                },
221            )
222        })
223    }
224
225    fn required_aes(&self) -> Vec<Aesthetic> {
226        ohlc_required()
227    }
228    fn default_stat(&self) -> Box<dyn Stat> {
229        Box::new(StatIdentity)
230    }
231    fn default_position(&self) -> Box<dyn Position> {
232        Box::new(PositionIdentity)
233    }
234    fn default_params(&self) -> GeomParams {
235        GeomParams::default()
236    }
237    fn name(&self) -> &str {
238        "candlestick"
239    }
240    fn setup_data(&self, data: &mut DataFrame, _warnings: &mut Vec<String>) {
241        ohlc_setup(data);
242    }
243}
244
245impl Geom for GeomOhlc {
246    fn draw(
247        &self,
248        data: &DataFrame,
249        coord: &dyn Coord,
250        scales: &ScaleSet,
251        _theme: &Theme,
252        backend: &mut dyn DrawBackend,
253    ) -> Result<(), RenderError> {
254        let area = backend.plot_area();
255        for_each_period(data, scales, self.width, backend, |p, half, b| {
256            let style = LineStyle {
257                color: if p.up { self.up } else { self.down },
258                alpha: self.alpha,
259                width: self.line_width,
260                linetype: Linetype::Solid,
261                ..Default::default()
262            };
263            let t = |x: f64, y: f64| coord.transform((x, y), &area);
264            b.draw_line(&[t(p.nx, p.low), t(p.nx, p.high)], &style)?;
265            b.draw_line(&[t(p.nx - half, p.open), t(p.nx, p.open)], &style)?;
266            b.draw_line(&[t(p.nx, p.close), t(p.nx + half, p.close)], &style)
267        })
268    }
269
270    fn required_aes(&self) -> Vec<Aesthetic> {
271        ohlc_required()
272    }
273    fn default_stat(&self) -> Box<dyn Stat> {
274        Box::new(StatIdentity)
275    }
276    fn default_position(&self) -> Box<dyn Position> {
277        Box::new(PositionIdentity)
278    }
279    fn default_params(&self) -> GeomParams {
280        GeomParams::default()
281    }
282    fn name(&self) -> &str {
283        "ohlc"
284    }
285    fn setup_data(&self, data: &mut DataFrame, _warnings: &mut Vec<String>) {
286        ohlc_setup(data);
287    }
288}