Skip to main content

kuva/plot/
candlestick.rs

1/// A single OHLC data point rendered as one candle.
2pub struct CandleDataPoint {
3    /// Categorical label shown on the x-axis tick (or attached to the candle when
4    /// a numeric x position is set via [`CandlestickPlot::with_candle_at`]).
5    pub label: String,
6    /// Explicit numeric x position. `None` means the candle is placed at its
7    /// insertion index (categorical mode).
8    pub x: Option<f64>,
9    /// Opening price.
10    pub open: f64,
11    /// Highest price during the period.
12    pub high: f64,
13    /// Lowest price during the period.
14    pub low: f64,
15    /// Closing price.
16    pub close: f64,
17    /// Optional trading volume, used by the volume panel when
18    /// [`CandlestickPlot::with_volume_panel`] is enabled.
19    pub volume: Option<f64>,
20}
21
22/// Builder for a candlestick (OHLC) chart.
23///
24/// Each candle encodes four values — **open**, **high**, **low**, **close** —
25/// for a single period:
26///
27/// - The **body** spans from open to close. A bullish candle (`close > open`)
28///   is filled with [`color_up`](Self::with_color_up) (default green). A
29///   bearish candle (`close < open`) is filled with
30///   [`color_down`](Self::with_color_down) (default red). A doji
31///   (`close == open`) is drawn with [`color_doji`](Self::with_color_doji)
32///   (default gray).
33/// - The **wicks** are thin vertical lines extending from the body to `high`
34///   (upper wick) and `low` (lower wick).
35///
36/// An optional **volume panel** can be shown below the price chart by
37/// attaching volumes with [`with_volume`](Self::with_volume) and enabling the
38/// panel with [`with_volume_panel`](Self::with_volume_panel).
39///
40/// # Categorical vs numeric x-axis
41///
42/// Two input modes are available:
43///
44/// - **Categorical** ([`with_candle`](Self::with_candle)): candles are placed
45///   at evenly spaced integer positions and the labels are shown as x-axis
46///   category ticks.
47/// - **Numeric** ([`with_candle_at`](Self::with_candle_at)): each candle is
48///   placed at an explicit `f64` x position, enabling uneven spacing and a
49///   true numeric x-axis. Useful for quarterly or irregularly spaced data.
50///
51/// # Example
52///
53/// ```rust,no_run
54/// use kuva::plot::CandlestickPlot;
55/// use kuva::backend::svg::SvgBackend;
56/// use kuva::render::render::render_multiple;
57/// use kuva::render::layout::Layout;
58/// use kuva::render::plots::Plot;
59///
60/// let plot = CandlestickPlot::new()
61///     .with_candle("Mon", 100.0, 106.5,  99.2, 105.8)
62///     .with_candle("Tue", 105.8, 108.0, 104.1, 104.5)
63///     .with_candle("Wed", 104.5, 109.2, 104.0, 108.0)
64///     .with_candle("Thu", 108.0, 111.5, 107.3, 110.9)
65///     .with_candle("Fri", 110.9, 111.0, 107.8, 108.5)
66///     .with_legend("ACME");
67///
68/// let plots = vec![Plot::Candlestick(plot)];
69/// let layout = Layout::auto_from_plots(&plots)
70///     .with_title("Weekly OHLC")
71///     .with_x_label("Day")
72///     .with_y_label("Price (USD)");
73///
74/// let svg = SvgBackend.render_scene(&render_multiple(plots, layout));
75/// std::fs::write("candlestick.svg", svg).unwrap();
76/// ```
77pub struct CandlestickPlot {
78    /// All candles in insertion order.
79    pub candles: Vec<CandleDataPoint>,
80    /// Candle body width as a fraction of the slot width between candles
81    /// (default `0.7`; range `0.0`–`1.0`).
82    pub candle_width: f64,
83    /// Wick stroke width in pixels (default `1.5`).
84    pub wick_width: f64,
85    /// Fill color for bullish candles (`close > open`). Default green.
86    pub color_up: String,
87    /// Fill color for bearish candles (`close < open`). Default red.
88    pub color_down: String,
89    /// Fill color for doji candles (`close == open`). Default `#888888`.
90    pub color_doji: String,
91    /// Whether to render the volume bar panel below the price chart.
92    pub show_volume: bool,
93    /// Fraction of the total chart height reserved for the volume panel
94    /// (default `0.22`).
95    pub volume_ratio: f64,
96    /// Optional legend entry label. When set a legend box is drawn inside
97    /// the plot area.
98    pub legend_label: Option<String>,
99    pub show_tooltips: bool,
100    pub tooltip_labels: Option<Vec<String>>,
101}
102
103impl Default for CandlestickPlot {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl CandlestickPlot {
110    /// Create a candlestick plot with default settings.
111    ///
112    /// Defaults: candle width `0.7`, wick width `1.5`, green/red/gray colors,
113    /// no volume panel, no legend.
114    pub fn new() -> Self {
115        Self {
116            candles: Vec::new(),
117            candle_width: 0.7,
118            wick_width: 1.5,
119            color_up: "rgb(68,170,68)".into(),
120            color_down: "rgb(204,68,68)".into(),
121            color_doji: "#888888".into(),
122            show_volume: false,
123            volume_ratio: 0.22,
124            legend_label: None,
125            show_tooltips: false,
126            tooltip_labels: None,
127        }
128    }
129
130    /// Append a candle in **categorical** mode.
131    ///
132    /// The candle is placed at its insertion index and `label` is shown as an
133    /// x-axis category tick. Use this when candles are evenly spaced (daily,
134    /// weekly data).
135    ///
136    /// ```rust,no_run
137    /// # use kuva::plot::CandlestickPlot;
138    /// let plot = CandlestickPlot::new()
139    ///     .with_candle("Mon", 100.0, 106.5,  99.2, 105.8)  // open, high, low, close
140    ///     .with_candle("Tue", 105.8, 108.0, 104.1, 104.5);
141    /// ```
142    pub fn with_candle<S: Into<String>>(
143        mut self,
144        label: S,
145        open: impl Into<f64>,
146        high: impl Into<f64>,
147        low: impl Into<f64>,
148        close: impl Into<f64>,
149    ) -> Self {
150        self.candles.push(CandleDataPoint {
151            label: label.into(),
152            x: None,
153            open: open.into(),
154            high: high.into(),
155            low: low.into(),
156            close: close.into(),
157            volume: None,
158        });
159        self
160    }
161
162    /// Append a candle at an explicit **numeric** x position.
163    ///
164    /// The candle body is centred at `x` on a continuous numeric x-axis.
165    /// Use this when candles are unevenly spaced — for example quarterly data
166    /// where `x` is a fractional year — or when the x position carries meaning
167    /// beyond a simple sequence index.
168    ///
169    /// When using this method, call [`with_candle_width`](Self::with_candle_width)
170    /// to set an appropriate body width in data units (e.g. `0.15` for
171    /// quarterly data spaced `0.25` units apart).
172    ///
173    /// ```rust,no_run
174    /// # use kuva::plot::CandlestickPlot;
175    /// let plot = CandlestickPlot::new()
176    ///     // x = fractional year; candles spaced 0.25 apart
177    ///     .with_candle_at(2023.00, "Q1", 110.0, 118.0, 108.0, 116.0)
178    ///     .with_candle_at(2023.25, "Q2", 116.0, 122.0, 114.0, 121.0)
179    ///     .with_candle_at(2023.50, "Q3", 121.0, 126.0, 118.5, 119.5)
180    ///     .with_candle_width(0.15);
181    /// ```
182    pub fn with_candle_at<S: Into<String>>(
183        mut self,
184        x: f64,
185        label: S,
186        open: impl Into<f64>,
187        high: impl Into<f64>,
188        low: impl Into<f64>,
189        close: impl Into<f64>,
190    ) -> Self {
191        self.candles.push(CandleDataPoint {
192            label: label.into(),
193            x: Some(x),
194            open: open.into(),
195            high: high.into(),
196            low: low.into(),
197            close: close.into(),
198            volume: None,
199        });
200        self
201    }
202
203    /// Attach volume values to existing candles.
204    ///
205    /// Values are matched to candles in insertion order. If there are fewer
206    /// volume values than candles, the remaining candles receive no volume.
207    /// The volume data is not rendered until [`with_volume_panel`](Self::with_volume_panel)
208    /// is also called.
209    ///
210    /// ```rust,no_run
211    /// # use kuva::plot::CandlestickPlot;
212    /// let plot = CandlestickPlot::new()
213    ///     .with_candle("Mon", 100.0, 106.0, 99.0, 105.0)
214    ///     .with_candle("Tue", 105.0, 108.0, 104.0, 104.5)
215    ///     .with_volume([1_250_000.0, 980_000.0])
216    ///     .with_volume_panel();
217    /// ```
218    pub fn with_volume<T, I>(mut self, volumes: I) -> Self
219    where
220        T: Into<f64>,
221        I: IntoIterator<Item = T>,
222    {
223        for (candle, vol) in self.candles.iter_mut().zip(volumes) {
224            candle.volume = Some(vol.into());
225        }
226        self
227    }
228
229    /// Enable the volume bar panel below the price chart.
230    ///
231    /// The panel occupies the bottom portion of the chart area (default 22 %).
232    /// Requires volume data attached via [`with_volume`](Self::with_volume).
233    /// Volume bars are colored to match their candle (green = up, red = down).
234    pub fn with_volume_panel(mut self) -> Self {
235        self.show_volume = true;
236        self
237    }
238
239    /// Set the fraction of the total chart height used by the volume panel
240    /// (default `0.22`).
241    ///
242    /// For example `0.30` gives the volume panel 30 % of the chart height and
243    /// leaves 70 % for the price chart. Has no effect unless
244    /// [`with_volume_panel`](Self::with_volume_panel) is also called.
245    pub fn with_volume_ratio(mut self, ratio: f64) -> Self {
246        self.volume_ratio = ratio;
247        self
248    }
249
250    /// Set the candle body width as a fraction of the slot between candles
251    /// (default `0.7`).
252    ///
253    /// In categorical mode the slot width is `1.0` (one index unit), so `0.7`
254    /// gives a body that fills 70 % of the available space. In numeric mode
255    /// (`with_candle_at`) this value is in data units — set it to be smaller
256    /// than the spacing between candles.
257    ///
258    /// Complement of [`with_gap`](Self::with_gap): `width = 1.0 - gap`.
259    pub fn with_candle_width(mut self, width: f64) -> Self {
260        self.candle_width = width;
261        self
262    }
263
264    /// Set the gap between candles as a fraction of the slot (default `0.3`).
265    ///
266    /// Only meaningful in categorical mode. Complement of
267    /// [`with_candle_width`](Self::with_candle_width): `gap = 1.0 - width`.
268    pub fn with_gap(mut self, gap: f64) -> Self {
269        self.candle_width = (1.0 - gap).clamp(0.0, 1.0);
270        self
271    }
272
273    /// Set the wick stroke width in pixels (default `1.5`).
274    pub fn with_wick_width(mut self, width: f64) -> Self {
275        self.wick_width = width;
276        self
277    }
278
279    /// Set the fill color for bullish candles where `close > open`
280    /// (default `"rgb(68,170,68)"` — green).
281    ///
282    /// Accepts any CSS color string.
283    pub fn with_color_up<S: Into<String>>(mut self, color: S) -> Self {
284        self.color_up = color.into();
285        self
286    }
287
288    /// Set the fill color for bearish candles where `close < open`
289    /// (default `"rgb(204,68,68)"` — red).
290    ///
291    /// Accepts any CSS color string.
292    pub fn with_color_down<S: Into<String>>(mut self, color: S) -> Self {
293        self.color_down = color.into();
294        self
295    }
296
297    /// Set the fill color for doji candles where `close == open`
298    /// (default `"#888888"` — gray).
299    ///
300    /// A doji typically signals indecision in the market. Accepts any CSS color string.
301    pub fn with_color_doji<S: Into<String>>(mut self, color: S) -> Self {
302        self.color_doji = color.into();
303        self
304    }
305
306    /// Add a legend label, causing a legend box to appear inside the plot area.
307    ///
308    /// ```rust,no_run
309    /// # use kuva::plot::CandlestickPlot;
310    /// let plot = CandlestickPlot::new()
311    ///     .with_candle("Jan", 100.0, 108.0, 98.0, 106.0)
312    ///     .with_legend("ACME Corp");
313    /// ```
314    pub fn with_legend<S: Into<String>>(mut self, label: S) -> Self {
315        self.legend_label = Some(label.into());
316        self
317    }
318
319    pub fn with_tooltips(mut self) -> Self {
320        self.show_tooltips = true;
321        self
322    }
323
324    pub fn with_tooltip_labels(
325        mut self,
326        labels: impl IntoIterator<Item = impl Into<String>>,
327    ) -> Self {
328        self.tooltip_labels = Some(labels.into_iter().map(|s| s.into()).collect());
329        self
330    }
331}