Skip to main content

egui_charts/ui/context_menu/
trade_profile.rs

1//! Merge and split chart-owned and host-owned execution profiles.
2
3use super::{ContextMenuAction, MenuEntry, MenuItem};
4use crate::localization as l10n;
5use crate::model::{BarClock, ChartType};
6use crate::studies::TradeProfileRanges;
7use crate::widget::Chart;
8use chrono::{DateTime, Utc};
9use egui::Pos2;
10use std::ops::Range;
11
12/// Shows merge/split commands for `index` inside a host menu. `split_at` is a bar boundary
13/// selected by the user, such as the hovered bar. Returns whether the ranges changed; rebuild
14/// the profiles with [`crate::studies::trade_profiles`] after a change.
15pub fn trade_profile_menu(
16    ui: &mut egui::Ui,
17    ranges: &mut TradeProfileRanges,
18    index: usize,
19    split_at: Option<DateTime<Utc>>,
20) -> bool {
21    let merge = index < ranges.ranges().len().saturating_sub(1);
22    if ui
23        .add_enabled(
24            merge,
25            egui::Button::new(l10n::text(ui.ctx(), "Merge with next profile")),
26        )
27        .clicked()
28    {
29        ui.close();
30        return ranges.merge_next(index);
31    }
32    let split = split_at.filter(|time| {
33        ranges
34            .ranges()
35            .get(index)
36            .is_some_and(|r| r.start < *time && *time < r.end)
37    });
38    if ui
39        .add_enabled(
40            split.is_some(),
41            egui::Button::new(l10n::text(ui.ctx(), "Split profile at selected bar")),
42        )
43        .clicked()
44    {
45        ui.close();
46        return split.is_some_and(|time| ranges.split(index, time));
47    }
48    false
49}
50
51pub(super) struct TradeProfileMenu {
52    ranges: Vec<Range<DateTime<Utc>>>,
53    index: usize,
54    time: DateTime<Utc>,
55    symbol: String,
56    clock: BarClock,
57}
58
59impl TradeProfileMenu {
60    pub(super) fn open(chart: &Chart, pos: Pos2, time: Option<DateTime<Utc>>) -> Option<Self> {
61        if chart.chart_type() != ChartType::SessionVolumeProfile
62            || !chart.is_main_series_visible()
63            || !chart.get_rendered_price_rect().contains(pos)
64        {
65            return None;
66        }
67        let time = chart.profile_clock().bar_open(time?);
68        let ranges = chart.trade_profile_ranges()?.ranges().to_vec();
69        let index = ranges.iter().position(|r| r.contains(&time))?;
70        Some(Self {
71            ranges,
72            index,
73            time,
74            symbol: chart.symbol().to_owned(),
75            clock: chart.profile_clock(),
76        })
77    }
78
79    pub(super) fn entries(&self) -> Vec<MenuEntry<ContextMenuAction>> {
80        [
81            (
82                "Merge with previous profile",
83                ContextMenuAction::MergeTradeProfilePrevious,
84                self.index > 0,
85            ),
86            (
87                "Merge with next profile",
88                ContextMenuAction::MergeTradeProfileNext,
89                self.index + 1 < self.ranges.len(),
90            ),
91            (
92                "Split profile at selected bar",
93                ContextMenuAction::SplitTradeProfile,
94                self.time > self.ranges[self.index].start,
95            ),
96        ]
97        .into_iter()
98        .map(|(label, action, enabled)| {
99            let mut item = MenuItem::new(label, action);
100            item.enabled = enabled;
101            MenuEntry::Item(item)
102        })
103        .collect()
104    }
105
106    pub(super) fn apply(&self, chart: &mut Chart, action: &ContextMenuAction) {
107        if chart.chart_type() != ChartType::SessionVolumeProfile
108            || chart.symbol() != self.symbol
109            || chart.profile_clock() != self.clock
110            || chart
111                .trade_profile_ranges()
112                .is_none_or(|r| r.ranges() != self.ranges)
113        {
114            return;
115        }
116        match action {
117            ContextMenuAction::MergeTradeProfilePrevious => {
118                if let Some(previous) = self.index.checked_sub(1) {
119                    chart.merge_trade_profile(previous);
120                }
121            }
122            ContextMenuAction::MergeTradeProfileNext => {
123                chart.merge_trade_profile(self.index);
124            }
125            ContextMenuAction::SplitTradeProfile => {
126                chart.split_trade_profile(self.index, self.time);
127            }
128            _ => {}
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::chart::overlays::{FootprintMode, TradeProfileSettings};
137    use crate::data::{Datafeed, HistoryPage, HistoryRequest, Reply, TradePage, TradeRequest};
138    use crate::model::{Bar, Instrument, LogicalRange, OrderSide, Tick, Timeframe};
139    use crate::theme::{Theme, apply_to_egui};
140    use crate::ui::context_menu::{ChartContextAction, ChartMenus};
141    use crate::widget::{ChartBuilder, SeriesId};
142    use chrono::TimeDelta;
143    use egui::{Event, PointerButton, Pos2, RawInput, Rect, Shape, Vec2};
144    use std::sync::Arc;
145    use std::sync::atomic::{AtomicBool, Ordering};
146
147    /// The runtime painter and the actual chart menu share execution ranges, including a
148    /// selected later session, a composite scrolled partly out of view, evicted history and
149    /// bars timestamped inside their slots.
150    #[test]
151    fn execution_profile_settings_and_menu_reach_the_displayed_rows() {
152        struct Feed {
153            bars: Vec<Bar>,
154            ticks: Vec<Tick>,
155            partial: AtomicBool,
156        }
157        impl Datafeed for Feed {
158            fn resolve(&self, symbol: &str, reply: Reply<Instrument>) {
159                reply.send(Ok(Instrument::new(symbol).with_tick_size(1.0)));
160            }
161            fn history(&self, _: HistoryRequest, reply: Reply<HistoryPage>) {
162                reply.send(Ok(HistoryPage::new(self.bars.clone(), true)));
163            }
164            fn trades(&self, _: TradeRequest, reply: Reply<TradePage>) {
165                let partial = self.partial.load(Ordering::Relaxed);
166                let ticks = self
167                    .ticks
168                    .iter()
169                    .filter(|t| {
170                        !partial
171                            || t.ts >= BarClock::new(Timeframe::Min1).bar_open(self.bars[2].time)
172                    })
173                    .cloned()
174                    .collect();
175                reply.send(Ok(TradePage::new(ticks, !partial)));
176            }
177        }
178        let start: DateTime<Utc> = "2026-01-05T10:00:00Z".parse().unwrap();
179        let time = |day, minute| start + TimeDelta::days(day) + TimeDelta::minutes(minute);
180        let bars: Vec<_> = (0..3)
181            .flat_map(|day| {
182                (0..2).map(move |minute| Bar::new(time(day, minute), 99.0, 104.0, 97.0, 102.0, 0.0))
183            })
184            .collect();
185        let ticks: Vec<_> = [
186            (0, 0, 99.0, 2.0, Some(OrderSide::Sell)),
187            (0, 0, 100.0, 8.0, Some(OrderSide::Buy)),
188            (0, 0, 101.0, 1.0, None),
189            (0, 0, 102.0, 5.0, Some(OrderSide::Sell)),
190            (0, 1, 99.0, 1.0, Some(OrderSide::Sell)),
191            (1, 0, 100.0, 4.0, Some(OrderSide::Buy)),
192            (1, 0, 101.0, 1.0, Some(OrderSide::Sell)),
193            (1, 1, 101.0, 6.0, Some(OrderSide::Buy)),
194            (2, 0, 99.0, 2.0, Some(OrderSide::Sell)),
195            (2, 0, 102.0, 3.0, Some(OrderSide::Buy)),
196            (2, 1, 101.0, 2.0, Some(OrderSide::Buy)),
197        ]
198        .into_iter()
199        .map(|(day, minute, price, size, side)| Tick::new(time(day, minute), price, size, side))
200        .collect();
201        for (theme, stamp, size) in [
202            (Theme::dark(), TimeDelta::zero()),
203            (Theme::light(), TimeDelta::zero()),
204            (Theme::dark(), TimeDelta::seconds(1)),
205            (Theme::light(), TimeDelta::seconds(1)),
206        ]
207        .into_iter()
208        .flat_map(|(theme, stamp)| {
209            [Vec2::new(1440.0, 900.0), Vec2::new(360.0, 640.0)]
210                .map(move |size| (theme.clone(), stamp, size))
211        }) {
212            let bars: Vec<_> = bars
213                .iter()
214                .cloned()
215                .map(|mut bar| {
216                    bar.time += stamp;
217                    bar
218                })
219                .collect();
220            let ctx = egui::Context::default();
221            apply_to_egui(&ctx, &theme);
222            let feed = Arc::new(Feed {
223                bars: bars.clone(),
224                ticks: ticks.clone(),
225                partial: AtomicBool::new(false),
226            });
227            let mut chart = ChartBuilder::new()
228                .with_symbol("PROFILE")
229                .with_timeframe(Timeframe::Min1)
230                .with_chart_type(ChartType::SessionVolumeProfile)
231                .with_feed(feed.clone())
232                .build();
233            chart.set_trade_profile_settings(TradeProfileSettings {
234                mode: FootprintMode::Delta,
235                developing_poc: true,
236                nodes: Some(crate::data::NodeSensitivity::default()),
237                value_area: Some(0.6),
238                value_area_guides: true,
239            });
240            let mut menus = ChartMenus::default();
241            let frame = |chart: &mut Chart, menus: &mut ChartMenus, events| {
242                let mut action = None;
243                let mut output = ctx.run_ui(
244                    RawInput {
245                        screen_rect: Some(Rect::from_min_size(Pos2::ZERO, size)),
246                        events,
247                        ..Default::default()
248                    },
249                    |ui| {
250                        chart.show(ui);
251                        action = menus.show(ui.ctx(), chart);
252                    },
253                );
254                let shapes = std::mem::take(&mut output.shapes);
255                output.drop_without_applying_deltas();
256                (action, shapes)
257            };
258            for _ in 0..6 {
259                frame(&mut chart, &mut menus, vec![]);
260            }
261            assert!(chart.set_visible_logical_range(LogicalRange::new(-0.5, 6.5)));
262            let (_, painted) = frame(&mut chart, &mut menus, vec![]);
263            let map = chart.get_chart_mapping();
264            let has_line = |painted: &[egui::epaint::ClippedShape], a: Pos2, b: Pos2| {
265                painted.iter().any(|shape| {
266                    matches!(&shape.shape,
267                    Shape::LineSegment { points, .. } if *points == [a, b])
268                })
269            };
270            // Session 1 totals [4, 7] at [100, 101]: its cumulative POC moves from
271            // 100 (4 > 1) to 101 (7 > 4), independent of its deltas [+4, +5].
272            assert!(has_line(
273                &painted,
274                Pos2::new(map.idx_to_x(2), map.price_to_y(100.0)),
275                Pos2::new(map.idx_to_x(3), map.price_to_y(101.0))
276            ));
277            for label in ["HVN", "LVN"] {
278                assert!(
279                    painted
280                        .iter()
281                        .any(|s| matches!(&s.shape, Shape::Text(t) if t.galley.text() == label))
282                );
283            }
284            // Session 0 totals [3, 8, 1, 5]. POC 100 plus row 99 reaches 11/17,
285            // exceeding 60%; its guides bound [98.5, 100.5].
286            let left = map.idx_to_x(0) - map.bar_spacing * 0.45;
287            let right = map.idx_to_x(1) + map.bar_spacing * 0.5;
288            for edge in [98.5, 100.5] {
289                assert!(has_line(
290                    &painted,
291                    Pos2::new(left, map.price_to_y(edge)),
292                    Pos2::new(right, map.price_to_y(edge))
293                ));
294            }
295            for nodes in [
296                None,
297                Some(crate::data::NodeSensitivity {
298                    smoothing: 0,
299                    prominence: 1.0,
300                }),
301            ] {
302                let mut settings = chart.trade_profile_settings().clone();
303                settings.mode = FootprintMode::Volume;
304                settings.developing_poc = false;
305                settings.nodes = nodes;
306                settings.value_area_guides = false;
307                chart.set_trade_profile_settings(settings);
308                let (_, hidden) = frame(&mut chart, &mut menus, vec![]);
309                assert!(!has_line(
310                    &hidden,
311                    Pos2::new(map.idx_to_x(2), map.price_to_y(100.0)),
312                    Pos2::new(map.idx_to_x(3), map.price_to_y(101.0))
313                ));
314                assert!(!hidden.iter().any(|s| matches!(&s.shape, Shape::Text(t) if ["HVN", "LVN"].contains(&t.galley.text()))));
315                assert!(!has_line(
316                    &hidden,
317                    Pos2::new(left, map.price_to_y(98.5)),
318                    Pos2::new(right, map.price_to_y(98.5))
319                ));
320                // Volume mode draws all 4 contracts at 100 from the profile's left edge,
321                // relative to the largest total row, 7 at 101.
322                let left = map.idx_to_x(2) - map.bar_spacing * 0.45;
323                let width = map.idx_to_x(3) + map.bar_spacing * 0.5 - left;
324                assert!(
325                    hidden
326                        .iter()
327                        .any(|shape| matches!(&shape.shape, Shape::Rect(r)
328                    if (r.rect.left() - left).abs() < 0.001
329                    && (r.rect.width() - width * 4.0 / 7.0).abs() < 0.001
330                    && r.rect.contains(Pos2::new(r.rect.center().x, map.price_to_y(100.0)))))
331                );
332            }
333            let mut settings = chart.trade_profile_settings().clone();
334            settings.mode = FootprintMode::Delta;
335            settings.developing_poc = true;
336            settings.nodes = Some(Default::default());
337            settings.value_area_guides = true;
338            chart.set_trade_profile_settings(settings);
339            let click = |chart: &mut Chart, menus: &mut ChartMenus, pos, button| {
340                frame(chart, menus, vec![Event::PointerMoved(pos)]);
341                let mut action = None;
342                for pressed in [true, false] {
343                    action = action.or(frame(
344                        chart,
345                        menus,
346                        vec![Event::PointerButton {
347                            pos,
348                            button,
349                            pressed,
350                            modifiers: Default::default(),
351                        }],
352                    )
353                    .0);
354                }
355                action
356            };
357            let pick = |chart: &mut Chart, menus: &mut ChartMenus, bar, label: &str| {
358                frame(chart, menus, vec![]);
359                let map = chart.get_chart_mapping();
360                click(
361                    chart,
362                    menus,
363                    Pos2::new(map.idx_to_x(bar), map.price_to_y(101.0)),
364                    PointerButton::Secondary,
365                );
366                let (_, shapes) = frame(chart, menus, vec![]);
367                let row = shapes
368                    .iter()
369                    .find_map(|shape| match &shape.shape {
370                        Shape::Text(t) if t.galley.text() == label => {
371                            Some(t.pos + t.galley.size() / 2.0)
372                        }
373                        _ => None,
374                    })
375                    .unwrap_or_else(|| panic!("no {label} row"));
376                click(chart, menus, row, PointerButton::Primary)
377            };
378            assert_eq!(chart.trade_profile_ranges().unwrap().ranges().len(), 3);
379            assert!(matches!(
380                pick(&mut chart, &mut menus, 2, "Merge with next profile"),
381                Some(ChartContextAction::Chart(
382                    ContextMenuAction::MergeTradeProfileNext
383                ))
384            ));
385            let ranges = chart.trade_profile_ranges().unwrap();
386            assert_eq!(ranges.ranges().len(), 2);
387            assert_eq!(ranges.ranges()[1].start, time(1, 0));
388            chart
389                .export_layout_svg(
390                    &ctx,
391                    &crate::widget::ImageExportOptions::default(),
392                    |ui, snapshot| {
393                        snapshot.show(ui);
394                        assert_eq!(snapshot.trade_profile_settings().mode, FootprintMode::Delta);
395                        assert_eq!(snapshot.trade_profile_ranges().unwrap().ranges().len(), 2);
396                    },
397                )
398                .unwrap();
399            let (_, merged) = frame(&mut chart, &mut menus, vec![]);
400            let map = chart.get_chart_mapping();
401            // Merged rows: bid/ask 2/0, 0/4, 1/8, 0/3. Delta -2, +4, +7, +3.
402            // The maximum total row is 9. Each signed bar starts at the centre.
403            let left = map.idx_to_x(2) - map.bar_spacing * 0.45;
404            let width = map.idx_to_x(5) + map.bar_spacing * 0.5 - left;
405            let centre = left + width * 0.5;
406            for (price, delta) in [(99.0, -2.0_f32), (100.0, 4.0), (101.0, 7.0), (102.0, 3.0)] {
407                let end = centre + width * 0.5 * delta / 9.0;
408                assert!(
409                    merged
410                        .iter()
411                        .any(|shape| matches!(&shape.shape, Shape::Rect(r)
412                    if (r.rect.left() - centre.min(end)).abs() < 0.001
413                    && (r.rect.right() - centre.max(end)).abs() < 0.001
414                    && r.rect.contains(Pos2::new(r.rect.center().x, map.price_to_y(price))))),
415                    "delta at {price}"
416                );
417            }
418            assert!(chart.set_visible_logical_range(LogicalRange::new(2.5, 6.5)));
419            assert!(matches!(
420                pick(&mut chart, &mut menus, 4, "Split profile at selected bar"),
421                Some(ChartContextAction::Chart(
422                    ContextMenuAction::SplitTradeProfile
423                ))
424            ));
425            assert_eq!(chart.trade_profile_ranges().unwrap().ranges().len(), 3);
426            let (_, split) = frame(&mut chart, &mut menus, vec![]);
427            let map = chart.get_chart_mapping();
428            assert!(has_line(
429                &split,
430                Pos2::new(map.idx_to_x(2), map.price_to_y(100.0)),
431                Pos2::new(map.idx_to_x(3), map.price_to_y(101.0))
432            ));
433            let left = (map.idx_to_x(2) - map.bar_spacing * 0.45).max(map.rect.left());
434            let width = map.idx_to_x(3) + map.bar_spacing * 0.5 - left;
435            let centre = left + width * 0.5;
436            for (price, delta) in [(100.0, 4.0), (101.0, 5.0)] {
437                assert!(
438                    split
439                        .iter()
440                        .any(|shape| matches!(&shape.shape, Shape::Rect(r)
441                    if (r.rect.left() - centre).abs() < 0.001
442                    && (r.rect.width() - width * 0.5 * delta / 7.0).abs() < 0.001
443                    && r.rect.contains(Pos2::new(r.rect.center().x, map.price_to_y(price)))))
444                );
445            }
446            assert!(!chart.split_trade_profile(1, time(1, 0) + TimeDelta::seconds(30)));
447            assert!(chart.merge_trade_profile(1));
448            let mut extended = bars.clone();
449            extended.push(Bar::new(time(3, 0), 99.0, 104.0, 97.0, 102.0, 0.0));
450            chart.set_data(SeriesId::MAIN, extended);
451            assert_eq!(
452                chart.trade_profile_ranges().unwrap().ranges().len(),
453                3,
454                "new session stays separate"
455            );
456            let mut prepended = bars.clone();
457            prepended.insert(2, Bar::new(time(1, -1), 99.0, 104.0, 97.0, 102.0, 0.0));
458            chart.set_data(SeriesId::MAIN, prepended);
459            assert_eq!(
460                chart.trade_profile_ranges().unwrap().ranges()[1].start,
461                time(1, -1)
462            );
463            chart.set_data(SeriesId::MAIN, bars.clone());
464            chart.set_bar_limit(SeriesId::MAIN, std::num::NonZeroUsize::new(3));
465            chart.set_visible_logical_range(LogicalRange::new(-0.5, 3.5));
466            let (_, covered) = frame(&mut chart, &mut menus, vec![]);
467            assert!(!covered.iter().any(
468                |s| matches!(&s.shape, Shape::Text(t) if t.galley.text() == "Partial executions")
469            ));
470            let map = chart.get_chart_mapping();
471            assert!(!has_line(
472                &covered,
473                Pos2::new(map.idx_to_x(0), map.price_to_y(100.0)),
474                Pos2::new(map.idx_to_x(0), map.price_to_y(101.0))
475            ));
476            feed.partial.store(true, Ordering::Relaxed);
477            chart.reload_feed();
478            for _ in 0..6 {
479                frame(&mut chart, &mut menus, vec![]);
480            }
481            chart.set_visible_logical_range(LogicalRange::new(-0.5, 3.5));
482            let (_, evicted) = frame(&mut chart, &mut menus, vec![]);
483            let ranges = chart.trade_profile_ranges().unwrap();
484            assert_eq!(ranges.ranges()[0].start, time(1, 0));
485            assert!(evicted.iter().any(
486                |s| matches!(&s.shape, Shape::Text(t) if t.galley.text() == "Partial executions")
487            ));
488            let map = chart.get_chart_mapping();
489            assert!(has_line(
490                &evicted,
491                Pos2::new(map.idx_to_x(0), map.price_to_y(101.0)),
492                Pos2::new(map.idx_to_x(1), map.price_to_y(101.0))
493            ));
494            let left = (map.idx_to_x(0) - map.bar_spacing * 0.45).max(map.rect.left());
495            let width = map.idx_to_x(2) + map.bar_spacing * 0.5 - left;
496            let centre = left + width * 0.5;
497            // The incomplete first bar is excluded: row 101 now holds eight buys, no sell.
498            assert!(
499                evicted
500                    .iter()
501                    .any(|shape| matches!(&shape.shape, Shape::Rect(r)
502                if (r.rect.left() - centre).abs() < 0.001
503                && (r.rect.width() - width * 0.5).abs() < 0.001
504                && r.rect.contains(Pos2::new(r.rect.center().x, map.price_to_y(101.0)))))
505            );
506            assert!(chart.set_visible_logical_range(LogicalRange::new(-0.5, 1000.5)));
507            let (_, narrow) = frame(&mut chart, &mut menus, vec![]);
508            assert!(
509                !narrow.iter().any(|s| matches!(&s.shape, Shape::Text(t)
510                if ["HVN", "LVN"].contains(&t.galley.text()))),
511                "node labels must fit within their profile"
512            );
513            // Splitting a natural session retains the start of either surviving part,
514            // including its executions and incomplete-history flag after candle eviction.
515            for (split, evict, first, volume, partial_volume) in
516                [(2, 1, 0, 17.0, 1.0), (1, 2, 1, 1.0, 0.0)]
517            {
518                chart.reset_trade_profile_ranges();
519                chart.set_bar_limit(SeriesId::MAIN, None);
520                chart.set_data(
521                    SeriesId::MAIN,
522                    (0..4)
523                        .map(|minute| {
524                            Bar::new(time(0, minute) + stamp, 99.0, 104.0, 97.0, 102.0, 0.0)
525                        })
526                        .collect(),
527                );
528                assert!(chart.split_trade_profile(0, time(0, split)));
529                chart.set_bar_limit(SeriesId::MAIN, std::num::NonZeroUsize::new(4 - evict));
530                let ranges = chart.trade_profile_ranges().unwrap();
531                assert_eq!(ranges.ranges()[0].start, time(0, first));
532                for partial in [false, true] {
533                    let mut flow =
534                        crate::data::Footprint::new(BarClock::new(Timeframe::Min1), 1.0, 1)
535                            .unwrap();
536                    for tick in ticks.iter().filter(|tick| {
537                        tick.ts < time(1, 0) && (!partial || tick.ts > time(0, first))
538                    }) {
539                        flow.push(tick).unwrap();
540                    }
541                    let profiles = crate::studies::trade_profiles(&flow, &ranges);
542                    assert_eq!(profiles[0].complete, !partial);
543                    assert_eq!(
544                        profiles[0].volume.volume(),
545                        if partial { partial_volume } else { volume }
546                    );
547                }
548            }
549            chart.set_symbol("OTHER");
550            assert!(!chart.merge_trade_profile(usize::MAX));
551        }
552    }
553}