1use egui::{Align2, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
7use facett_core::{Facet, FacetCaps, theme};
8
9use crate::boxplot::paint_tooltip;
10
11#[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 pub fn bullish(&self) -> bool {
27 self.close >= self.open
28 }
29}
30
31#[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 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 pub fn x_center(&self, i: usize) -> f32 {
54 self.plot.left() + self.slot * (i as f32 + 0.5)
55 }
56
57 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 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
73pub 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 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 let up = th.point;
129 let down = crate::ramp::lerp_color(th.text_dim, th.accent, 0.0);
130
131 for (i, b) in self.bars.iter().enumerate() {
132 let cx = lay.x_center(i);
133 let color = if b.bullish() { up } else { down };
134 painter.line_segment([Pos2::new(cx, lay.y_of(b.high)), Pos2::new(cx, lay.y_of(b.low))], Stroke::new(1.0, color));
136 let (top, bot) = (b.open.max(b.close), b.open.min(b.close));
138 let body = Rect::from_min_max(
139 Pos2::new(cx - lay.body_half, lay.y_of(top)),
140 Pos2::new(cx + lay.body_half, lay.y_of(bot)),
141 );
142 let body = if body.height() < 1.0 {
144 Rect::from_min_max(body.min, Pos2::new(body.right(), body.top() + 1.0))
145 } else {
146 body
147 };
148 if b.bullish() {
149 painter.rect_filled(body, egui::CornerRadius::same(1), color.gamma_multiply(0.7));
150 } else {
151 painter.rect_filled(body, egui::CornerRadius::same(1), th.bg);
152 painter.rect_stroke(body, egui::CornerRadius::same(1), Stroke::new(1.2, color), egui::StrokeKind::Middle);
153 }
154 }
155
156 if let Some(hp) = resp.hover_pos() {
158 if let Some(i) = lay.bar_at_x(hp.x) {
159 let b = &self.bars[i];
160 let cx = lay.x_center(i);
161 painter.line_segment([Pos2::new(cx, plot.top()), Pos2::new(cx, plot.bottom())], Stroke::new(0.5, th.text_dim.gamma_multiply(0.5)));
162 paint_tooltip(
163 &painter,
164 &th,
165 hp,
166 &format!("{}\nO {:.2}\nH {:.2}\nL {:.2}\nC {:.2}", b.label, b.open, b.high, b.low, b.close),
167 );
168 }
169 }
170 }
171 fn state_json(&self) -> serde_json::Value {
172 let (mn, mx) = self.price_range().unwrap_or((0.0, 0.0));
173 let up = self.bars.iter().filter(|b| b.bullish()).count();
174 serde_json::json!({
175 "kind": "candlestick",
176 "bars": self.bars.len(),
177 "bullish": up,
178 "bearish": self.bars.len() - up,
179 "low": mn, "high": mx,
180 })
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 fn plot() -> Rect {
189 Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(200.0, 100.0))
190 }
191
192 #[test]
193 fn bullish_classification() {
194 assert!(Ohlc::new("d", 10.0, 12.0, 9.0, 11.0).bullish());
195 assert!(!Ohlc::new("d", 11.0, 12.0, 9.0, 10.0).bullish());
196 assert!(Ohlc::new("d", 10.0, 12.0, 9.0, 10.0).bullish());
198 }
199
200 #[test]
201 fn y_mapping_is_inverted_and_spans_plot() {
202 let lay = OhlcLayout::new(plot(), 0.0, 100.0, 4);
203 assert!((lay.y_of(100.0) - plot().top()).abs() < 1e-3);
205 assert!((lay.y_of(0.0) - plot().bottom()).abs() < 1e-3);
206 assert!((lay.y_of(50.0) - plot().center().y).abs() < 1e-3);
208 }
209
210 #[test]
211 fn x_centers_are_evenly_spaced() {
212 let lay = OhlcLayout::new(plot(), 0.0, 1.0, 4);
213 let c0 = lay.x_center(0);
214 let c1 = lay.x_center(1);
215 assert!((c1 - c0 - lay.slot).abs() < 1e-4);
216 assert!((c0 - lay.slot * 0.5).abs() < 1e-4);
218 }
219
220 #[test]
221 fn bar_at_x_round_trips_through_centers() {
222 let lay = OhlcLayout::new(plot(), 0.0, 1.0, 5);
223 for i in 0..5 {
224 assert_eq!(lay.bar_at_x(lay.x_center(i)), Some(i));
225 }
226 assert_eq!(lay.bar_at_x(plot().left() - 5.0), None);
227 assert_eq!(lay.bar_at_x(plot().right() + 5.0), None);
228 }
229
230 #[test]
231 fn price_range_covers_low_and_high() {
232 let v = CandlestickView::new(
233 "t",
234 vec![Ohlc::new("a", 5.0, 9.0, 3.0, 7.0), Ohlc::new("b", 7.0, 12.0, 6.0, 8.0)],
235 );
236 assert_eq!(v.price_range(), Some((3.0, 12.0)));
237 }
238
239 #[test]
240 fn csv_header_and_rows() {
241 let v = CandlestickView::new("t", vec![Ohlc::new("a", 1.0, 2.0, 0.5, 1.5)]);
242 let csv = v.to_csv();
243 assert!(csv.starts_with("label,open,high,low,close\n"));
244 assert_eq!(csv.lines().count(), 2);
245 }
246}