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::clip::{ClipKind, ClipPayload, CopySource};
8use facett_core::{Elm, FacetCaps, Semantics, a11y_node, theme};
9use serde::{Deserialize, Serialize};
10
11use crate::boxplot::paint_tooltip;
12
13/// One OHLC bar. `label` is the x-axis tick (e.g. a date).
14#[derive(Clone, Debug, PartialEq)]
15pub struct Ohlc {
16    pub label: String,
17    pub open: f64,
18    pub high: f64,
19    pub low: f64,
20    pub close: f64,
21}
22
23impl Ohlc {
24    pub fn new(label: impl Into<String>, open: f64, high: f64, low: f64, close: f64) -> Self {
25        Self { label: label.into(), open, high, low, close }
26    }
27    /// A candle is "up" when it closed at or above its open.
28    pub fn bullish(&self) -> bool {
29        self.close >= self.open
30    }
31}
32
33/// The price→pixel mapping for a candlestick chart: where each bar's x-centre
34/// sits, the body half-width, and a `y_of(price)` closure source. Pure data so
35/// it can be asserted without a painter.
36#[derive(Clone, Copy, Debug)]
37pub struct OhlcLayout {
38    plot: Rect,
39    pub price_min: f64,
40    pub price_max: f64,
41    pub count: usize,
42    pub slot: f32,
43    pub body_half: f32,
44}
45
46impl OhlcLayout {
47    /// Build the layout for `count` bars in `plot`, given the price range.
48    pub fn new(plot: Rect, price_min: f64, price_max: f64, count: usize) -> Self {
49        let slot = if count == 0 { plot.width() } else { plot.width() / count as f32 };
50        let body_half = (slot * 0.3).clamp(1.0, 30.0);
51        Self { plot, price_min, price_max, count, slot, body_half }
52    }
53
54    /// X-centre of bar `i`.
55    pub fn x_center(&self, i: usize) -> f32 {
56        self.plot.left() + self.slot * (i as f32 + 0.5)
57    }
58
59    /// Map a price to a y pixel (higher price → higher on screen).
60    pub fn y_of(&self, price: f64) -> f32 {
61        let span = (self.price_max - self.price_min).max(1e-9);
62        self.plot.bottom() - ((price - self.price_min) / span) as f32 * self.plot.height()
63    }
64
65    /// Which bar index (if any) the x coordinate falls in.
66    pub fn bar_at_x(&self, x: f32) -> Option<usize> {
67        if x < self.plot.left() || x > self.plot.right() || self.count == 0 {
68            return None;
69        }
70        let i = ((x - self.plot.left()) / self.slot).floor() as usize;
71        if i < self.count { Some(i) } else { None }
72    }
73}
74
75/// Persistent, observable interaction state (FC-3): the bar index the pointer
76/// last hovered. Serialized so it round-trips headlessly.
77#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
78pub struct CandlestickState {
79    /// Index of the hovered bar (into `bars`), or `None`.
80    pub hover: Option<usize>,
81}
82
83/// FC-2 input surface. One input: the hovered bar changing (hit-tested purely in
84/// [`CandlestickView::render`], applied in [`CandlestickView::update`]).
85#[derive(Clone, Debug, PartialEq)]
86pub enum CandlestickMsg {
87    /// Set (or clear, with `None`) the hovered bar index.
88    Hover(Option<usize>),
89}
90
91/// FC-8: the candlestick chart does no I/O.
92#[derive(Clone, Debug)]
93pub enum CandlestickEffect {}
94
95/// A candlestick chart over a series of OHLC bars.
96pub struct CandlestickView {
97    pub title: String,
98    pub bars: Vec<Ohlc>,
99    /// Observable interaction state (FC-3).
100    pub state: CandlestickState,
101}
102
103impl CandlestickView {
104    pub fn new(title: impl Into<String>, bars: Vec<Ohlc>) -> Self {
105        Self { title: title.into(), bars, state: CandlestickState::default() }
106    }
107
108    /// FC-3: read the complete observable interaction state.
109    pub fn state(&self) -> &CandlestickState {
110        &self.state
111    }
112
113    /// FC-2/FC-8: the single mutation path. Apply one [`CandlestickMsg`]; no effects.
114    pub fn update(&mut self, msg: CandlestickMsg) -> Vec<CandlestickEffect> {
115        match msg {
116            CandlestickMsg::Hover(h) => self.state.hover = h,
117        }
118        Vec::new()
119    }
120
121    /// `(min low, max high)` across all bars.
122    pub fn price_range(&self) -> Option<(f64, f64)> {
123        let mut r: Option<(f64, f64)> = None;
124        for b in &self.bars {
125            r = Some(match r {
126                None => (b.low, b.high),
127                Some((lo, hi)) => (lo.min(b.low), hi.max(b.high)),
128            });
129        }
130        r
131    }
132
133    fn to_csv(&self) -> String {
134        let mut s = String::from("label,open,high,low,close\n");
135        for b in &self.bars {
136            s.push_str(&format!("{},{},{},{},{}\n", b.label, b.open, b.high, b.low, b.close));
137        }
138        s
139    }
140}
141
142// ── typed copy (§16) — the chart dataset as CSV Text ──────────────────
143impl CopySource for CandlestickView {
144    fn copy_kinds(&self) -> &[ClipKind] {
145        &[ClipKind::Text]
146    }
147    fn copy_payload(&self) -> Option<ClipPayload> {
148        let csv = self.to_csv();
149        (!csv.is_empty()).then_some(ClipPayload::Text(csv))
150    }
151}
152
153impl CandlestickView {
154    /// FC-9 pure view: paints the candles + hover tooltip (from the *stored*
155    /// hover) and **returns** the hovered-bar change as a `Msg` — never mutates.
156    fn render(&self, ui: &mut Ui) -> Vec<CandlestickMsg> {
157        let th = theme(ui);
158        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
159        let painter = ui.painter_at(rect);
160        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
161        let Some((pmin, pmax)) = self.price_range() else {
162            // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
163            #[cfg(feature = "testmatrix")]
164            facett_core::testmatrix::emit(
165                "facett-plotters::CandlestickView::ui",
166                "ui_render",
167                self.bars.is_empty(),
168                &format!("drew=empty_hint bars={}", self.bars.len()),
169            );
170            painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
171            return hover_msg(self.state.hover, None);
172        };
173        let plot = rect.shrink2(vec2(36.0, 16.0));
174        let lay = OhlcLayout::new(plot, pmin, pmax, self.bars.len());
175        // Up = theme point/accent, down = a desaturated text_dim/red-ish; derive
176        // both from the theme so no palette is hardcoded.
177        let up = th.point;
178        let down = crate::ramp::lerp_color(th.text_dim, th.accent, 0.0);
179        let base = ui.id().with("candlestick");
180
181        for (i, b) in self.bars.iter().enumerate() {
182            let cx = lay.x_center(i);
183            let color = if b.bullish() { up } else { down };
184            // AccessKit node over the candle's full high→low extent (FC-4):
185            // role=Image, label = the bar label (e.g. date), numeric_value = the
186            // close price (the candle's headline figure). Keyed on the bar index
187            // so each candle is individually addressable (FC-5); the label is
188            // disambiguated with the index in case two bars share a date.
189            let candle_rect = Rect::from_min_max(
190                Pos2::new(cx - lay.body_half, lay.y_of(b.high)),
191                Pos2::new(cx + lay.body_half, lay.y_of(b.low)),
192            );
193            a11y_node(
194                ui,
195                base,
196                i,
197                Sense::hover(),
198                candle_rect,
199                Semantics::image(format!("{} · bar {i}", b.label)).value(b.close),
200            );
201            // Wick.
202            painter.line_segment([Pos2::new(cx, lay.y_of(b.high)), Pos2::new(cx, lay.y_of(b.low))], Stroke::new(1.0_f32, color));
203            // Body.
204            let (top, bot) = (b.open.max(b.close), b.open.min(b.close));
205            let body = Rect::from_min_max(
206                Pos2::new(cx - lay.body_half, lay.y_of(top)),
207                Pos2::new(cx + lay.body_half, lay.y_of(bot)),
208            );
209            // A doji (open==close) collapses to a thin line — give it min height.
210            let body = if body.height() < 1.0 {
211                Rect::from_min_max(body.min, Pos2::new(body.right(), body.top() + 1.0))
212            } else {
213                body
214            };
215            if b.bullish() {
216                painter.rect_filled(body, egui::CornerRadius::same(1), color.gamma_multiply(0.7));
217            } else {
218                painter.rect_filled(body, egui::CornerRadius::same(1), th.bg);
219                painter.rect_stroke(body, egui::CornerRadius::same(1), Stroke::new(1.2_f32, color), egui::StrokeKind::Middle);
220            }
221        }
222
223        // Pure hit-test: which bar is under the cursor this frame.
224        let new_hover = resp.hover_pos().and_then(|hp| lay.bar_at_x(hp.x));
225        // Tooltip painted from the *persisted* hover (set by `update` last frame),
226        // keeping `render` a pure function of `&self`.
227        if let Some(hp) = resp.hover_pos() {
228            if let Some(b) = self.state.hover.and_then(|i| self.bars.get(i)) {
229                let cx = lay.x_center(self.state.hover.unwrap());
230                painter.line_segment([Pos2::new(cx, plot.top()), Pos2::new(cx, plot.bottom())], Stroke::new(0.5_f32, th.text_dim.gamma_multiply(0.5)));
231                paint_tooltip(
232                    &painter,
233                    &th,
234                    hp,
235                    &format!("{}\nO {:.2}\nH {:.2}\nL {:.2}\nC {:.2}", b.label, b.open, b.high, b.low, b.close),
236                );
237            }
238        }
239        // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
240        #[cfg(feature = "testmatrix")]
241        facett_core::testmatrix::emit(
242            "facett-plotters::CandlestickView::ui",
243            "ui_render",
244            !self.bars.is_empty() && lay.count == self.bars.len(),
245            &format!("bars={} drawn={}", self.bars.len(), lay.count),
246        );
247        hover_msg(self.state.hover, new_hover)
248    }
249}
250
251/// Emit a `Hover` message only when the hovered bar actually changed.
252fn hover_msg(current: Option<usize>, next: Option<usize>) -> Vec<CandlestickMsg> {
253    if current != next { vec![CandlestickMsg::Hover(next)] } else { Vec::new() }
254}
255
256impl Elm for CandlestickView {
257    type Model = CandlestickState;
258    type Msg = CandlestickMsg;
259    type Effect = CandlestickEffect;
260
261    fn title(&self) -> &str {
262        &self.title
263    }
264    fn state(&self) -> &CandlestickState {
265        &self.state
266    }
267    fn update(&mut self, msg: CandlestickMsg) -> Vec<CandlestickEffect> {
268        CandlestickView::update(self, msg)
269    }
270    fn view(&self, ui: &mut Ui) -> Vec<CandlestickMsg> {
271        self.render(ui)
272    }
273}
274
275// Form 3: richer derived `state_json` (bull/bear counts + price range) preserved
276// exactly, so the macro's default `serde(state())` is suppressed.
277facett_core::impl_facet_via_elm!(CandlestickView, custom_state_json, {
278    fn caps(&self) -> FacetCaps {
279        FacetCaps::NONE.themeable().copyable().resizable()
280    }
281    fn copy(&mut self) -> Option<String> {
282        Some(self.to_csv())
283    }
284    fn state_json(&self) -> serde_json::Value {
285        let (mn, mx) = self.price_range().unwrap_or((0.0, 0.0));
286        let up = self.bars.iter().filter(|b| b.bullish()).count();
287        serde_json::json!({
288            "kind": "candlestick",
289            "bars": self.bars.len(),
290            "bullish": up,
291            "bearish": self.bars.len() - up,
292            "low": mn, "high": mx,
293        })
294    }
295});
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn typed_copy_is_the_dataset_csv() {
303        use facett_core::clip::{ClipKind, CopySource};
304        let v = CandlestickView::new("c", vec![Ohlc::new("d0", 1.0, 3.0, 0.5, 2.0)]);
305        let p = v.copy_payload().expect("bars copy");
306        assert_eq!(p.kind(), ClipKind::Text);
307        assert_eq!(p.as_text(), v.to_csv());
308    }
309
310    fn plot() -> Rect {
311        Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(200.0, 100.0))
312    }
313
314    #[test]
315    fn bullish_classification() {
316        assert!(Ohlc::new("d", 10.0, 12.0, 9.0, 11.0).bullish());
317        assert!(!Ohlc::new("d", 11.0, 12.0, 9.0, 10.0).bullish());
318        // open == close is treated as up (non-decreasing)
319        assert!(Ohlc::new("d", 10.0, 12.0, 9.0, 10.0).bullish());
320    }
321
322    #[test]
323    fn y_mapping_is_inverted_and_spans_plot() {
324        let lay = OhlcLayout::new(plot(), 0.0, 100.0, 4);
325        // max price → top of plot, min price → bottom
326        assert!((lay.y_of(100.0) - plot().top()).abs() < 1e-3);
327        assert!((lay.y_of(0.0) - plot().bottom()).abs() < 1e-3);
328        // midpoint maps to vertical centre
329        assert!((lay.y_of(50.0) - plot().center().y).abs() < 1e-3);
330    }
331
332    #[test]
333    fn x_centers_are_evenly_spaced() {
334        let lay = OhlcLayout::new(plot(), 0.0, 1.0, 4);
335        let c0 = lay.x_center(0);
336        let c1 = lay.x_center(1);
337        assert!((c1 - c0 - lay.slot).abs() < 1e-4);
338        // first centre is half a slot in
339        assert!((c0 - lay.slot * 0.5).abs() < 1e-4);
340    }
341
342    #[test]
343    fn bar_at_x_round_trips_through_centers() {
344        let lay = OhlcLayout::new(plot(), 0.0, 1.0, 5);
345        for i in 0..5 {
346            assert_eq!(lay.bar_at_x(lay.x_center(i)), Some(i));
347        }
348        assert_eq!(lay.bar_at_x(plot().left() - 5.0), None);
349        assert_eq!(lay.bar_at_x(plot().right() + 5.0), None);
350    }
351
352    #[test]
353    fn price_range_covers_low_and_high() {
354        let v = CandlestickView::new(
355            "t",
356            vec![Ohlc::new("a", 5.0, 9.0, 3.0, 7.0), Ohlc::new("b", 7.0, 12.0, 6.0, 8.0)],
357        );
358        assert_eq!(v.price_range(), Some((3.0, 12.0)));
359    }
360
361    #[test]
362    fn csv_header_and_rows() {
363        let v = CandlestickView::new("t", vec![Ohlc::new("a", 1.0, 2.0, 0.5, 1.5)]);
364        let csv = v.to_csv();
365        assert!(csv.starts_with("label,open,high,low,close\n"));
366        assert_eq!(csv.lines().count(), 2);
367    }
368
369    // ── Elm (FC-2/FC-3) harness properties ────────────────────────────────
370    #[test]
371    fn hover_transitions_are_observable() {
372        let mut v = CandlestickView::new(
373            "t",
374            vec![Ohlc::new("a", 1.0, 2.0, 0.5, 1.5), Ohlc::new("b", 1.5, 3.0, 1.0, 2.5)],
375        );
376        assert_eq!(v.state().hover, None);
377        let snap = facett_core::harness::snapshot(&mut v, [CandlestickMsg::Hover(Some(1))]);
378        assert_eq!(snap.hover, Some(1));
379        let snap = facett_core::harness::snapshot(&mut v, [CandlestickMsg::Hover(None)]);
380        assert_eq!(snap.hover, None);
381    }
382
383    #[test]
384    fn drive_produces_no_effects() {
385        let mut v = CandlestickView::new("t", vec![Ohlc::new("a", 1.0, 2.0, 0.5, 1.5)]);
386        let effects = facett_core::harness::drive(&mut v, [CandlestickMsg::Hover(Some(0)), CandlestickMsg::Hover(None)]);
387        assert!(effects.is_empty());
388    }
389
390    #[test]
391    fn state_serde_round_trips() {
392        let st = CandlestickState { hover: Some(3) };
393        let back: CandlestickState = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
394        assert_eq!(st, back);
395    }
396}