1use 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 pub fn set_price_targets_visible(&mut self, visible: bool) {
25 self.show_price_targets = visible;
26 }
27
28 pub fn price_targets_visible(&self) -> bool {
30 self.show_price_targets
31 }
32
33 pub fn price_targets(&self) -> Option<&PriceTargets> {
35 self.targets.values.as_ref()
36 }
37
38 pub fn reload_price_targets(&mut self) {
40 self.targets = TargetState::default();
41 }
42
43 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}