Skip to main content

egui_charts/ui/widget_bar/panels/
time_and_sales.rs

1//! Host-fed time and sales: the latest trades with aggressor colours, size filters and sound
2//! alert intents. The host feeds trades from its stream and plays any sound.
3
4use crate::localization as l10n;
5use std::{collections::VecDeque, ops::Range};
6
7use egui::{Align, Align2, Label, Layout, RichText, Sense, Stroke, TextStyle, Ui, pos2, vec2};
8use serde::{Deserialize, Serialize};
9
10use super::format_price;
11use crate::data::combine_trades;
12use crate::icons::icons;
13use crate::model::{OrderSide, Tick, TimezoneId};
14use crate::scales::StudyFormat;
15use crate::theme::ThemeExt;
16use crate::ui_kit::{EmptyState, IconButton, PanelHeader};
17
18/// Trades retained, the oldest making room for new ones.
19const RETAINED: usize = 10_000;
20const ROW_HEIGHT: f32 = 20.0;
21
22/// A size filter of the time and sales panel.
23#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
24pub struct TradeSizeFilter {
25    /// Smallest matching size, inclusive.
26    pub min_size: f64,
27    /// Report matching trades from [`TimeAndSalesPanel::push`] so the host plays a sound.
28    pub sound: bool,
29}
30
31/// Formatting of the time and sales panel.
32#[derive(Clone, Debug, Default)]
33pub struct TimeAndSalesConfig {
34    /// Decimals of prices, as `Instrument::price_decimals`. `None` chooses them from each price.
35    pub price_decimals: Option<u8>,
36    /// Decimals of sizes, as `Instrument::volume_decimals`.
37    pub size_decimals: Option<u8>,
38    /// Time zone of the time column, such as the chart's.
39    pub timezone: TimezoneId,
40}
41
42/// The latest trades of one symbol, newest first, fed by the host.
43///
44/// Push each trade of the stream once with [`push`](Self::push), and [`clear`](Self::clear) when
45/// the symbol changes. Prices and sizes take the aggressor colour: buys rising, sells falling, and
46/// trades of unknown side neutral. The user adds, edits and removes size [`filters`](Self::filters)
47/// in the panel: with any filter, only trades matching one are listed, and each filter can ask
48/// for a sound.
49pub struct TimeAndSalesPanel {
50    /// Display and layout options for this component.
51    pub config: TimeAndSalesConfig,
52    /// A row is listed when its size reaches the `min_size` of any filter, or always when there
53    /// are none.
54    pub filters: Vec<TradeSizeFilter>,
55    /// Combine consecutive fills with the same explicit aggressor id, or, without ids,
56    /// the same exchange timestamp and known side. Off by default; raw fills stay retained.
57    /// Size filters then apply to the combined row.
58    pub combine_fills: bool,
59    /// Show three fractional-second digits in the time column. Off by default.
60    /// Every row's hover retains the full timestamp regardless of this setting.
61    pub show_milliseconds: bool,
62    trades: VecDeque<Tick>,
63    /// Retained size of the current consecutive run.
64    run_size: f64,
65    /// Highest retained size reached by the run, so eviction cannot repeat a sound.
66    run_max_size: f64,
67}
68
69struct TapeRow {
70    fills: Range<usize>,
71    size: f64,
72    side: Option<OrderSide>,
73}
74
75/// Explicit ids take precedence; a timestamp alone cannot identify an unknown aggressor.
76fn same_run(a: &Tick, b: &Tick) -> bool {
77    match (a.aggressor_order_id, b.aggressor_order_id) {
78        (Some(a), Some(b)) => a == b,
79        (None, None) => a.ts == b.ts && a.side.is_some() && a.side == b.side,
80        _ => false,
81    }
82}
83
84impl Default for TimeAndSalesPanel {
85    fn default() -> Self {
86        Self::new(TimeAndSalesConfig::default())
87    }
88}
89
90impl TimeAndSalesPanel {
91    /// A panel without trades or filters.
92    pub fn new(config: TimeAndSalesConfig) -> Self {
93        Self {
94            config,
95            filters: Vec::new(),
96            combine_fills: false,
97            show_milliseconds: false,
98            trades: VecDeque::new(),
99            run_size: 0.0,
100            run_max_size: 0.0,
101        }
102    }
103
104    /// Adds the next trade of the stream, keeping the latest 10,000. A trade without a finite
105    /// price and a finite, non-negative size is ignored. Returns the index of the matching filter
106    /// with sound that has the largest `min_size`, for the host to play that filter's sound.
107    /// With [`combine_fills`](Self::combine_fills), reports only thresholds first reached by
108    /// this fill in its consecutive run; later fills and toggling the option do not replay them.
109    pub fn push(&mut self, trade: Tick) -> Option<usize> {
110        if !trade.price.is_finite() || !trade.size.is_finite() || trade.size < 0.0 {
111            return None;
112        }
113        let previous = self
114            .trades
115            .back()
116            .filter(|last| same_run(last, &trade))
117            .map(|_| self.run_max_size);
118        if previous.is_none() {
119            self.run_size = 0.0;
120        }
121        if self.trades.len() == RETAINED
122            && let Some(evicted) = self.trades.pop_front()
123            && previous.is_some()
124            && same_run(&evicted, &trade)
125        {
126            // Re-sum instead of subtracting: a saturated sum has lost the evicted contribution.
127            let start = self
128                .trades
129                .iter()
130                .rposition(|fill| !same_run(fill, &trade))
131                .map_or(0, |index| index + 1);
132            self.run_size = self
133                .trades
134                .range(start..)
135                .fold(0.0_f64, |size, fill| (size + fill.size).min(f64::MAX));
136        }
137        self.run_size = (self.run_size + trade.size).min(f64::MAX);
138        self.run_max_size = previous.unwrap_or(0.0).max(self.run_size);
139        let size = if self.combine_fills {
140            self.run_size
141        } else {
142            trade.size
143        };
144        let sound = self
145            .filters
146            .iter()
147            .enumerate()
148            .filter(|(_, filter)| {
149                filter.sound
150                    && size >= filter.min_size
151                    && (!self.combine_fills || previous.is_none_or(|size| size < filter.min_size))
152            })
153            .max_by(|(_, a), (_, b)| a.min_size.total_cmp(&b.min_size))
154            .map(|(index, _)| index);
155        self.trades.push_back(trade);
156        sound
157    }
158
159    /// Removes every trade, such as when the symbol changes.
160    pub fn clear(&mut self) {
161        self.trades.clear();
162        self.run_size = 0.0;
163        self.run_max_size = 0.0;
164    }
165
166    /// Render the filters and the listed trades, newest first.
167    pub fn show(&mut self, ui: &mut Ui) {
168        PanelHeader::new("Time and sales").show(ui, |ui| {
169            if IconButton::new(&icons::PLUS)
170                .tooltip("Add size filter")
171                .show(ui)
172                .clicked()
173            {
174                let min_size = self.filters.iter().map(|f| f.min_size).fold(0.0, f64::max);
175                self.filters.push(TradeSizeFilter {
176                    min_size: if min_size > 0.0 {
177                        min_size * 10.0
178                    } else {
179                        100.0
180                    },
181                    sound: false,
182                });
183            }
184            ui.checkbox(
185                &mut self.combine_fills,
186                l10n::text(ui.ctx(), "Combine fills"),
187            );
188        });
189        ui.checkbox(
190            &mut self.show_milliseconds,
191            l10n::text(ui.ctx(), "Milliseconds"),
192        );
193        let mut removed = None;
194        egui::ScrollArea::vertical()
195            .id_salt("size_filters")
196            .max_height(ui.available_height() * 0.4)
197            .show(ui, |ui| {
198                for (index, filter) in self.filters.iter_mut().enumerate() {
199                    ui.push_id(index, |ui| {
200                        ui.horizontal_wrapped(|ui| {
201                            let size_label = ui.label(l10n::text(ui.ctx(), "Size ≥"));
202                            ui.add(
203                                egui::DragValue::new(&mut filter.min_size)
204                                    .range(0.0..=f64::MAX)
205                                    .speed(1.0),
206                            )
207                            .labelled_by(size_label.id);
208                            ui.checkbox(&mut filter.sound, l10n::text(ui.ctx(), "Sound"));
209                            if IconButton::new(&icons::TRASH)
210                                .tooltip("Remove filter")
211                                .show(ui)
212                                .clicked()
213                            {
214                                removed = Some(index);
215                            }
216                        });
217                    });
218                }
219            });
220        if let Some(index) = removed {
221            self.filters.remove(index);
222        }
223
224        let theme = ui.active_theme();
225        let font = TextStyle::Monospace.resolve(ui.style());
226        let small = TextStyle::Small.resolve(ui.style());
227        let column_count = if self.combine_fills { 4 } else { 3 };
228        let time_format = if self.show_milliseconds {
229            "%H:%M:%S%.3f"
230        } else {
231            "%H:%M:%S"
232        };
233        let time_width = if self.show_milliseconds {
234            ui.painter()
235                .layout_no_wrap("00:00:00.000".into(), font.clone(), theme.ui.text_secondary)
236                .size()
237                .x
238                .ceil()
239                + 4.0
240        } else {
241            0.0
242        };
243        let columns = |rect: egui::Rect| {
244            let right = rect.right() - 8.0;
245            let width = (rect.width() - 16.0).max(0.0);
246            let left = rect.left() + 8.0;
247            let time_width = time_width
248                .max(width * if self.combine_fills { 0.3 } else { 0.34 })
249                .min(width);
250            let remaining = width - time_width;
251            let edges = if self.combine_fills {
252                [
253                    left,
254                    left + time_width,
255                    left + time_width + remaining * (27.0 / 70.0),
256                    right - remaining * (17.0 / 70.0),
257                    right,
258                ]
259            } else {
260                [
261                    left,
262                    left + time_width,
263                    right - remaining * (30.0 / 66.0),
264                    right,
265                    right,
266                ]
267            };
268            std::array::from_fn::<_, 4, _>(|i| {
269                egui::Rect::from_min_max(
270                    pos2(edges[i], rect.top()),
271                    pos2((edges[i + 1] - 4.0).max(edges[i]), rect.bottom()),
272                )
273            })
274        };
275        let (header, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover());
276        let cells = columns(header);
277        for (index, label) in ["Time", "Price", "Size", "Fills"]
278            .into_iter()
279            .take(column_count)
280            .enumerate()
281        {
282            ui.painter().text(
283                egui::pos2(
284                    if index == 0 {
285                        cells[index].left()
286                    } else {
287                        cells[index].right()
288                    },
289                    header.center().y,
290                ),
291                if index == 0 {
292                    Align2::LEFT_CENTER
293                } else {
294                    Align2::RIGHT_CENTER
295                },
296                l10n::text(ui.ctx(), label),
297                small.clone(),
298                theme.ui.text_secondary,
299            );
300        }
301        ui.painter().hline(
302            header.x_range(),
303            header.bottom(),
304            Stroke::new(1.0, theme.ui.border),
305        );
306
307        let mut listed: Vec<TapeRow> = Vec::new();
308        for (index, trade) in self.trades.iter().enumerate() {
309            if self.combine_fills
310                && let Some(row) = listed.last_mut()
311                && same_run(&self.trades[index - 1], trade)
312            {
313                row.fills.end = index + 1;
314                row.size = (row.size + trade.size).min(f64::MAX);
315                if row.side != trade.side {
316                    row.side = None;
317                }
318            } else {
319                listed.push(TapeRow {
320                    fills: index..index + 1,
321                    size: trade.size,
322                    side: trade.side,
323                });
324            }
325        }
326        let smallest = self.filters.iter().map(|f| f.min_size).reduce(f64::min);
327        listed.retain(|row| smallest.is_none_or(|min| row.size >= min));
328        listed.reverse();
329        if listed.is_empty() {
330            EmptyState::new("No trades yet").fill_height().show(ui);
331            return;
332        }
333        egui::ScrollArea::vertical()
334            .auto_shrink([false, false])
335            .show_rows(ui, ROW_HEIGHT, listed.len(), |ui, rows| {
336                for row in &listed[rows] {
337                    let first = &self.trades[row.fills.start];
338                    let last = &self.trades[row.fills.end - 1];
339                    let (rect, response) = ui.allocate_exact_size(
340                        vec2(ui.available_width(), ROW_HEIGHT),
341                        Sense::hover(),
342                    );
343                    let cells = columns(rect);
344                    let color = match row.side {
345                        Some(OrderSide::Buy) => theme.chart.candle_up,
346                        Some(OrderSide::Sell) => theme.chart.candle_down,
347                        None => theme.ui.text,
348                    };
349                    let time = first.ts.with_timezone(&self.config.timezone);
350                    for (column, text, color) in [
351                        (
352                            0,
353                            time.format(time_format).to_string(),
354                            theme.ui.text_secondary,
355                        ),
356                        (
357                            1,
358                            format_price(last.price, self.config.price_decimals),
359                            color,
360                        ),
361                        (
362                            2,
363                            StudyFormat::Volume
364                                .format(row.size, self.config.size_decimals.map(usize::from)),
365                            color,
366                        ),
367                        (3, row.fills.len().to_string(), theme.ui.text_secondary),
368                    ]
369                    .into_iter()
370                    .take(column_count)
371                    {
372                        ui.new_child(egui::UiBuilder::new().max_rect(cells[column]).layout(
373                            if column == 0 {
374                                Layout::left_to_right(Align::Center)
375                            } else {
376                                Layout::right_to_left(Align::Center)
377                            },
378                        ))
379                        .add(
380                            Label::new(
381                                RichText::new(l10n::text(ui.ctx(), text))
382                                    .font(font.clone())
383                                    .color(color),
384                            )
385                            .truncate()
386                            .selectable(false),
387                        );
388                    }
389                    response.on_hover_ui(|ui| {
390                        ui.label(time.format("%Y-%m-%d %H:%M:%S%.f %::z").to_string());
391                        if first.ts != last.ts {
392                            ui.label(l10n::format_text(
393                                ui.ctx(),
394                                "Last execution: {0}",
395                                &[(
396                                    "0",
397                                    last.ts
398                                        .with_timezone(&self.config.timezone)
399                                        .format("%Y-%m-%d %H:%M:%S%.f %::z")
400                                        .to_string(),
401                                )],
402                            ));
403                        }
404                        if self.combine_fills || first.aggressor_order_id.is_some() {
405                            self.show_breakdown(ui, row.fills.clone());
406                        }
407                    });
408                }
409            });
410    }
411
412    /// The resting orders consumed by this row, or by its explicit order in individual mode.
413    fn show_breakdown(&self, ui: &mut Ui, fills: Range<usize>) {
414        let id = self.trades[fills.start].aggressor_order_id;
415        let orders = if self.combine_fills {
416            combine_trades(self.trades.range(fills))
417        } else {
418            combine_trades(
419                self.trades
420                    .iter()
421                    .filter(|trade| trade.aggressor_order_id == id),
422            )
423        };
424        let size =
425            |size| StudyFormat::Volume.format(size, self.config.size_decimals.map(usize::from));
426        let total = orders
427            .iter()
428            .fold(0.0_f64, |size, order| (size + order.size).min(f64::MAX));
429        let count: usize = orders.iter().map(|order| order.fills).sum();
430        ui.label(if let Some(id) = id {
431            l10n::format_text(
432                ui.ctx(),
433                "Order {0}: {1} in {2} fills",
434                &[
435                    ("0", id.to_string()),
436                    ("1", size(total)),
437                    ("2", count.to_string()),
438                ],
439            )
440        } else {
441            l10n::format_text(
442                ui.ctx(),
443                "{0} in {1} fills",
444                &[("0", size(total)), ("1", count.to_string())],
445            )
446        });
447        for consumed in orders.iter().flat_map(|order| &order.consumed) {
448            let name = consumed.resting_order_id.map_or_else(
449                || l10n::text(ui.ctx(), "Unidentified"),
450                |id| l10n::format_text(ui.ctx(), "Order {0}", &[("0", id.to_string())]),
451            );
452            ui.label(l10n::format_text(
453                ui.ctx(),
454                "{0}: {1} at {2}",
455                &[
456                    ("0", name),
457                    ("1", size(consumed.size)),
458                    (
459                        "2",
460                        format_price(consumed.price, self.config.price_decimals),
461                    ),
462                ],
463            ));
464        }
465        if orders
466            .iter()
467            .flat_map(|order| &order.consumed)
468            .any(|order| order.resting_order_id.is_none())
469        {
470            ui.weak(l10n::text(
471                ui.ctx(),
472                "Incomplete: some fills do not name their resting order",
473            ));
474        }
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use egui_kittest::{Harness, kittest::Queryable};
482
483    #[test]
484    fn push_reports_the_largest_matching_sound_filter_and_drops_invalid_trades() {
485        let mut panel = TimeAndSalesPanel {
486            filters: vec![
487                TradeSizeFilter {
488                    min_size: 100.0,
489                    sound: true,
490                },
491                TradeSizeFilter {
492                    min_size: 1000.0,
493                    sound: true,
494                },
495                TradeSizeFilter {
496                    min_size: 5000.0,
497                    sound: false,
498                },
499                TradeSizeFilter {
500                    min_size: 0.0,
501                    sound: false,
502                },
503            ],
504            ..TimeAndSalesPanel::default()
505        };
506        let trade = |price, size| Tick::new(chrono::Utc::now(), price, size, None);
507        for (price, size, expected) in [
508            (10.0, 50.0, None),
509            (10.0, 100.0, Some(0)),
510            (10.0, 999.0, Some(0)),
511            (10.0, 6000.0, Some(1)),
512            (f64::NAN, 6000.0, None),
513            (10.0, -1.0, None),
514        ] {
515            assert_eq!(panel.push(trade(price, size)), expected, "{price} {size}");
516        }
517        assert_eq!(panel.trades.len(), 4);
518    }
519
520    #[test]
521    fn milliseconds_distinguish_executions_and_keep_filtered_columns_aligned() {
522        use crate::theme::{Theme, apply_to_egui};
523        use chrono::{DateTime, TimeDelta, Utc};
524
525        let start = "2024-03-15T04:15:00.000000001Z"
526            .parse::<DateTime<Utc>>()
527            .unwrap();
528        let times = [
529            "10:00:00.100",
530            "10:00:00.250",
531            "10:00:00.500",
532            "10:00:00.900",
533            "10:00:01.000",
534            "10:00:01.125",
535        ];
536        for theme in [Theme::light(), Theme::dark()] {
537            for width in [300.0, 360.0] {
538                for combine in [false, true] {
539                    let mut panel = TimeAndSalesPanel::new(TimeAndSalesConfig {
540                        price_decimals: Some(2),
541                        size_decimals: Some(0),
542                        timezone: "+05:45".parse().unwrap(),
543                    });
544                    panel.combine_fills = combine;
545                    let sizes = [2.0, 20.0, 3.0, 40.0, 5.0, 60.0];
546                    for (i, ms) in [100, 250, 500, 900, 1000, 1125].into_iter().enumerate() {
547                        let mut trade = Tick::new(
548                            start + TimeDelta::milliseconds(ms),
549                            6100.0 + i as f64 * 0.25,
550                            sizes[i],
551                            Some(OrderSide::Buy),
552                        );
553                        trade.aggressor_order_id = (i % 2 == 0).then_some(i as u64);
554                        panel.push(trade);
555                    }
556                    let theme = theme.clone();
557                    let mut h = Harness::builder()
558                        .with_size(vec2(width, 500.0))
559                        .with_context_setup(move |ctx| {
560                            crate::icons::install_loader(ctx);
561                            apply_to_egui(ctx, &theme);
562                        })
563                        .build_ui_state(|ui, panel: &mut TimeAndSalesPanel| panel.show(ui), panel);
564                    h.run();
565                    assert_eq!(h.query_all_by_label("10:00:00").count(), 4);
566                    h.get_by_label("Milliseconds").click();
567                    h.run();
568                    let bounds = |h: &Harness<'_, TimeAndSalesPanel>, label: &str| {
569                        let text = h.output().shapes.iter().find_map(|shape| {
570                            if let egui::Shape::Text(text) = &shape.shape
571                                && text.galley.text() == label
572                            {
573                                assert!(!text.galley.elided, "{width}: truncated {label}");
574                                return Some(
575                                    text.galley
576                                        .rect
577                                        .translate(text.pos.to_vec2())
578                                        .intersect(shape.clip_rect),
579                                );
580                            }
581                            None
582                        });
583                        text.unwrap_or_else(|| panic!("{width}: missing visible {label}"))
584                    };
585                    let mut previous_y = f32::INFINITY;
586                    let price_right = bounds(&h, "6100.00").right();
587                    let size_right = bounds(&h, "20").right();
588                    for filter in [false, true] {
589                        if filter {
590                            h.state_mut().filters.push(TradeSizeFilter {
591                                min_size: 10.0,
592                                sound: false,
593                            });
594                            h.run();
595                        }
596                        for (i, time) in times.iter().enumerate() {
597                            if filter && sizes[i] < 10.0 {
598                                assert!(h.query_by_label(time).is_none());
599                                continue;
600                            }
601                            let time = bounds(&h, time);
602                            let price = bounds(&h, &format!("{:.2}", 6100.0 + i as f64 * 0.25));
603                            let size = bounds(&h, &(sizes[i] as u32).to_string());
604                            assert!(time.center().y < previous_y, "newest first");
605                            previous_y = time.center().y;
606                            assert!((time.center().y - price.center().y).abs() < 1.0);
607                            assert!((size.center().y - price.center().y).abs() < 1.0);
608                            assert!(time.right() < price.left() && price.right() < size.left());
609                            assert!((price.right() - price_right).abs() < 1.0);
610                            assert!(
611                                (size.right() - size_right).abs() < 1.0,
612                                "{width}, combine={combine}, filter={filter}, row={i}: {size:?}, column right={size_right}"
613                            );
614                        }
615                        previous_y = f32::INFINITY;
616                    }
617                    h.get_by_label("Milliseconds").click();
618                    h.run();
619                    assert_eq!(h.query_all_by_label("10:00:00").count(), 2);
620                }
621            }
622        }
623    }
624
625    #[test]
626    fn every_execution_hover_keeps_the_full_local_timestamp_and_order_breakdown() {
627        for (zone, local) in [
628            ("UTC", "2024-03-15 23:59:59.123456789 +00:00:00"),
629            ("+05:45", "2024-03-16 05:44:59.123456789 +05:45:00"),
630            ("-03:30", "2024-03-15 20:29:59.123456789 -03:30:00"),
631            ("+00:19:32", "2024-03-16 00:19:31.123456789 +00:19:32"),
632        ] {
633            for id in [None, Some(7)] {
634                let mut panel = TimeAndSalesPanel::new(TimeAndSalesConfig {
635                    timezone: zone.parse().unwrap(),
636                    price_decimals: Some(2),
637                    size_decimals: Some(0),
638                });
639                let mut trade = Tick::new(
640                    "2024-03-15T23:59:59.123456789Z".parse().unwrap(),
641                    6100.25,
642                    20.0,
643                    Some(OrderSide::Buy),
644                );
645                trade.aggressor_order_id = id;
646                trade.resting_order_id = Some(9);
647                panel.push(trade);
648                let mut h = Harness::builder()
649                    .with_size(vec2(300.0, 400.0))
650                    .with_context_setup(crate::icons::install_loader)
651                    .build_ui_state(|ui, panel: &mut TimeAndSalesPanel| panel.show(ui), panel);
652                h.run();
653                h.hover_at(h.get_by_label("6100.25").rect().center());
654                h.run_steps(90);
655                h.get_by_label(local);
656                if id.is_some() {
657                    h.get_by_label("Order 7: 20 in 1 fills");
658                    h.get_by_label("Order 9: 20 at 6100.25");
659                } else {
660                    assert!(h.query_by_label("20 in 1 fills").is_none());
661                }
662            }
663        }
664    }
665
666    #[test]
667    fn combine_fills_groups_only_consecutive_matches_and_restores_raw_fills() {
668        use chrono::{DateTime, TimeDelta, Utc};
669        let start = "2024-03-15T10:00:00.100Z".parse::<DateTime<Utc>>().unwrap();
670        let buy = Some(OrderSide::Buy);
671        let sell = Some(OrderSide::Sell);
672        // (millisecond offset, size, side, aggressor id), expected rows in arrival order.
673        #[rustfmt::skip]
674        let cases = [
675            ("explicit ids across prices and times",
676                vec![(0, 2.0, buy, Some(7)), (1000, 3.0, buy, Some(7)), (2000, 4.0, buy, Some(8))],
677                vec![(0, 1, 5.0, 2), (2, 2, 4.0, 1)]),
678            ("different explicit ids at the same instant stay separate",
679                vec![(0, 2.0, buy, Some(7)), (0, 3.0, buy, Some(8))],
680                vec![(0, 0, 2.0, 1), (1, 1, 3.0, 1)]),
681            ("timestamp and side without ids",
682                vec![(0, 2.0, buy, None), (0, 3.0, buy, None), (0, 4.0, sell, None), (1, 1.0, buy, None)],
683                vec![(0, 1, 5.0, 2), (2, 2, 4.0, 1), (3, 3, 1.0, 1)]),
684            ("unknown sides need explicit ids",
685                vec![(0, 2.0, None, None), (0, 3.0, None, None), (0, 4.0, None, Some(7)), (1, 1.0, None, Some(7))],
686                vec![(0, 0, 2.0, 1), (1, 1, 3.0, 1), (2, 3, 5.0, 2)]),
687            ("ids do not join unidentified fills or skip intervening rows",
688                vec![(0, 2.0, buy, Some(7)), (0, 3.0, buy, None), (0, 4.0, buy, Some(7))],
689                vec![(0, 0, 2.0, 1), (1, 1, 3.0, 1), (2, 2, 4.0, 1)]),
690        ];
691        for (name, inputs, expected) in cases {
692            let mut panel = TimeAndSalesPanel::default();
693            panel.config.price_decimals = Some(2);
694            panel.config.size_decimals = Some(0);
695            for (index, &(ms, size, side, id)) in inputs.iter().enumerate() {
696                let mut trade = Tick::new(
697                    start + TimeDelta::milliseconds(ms),
698                    100.0 + index as f64 * 0.25,
699                    size,
700                    side,
701                );
702                trade.aggressor_order_id = id;
703                trade.resting_order_id = Some(10 + index as u64);
704                panel.push(trade);
705            }
706            let mut h = Harness::builder()
707                .with_size(vec2(600.0, 500.0))
708                .with_context_setup(crate::icons::install_loader)
709                .build_ui_state(|ui, panel: &mut TimeAndSalesPanel| panel.show(ui), panel);
710            h.run();
711            let row_times = |h: &Harness<'_, TimeAndSalesPanel>| {
712                ["10:00:00", "10:00:01", "10:00:02"]
713                    .iter()
714                    .map(|time| h.query_all_by_label(time).count())
715                    .sum::<usize>()
716            };
717            assert_eq!(
718                row_times(&h),
719                inputs.len(),
720                "{name}: default is individual fills"
721            );
722            h.get_by_label("Combine fills").click();
723            h.run();
724            assert_eq!(row_times(&h), expected.len(), "{name}: combined row count");
725            for &(first, last, size, fills) in &expected {
726                let time = (start + TimeDelta::milliseconds(inputs[first].0))
727                    .format("%H:%M:%S")
728                    .to_string();
729                let price = format!("{:.2}", 100.0 + last as f64 * 0.25);
730                let y = h.get_by_label(&price).rect().center().y;
731                let mut right = 0.0;
732                for label in [time, price, size.to_string(), fills.to_string()] {
733                    let cell = h
734                        .query_all_by_label(&label)
735                        .map(|node| node.rect())
736                        .find(|rect| (rect.center().y - y).abs() < 1.0 && rect.left() >= right)
737                        .unwrap_or_else(|| panic!("{name}: missing {label} in its row and column"));
738                    right = cell.right();
739                }
740            }
741            if let Some(&(first, last, size, count)) = expected.iter().find(|row| row.3 > 1) {
742                let price = format!("{:.2}", 100.0 + last as f64 * 0.25);
743                let pos = h.get_by_label(&price).rect().center();
744                h.hover_at(pos);
745                h.run_steps(90);
746                let summary = inputs[first].3.map_or_else(
747                    || format!("{size} in {count} fills"),
748                    |id| format!("Order {id}: {size} in {count} fills"),
749                );
750                h.get_by_label(&summary);
751                let timestamp = |index: usize| {
752                    (start + TimeDelta::milliseconds(inputs[index].0))
753                        .format("%Y-%m-%d %H:%M:%S%.f %::z")
754                        .to_string()
755                };
756                h.get_by_label(&timestamp(first));
757                if inputs[first].0 != inputs[last].0 {
758                    h.get_by_label(&format!("Last execution: {}", timestamp(last)));
759                }
760                for (index, input) in inputs.iter().enumerate().take(last + 1).skip(first) {
761                    h.get_by_label(&format!(
762                        "Order {}: {} at {:.2}",
763                        10 + index,
764                        input.1,
765                        100.0 + index as f64 * 0.25
766                    ));
767                }
768                h.remove_cursor();
769                h.run();
770            }
771            h.get_by_label("Combine fills").click();
772            h.run();
773            assert_eq!(
774                row_times(&h),
775                inputs.len(),
776                "{name}: toggle restores retained fills"
777            );
778            assert_eq!(
779                h.state().trades.len(),
780                inputs.len(),
781                "{name}: raw retention"
782            );
783        }
784    }
785
786    #[test]
787    fn combined_size_filters_sound_once_when_the_run_crosses_each_threshold() {
788        let ts = "2024-03-15T10:00:00.100Z"
789            .parse::<chrono::DateTime<chrono::Utc>>()
790            .unwrap();
791        for order in [None, Some(7)] {
792            for combine in [true, false] {
793                let panel = TimeAndSalesPanel::default();
794                let mut h = Harness::builder()
795                    .with_size(vec2(600.0, 500.0))
796                    .with_context_setup(crate::icons::install_loader)
797                    .build_ui_state(|ui, panel: &mut TimeAndSalesPanel| panel.show(ui), panel);
798                h.run();
799                if combine {
800                    h.get_by_label("Combine fills").click();
801                    h.run();
802                }
803                h.state_mut().filters = vec![
804                    TradeSizeFilter {
805                        min_size: 5.0,
806                        sound: true,
807                    },
808                    TradeSizeFilter {
809                        min_size: 10.0,
810                        sound: true,
811                    },
812                ];
813                h.run();
814                for (size, expected) in [
815                    (2.0, None),
816                    (3.0, Some(0)),
817                    (1.0, None),
818                    (6.0, Some(1)),
819                    (1.0, None),
820                ] {
821                    let mut trade = Tick::new(ts, 100.0, size, Some(OrderSide::Buy));
822                    trade.aggressor_order_id = order;
823                    let expected = if combine {
824                        expected
825                    } else if size >= 5.0 {
826                        Some(0)
827                    } else {
828                        None
829                    };
830                    assert_eq!(
831                        h.state_mut().push(trade),
832                        expected,
833                        "{order:?}, combine={combine}, size={size}"
834                    );
835                    h.run();
836                    if size == 3.0 {
837                        assert_eq!(
838                            h.query_all_by_label("10:00:00").count(),
839                            usize::from(combine)
840                        );
841                    }
842                }
843                h.state_mut().clear();
844                assert_eq!(
845                    h.state_mut()
846                        .push(Tick::new(ts, 100.0, 5.0, Some(OrderSide::Buy))),
847                    Some(0),
848                    "clear resets the run"
849                );
850                if combine && order.is_none() {
851                    for _ in 0..RETAINED {
852                        assert_eq!(
853                            h.state_mut()
854                                .push(Tick::new(ts, 100.0, 0.0, Some(OrderSide::Buy))),
855                            None
856                        );
857                    }
858                    assert_eq!(h.state().trades.len(), RETAINED);
859                    assert_eq!(
860                        h.state_mut()
861                            .push(Tick::new(ts, 100.0, 5.0, Some(OrderSide::Buy))),
862                        None,
863                        "eviction does not repeat the threshold at 5 or sound 10 before the retained row reaches it"
864                    );
865                    assert_eq!(
866                        h.state_mut()
867                            .push(Tick::new(ts, 100.0, 5.0, Some(OrderSide::Buy))),
868                        Some(1)
869                    );
870                }
871            }
872        }
873    }
874}