1use 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
18const RETAINED: usize = 10_000;
20const ROW_HEIGHT: f32 = 20.0;
21
22#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
24pub struct TradeSizeFilter {
25 pub min_size: f64,
27 pub sound: bool,
29}
30
31#[derive(Clone, Debug, Default)]
33pub struct TimeAndSalesConfig {
34 pub price_decimals: Option<u8>,
36 pub size_decimals: Option<u8>,
38 pub timezone: TimezoneId,
40}
41
42pub struct TimeAndSalesPanel {
50 pub config: TimeAndSalesConfig,
52 pub filters: Vec<TradeSizeFilter>,
55 pub combine_fills: bool,
59 pub show_milliseconds: bool,
62 trades: VecDeque<Tick>,
63 run_size: f64,
65 run_max_size: f64,
67}
68
69struct TapeRow {
70 fills: Range<usize>,
71 size: f64,
72 side: Option<OrderSide>,
73}
74
75fn 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 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 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 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 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 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 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 #[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(×tamp(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}