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}