Skip to main content

egui_charts/widget/
signals.rs

1//! Host analytics, scoped to the chart's symbol and interval.
2
3use super::Chart;
4use crate::chart::coords::ChartMapping;
5use crate::data::inbox::Inbox;
6use crate::localization as l10n;
7use crate::model::{Direction, Signal, SignalShape, Timeframe};
8use crate::services::{Listener, SignalEvent, Signals};
9use crate::theme::ThemeExt;
10use egui::{FontId, Pos2, Rect, Sense, Stroke, Vec2};
11use std::sync::Arc;
12
13pub(super) struct SignalSource {
14    source: Arc<dyn Signals>,
15    inbox: Arc<Inbox>,
16    listener: Option<Listener>,
17    scope: Option<(String, Timeframe)>,
18    signals: Vec<Signal>,
19}
20
21impl SignalSource {
22    fn pump(&mut self, ctx: &egui::Context, scope: &(String, Timeframe)) {
23        if self.scope.as_ref() != Some(scope)
24            || self.listener.as_ref().is_none_or(|l| !l.is_active())
25        {
26            self.listener = None;
27            self.signals.clear();
28            self.inbox = Arc::default();
29            let repaint = ctx.clone();
30            let viewport = ctx.viewport_id();
31            self.inbox
32                .set_waker(move || repaint.request_repaint_of(viewport));
33            let (listener, events) = Listener::new(&self.inbox, 0);
34            self.listener = Some(listener);
35            self.scope = Some(scope.clone());
36            self.source.subscribe(&scope.0, scope.1, events);
37        }
38        if self.listener.as_ref().is_some_and(|l| !l.is_active()) {
39            self.signals.clear();
40            ctx.request_repaint();
41            return;
42        }
43        for (_, event) in self.inbox.drain().answers {
44            match *event.downcast::<SignalEvent>().expect("signal event") {
45                SignalEvent::Upsert(signal) => {
46                    if let Some(index) = self.signals.iter().position(|old| old.id == signal.id) {
47                        self.signals.remove(index);
48                    }
49                    if valid(&signal.shape) {
50                        self.signals.push(signal);
51                    }
52                }
53                SignalEvent::Remove(id) => self.signals.retain(|signal| signal.id != id),
54                SignalEvent::Clear => self.signals.clear(),
55            }
56        }
57    }
58}
59
60impl Chart {
61    /// Connects host analytics. Replacing or removing the service clears its signals and stream.
62    pub fn set_signal_source(&mut self, source: Option<Arc<dyn Signals>>) {
63        self.set_signal_sources(source.into_iter().collect());
64    }
65
66    /// Connects several analytics services, each with its own stream and signals, so that one
67    /// source's `Clear` or lagged stream leaves the others' signals in place. Replacing the
68    /// sources clears their signals and streams.
69    pub fn set_signal_sources(&mut self, sources: Vec<Arc<dyn Signals>>) {
70        self.signal_sources = sources
71            .into_iter()
72            .map(|source| SignalSource {
73                source,
74                inbox: Arc::default(),
75                listener: None,
76                scope: None,
77                signals: Vec::new(),
78            })
79            .collect();
80    }
81
82    /// Hides or restores a category without discarding signals or requesting them again.
83    pub fn set_signal_category_visible(&mut self, category: &str, visible: bool) {
84        if visible {
85            self.hidden_signal_categories.remove(category);
86        } else {
87            self.hidden_signal_categories.insert(category.into());
88        }
89    }
90
91    pub(super) fn clear_source_signals(&mut self) {
92        for source in &mut self.signal_sources {
93            source.listener = None;
94            source.scope = None;
95            source.signals.clear();
96            source.inbox = Arc::default();
97        }
98    }
99
100    pub(super) fn pump_signals(&mut self, ctx: &egui::Context) {
101        let scope = (self.symbol.clone(), self.timeframe.unwrap_or_default());
102        for source in &mut self.signal_sources {
103            source.pump(ctx, &scope);
104        }
105    }
106
107    pub(super) fn paint_signals(&self, ui: &mut egui::Ui, coords: &ChartMapping) {
108        if self
109            .signal_sources
110            .iter()
111            .all(|source| source.signals.is_empty())
112        {
113            return;
114        }
115        let theme = ui.active_theme();
116        let painter = ui.painter().with_clip_rect(coords.rect);
117        let bars = self.state.shown();
118        let x = |time| {
119            crate::chart::rendering::candles::price_based::element_index_at(
120                &self.state,
121                self.chart_type,
122                &self.chart_type_params,
123                self.older_bars_drawn,
124                time,
125            )
126            .map(|index| index as f64)
127            .or_else(|| self.time_to_index(time))
128            .map(|index| coords.idx_to_x(0) + index as f32 * coords.bar_spacing)
129        };
130        let mut occupied = Vec::new();
131        let half_width = (coords.bar_spacing * self.config.candle_width)
132            .max(self.config.wick_width)
133            .max(1.0)
134            / 2.0;
135        if self.main_series_visible {
136            let end = coords
137                .x_to_idx(coords.rect.right())
138                .saturating_add(1)
139                .min(bars.len());
140            let mut reserve = |left, right, bar: &crate::model::Bar| {
141                if !bar.is_whitespace() {
142                    occupied.push(Rect::from_two_pos(
143                        Pos2::new(left, coords.price.price_to_y(bar.high)),
144                        Pos2::new(right, coords.price.price_to_y(bar.low)),
145                    ));
146                }
147            };
148            let factor = self.merge_groups.factor();
149            if factor > 1 {
150                for index in self.merge_groups.covering(coords.start_idx..end) {
151                    let first = index * factor;
152                    let last = (first + factor).min(bars.len()) - 1;
153                    let left = coords.idx_to_x(first);
154                    let right = coords.idx_to_x(last);
155                    let center = (left + right) / 2.0;
156                    let half_group = ((right - left).abs() / 2.0 + half_width)
157                        .max(coords.bar_spacing * factor as f32 * self.config.candle_width / 2.0);
158                    reserve(
159                        center - half_group,
160                        center + half_group,
161                        &self.merge_groups.groups[index].bar,
162                    );
163                }
164            } else {
165                for (index, bar) in bars.iter().enumerate().take(end).skip(coords.start_idx) {
166                    let x = coords.idx_to_x(index);
167                    reserve(x - half_width, x + half_width, bar);
168                }
169            }
170        }
171        let price_obstacles = occupied.len();
172        let mut visible = Vec::new();
173        // Zones stay behind their markers, including hit targets after a zone update.
174        let signals = [false, true].into_iter().flat_map(|markers| {
175            self.signal_sources
176                .iter()
177                .enumerate()
178                .flat_map(move |(index, source)| {
179                    source
180                        .signals
181                        .iter()
182                        .filter(move |signal| {
183                            matches!(signal.shape, SignalShape::Marker { .. }) == markers
184                        })
185                        .map(move |signal| (index, source, signal))
186                })
187        });
188        for (index, source, signal) in signals {
189            if self.hidden_signal_categories.contains(&signal.category) {
190                continue;
191            }
192            let neutral = theme.ui.accent;
193            let (hit, label, color) = match signal.shape {
194                SignalShape::Marker {
195                    time,
196                    price,
197                    direction,
198                } => {
199                    let Some(index) =
200                        crate::chart::rendering::candles::price_based::element_index_at(
201                            &self.state,
202                            self.chart_type,
203                            &self.chart_type_params,
204                            self.older_bars_drawn,
205                            time,
206                        )
207                        .map(|index| index as f64)
208                        .or_else(|| self.time_to_index(time).map(f64::floor))
209                    else {
210                        continue;
211                    };
212                    let px = coords.idx_to_x(0) + index as f32 * coords.bar_spacing;
213                    let color = match direction {
214                        Direction::Up => theme.chart.candle_up,
215                        Direction::Down => theme.chart.candle_down,
216                        Direction::Neutral => neutral,
217                    };
218                    let value = if let Some(price) = price {
219                        price
220                    } else {
221                        if index < 0.0 {
222                            continue;
223                        }
224                        let Some(bar) = bars.get(index as usize) else {
225                            continue;
226                        };
227                        if direction == Direction::Up {
228                            bar.low
229                        } else {
230                            bar.high
231                        }
232                    };
233                    if !value.is_finite() {
234                        continue;
235                    }
236                    let y = coords.price.price_to_y(value)
237                        + if price.is_some() {
238                            0.0
239                        } else if direction == Direction::Up {
240                            12.0
241                        } else {
242                            -12.0
243                        };
244                    let point = Pos2::new(px, y);
245                    if !coords.rect.expand(8.0).contains(point) {
246                        continue;
247                    }
248                    let sign = if direction == Direction::Up {
249                        1.0
250                    } else {
251                        -1.0
252                    };
253                    if direction == Direction::Neutral {
254                        painter.circle(point, 4.0, color, Stroke::new(1.0, theme.chart.bg));
255                    } else {
256                        painter.add(egui::Shape::convex_polygon(
257                            vec![
258                                point + Vec2::new(0.0, -5.0 * sign),
259                                point + Vec2::new(-5.0, 4.0 * sign),
260                                point + Vec2::new(5.0, 4.0 * sign),
261                            ],
262                            color,
263                            Stroke::new(1.0, theme.chart.bg),
264                        ));
265                    }
266                    (
267                        Rect::from_center_size(point, Vec2::splat(14.0)),
268                        point + Vec2::new(9.0, 0.0),
269                        color,
270                    )
271                }
272                SignalShape::Level { price, from } => {
273                    let left = match from {
274                        Some(time) => {
275                            let Some(x) = x(time) else {
276                                continue;
277                            };
278                            x.max(coords.rect.left())
279                        }
280                        None => coords.rect.left(),
281                    };
282                    let y = coords.price.price_to_y(price);
283                    if left > coords.rect.right() || !coords.rect.y_range().contains(y) {
284                        continue;
285                    }
286                    painter.line_segment(
287                        [Pos2::new(left, y), Pos2::new(coords.rect.right(), y)],
288                        Stroke::new(1.0, neutral),
289                    );
290                    (
291                        Rect::from_min_max(
292                            Pos2::new(left, y - 4.0),
293                            Pos2::new(coords.rect.right(), y + 4.0),
294                        ),
295                        Pos2::new(left + 6.0, y - 11.0),
296                        neutral,
297                    )
298                }
299                SignalShape::PriceZone {
300                    from,
301                    to,
302                    price,
303                    direction,
304                } => {
305                    let Some(left) = x(from) else {
306                        continue;
307                    };
308                    let right = match to {
309                        Some(time) => {
310                            let Some(right) = x(time) else {
311                                continue;
312                            };
313                            right
314                        }
315                        None => coords.rect.right(),
316                    };
317                    let y = coords.price.price_to_y(price);
318                    if right < coords.rect.left()
319                        || left > coords.rect.right()
320                        || !coords.rect.y_range().contains(y)
321                    {
322                        continue;
323                    }
324                    let color = match direction {
325                        Direction::Up => theme.chart.candle_up,
326                        Direction::Down => theme.chart.candle_down,
327                        Direction::Neutral => neutral,
328                    };
329                    let left = left.max(coords.rect.left());
330                    let right = right.max(left + 1.0).min(coords.rect.right());
331                    painter.line_segment(
332                        [Pos2::new(left, y), Pos2::new(right, y)],
333                        Stroke::new(2.0, color),
334                    );
335                    (
336                        Rect::from_min_max(Pos2::new(left, y - 4.0), Pos2::new(right, y + 4.0)),
337                        Pos2::new(left + 6.0, y - 11.0),
338                        color,
339                    )
340                }
341                SignalShape::Zone {
342                    from,
343                    to,
344                    low,
345                    high,
346                } => {
347                    let Some(left) = x(from) else {
348                        continue;
349                    };
350                    let right = match to {
351                        Some(time) => {
352                            let Some(x) = x(time) else {
353                                continue;
354                            };
355                            x
356                        }
357                        None => coords.rect.right(),
358                    };
359                    let rect = Rect::from_two_pos(
360                        Pos2::new(left, coords.price.price_to_y(high)),
361                        Pos2::new(right, coords.price.price_to_y(low)),
362                    )
363                    .intersect(coords.rect);
364                    if !rect.is_positive() {
365                        continue;
366                    }
367                    painter.rect_filled(rect, 0.0, neutral.linear_multiply(0.12));
368                    painter.rect_stroke(
369                        rect,
370                        0.0,
371                        Stroke::new(1.0, neutral),
372                        egui::StrokeKind::Inside,
373                    );
374                    (rect, rect.left_top() + Vec2::new(6.0, 13.0), neutral)
375                }
376            };
377            match signal.shape {
378                SignalShape::Marker { .. } => occupied.push(hit.shrink(2.0)),
379                SignalShape::Level { .. } | SignalShape::PriceZone { .. } => {
380                    occupied.push(Rect::from_center_size(
381                        hit.center(),
382                        Vec2::new(hit.width(), 2.0),
383                    ));
384                }
385                SignalShape::Zone { .. } => {
386                    for [a, b] in [
387                        [hit.left_top(), hit.right_top()],
388                        [hit.right_top(), hit.right_bottom()],
389                        [hit.right_bottom(), hit.left_bottom()],
390                        [hit.left_bottom(), hit.left_top()],
391                    ] {
392                        occupied.push(Rect::from_two_pos(a, b).expand(0.5));
393                    }
394                }
395            }
396            visible.push((index, source, signal, hit, label, color, None));
397        }
398        // Recent marker captions take space before older captions and zone names.
399        for markers in [true, false] {
400            for (_, _, signal, hit, anchor, color, caption) in visible.iter_mut().rev() {
401                if matches!(signal.shape, SignalShape::Marker { .. }) != markers {
402                    continue;
403                }
404                let Some(text) = &signal.label else { continue };
405                let galley = painter.layout_no_wrap(
406                    l10n::text(ui.ctx(), text),
407                    FontId::proportional(11.0),
408                    *color,
409                );
410                let pos = *anchor - Vec2::new(0.0, galley.size().y / 2.0);
411                let bounds = galley.mesh_bounds.translate(pos.to_vec2());
412                let beside_body = if markers
413                    && occupied[..price_obstacles]
414                        .iter()
415                        .any(|obstacle| obstacle.intersects(bounds.expand(2.0)))
416                {
417                    (half_width - 5.0).max(0.0)
418                } else {
419                    0.0
420                };
421                for offset in [0.0, beside_body] {
422                    let pos = pos + Vec2::new(offset, 0.0);
423                    let bounds = bounds.translate(Vec2::new(offset, 0.0));
424                    let padded = bounds.expand(2.0);
425                    if coords.rect.contains_rect(padded)
426                        && !occupied.iter().any(|obstacle| obstacle.intersects(padded))
427                    {
428                        occupied.push(padded);
429                        *hit = hit.union(bounds);
430                        *caption = Some((pos, galley));
431                        break;
432                    }
433                }
434            }
435        }
436        let pointer_layer = ui
437            .input(|input| input.pointer.hover_pos())
438            .and_then(|point| ui.ctx().layer_id_at(point));
439        for (index, source, signal, hit, anchor, color, caption) in &visible {
440            if let Some((pos, galley)) = caption {
441                painter.galley(*pos, galley.clone(), *color);
442            }
443            let response = ui.interact(
444                hit.intersect(coords.rect),
445                ui.scope_id().with(("host_signal", *index, &signal.id.0)),
446                Sense::click(),
447            );
448            if response.clicked() {
449                source.source.clicked(&signal.id);
450            }
451            // Wheel input normally dismisses tooltips; retain the active list under the pointer.
452            let inside_tooltip = response.is_tooltip_open()
453                && pointer_layer
454                    == Some(egui::LayerId::new(
455                        egui::Order::Tooltip,
456                        egui::Tooltip::next_tooltip_id(ui.ctx(), response.id),
457                    ));
458            if response.enabled()
459                && (inside_tooltip || egui::Tooltip::should_show_tooltip(&response, true))
460            {
461                let marker = matches!(signal.shape, SignalShape::Marker { .. });
462                let compact =
463                    Rect::from_center_size(*anchor - Vec2::new(9.0, 0.0), Vec2::splat(14.0));
464                let details: Vec<_> = visible
465                    .iter()
466                    .filter(|(_, _, other, other_hit, ..)| {
467                        if marker {
468                            other_hit.intersects(compact)
469                        } else {
470                            std::ptr::eq(*other, *signal)
471                        }
472                    })
473                    .flat_map(|(_, _, other, ..)| {
474                        other
475                            .label
476                            .iter()
477                            .chain(other.tooltip.iter())
478                            .map(|text| l10n::text(ui.ctx(), text))
479                    })
480                    .collect();
481                if !details.is_empty() {
482                    egui::Tooltip::for_widget(&response).show(|ui| {
483                        egui::ScrollArea::vertical()
484                            .max_height(320.0)
485                            .show(ui, |ui| {
486                                for text in &details {
487                                    ui.label(text);
488                                }
489                            });
490                    });
491                }
492            }
493        }
494    }
495
496    pub(super) fn show_signal_menu(&mut self, ui: &mut egui::Ui, rect: Rect) {
497        let categories: std::collections::BTreeSet<_> = self
498            .signal_sources
499            .iter()
500            .flat_map(|source| &source.signals)
501            .map(|signal| signal.category.clone())
502            .collect();
503        if !categories.is_empty() {
504            ui.scope_builder(
505                egui::UiBuilder::new().max_rect(Rect::from_min_size(
506                    rect.right_top() + Vec2::new(-85.0, 8.0),
507                    Vec2::new(80.0, 24.0),
508                )),
509                |ui| {
510                    ui.menu_button(l10n::text(ui.ctx(), "Signals"), |ui| {
511                        for category in categories {
512                            let mut visible = !self.hidden_signal_categories.contains(&category);
513                            if ui
514                                .checkbox(&mut visible, l10n::text(ui.ctx(), &category))
515                                .changed()
516                            {
517                                self.set_signal_category_visible(&category, visible);
518                            }
519                        }
520                    });
521                },
522            );
523        }
524    }
525}
526
527fn valid(shape: &SignalShape) -> bool {
528    match *shape {
529        SignalShape::Marker { price, .. } => price.is_none_or(f64::is_finite),
530        SignalShape::Level { price, .. } => price.is_finite(),
531        SignalShape::PriceZone {
532            from, to, price, ..
533        } => price.is_finite() && to.is_none_or(|to| to >= from),
534        SignalShape::Zone {
535            from,
536            to,
537            low,
538            high,
539        } => low.is_finite() && high.is_finite() && low <= high && to.is_none_or(|to| to >= from),
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use crate::model::SignalId;
547    use crate::services::Events;
548    use crate::widget::test_frames;
549    use std::sync::Mutex;
550
551    #[derive(Default)]
552    struct Host {
553        streams: Mutex<Vec<(String, Timeframe, Events<SignalEvent>)>>,
554        clicks: Mutex<Vec<SignalId>>,
555    }
556    impl Signals for Host {
557        fn subscribe(&self, symbol: &str, timeframe: Timeframe, events: Events<SignalEvent>) {
558            self.streams
559                .lock()
560                .unwrap()
561                .push((symbol.into(), timeframe, events));
562        }
563        fn clicked(&self, id: &SignalId) {
564            self.clicks.lock().unwrap().push(id.clone());
565        }
566    }
567
568    #[test]
569    fn dense_captions_leave_prices_and_signals_clear_without_losing_hover_details() {
570        for size in [Vec2::new(800.0, 600.0), Vec2::new(1440.0, 900.0)] {
571            for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
572                for visible in [390, 12] {
573                    let ctx = egui::Context::default();
574                    let mut chart = test_frames::chart(&[40.0], &[60.0]);
575                    chart.set_symbol("A");
576                    chart.set_timeframe(Timeframe::Day1);
577                    let first = chart.state.shown()[0].clone();
578                    let bars: Vec<_> = (0..390)
579                        .map(|i| {
580                            let mut bar = first.clone();
581                            bar.time += chrono::TimeDelta::days(i);
582                            bar.open = 45.0;
583                            bar.close = 55.0;
584                            bar
585                        })
586                        .collect();
587                    chart.set_data(crate::widget::SeriesId::MAIN, bars.clone());
588                    let hosts = [Arc::new(Host::default()), Arc::new(Host::default())];
589                    chart.set_signal_sources(hosts.iter().map(|host| host.clone() as _).collect());
590                    chart.pump_signals(&ctx);
591                    for (i, bar) in bars.iter().enumerate() {
592                        for (host, name, price) in [
593                            (&hosts[0], "First", 82.0),
594                            (&hosts[1], "Second", 82.0),
595                            (&hosts[0], "Body", 50.0),
596                            (&hosts[0], "Border", 72.0),
597                        ] {
598                            host.streams.lock().unwrap()[0]
599                                .2
600                                .push(SignalEvent::Upsert(Signal {
601                                    id: SignalId(format!("{name}-{i}")),
602                                    shape: SignalShape::Marker {
603                                        time: bar.time,
604                                        price: Some(price),
605                                        direction: Direction::Neutral,
606                                    },
607                                    category: "Executions".into(),
608                                    label: Some(format!("{name} {i}")),
609                                    tooltip: (name != "Second")
610                                        .then(|| format!("{name} details {i}: 500 lots")),
611                                }));
612                        }
613                    }
614                    for (label, price) in [("1", 96.0), ("2", 97.0)] {
615                        hosts[0].streams.lock().unwrap()[0]
616                            .2
617                            .push(SignalEvent::Upsert(Signal {
618                                id: SignalId(label.into()),
619                                shape: SignalShape::Marker {
620                                    time: bars[380].time,
621                                    price: Some(price),
622                                    direction: Direction::Neutral,
623                                },
624                                category: "Executions".into(),
625                                label: Some(label.into()),
626                                tooltip: None,
627                            }));
628                    }
629                    hosts[0].streams.lock().unwrap()[0]
630                        .2
631                        .push(SignalEvent::Upsert(Signal {
632                            id: SignalId("short-body-caption".into()),
633                            shape: SignalShape::Marker {
634                                time: bars[382].time,
635                                price: Some(50.0),
636                                direction: Direction::Neutral,
637                            },
638                            category: "Executions".into(),
639                            label: Some("5".into()),
640                            tooltip: None,
641                        }));
642                    // Insert the zone last: its borders must still reserve space before captions.
643                    hosts[0].streams.lock().unwrap()[0]
644                        .2
645                        .push(SignalEvent::Upsert(Signal {
646                            id: SignalId("zone".into()),
647                            shape: SignalShape::Zone {
648                                from: bars[0].time,
649                                to: None,
650                                low: 72.0,
651                                high: 90.0,
652                            },
653                            category: "Zones".into(),
654                            label: Some("Zone caption".into()),
655                            tooltip: Some("Zone details".into()),
656                        }));
657                    chart.pump_signals(&ctx);
658                    let rect = Rect::from_min_size(Pos2::ZERO, size);
659                    let coords = ChartMapping::new(
660                        rect,
661                        size.x / visible as f32,
662                        390 - visible,
663                        389,
664                        0.0,
665                        crate::scales::PriceMap::new(
666                            crate::scales::PriceTransform::default(),
667                            crate::scales::PriceRange::new(0.0, 100.0),
668                            0.0,
669                            size.y,
670                            false,
671                        ),
672                    );
673                    let candle_width = chart.config.candle_width;
674                    let mut frame = |time, events| {
675                        let mut output = ctx.run_ui(
676                            egui::RawInput {
677                                screen_rect: Some(rect),
678                                time: Some(time),
679                                events,
680                                ..Default::default()
681                            },
682                            |ui| {
683                                chart.set_theme(ui.ctx(), &theme);
684                                chart.paint_signals(ui, &coords);
685                            },
686                        );
687                        let shapes = std::mem::take(&mut output.shapes);
688                        output.drop_without_applying_deltas();
689                        shapes
690                    };
691                    let shapes = frame(0.0, vec![]);
692                    let captions: Vec<_> = shapes
693                        .iter()
694                        .filter_map(|clipped| match &clipped.shape {
695                            egui::Shape::Text(text) => {
696                                Some((text.galley.text(), text.visual_bounding_rect()))
697                            }
698                            _ => None,
699                        })
700                        .collect();
701                    assert!(captions.iter().any(|(text, _)| *text == "2"));
702                    assert!(
703                        !captions.iter().any(|(text, _)| *text == "1"),
704                        "the recent caption has priority"
705                    );
706                    assert!(
707                        captions.len() < visible * 4,
708                        "dense captions are suppressed"
709                    );
710                    assert_eq!(
711                        captions.iter().any(|(text, _)| *text == "5"),
712                        visible == 12,
713                        "zooming in restores a caption beside the candle body"
714                    );
715                    let markers = shapes
716                        .iter()
717                        .filter(|clipped| matches!(clipped.shape, egui::Shape::Circle(_)))
718                        .count();
719                    assert_eq!(markers, visible * 4 + 3, "every compact marker remains");
720                    for (i, (name, bounds)) in captions.iter().enumerate() {
721                        assert!(rect.contains_rect(*bounds), "clipped {name}");
722                        assert!(!name.starts_with("Border"), "obstructed {name}");
723                        for (_, other) in &captions[..i] {
724                            assert!(
725                                !bounds.expand(2.0).intersects(*other),
726                                "overlapping captions"
727                            );
728                        }
729                        // Prices 40..60 occupy y = 0.4h..0.6h; the zone edges are at 0.1h and 0.28h.
730                        for index in 390 - visible..390 {
731                            let x = coords.idx_to_x(index);
732                            let half_body = size.x / visible as f32 * candle_width / 2.0;
733                            assert!(!bounds.intersects(Rect::from_min_max(
734                                Pos2::new(x - half_body, size.y * 0.4),
735                                Pos2::new(x + half_body, size.y * 0.6)
736                            )));
737                        }
738                        for y in [size.y * 0.1, size.y * 0.28] {
739                            assert!(!(bounds.top() <= y && bounds.bottom() >= y));
740                        }
741                    }
742                    let point = Pos2::new(coords.idx_to_x(388), size.y * 0.18);
743                    frame(0.1, vec![egui::Event::PointerMoved(point)]);
744                    frame(1.0, vec![]);
745                    let hovered = frame(1.1, vec![]);
746                    let details = hovered
747                        .iter()
748                        .filter_map(|clipped| match &clipped.shape {
749                            egui::Shape::Text(text) => Some(text.galley.text()),
750                            _ => None,
751                        })
752                        .collect::<Vec<_>>()
753                        .join("\n");
754                    assert!(details.contains("First details 388: 500 lots"));
755                    if size.x == 800.0 && visible == 390 {
756                        let inside = hovered
757                            .iter()
758                            .find_map(|clipped| match &clipped.shape {
759                                egui::Shape::Text(text) if text.galley.text() == "Zone details" => {
760                                    Some(text.visual_bounding_rect().center())
761                                }
762                                _ => None,
763                            })
764                            .expect("tooltip entry");
765                        frame(1.2, vec![egui::Event::PointerMoved(inside)]);
766                        frame(
767                            1.3,
768                            vec![egui::Event::MouseWheel {
769                                unit: egui::MouseWheelUnit::Point,
770                                delta: Vec2::new(0.0, -1000.0),
771                                phase: egui::TouchPhase::Move,
772                                source: egui::MouseWheelSource::Unknown,
773                                modifiers: Default::default(),
774                            }],
775                        );
776                        let scrolled = frame(1.4, vec![]);
777                        assert!(
778                            scrolled.iter().any(|clipped| matches!(&clipped.shape,
779                            egui::Shape::Text(text) if text.galley.text() == "Second 388")),
780                            "moving into the tooltip keeps its events available to scroll: inside={inside:?}, texts={:?}",
781                            scrolled
782                                .iter()
783                                .filter_map(|clipped| match &clipped.shape {
784                                    egui::Shape::Text(text) => Some(text.galley.text()),
785                                    _ => None,
786                                })
787                                .collect::<Vec<_>>()
788                        );
789                    } else {
790                        assert!(
791                            details.contains("Second 388"),
792                            "a caption without a tooltip remains discoverable"
793                        );
794                    }
795                }
796            }
797        }
798    }
799
800    #[test]
801    fn host_signals_update_render_and_clear_with_the_subscription() {
802        let ctx = egui::Context::default();
803        let mut chart = test_frames::chart(&[95.0], &[105.0]);
804        chart.config_mut().show_volume = false;
805        chart.set_main_series_visible(false);
806        chart.price_scale_mut().set_range(94.0, 106.0);
807        chart.set_timeframe(Timeframe::Day1);
808        chart.set_symbol("A");
809        let host = Arc::new(Host::default());
810        chart.set_signal_source(Some(host.clone()));
811        test_frames::frame(&ctx, &mut chart, 0.0, vec![]);
812        let (symbol, timeframe, stream) = host.streams.lock().unwrap().pop().unwrap();
813        assert_eq!((symbol.as_str(), timeframe), ("A", Timeframe::Day1));
814        let time = chart.state.shown()[70].time;
815        let signal = |shape, name: &str| Signal {
816            id: SignalId(name.into()),
817            shape,
818            category: name.into(),
819            label: Some(name.into()),
820            tooltip: Some("Host details".into()),
821        };
822        let marker = signal(
823            SignalShape::Marker {
824                time,
825                price: Some(100.0),
826                direction: Direction::Up,
827            },
828            "Marker",
829        );
830        for item in [
831            marker.clone(),
832            signal(
833                SignalShape::Level {
834                    price: 104.0,
835                    from: None,
836                },
837                "Level",
838            ),
839            signal(
840                SignalShape::Zone {
841                    from: time,
842                    to: None,
843                    low: 96.0,
844                    high: 98.0,
845                },
846                "Zone",
847            ),
848        ] {
849            stream.push(SignalEvent::Upsert(item));
850        }
851        let shapes = test_frames::shapes(&ctx, &mut chart, 0.1, vec![]);
852        let label_at = |shapes: &[egui::Shape], label: &str| {
853            shapes.iter().find_map(|shape| match shape {
854                egui::Shape::Text(text) if text.galley.text() == label => Some(text.pos),
855                _ => None,
856            })
857        };
858        for label in ["Marker", "Level", "Zone"] {
859            assert!(label_at(&shapes, label).is_some(), "missing {label}");
860        }
861        let expected_x = chart.index_to_x(70.0).unwrap();
862        assert!((label_at(&shapes, "Marker").unwrap().x - expected_x - 9.0).abs() < 0.01);
863        let point = Pos2::new(expected_x, chart.last_price_map.price_to_y(100.0));
864        test_frames::frame(
865            &ctx,
866            &mut chart,
867            0.2,
868            vec![egui::Event::PointerMoved(point)],
869        );
870        for pressed in [true, false] {
871            test_frames::frame(
872                &ctx,
873                &mut chart,
874                0.3 + f64::from(!pressed) * 0.1,
875                vec![egui::Event::PointerButton {
876                    pos: point,
877                    button: egui::PointerButton::Primary,
878                    pressed,
879                    modifiers: Default::default(),
880                }],
881            );
882        }
883        assert_eq!(
884            host.clicks.lock().unwrap().as_slice(),
885            std::slice::from_ref(&marker.id)
886        );
887        chart.set_signal_category_visible("Marker", false);
888        let shapes = test_frames::shapes(&ctx, &mut chart, 0.5, vec![]);
889        assert!(label_at(&shapes, "Marker").is_none());
890        chart.set_signal_category_visible("Marker", true);
891        let mut replacement = marker.clone();
892        replacement.label = Some("Updated".into());
893        stream.push(SignalEvent::Upsert(replacement));
894        let shapes = test_frames::shapes(&ctx, &mut chart, 0.6, vec![]);
895        assert!(label_at(&shapes, "Marker").is_none());
896        assert!(label_at(&shapes, "Updated").is_some());
897        let future = signal(
898            SignalShape::Marker {
899                time: chart.state.shown().last().unwrap().time + chrono::TimeDelta::days(5),
900                price: Some(100.0),
901                direction: Direction::Neutral,
902            },
903            "Future",
904        );
905        chart.set_visible_logical_range(crate::model::LogicalRange::new(40.0, 120.0));
906        stream.push(SignalEvent::Upsert(future.clone()));
907        let shapes = test_frames::shapes(&ctx, &mut chart, 0.61, vec![]);
908        assert!(
909            (label_at(&shapes, "Future")
910                .expect("priced marker in future whitespace")
911                .x
912                - chart.index_to_x(104.0).unwrap()
913                - 9.0)
914                .abs()
915                < 0.01
916        );
917        stream.push(SignalEvent::Remove(future.id));
918        let mut bars = chart.state.shown().to_vec();
919        let first = bars[0].clone();
920        for days in 1..=10 {
921            let mut older = first.clone();
922            older.time -= chrono::TimeDelta::days(days);
923            bars.push(older);
924        }
925        chart.set_data(crate::widget::SeriesId::MAIN, bars);
926        let shapes = test_frames::shapes(&ctx, &mut chart, 0.7, vec![]);
927        assert!(
928            (label_at(&shapes, "Updated").unwrap().x - chart.index_to_x(80.0).unwrap() - 9.0).abs()
929                < 0.01
930        );
931        stream.push(SignalEvent::Remove(marker.id.clone()));
932        test_frames::frame(&ctx, &mut chart, 0.8, vec![]);
933        assert_eq!(chart.signal_sources[0].signals.len(), 2);
934        stream.push(SignalEvent::Clear);
935        test_frames::frame(&ctx, &mut chart, 0.9, vec![]);
936        assert!(chart.signal_sources[0].signals.is_empty());
937        chart.set_timeframe(Timeframe::Hour1);
938        assert!(!stream.is_active());
939        test_frames::frame(&ctx, &mut chart, 1.0, vec![]);
940        let (_, interval, new) = host.streams.lock().unwrap().pop().unwrap();
941        assert_eq!(interval, Timeframe::Hour1);
942        stream.push(SignalEvent::Upsert(marker.clone()));
943        new.push(SignalEvent::Upsert(marker.clone()));
944        test_frames::frame(&ctx, &mut chart, 1.1, vec![]);
945        assert_eq!(chart.signal_sources[0].signals.len(), 1);
946        chart.set_symbol("B");
947        assert!(!new.is_active());
948        test_frames::frame(&ctx, &mut chart, 1.2, vec![]);
949        let (symbol, _, stream) = host.streams.lock().unwrap().pop().unwrap();
950        assert_eq!(symbol, "B");
951        assert!(chart.signal_sources[0].signals.is_empty());
952        for _ in 0..10_001 {
953            stream.push(SignalEvent::Upsert(marker.clone()));
954        }
955        test_frames::frame(&ctx, &mut chart, 1.3, vec![]);
956        assert!(!stream.is_active());
957        assert!(chart.signal_sources[0].signals.is_empty());
958        assert_eq!(
959            host.streams.lock().unwrap().len(),
960            1,
961            "lag starts a new stream"
962        );
963        chart.set_signal_source(None);
964        assert!(chart.signal_sources.is_empty());
965    }
966
967    #[test]
968    fn a_clear_from_one_source_leaves_the_signals_of_the_others() {
969        let ctx = egui::Context::default();
970        let mut chart = test_frames::chart(&[95.0], &[105.0]);
971        chart.config_mut().show_volume = false;
972        chart.set_main_series_visible(false);
973        chart.price_scale_mut().set_range(94.0, 106.0);
974        chart.set_timeframe(Timeframe::Day1);
975        chart.set_symbol("A");
976        let hosts = [Arc::new(Host::default()), Arc::new(Host::default())];
977        chart.set_signal_sources(hosts.iter().map(|host| host.clone() as _).collect());
978        test_frames::frame(&ctx, &mut chart, 0.0, vec![]);
979        // Both sources use the same identity: it belongs to each subscription.
980        for (i, (host, label)) in hosts.iter().zip(["From A", "From B"]).enumerate() {
981            host.streams.lock().unwrap()[0]
982                .2
983                .push(SignalEvent::Upsert(Signal {
984                    id: SignalId("level".into()),
985                    shape: SignalShape::Level {
986                        price: 100.0 + i as f64 * 2.0,
987                        from: None,
988                    },
989                    category: "Levels".into(),
990                    label: Some(label.into()),
991                    tooltip: None,
992                }));
993        }
994        let labels = |chart: &mut Chart, time| {
995            test_frames::shapes(&ctx, chart, time, vec![])
996                .into_iter()
997                .filter_map(|shape| match shape {
998                    egui::Shape::Text(text) if text.galley.text().starts_with("From") => {
999                        Some(text.galley.text().to_owned())
1000                    }
1001                    _ => None,
1002                })
1003                .collect::<Vec<_>>()
1004        };
1005        assert_eq!(labels(&mut chart, 0.1), ["From A", "From B"]);
1006        hosts[0].streams.lock().unwrap()[0]
1007            .2
1008            .push(SignalEvent::Clear);
1009        assert_eq!(labels(&mut chart, 0.2), ["From B"]);
1010    }
1011    #[test]
1012    fn one_price_zones_render_with_their_side_even_with_no_duration() {
1013        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
1014            for (direction, color) in [
1015                (Direction::Up, theme.chart.candle_up),
1016                (Direction::Down, theme.chart.candle_down),
1017            ] {
1018                let ctx = egui::Context::default();
1019                let mut chart = test_frames::chart(&[95.0], &[105.0]);
1020                chart.set_theme_override(Some(theme.clone()));
1021                chart.set_symbol("A");
1022                let host = Arc::new(Host::default());
1023                chart.set_signal_source(Some(host.clone()));
1024                test_frames::frame(&ctx, &mut chart, 0.0, vec![]);
1025                let stream = host.streams.lock().unwrap()[0].2.clone();
1026                let time = chart.state.shown()[70].time;
1027                let zone = Signal {
1028                    id: SignalId("price-zone".into()),
1029                    shape: SignalShape::PriceZone {
1030                        from: time,
1031                        to: Some(time),
1032                        price: 100.0,
1033                        direction,
1034                    },
1035                    category: "Depth".into(),
1036                    label: Some("Held level".into()),
1037                    tooltip: Some("Absorbed 10".into()),
1038                };
1039                stream.push(SignalEvent::Upsert(zone.clone()));
1040                let shapes = test_frames::shapes(&ctx, &mut chart, 0.1, vec![]);
1041                let x = chart.index_to_x(70.0).unwrap();
1042                let y = chart.last_price_map.price_to_y(100.0);
1043                let zone_visible = |shapes: &[egui::Shape]| {
1044                    shapes.iter().any(|shape| matches!(shape,
1045                    egui::Shape::LineSegment { points, stroke }
1046                    if (points[0].x - x).abs() < 0.01 && (points[0].y - y).abs() < 0.01
1047                    && (points[1].x - points[0].x - 1.0).abs() < 0.01 && stroke.color == color && stroke.width == 2.0))
1048                };
1049                assert!(zone_visible(&shapes));
1050                stream.push(SignalEvent::Upsert(Signal {
1051                    id: SignalId("wall".into()),
1052                    shape: SignalShape::Marker {
1053                        time,
1054                        price: Some(100.0),
1055                        direction,
1056                    },
1057                    category: "Depth".into(),
1058                    label: None,
1059                    tooltip: Some("Wall executions".into()),
1060                }));
1061                // A later zone update must leave the coincident marker available on hover.
1062                stream.push(SignalEvent::Upsert(zone));
1063                test_frames::frame(
1064                    &ctx,
1065                    &mut chart,
1066                    0.2,
1067                    vec![egui::Event::PointerMoved(Pos2::new(x, y))],
1068                );
1069                test_frames::frame(&ctx, &mut chart, 1.0, vec![]);
1070                let hovered = test_frames::shapes(&ctx, &mut chart, 1.1, vec![]);
1071                assert!(hovered.iter().any(|shape| matches!(shape, egui::Shape::Path(marker)
1072                    if marker.points.len() == 3 && marker.fill == color
1073                    && marker.stroke == egui::epaint::PathStroke::from(Stroke::new(1.0, theme.chart.bg)))));
1074                assert!(
1075                    hovered
1076                        .iter()
1077                        .any(|shape| matches!(shape, egui::Shape::Text(text)
1078                    if text.galley.text() == "Wall executions"))
1079                );
1080                chart.set_signal_category_visible("Depth", false);
1081                let hidden = test_frames::shapes(&ctx, &mut chart, 1.2, vec![]);
1082                assert!(!zone_visible(&hidden));
1083                assert!(!valid(&SignalShape::PriceZone {
1084                    from: time,
1085                    to: Some(time - chrono::TimeDelta::seconds(1)),
1086                    price: 100.0,
1087                    direction
1088                }));
1089                assert!(!valid(&SignalShape::PriceZone {
1090                    from: time,
1091                    to: None,
1092                    price: f64::NAN,
1093                    direction
1094                }));
1095            }
1096        }
1097    }
1098}