1use egui::{Align2, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
7use facett_core::clip::{ClipKind, ClipPayload, CopySource};
8use facett_core::{Facet, FacetCaps, Semantics, a11y_node, theme};
9
10use crate::boxplot::paint_tooltip;
11
12#[derive(Clone, Debug, PartialEq)]
14pub struct Ohlc {
15 pub label: String,
16 pub open: f64,
17 pub high: f64,
18 pub low: f64,
19 pub close: f64,
20}
21
22impl Ohlc {
23 pub fn new(label: impl Into<String>, open: f64, high: f64, low: f64, close: f64) -> Self {
24 Self { label: label.into(), open, high, low, close }
25 }
26 pub fn bullish(&self) -> bool {
28 self.close >= self.open
29 }
30}
31
32#[derive(Clone, Copy, Debug)]
36pub struct OhlcLayout {
37 plot: Rect,
38 pub price_min: f64,
39 pub price_max: f64,
40 pub count: usize,
41 pub slot: f32,
42 pub body_half: f32,
43}
44
45impl OhlcLayout {
46 pub fn new(plot: Rect, price_min: f64, price_max: f64, count: usize) -> Self {
48 let slot = if count == 0 { plot.width() } else { plot.width() / count as f32 };
49 let body_half = (slot * 0.3).clamp(1.0, 30.0);
50 Self { plot, price_min, price_max, count, slot, body_half }
51 }
52
53 pub fn x_center(&self, i: usize) -> f32 {
55 self.plot.left() + self.slot * (i as f32 + 0.5)
56 }
57
58 pub fn y_of(&self, price: f64) -> f32 {
60 let span = (self.price_max - self.price_min).max(1e-9);
61 self.plot.bottom() - ((price - self.price_min) / span) as f32 * self.plot.height()
62 }
63
64 pub fn bar_at_x(&self, x: f32) -> Option<usize> {
66 if x < self.plot.left() || x > self.plot.right() || self.count == 0 {
67 return None;
68 }
69 let i = ((x - self.plot.left()) / self.slot).floor() as usize;
70 if i < self.count { Some(i) } else { None }
71 }
72}
73
74pub struct CandlestickView {
76 pub title: String,
77 pub bars: Vec<Ohlc>,
78}
79
80impl CandlestickView {
81 pub fn new(title: impl Into<String>, bars: Vec<Ohlc>) -> Self {
82 Self { title: title.into(), bars }
83 }
84
85 pub fn price_range(&self) -> Option<(f64, f64)> {
87 let mut r: Option<(f64, f64)> = None;
88 for b in &self.bars {
89 r = Some(match r {
90 None => (b.low, b.high),
91 Some((lo, hi)) => (lo.min(b.low), hi.max(b.high)),
92 });
93 }
94 r
95 }
96
97 fn to_csv(&self) -> String {
98 let mut s = String::from("label,open,high,low,close\n");
99 for b in &self.bars {
100 s.push_str(&format!("{},{},{},{},{}\n", b.label, b.open, b.high, b.low, b.close));
101 }
102 s
103 }
104}
105
106impl CopySource for CandlestickView {
108 fn copy_kinds(&self) -> &[ClipKind] {
109 &[ClipKind::Text]
110 }
111 fn copy_payload(&self) -> Option<ClipPayload> {
112 let csv = self.to_csv();
113 (!csv.is_empty()).then_some(ClipPayload::Text(csv))
114 }
115}
116
117impl Facet for CandlestickView {
118 fn title(&self) -> &str {
119 &self.title
120 }
121 fn caps(&self) -> FacetCaps {
122 FacetCaps::NONE.themeable().copyable().resizable()
123 }
124 fn copy(&mut self) -> Option<String> {
125 Some(self.to_csv())
126 }
127 fn ui(&mut self, ui: &mut Ui) {
128 let th = theme(ui);
129 let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
130 let painter = ui.painter_at(rect);
131 painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
132 let Some((pmin, pmax)) = self.price_range() else {
133 #[cfg(feature = "testmatrix")]
135 facett_core::testmatrix::emit(
136 "facett-plotters::CandlestickView::ui",
137 "ui_render",
138 self.bars.is_empty(),
139 &format!("drew=empty_hint bars={}", self.bars.len()),
140 );
141 painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
142 return;
143 };
144 let plot = rect.shrink2(vec2(36.0, 16.0));
145 let lay = OhlcLayout::new(plot, pmin, pmax, self.bars.len());
146 let up = th.point;
149 let down = crate::ramp::lerp_color(th.text_dim, th.accent, 0.0);
150 let base = ui.id().with("candlestick");
151
152 for (i, b) in self.bars.iter().enumerate() {
153 let cx = lay.x_center(i);
154 let color = if b.bullish() { up } else { down };
155 let candle_rect = Rect::from_min_max(
161 Pos2::new(cx - lay.body_half, lay.y_of(b.high)),
162 Pos2::new(cx + lay.body_half, lay.y_of(b.low)),
163 );
164 a11y_node(
165 ui,
166 base,
167 i,
168 Sense::hover(),
169 candle_rect,
170 Semantics::image(format!("{} · bar {i}", b.label)).value(b.close),
171 );
172 painter.line_segment([Pos2::new(cx, lay.y_of(b.high)), Pos2::new(cx, lay.y_of(b.low))], Stroke::new(1.0, color));
174 let (top, bot) = (b.open.max(b.close), b.open.min(b.close));
176 let body = Rect::from_min_max(
177 Pos2::new(cx - lay.body_half, lay.y_of(top)),
178 Pos2::new(cx + lay.body_half, lay.y_of(bot)),
179 );
180 let body = if body.height() < 1.0 {
182 Rect::from_min_max(body.min, Pos2::new(body.right(), body.top() + 1.0))
183 } else {
184 body
185 };
186 if b.bullish() {
187 painter.rect_filled(body, egui::CornerRadius::same(1), color.gamma_multiply(0.7));
188 } else {
189 painter.rect_filled(body, egui::CornerRadius::same(1), th.bg);
190 painter.rect_stroke(body, egui::CornerRadius::same(1), Stroke::new(1.2, color), egui::StrokeKind::Middle);
191 }
192 }
193
194 if let Some(hp) = resp.hover_pos() {
196 if let Some(i) = lay.bar_at_x(hp.x) {
197 let b = &self.bars[i];
198 let cx = lay.x_center(i);
199 painter.line_segment([Pos2::new(cx, plot.top()), Pos2::new(cx, plot.bottom())], Stroke::new(0.5, th.text_dim.gamma_multiply(0.5)));
200 paint_tooltip(
201 &painter,
202 &th,
203 hp,
204 &format!("{}\nO {:.2}\nH {:.2}\nL {:.2}\nC {:.2}", b.label, b.open, b.high, b.low, b.close),
205 );
206 }
207 }
208 #[cfg(feature = "testmatrix")]
210 facett_core::testmatrix::emit(
211 "facett-plotters::CandlestickView::ui",
212 "ui_render",
213 !self.bars.is_empty() && lay.count == self.bars.len(),
214 &format!("bars={} drawn={}", self.bars.len(), lay.count),
215 );
216 }
217 fn state_json(&self) -> serde_json::Value {
218 let (mn, mx) = self.price_range().unwrap_or((0.0, 0.0));
219 let up = self.bars.iter().filter(|b| b.bullish()).count();
220 serde_json::json!({
221 "kind": "candlestick",
222 "bars": self.bars.len(),
223 "bullish": up,
224 "bearish": self.bars.len() - up,
225 "low": mn, "high": mx,
226 })
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn typed_copy_is_the_dataset_csv() {
236 use facett_core::clip::{ClipKind, CopySource};
237 let v = CandlestickView::new("c", vec![Ohlc::new("d0", 1.0, 3.0, 0.5, 2.0)]);
238 let p = v.copy_payload().expect("bars copy");
239 assert_eq!(p.kind(), ClipKind::Text);
240 assert_eq!(p.as_text(), v.to_csv());
241 }
242
243 fn plot() -> Rect {
244 Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(200.0, 100.0))
245 }
246
247 #[test]
248 fn bullish_classification() {
249 assert!(Ohlc::new("d", 10.0, 12.0, 9.0, 11.0).bullish());
250 assert!(!Ohlc::new("d", 11.0, 12.0, 9.0, 10.0).bullish());
251 assert!(Ohlc::new("d", 10.0, 12.0, 9.0, 10.0).bullish());
253 }
254
255 #[test]
256 fn y_mapping_is_inverted_and_spans_plot() {
257 let lay = OhlcLayout::new(plot(), 0.0, 100.0, 4);
258 assert!((lay.y_of(100.0) - plot().top()).abs() < 1e-3);
260 assert!((lay.y_of(0.0) - plot().bottom()).abs() < 1e-3);
261 assert!((lay.y_of(50.0) - plot().center().y).abs() < 1e-3);
263 }
264
265 #[test]
266 fn x_centers_are_evenly_spaced() {
267 let lay = OhlcLayout::new(plot(), 0.0, 1.0, 4);
268 let c0 = lay.x_center(0);
269 let c1 = lay.x_center(1);
270 assert!((c1 - c0 - lay.slot).abs() < 1e-4);
271 assert!((c0 - lay.slot * 0.5).abs() < 1e-4);
273 }
274
275 #[test]
276 fn bar_at_x_round_trips_through_centers() {
277 let lay = OhlcLayout::new(plot(), 0.0, 1.0, 5);
278 for i in 0..5 {
279 assert_eq!(lay.bar_at_x(lay.x_center(i)), Some(i));
280 }
281 assert_eq!(lay.bar_at_x(plot().left() - 5.0), None);
282 assert_eq!(lay.bar_at_x(plot().right() + 5.0), None);
283 }
284
285 #[test]
286 fn price_range_covers_low_and_high() {
287 let v = CandlestickView::new(
288 "t",
289 vec![Ohlc::new("a", 5.0, 9.0, 3.0, 7.0), Ohlc::new("b", 7.0, 12.0, 6.0, 8.0)],
290 );
291 assert_eq!(v.price_range(), Some((3.0, 12.0)));
292 }
293
294 #[test]
295 fn csv_header_and_rows() {
296 let v = CandlestickView::new("t", vec![Ohlc::new("a", 1.0, 2.0, 0.5, 1.5)]);
297 let csv = v.to_csv();
298 assert!(csv.starts_with("label,open,high,low,close\n"));
299 assert_eq!(csv.lines().count(), 2);
300 }
301}