1use 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
12pub 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 #[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 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 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 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 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 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 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}