Skip to main content

facett_plotters/
candlestick.rs

1//! **Candlestick** (OHLC) chart. Each bar is an open/high/low/close tuple drawn
2//! as a wick (high→low) + a body (open↔close), coloured up/down from theme
3//! colours. The price→pixel mapping ([`OhlcLayout`]) is a pure function with
4//! tests so the geometry is verifiable headlessly.
5
6use egui::{Align2, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
7use facett_core::{Facet, FacetCaps, Semantics, a11y_node, theme};
8
9use crate::boxplot::paint_tooltip;
10
11/// One OHLC bar. `label` is the x-axis tick (e.g. a date).
12#[derive(Clone, Debug, PartialEq)]
13pub struct Ohlc {
14    pub label: String,
15    pub open: f64,
16    pub high: f64,
17    pub low: f64,
18    pub close: f64,
19}
20
21impl Ohlc {
22    pub fn new(label: impl Into<String>, open: f64, high: f64, low: f64, close: f64) -> Self {
23        Self { label: label.into(), open, high, low, close }
24    }
25    /// A candle is "up" when it closed at or above its open.
26    pub fn bullish(&self) -> bool {
27        self.close >= self.open
28    }
29}
30
31/// The price→pixel mapping for a candlestick chart: where each bar's x-centre
32/// sits, the body half-width, and a `y_of(price)` closure source. Pure data so
33/// it can be asserted without a painter.
34#[derive(Clone, Copy, Debug)]
35pub struct OhlcLayout {
36    plot: Rect,
37    pub price_min: f64,
38    pub price_max: f64,
39    pub count: usize,
40    pub slot: f32,
41    pub body_half: f32,
42}
43
44impl OhlcLayout {
45    /// Build the layout for `count` bars in `plot`, given the price range.
46    pub fn new(plot: Rect, price_min: f64, price_max: f64, count: usize) -> Self {
47        let slot = if count == 0 { plot.width() } else { plot.width() / count as f32 };
48        let body_half = (slot * 0.3).clamp(1.0, 30.0);
49        Self { plot, price_min, price_max, count, slot, body_half }
50    }
51
52    /// X-centre of bar `i`.
53    pub fn x_center(&self, i: usize) -> f32 {
54        self.plot.left() + self.slot * (i as f32 + 0.5)
55    }
56
57    /// Map a price to a y pixel (higher price → higher on screen).
58    pub fn y_of(&self, price: f64) -> f32 {
59        let span = (self.price_max - self.price_min).max(1e-9);
60        self.plot.bottom() - ((price - self.price_min) / span) as f32 * self.plot.height()
61    }
62
63    /// Which bar index (if any) the x coordinate falls in.
64    pub fn bar_at_x(&self, x: f32) -> Option<usize> {
65        if x < self.plot.left() || x > self.plot.right() || self.count == 0 {
66            return None;
67        }
68        let i = ((x - self.plot.left()) / self.slot).floor() as usize;
69        if i < self.count { Some(i) } else { None }
70    }
71}
72
73/// A candlestick chart over a series of OHLC bars.
74pub struct CandlestickView {
75    pub title: String,
76    pub bars: Vec<Ohlc>,
77}
78
79impl CandlestickView {
80    pub fn new(title: impl Into<String>, bars: Vec<Ohlc>) -> Self {
81        Self { title: title.into(), bars }
82    }
83
84    /// `(min low, max high)` across all bars.
85    pub fn price_range(&self) -> Option<(f64, f64)> {
86        let mut r: Option<(f64, f64)> = None;
87        for b in &self.bars {
88            r = Some(match r {
89                None => (b.low, b.high),
90                Some((lo, hi)) => (lo.min(b.low), hi.max(b.high)),
91            });
92        }
93        r
94    }
95
96    fn to_csv(&self) -> String {
97        let mut s = String::from("label,open,high,low,close\n");
98        for b in &self.bars {
99            s.push_str(&format!("{},{},{},{},{}\n", b.label, b.open, b.high, b.low, b.close));
100        }
101        s
102    }
103}
104
105impl Facet for CandlestickView {
106    fn title(&self) -> &str {
107        &self.title
108    }
109    fn caps(&self) -> FacetCaps {
110        FacetCaps::NONE.themeable().copyable().resizable()
111    }
112    fn copy(&mut self) -> Option<String> {
113        Some(self.to_csv())
114    }
115    fn ui(&mut self, ui: &mut Ui) {
116        let th = theme(ui);
117        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
118        let painter = ui.painter_at(rect);
119        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
120        let Some((pmin, pmax)) = self.price_range() else {
121            painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
122            return;
123        };
124        let plot = rect.shrink2(vec2(36.0, 16.0));
125        let lay = OhlcLayout::new(plot, pmin, pmax, self.bars.len());
126        // Up = theme point/accent, down = a desaturated text_dim/red-ish; derive
127        // both from the theme so no palette is hardcoded.
128        let up = th.point;
129        let down = crate::ramp::lerp_color(th.text_dim, th.accent, 0.0);
130        let base = ui.id().with("candlestick");
131
132        for (i, b) in self.bars.iter().enumerate() {
133            let cx = lay.x_center(i);
134            let color = if b.bullish() { up } else { down };
135            // AccessKit node over the candle's full high→low extent (FC-4):
136            // role=Image, label = the bar label (e.g. date), numeric_value = the
137            // close price (the candle's headline figure). Keyed on the bar index
138            // so each candle is individually addressable (FC-5); the label is
139            // disambiguated with the index in case two bars share a date.
140            let candle_rect = Rect::from_min_max(
141                Pos2::new(cx - lay.body_half, lay.y_of(b.high)),
142                Pos2::new(cx + lay.body_half, lay.y_of(b.low)),
143            );
144            a11y_node(
145                ui,
146                base,
147                i,
148                Sense::hover(),
149                candle_rect,
150                Semantics::image(format!("{} · bar {i}", b.label)).value(b.close),
151            );
152            // Wick.
153            painter.line_segment([Pos2::new(cx, lay.y_of(b.high)), Pos2::new(cx, lay.y_of(b.low))], Stroke::new(1.0, color));
154            // Body.
155            let (top, bot) = (b.open.max(b.close), b.open.min(b.close));
156            let body = Rect::from_min_max(
157                Pos2::new(cx - lay.body_half, lay.y_of(top)),
158                Pos2::new(cx + lay.body_half, lay.y_of(bot)),
159            );
160            // A doji (open==close) collapses to a thin line — give it min height.
161            let body = if body.height() < 1.0 {
162                Rect::from_min_max(body.min, Pos2::new(body.right(), body.top() + 1.0))
163            } else {
164                body
165            };
166            if b.bullish() {
167                painter.rect_filled(body, egui::CornerRadius::same(1), color.gamma_multiply(0.7));
168            } else {
169                painter.rect_filled(body, egui::CornerRadius::same(1), th.bg);
170                painter.rect_stroke(body, egui::CornerRadius::same(1), Stroke::new(1.2, color), egui::StrokeKind::Middle);
171            }
172        }
173
174        // Hover readout for the bar under the cursor.
175        if let Some(hp) = resp.hover_pos() {
176            if let Some(i) = lay.bar_at_x(hp.x) {
177                let b = &self.bars[i];
178                let cx = lay.x_center(i);
179                painter.line_segment([Pos2::new(cx, plot.top()), Pos2::new(cx, plot.bottom())], Stroke::new(0.5, th.text_dim.gamma_multiply(0.5)));
180                paint_tooltip(
181                    &painter,
182                    &th,
183                    hp,
184                    &format!("{}\nO {:.2}\nH {:.2}\nL {:.2}\nC {:.2}", b.label, b.open, b.high, b.low, b.close),
185                );
186            }
187        }
188    }
189    fn state_json(&self) -> serde_json::Value {
190        let (mn, mx) = self.price_range().unwrap_or((0.0, 0.0));
191        let up = self.bars.iter().filter(|b| b.bullish()).count();
192        serde_json::json!({
193            "kind": "candlestick",
194            "bars": self.bars.len(),
195            "bullish": up,
196            "bearish": self.bars.len() - up,
197            "low": mn, "high": mx,
198        })
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    fn plot() -> Rect {
207        Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(200.0, 100.0))
208    }
209
210    #[test]
211    fn bullish_classification() {
212        assert!(Ohlc::new("d", 10.0, 12.0, 9.0, 11.0).bullish());
213        assert!(!Ohlc::new("d", 11.0, 12.0, 9.0, 10.0).bullish());
214        // open == close is treated as up (non-decreasing)
215        assert!(Ohlc::new("d", 10.0, 12.0, 9.0, 10.0).bullish());
216    }
217
218    #[test]
219    fn y_mapping_is_inverted_and_spans_plot() {
220        let lay = OhlcLayout::new(plot(), 0.0, 100.0, 4);
221        // max price → top of plot, min price → bottom
222        assert!((lay.y_of(100.0) - plot().top()).abs() < 1e-3);
223        assert!((lay.y_of(0.0) - plot().bottom()).abs() < 1e-3);
224        // midpoint maps to vertical centre
225        assert!((lay.y_of(50.0) - plot().center().y).abs() < 1e-3);
226    }
227
228    #[test]
229    fn x_centers_are_evenly_spaced() {
230        let lay = OhlcLayout::new(plot(), 0.0, 1.0, 4);
231        let c0 = lay.x_center(0);
232        let c1 = lay.x_center(1);
233        assert!((c1 - c0 - lay.slot).abs() < 1e-4);
234        // first centre is half a slot in
235        assert!((c0 - lay.slot * 0.5).abs() < 1e-4);
236    }
237
238    #[test]
239    fn bar_at_x_round_trips_through_centers() {
240        let lay = OhlcLayout::new(plot(), 0.0, 1.0, 5);
241        for i in 0..5 {
242            assert_eq!(lay.bar_at_x(lay.x_center(i)), Some(i));
243        }
244        assert_eq!(lay.bar_at_x(plot().left() - 5.0), None);
245        assert_eq!(lay.bar_at_x(plot().right() + 5.0), None);
246    }
247
248    #[test]
249    fn price_range_covers_low_and_high() {
250        let v = CandlestickView::new(
251            "t",
252            vec![Ohlc::new("a", 5.0, 9.0, 3.0, 7.0), Ohlc::new("b", 7.0, 12.0, 6.0, 8.0)],
253        );
254        assert_eq!(v.price_range(), Some((3.0, 12.0)));
255    }
256
257    #[test]
258    fn csv_header_and_rows() {
259        let v = CandlestickView::new("t", vec![Ohlc::new("a", 1.0, 2.0, 0.5, 1.5)]);
260        let csv = v.to_csv();
261        assert!(csv.starts_with("label,open,high,low,close\n"));
262        assert_eq!(csv.lines().count(), 2);
263    }
264}