Skip to main content

egui_charts/widget/
targets.rs

1//! Optional analyst estimates, requested independently of bar history.
2
3use super::Chart;
4use crate::chart::coords::ChartMapping;
5use crate::data::PriceTargets;
6use crate::data::{FeedError, inbox::Inbox};
7use crate::localization as l10n;
8use crate::scales::{DefaultPriceFormatter, PriceFormatter, PriceScaleMode};
9use crate::theme::ThemeExt;
10use egui::{FontId, Pos2, Sense, Stroke, Vec2};
11use std::sync::Arc;
12
13#[derive(Default)]
14pub(super) struct TargetState {
15    inbox: Arc<Inbox>,
16    symbol: Option<String>,
17    values: Option<PriceTargets>,
18    error: Option<FeedError>,
19}
20
21impl Chart {
22    /// Show or hide the host's analyst estimates. Hidden by default; hiding retains the answer.
23    /// Leave future whitespace in the visible logical range to see the supplied horizon date.
24    pub fn set_price_targets_visible(&mut self, visible: bool) {
25        self.show_price_targets = visible;
26    }
27
28    /// Whether analyst estimates are enabled in chart settings.
29    pub fn price_targets_visible(&self) -> bool {
30        self.show_price_targets
31    }
32
33    /// The accepted targets for the current symbol, once answered by the feed.
34    pub fn price_targets(&self) -> Option<&PriceTargets> {
35        self.targets.values.as_ref()
36    }
37
38    /// Ask again on the next frame, for example after estimates change at the host.
39    pub fn reload_price_targets(&mut self) {
40        self.targets = TargetState::default();
41    }
42
43    /// Take a request or validation error. Unsupported feeds silently leave the chart unchanged.
44    pub fn take_price_targets_error(&mut self) -> Option<FeedError> {
45        self.targets.error.take()
46    }
47
48    pub(super) fn pump_targets(&mut self, ctx: &egui::Context) {
49        if !self.show_price_targets {
50            return;
51        }
52        let Some(link) = &self.feed_link else {
53            return;
54        };
55        if self.targets.symbol.as_deref() != Some(&self.symbol) {
56            self.targets = TargetState {
57                symbol: Some(self.symbol.clone()),
58                ..Default::default()
59            };
60            let repaint = ctx.clone();
61            let viewport = ctx.viewport_id();
62            self.targets
63                .inbox
64                .set_waker(move || repaint.request_repaint_of(viewport));
65            link.feed
66                .price_targets(&self.symbol, self.targets.inbox.reply(0));
67        }
68        for (_, answer) in self.targets.inbox.drain().answers {
69            match *answer
70                .downcast::<Result<PriceTargets, FeedError>>()
71                .expect("price targets")
72            {
73                Ok(targets) if valid(&targets) => {
74                    self.targets.values = Some(targets);
75                    self.targets.error = None;
76                }
77                Ok(_) => {
78                    self.targets.error = Some(FeedError::InvalidData(
79                        "Invalid analyst price targets".into(),
80                    ))
81                }
82                Err(FeedError::NotSupported) => {}
83                Err(error) => self.targets.error = Some(error),
84            }
85        }
86    }
87
88    fn visible_targets(&self) -> Option<&PriceTargets> {
89        (self.show_price_targets
90            && self.main_series_visible
91            && !self.chart_type.is_time_independent())
92        .then_some(())?;
93        let targets = self.price_targets()?;
94        let last = crate::model::last_with_value(self.state.shown())?;
95        let horizon = targets.horizon.and_hms_opt(0, 0, 0)?.and_utc();
96        let range = self.visible_time_range()?;
97        (horizon > last.time && range.to >= last.time && range.from <= horizon).then_some(targets)
98    }
99
100    pub(super) fn target_prices(&self) -> Vec<f64> {
101        self.visible_targets()
102            .map(|t| vec![t.low, t.high])
103            .unwrap_or_default()
104    }
105
106    pub(super) fn paint_targets(
107        &self,
108        ui: &egui::Ui,
109        coords: &ChartMapping,
110        label_top: f32,
111        line_slot: egui::layers::ShapeIdx,
112    ) {
113        let Some(targets) = self.visible_targets() else {
114            return;
115        };
116        let Some((index, last)) = self
117            .state
118            .shown()
119            .iter()
120            .enumerate()
121            .rev()
122            .find(|(_, bar)| !bar.is_whitespace())
123        else {
124            return;
125        };
126        let time = targets
127            .horizon
128            .and_hms_opt(0, 0, 0)
129            .expect("midnight")
130            .and_utc();
131        if time <= last.time {
132            return;
133        }
134        let Some(horizon) = self.time_to_index(time) else {
135            return;
136        };
137        let origin = Pos2::new(coords.idx_to_x(index), coords.price.price_to_y(last.close));
138        let end_x = coords.idx_to_x(0) + horizon as f32 * coords.bar_spacing;
139        let theme = ui.active_theme();
140        let painter = ui.painter().with_clip_rect(coords.rect);
141        let mut levels = [
142            ("High", targets.high),
143            ("Average", targets.average),
144            ("Low", targets.low),
145        ];
146        levels.sort_by(|a, b| {
147            coords
148                .price
149                .price_to_y(a.1)
150                .total_cmp(&coords.price.price_to_y(b.1))
151        });
152        let mut label_bottom = label_top.max(coords.rect.top());
153        let mut lines = Vec::new();
154        let formatter = self.series_label_formatter(super::SeriesId::MAIN, PriceScaleMode::Normal);
155        for (position, (name, price)) in levels.into_iter().enumerate() {
156            let end = Pos2::new(end_x, coords.price.price_to_y(price));
157            if !end.is_finite() || !origin.is_finite() {
158                continue;
159            }
160            let color = if price > last.close {
161                theme.chart.candle_up
162            } else if price < last.close {
163                theme.chart.candle_down
164            } else {
165                theme.ui.text_secondary
166            };
167            lines.push(egui::Shape::line_segment(
168                [origin, end],
169                Stroke::new(1.5, color),
170            ));
171            let decimals = self
172                .instrument()
173                .and_then(|instrument| instrument.price_decimals)
174                .map(usize::from)
175                .or_else(|| coords.price.price_precision())
176                .unwrap_or(2);
177            let price_text = formatter.as_ref().map_or_else(
178                || DefaultPriceFormatter::with_precision(decimals).format(price),
179                |formatter| formatter.format(price),
180            );
181            let change = percent_change(last.close, price)
182                .map(|change| format!(" ({change:+.2}%)"))
183                .unwrap_or_default();
184            let text = format!("{name} {price_text}{change}");
185            let galley = painter.layout_no_wrap(
186                l10n::text(ui.ctx(), text),
187                FontId::proportional(12.0),
188                color,
189            );
190            let size = galley.size();
191            let top = (end.y - size.y / 2.0)
192                .min(coords.rect.bottom() - (3 - position) as f32 * (size.y + 4.0))
193                .max(label_bottom + 2.0);
194            let label = egui::Rect::from_min_size(
195                Pos2::new(
196                    (end.x + 8.0)
197                        .min(coords.rect.right() - size.x - 4.0)
198                        .max(coords.rect.left() + 4.0),
199                    top,
200                ),
201                size,
202            );
203            label_bottom = label.bottom();
204            if (label.center().y - end.y).abs() > size.y / 2.0 {
205                lines.push(egui::Shape::line_segment(
206                    [end, label.left_center()],
207                    Stroke::new(1.0, color),
208                ));
209            }
210            painter.rect_filled(label.expand(2.0), 2.0, theme.chart.bg);
211            painter.galley(label.min, galley, color);
212            let mut hit = label.expand(4.0);
213            if coords.rect.contains(end) {
214                lines.push(egui::Shape::circle_filled(end, 3.0, color));
215                hit = hit.union(egui::Rect::from_center_size(end, Vec2::splat(12.0)));
216            }
217            ui.interact(
218                hit.intersect(coords.rect),
219                ui.scope_id().with(("price_target", name)),
220                Sense::hover(),
221            )
222            .on_hover_text(l10n::format_text(
223                ui.ctx(),
224                "{name}: {price_text}{change}\n{0} analysts\nPublished: {1}\nHorizon: {2}",
225                &[
226                    ("name", name.to_string()),
227                    ("price_text", price_text.to_string()),
228                    ("change", change.to_string()),
229                    ("0", format!("{}", targets.analysts)),
230                    ("1", format!("{}", targets.published)),
231                    ("2", format!("{}", targets.horizon)),
232                ],
233            ));
234        }
235        painter.set(line_slot, egui::Shape::Vec(lines));
236    }
237}
238
239fn valid(targets: &PriceTargets) -> bool {
240    [targets.high, targets.average, targets.low]
241        .into_iter()
242        .all(f64::is_finite)
243        && targets.low <= targets.average
244        && targets.average <= targets.high
245        && targets.analysts > 0
246        && targets.horizon > targets.published
247}
248
249fn percent_change(close: f64, target: f64) -> Option<f64> {
250    if close == 0.0 {
251        return None;
252    }
253    let change = (target - close) / close * 100.0;
254    change.is_finite().then_some(change)
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::data::{Datafeed, HistoryPage, HistoryRequest, Reply};
261    use crate::widget::SeriesId;
262    use crate::widget::test_frames;
263    use std::sync::Mutex;
264
265    #[derive(Default)]
266    struct Feed(Mutex<Vec<(String, Reply<PriceTargets>)>>);
267    impl Datafeed for Feed {
268        fn history(&self, _: HistoryRequest, reply: Reply<HistoryPage>) {
269            reply.send(Ok(HistoryPage::new(vec![], true)));
270        }
271        fn price_targets(&self, symbol: &str, reply: Reply<PriceTargets>) {
272            self.0.lock().unwrap().push((symbol.into(), reply));
273        }
274    }
275    fn targets() -> PriceTargets {
276        let published = chrono::NaiveDate::from_ymd_opt(2024, 2, 1).unwrap();
277        PriceTargets {
278            high: 120.0,
279            average: 110.0,
280            low: 80.0,
281            analysts: 12,
282            published,
283            horizon: published + chrono::TimeDelta::days(365),
284        }
285    }
286
287    #[test]
288    fn target_labels_use_the_last_close_and_clear_on_symbol_changes() {
289        let ctx = egui::Context::default();
290        let feed = Arc::new(Feed::default());
291        let mut chart = crate::widget::ChartBuilder::new()
292            .with_feed(feed.clone())
293            .with_symbol("A")
294            .build();
295        chart.set_data(
296            SeriesId::MAIN,
297            test_frames::chart(&[95.0], &[100.0])
298                .state
299                .data()
300                .bars
301                .clone(),
302        );
303        chart.set_timeframe(crate::model::Timeframe::Day1);
304        chart.set_feed_paused(true);
305        chart.set_price_targets_visible(true);
306        chart.pump_targets(&ctx);
307        let (_, old) = feed.0.lock().unwrap().pop().unwrap();
308        chart.set_symbol("B");
309        old.send(Ok(targets()));
310        chart.pump_targets(&ctx);
311        assert!(chart.price_targets().is_none(), "stale symbol reply");
312        let (symbol, reply) = feed.0.lock().unwrap().pop().unwrap();
313        assert_eq!(symbol, "B");
314        reply.send(Ok(targets()));
315        chart.pump_targets(&ctx);
316        assert_eq!(chart.price_targets(), Some(&targets()));
317        test_frames::frame(&ctx, &mut chart, 0.0, vec![]);
318        let horizon = chart
319            .time_to_index(targets().horizon.and_hms_opt(0, 0, 0).unwrap().and_utc())
320            .unwrap() as f32;
321        chart.set_visible_logical_range(crate::model::LogicalRange {
322            left: 0.0,
323            right: horizon + 200.0,
324        });
325        let shapes = test_frames::shapes(&ctx, &mut chart, 0.1, vec![]);
326        for label in [
327            "High 120.00 (+20.00%)",
328            "Average 110.00 (+10.00%)",
329            "Low 80.00 (-20.00%)",
330        ] {
331            assert!(
332                shapes
333                    .iter()
334                    .any(|s| matches!(s, egui::Shape::Text(t) if t.galley.text() == label)),
335                "missing {label}"
336            );
337        }
338        chart.set_visible_logical_range(crate::model::LogicalRange::new(0.0, 40.0));
339        let shapes = test_frames::shapes(&ctx, &mut chart, 0.101, vec![]);
340        assert!(
341            !shapes.iter().any(
342                |s| matches!(s, egui::Shape::Text(t) if t.galley.text().starts_with("High 120"))
343            ),
344            "projections must stay to the right of the last bar when viewing older history"
345        );
346        assert!(
347            chart.target_prices().is_empty(),
348            "offscreen targets do not widen the price range"
349        );
350        chart.set_visible_logical_range(crate::model::LogicalRange::new(60.0, 130.0));
351        let shapes = test_frames::shapes(&ctx, &mut chart, 0.102, vec![]);
352        let label = shapes
353            .iter()
354            .find_map(|s| match s {
355                egui::Shape::Text(t) if t.galley.text().starts_with("High 120") => {
356                    Some(egui::Rect::from_min_size(t.pos, t.galley.size()))
357                }
358                _ => None,
359            })
360            .unwrap();
361        ctx.all_styles_mut(|style| {
362            style.interaction.tooltip_delay = 0.0;
363            style.interaction.show_tooltips_only_when_still = false;
364        });
365        let mut hover_shapes = Vec::new();
366        for time in [0.103, 0.104, 0.105] {
367            hover_shapes = test_frames::shapes(
368                &ctx,
369                &mut chart,
370                time,
371                vec![egui::Event::PointerMoved(label.center())],
372            );
373        }
374        assert!(
375            hover_shapes.iter().any(
376                |s| matches!(s, egui::Shape::Text(t) if t.galley.text().contains("12 analysts"))
377            ),
378            "a visible label retains its tooltip when the horizon is offscreen"
379        );
380        chart.set_visible_logical_range(crate::model::LogicalRange::new(0.0, horizon + 200.0));
381        for custom in [false, true] {
382            chart.set_price_formatter(custom.then(|| {
383                Arc::new(crate::scales::CustomPriceFormatter::new(|price| {
384                    format!("host {price:.2}")
385                })) as Arc<dyn PriceFormatter>
386            }));
387            for mode in [
388                crate::scales::PriceScaleMode::Normal,
389                crate::scales::PriceScaleMode::Percentage,
390                crate::scales::PriceScaleMode::IndexedTo100,
391            ] {
392                chart.price_scale_mut().options_mut().mode = mode;
393                let shapes = test_frames::shapes(&ctx, &mut chart, 0.11, vec![]);
394                let label = if custom {
395                    "High host 120.00 (+20.00%)"
396                } else {
397                    "High 120.00 (+20.00%)"
398                };
399                assert!(
400                    shapes
401                        .iter()
402                        .any(|s| matches!(s, egui::Shape::Text(t) if t.galley.text() == label)),
403                    "target prices keep the host format and instrument units on {mode:?} scales"
404                );
405            }
406        }
407        chart.set_price_formatter(None);
408        chart.price_scale_mut().options_mut().mode = crate::scales::PriceScaleMode::Normal;
409        chart.state.time_scale_mut().set_min_bar_spacing(0.01);
410        chart.set_size(Some(Vec2::new(260.0, 600.0)));
411        chart.set_quote(Some(crate::model::Level1Quote {
412            ts: chart.state.shown().last().unwrap().time,
413            symbol: "B".into(),
414            bid: 99.0,
415            bid_size: 1.0,
416            ask: 101.0,
417            ask_size: 1.0,
418        }));
419        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
420            chart.set_theme(&ctx, &theme);
421            test_frames::frame(&ctx, &mut chart, 0.111, vec![egui::Event::PointerGone]);
422            chart.set_visible_logical_range(crate::model::LogicalRange::new(0.0, horizon + 200.0));
423            let shapes = test_frames::shapes(&ctx, &mut chart, 0.112, vec![]);
424            let quote_bottom = shapes
425                .iter()
426                .filter_map(|shape| match shape {
427                    egui::Shape::Text(text) if ["SELL", "BUY"].contains(&text.galley.text()) => {
428                        Some(text.pos.y + text.galley.size().y)
429                    }
430                    _ => None,
431                })
432                .reduce(f32::max)
433                .unwrap();
434            let labels: Vec<_> = shapes
435                .iter()
436                .filter_map(|shape| match shape {
437                    egui::Shape::Text(text)
438                        if ["High 120", "Average 110", "Low 80"]
439                            .iter()
440                            .any(|prefix| text.galley.text().starts_with(prefix)) =>
441                    {
442                        Some(text)
443                    }
444                    _ => None,
445                })
446                .collect();
447            assert_eq!(labels.len(), 3);
448            assert!(
449                labels.iter().all(|text| text.pos.y > quote_bottom),
450                "target labels stay below quote controls in a narrow chart"
451            );
452        }
453        chart.set_size(None);
454        chart.set_quote(None);
455        test_frames::frame(&ctx, &mut chart, 0.113, vec![]);
456        chart.reload_price_targets();
457        chart.pump_targets(&ctx);
458        let mut equal = targets();
459        equal.high = 100.0;
460        equal.average = 100.0;
461        equal.low = 100.0;
462        feed.0.lock().unwrap().pop().unwrap().1.send(Ok(equal));
463        chart.pump_targets(&ctx);
464        let shapes = test_frames::shapes(&ctx, &mut chart, 0.12, vec![]);
465        let labels: Vec<_> = shapes
466            .iter()
467            .filter_map(|s| match s {
468                egui::Shape::Text(t)
469                    if ["High 100.00", "Average 100.00", "Low 100.00"]
470                        .iter()
471                        .any(|prefix| t.galley.text().starts_with(prefix)) =>
472                {
473                    Some(egui::Rect::from_min_size(t.pos, t.galley.size()))
474                }
475                _ => None,
476            })
477            .collect();
478        assert_eq!(labels.len(), 3);
479        for (i, label) in labels.iter().enumerate() {
480            for other in &labels[i + 1..] {
481                assert!(
482                    !label.intersects(*other),
483                    "equal estimates must retain three readable labels"
484                );
485            }
486        }
487        chart.user_action(|chart| chart.set_price_targets_visible(false));
488        assert!(chart.undo());
489        assert!(chart.price_targets_visible());
490        assert!(chart.redo());
491        assert!(!chart.price_targets_visible());
492        let shapes = test_frames::shapes(&ctx, &mut chart, 0.2, vec![]);
493        assert!(
494            !shapes.iter().any(|s| matches!(s, egui::Shape::Text(t) if
495                ["High 100.00", "Average 100.00", "Low 100.00"].iter().any(|prefix| t.galley.text().starts_with(prefix))))
496        );
497        assert!(
498            chart.price_targets().is_some(),
499            "hiding retains the response"
500        );
501        chart.set_symbol("unsupported");
502        chart.set_price_targets_visible(true);
503        chart.pump_targets(&ctx);
504        feed.0
505            .lock()
506            .unwrap()
507            .pop()
508            .unwrap()
509            .1
510            .send(Err(FeedError::NotSupported));
511        chart.pump_targets(&ctx);
512        assert!(chart.price_targets().is_none());
513        assert!(chart.take_price_targets_error().is_none());
514        for (close, target, expected) in [
515            (100.0, 120.0, Some(20.0)),
516            (100.0, 80.0, Some(-20.0)),
517            (-100.0, -80.0, Some(-20.0)),
518            (0.0, 10.0, None),
519        ] {
520            assert_eq!(percent_change(close, target), expected);
521        }
522    }
523}