Skip to main content

egui_charts/ui/trading/
events.rs

1//! The broker's pushes and answers: applying them to the controller, and the notifications and
2//! execution sound they raise.
3
4use super::widgets::side_name;
5use super::{Trading, valid};
6use crate::chart::TradingLineAction;
7use crate::data::FeedError;
8use crate::localization as l10n;
9use crate::model::{OrderId, OrderPreview, OrderRequest, OrderStatus, OrderType, TradingRules};
10use crate::services::{AccountEvent, HistoryKind, HistoryPage};
11use crate::ui_kit::notifications::{Toast, ToastKind, Toasts};
12use crate::widget::settings::TradingNotifications;
13
14impl Trading {
15    /// Applies the broker's pushes and answers, reconnects a lagged stream, and reports refused
16    /// orders and fills in `toasts`. Call it every frame, even while the chart is hidden.
17    pub fn poll(&mut self, ctx: &egui::Context, toasts: &mut Toasts) {
18        let repaint = ctx.clone();
19        self.inbox.set_waker(move || repaint.request_repaint());
20        if self.listener.as_ref().is_some_and(|l| !l.is_active()) {
21            self.connect();
22        }
23        for (key, answer) in self.inbox.drain().answers {
24            if key == self.generation {
25                match answer.downcast::<AccountEvent>() {
26                    Ok(event) => self.apply(ctx, *event, toasts),
27                    Err(_) => self.restating = false,
28                }
29                continue;
30            }
31            if let Some(index) = self.placing.iter().position(|(id, _, _)| *id == key) {
32                let (_, _, mut order) = self.placing.remove(index);
33                if let Ok(answer) = answer.downcast::<Result<TradingRules, FeedError>>() {
34                    match *answer {
35                        Ok(rules) if rules.symbol == order.symbol && valid(&rules) => {
36                            order.time_in_force = rules.time_in_force[0];
37                            self.place(order);
38                        }
39                        Err(FeedError::NotSupported) => self.place(order),
40                        Err(error) => self.notify(
41                            toasts,
42                            Toast::error(l10n::format_text(
43                                ctx,
44                                "Trading rules unavailable: {error}",
45                                &[("error", error.to_string().as_str())],
46                            )),
47                        ),
48                        Ok(_) => self.notify(
49                            toasts,
50                            Toast::error("The broker returned invalid trading rules"),
51                        ),
52                    }
53                }
54                continue;
55            }
56            if self
57                .preview
58                .as_ref()
59                .is_some_and(|preview| preview.key == key)
60            {
61                if let Ok(answer) = answer.downcast::<Result<OrderPreview, FeedError>>() {
62                    match *answer {
63                        Ok(estimate) => {
64                            if let Some(preview) = &mut self.preview {
65                                preview.estimate = Some(estimate);
66                            }
67                        }
68                        Err(error) => {
69                            self.preview = None;
70                            self.notify(
71                                toasts,
72                                Toast::error(l10n::format_text(
73                                    ctx,
74                                    "Order preview unavailable: {error}",
75                                    &[("error", error.to_string().as_str())],
76                                )),
77                            );
78                        }
79                    }
80                }
81                continue;
82            }
83            if let Some(kind) = self.account_panel.loading(key) {
84                if let Ok(answer) = answer.downcast::<Result<HistoryPage, FeedError>>() {
85                    self.history_page(ctx, kind, *answer, toasts);
86                }
87                continue;
88            }
89            if key == self.rules_key {
90                if let Ok(answer) = answer.downcast::<Result<TradingRules, FeedError>>() {
91                    match *answer {
92                        Ok(rules) if rules.symbol == self.rules.symbol && valid(&rules) => {
93                            self.rules = rules;
94                            self.rules_ready = true;
95                        }
96                        Err(FeedError::NotSupported) => self.rules_ready = true,
97                        Err(error) => {
98                            self.rules_failed = !self.connection_error(&error);
99                            toasts.error(l10n::format_text(
100                                ctx,
101                                "Trading rules unavailable: {error}",
102                                &[("error", error.to_string().as_str())],
103                            ));
104                        }
105                        Ok(_) => {
106                            self.rules_failed = true;
107                            toasts.error("The broker returned invalid trading rules");
108                        }
109                    }
110                }
111                continue;
112            }
113            if key != 0 {
114                continue;
115            }
116            let error = match answer.downcast::<Result<OrderId, FeedError>>() {
117                Ok(placed) => placed.err(),
118                Err(answer) => answer
119                    .downcast::<Result<(), FeedError>>()
120                    .ok()
121                    .and_then(|done| done.err()),
122            };
123            if let Some(error) = error {
124                self.notify(
125                    toasts,
126                    Toast::error(l10n::format_text(
127                        ctx,
128                        "Order refused: {error}",
129                        &[("error", error.to_string().as_str())],
130                    )),
131                );
132            }
133        }
134    }
135
136    pub(super) fn apply(&mut self, ctx: &egui::Context, event: AccountEvent, toasts: &mut Toasts) {
137        if !valid_event(&event) {
138            return;
139        }
140        // What the broker pushes while connecting or not live restates the account: only what
141        // happens on a live stream is news.
142        let announce = !self.restating && self.offline().is_none();
143        let first_order = match &event {
144            AccountEvent::Order(order) => !self.account_panel.fills.orders.contains_key(&order.id),
145            _ => false,
146        };
147        self.account_panel.record(&event);
148        match event {
149            AccountEvent::Account(account) => {
150                self.placing
151                    .retain(|(_, _, order)| account.capabilities.accepts(&order.order_type));
152                if !account.capabilities.preview {
153                    self.preview = None;
154                }
155                if matches!(self.pending, Some(TradingLineAction::Modify(_, _)))
156                    && !account.capabilities.modify
157                    || matches!(self.pending, Some(TradingLineAction::Close(_)))
158                        && !account.capabilities.close
159                    || matches!(self.pending, Some(TradingLineAction::Reverse(_)))
160                        && !account.capabilities.reverse
161                    || matches!(&self.pending,
162                        Some(TradingLineAction::Exit(_, exit))
163                        if !account.capabilities.accepts(&exit.order_type))
164                {
165                    self.pending = None;
166                }
167                self.account = Some(account);
168            }
169            AccountEvent::Order(order) => {
170                if matches!(&self.pending,
171                    Some(TradingLineAction::Cancel(old) | TradingLineAction::Modify(old, _))
172                    if old.id == order.id && old != &order)
173                {
174                    self.pending = None;
175                }
176                self.orders.retain(|working| working.id != order.id);
177                if announce
178                    && first_order
179                    && order.status == OrderStatus::Working
180                    && order.request.order_type != OrderType::Market
181                {
182                    let price = match order.request.order_type {
183                        OrderType::Limit(price) | OrderType::Stop(price) => price,
184                        OrderType::StopLimit { stop, .. } => stop,
185                        _ => unreachable!(),
186                    };
187                    self.notify(
188                        toasts,
189                        order_toast(
190                            ctx,
191                            &order.request,
192                            price,
193                            order.request.quantity,
194                            self.decimals,
195                            false,
196                            self.logo(&order.request.symbol),
197                        ),
198                    );
199                }
200                match &order.status {
201                    OrderStatus::Working => self.orders.push(order),
202                    OrderStatus::Rejected(reason) if announce => {
203                        let toast = Toast::error(l10n::format_text(
204                            ctx,
205                            "Order rejected: {reason}",
206                            &[("reason", reason.as_str())],
207                        ));
208                        self.notify(toasts, toast);
209                    }
210                    _ => {}
211                }
212            }
213            AccountEvent::Position(position) => {
214                self.placing.retain(|(_, old, _)| {
215                    old.symbol != position.symbol
216                        || (old.quantity == position.quantity
217                            && old.average_price == position.average_price)
218                });
219                if matches!(&self.pending,
220                    Some(TradingLineAction::Close(old) | TradingLineAction::Reverse(old) | TradingLineAction::Exit(old, _))
221                    if old.symbol == position.symbol
222                        && (old.quantity != position.quantity || old.average_price != position.average_price))
223                {
224                    self.pending = None;
225                }
226                self.positions.retain(|open| open.symbol != position.symbol);
227                if position.quantity != 0.0 {
228                    self.positions.push(position);
229                }
230            }
231            AccountEvent::Execution(fill) if announce => {
232                let order = self.account_panel.fills.orders.get(&fill.order);
233                let side = l10n::text(ctx, side_name(fill.side));
234                let quantity = fill.quantity.to_string();
235                let price = format!("{:.*}", self.decimals, fill.price);
236                let text = match order {
237                    Some(order) => l10n::format_text(
238                        ctx,
239                        "{side} {quantity} {symbol} at {price}",
240                        &[
241                            ("side", side.as_str()),
242                            ("quantity", quantity.as_str()),
243                            ("symbol", order.symbol.as_str()),
244                            ("price", price.as_str()),
245                        ],
246                    ),
247                    None => l10n::format_text(
248                        ctx,
249                        "{side} {quantity} at {price}",
250                        &[
251                            ("side", side.as_str()),
252                            ("quantity", quantity.as_str()),
253                            ("price", price.as_str()),
254                        ],
255                    ),
256                };
257                let filled = self.account_panel.fills.filled_quantity(&fill.order);
258                // Fills that add up to the order within rounding complete it.
259                let mut toast = match order {
260                    Some(order) => {
261                        let mut executed = order.clone();
262                        executed.side = fill.side;
263                        order_toast(
264                            ctx,
265                            &executed,
266                            fill.price,
267                            fill.quantity,
268                            self.decimals,
269                            true,
270                            self.logo(&executed.symbol),
271                        )
272                    }
273                    None => Toast::success(l10n::format_text(
274                        ctx,
275                        "Filled: {fill}",
276                        &[("fill", text.as_str())],
277                    )),
278                };
279                if let Some(order) = order
280                    && filled < order.quantity * (1.0 - 1e-9)
281                {
282                    toast.kind = ToastKind::Info;
283                    toast.title = Some(l10n::format_text(
284                        ctx,
285                        "{order} order partially executed ({filled} of {quantity}) on",
286                        &[
287                            ("order", l10n::text(ctx, order_name(order)).as_str()),
288                            ("filled", filled.to_string().as_str()),
289                            ("quantity", order.quantity.to_string().as_str()),
290                        ],
291                    ));
292                    toast.message = l10n::format_text(
293                        ctx,
294                        "Partially filled: {fill} ({filled} of {quantity})",
295                        &[
296                            ("fill", text.as_str()),
297                            ("filled", filled.to_string().as_str()),
298                            ("quantity", order.quantity.to_string().as_str()),
299                        ],
300                    );
301                }
302                self.notify(toasts, toast);
303                if self.display.play_execution_sound {
304                    self.sound = Some(self.display.execution_sound.clone());
305                }
306            }
307            AccountEvent::Status(status) => {
308                let was_connected = self.is_connected();
309                self.connection = Some(status);
310                match (was_connected, self.is_connected()) {
311                    // Confirmed while the broker was live, these would be sent once their rules
312                    // arrive, after it is back: refuse them now instead.
313                    (true, false) => {
314                        for _ in std::mem::take(&mut self.placing) {
315                            self.refuse();
316                        }
317                    }
318                    // Ask once more for what the broker may not have answered meanwhile.
319                    (false, true) => {
320                        if !self.rules_ready && !self.rules_failed && !self.rules.symbol.is_empty()
321                        {
322                            self.ask_rules();
323                        }
324                        self.account_panel.resume();
325                    }
326                    _ => {}
327                }
328            }
329            _ => {}
330        }
331    }
332
333    /// Lists a page of older history the broker answered for `kind`, or stops paging there.
334    fn history_page(
335        &mut self,
336        ctx: &egui::Context,
337        kind: HistoryKind,
338        answer: Result<HistoryPage, FeedError>,
339        toasts: &mut Toasts,
340    ) {
341        match answer {
342            Ok(page) => {
343                let events = page.events.into_iter().filter(valid_event).collect();
344                self.account_panel.page(kind, events, page.next);
345            }
346            Err(error) => {
347                if error != FeedError::NotSupported && self.connection_error(&error) {
348                    // Asked again once the broker is live.
349                    self.account_panel.defer(kind);
350                } else {
351                    self.account_panel.end(kind);
352                }
353                if error != FeedError::NotSupported {
354                    toasts.error(l10n::format_text(
355                        ctx,
356                        "Account history unavailable: {error}",
357                        &[("error", error.to_string().as_str())],
358                    ));
359                }
360            }
361        }
362    }
363
364    /// Whether a request that failed with `error` may succeed once the broker is live again: the
365    /// error is recoverable, or the broker reported meanwhile that it is not live.
366    fn connection_error(&self, error: &FeedError) -> bool {
367        error.is_recoverable() || !self.is_connected()
368    }
369
370    /// The sound to play for the fills since the last call, when the user turned on
371    /// [`TradingSettings::play_execution_sound`](crate::widget::settings::TradingSettings::play_execution_sound): the name the user chose, or empty for the
372    /// host's first sound. The host plays it; call this after [`poll`](Self::poll).
373    pub fn take_execution_sound(&mut self) -> Option<String> {
374        self.sound.take()
375    }
376
377    /// Shows `toast` if the user's [`TradingSettings::notifications`] level includes it.
378    fn notify(&self, toasts: &mut Toasts, toast: Toast) {
379        let shown = match self.display.notifications {
380            TradingNotifications::All => true,
381            TradingNotifications::Errors => toast.kind == ToastKind::Error,
382            TradingNotifications::Off => false,
383        };
384        if shown {
385            toasts.add(toast);
386        }
387    }
388}
389
390/// Whether every price and quantity of `event` is finite, quantities are positive and an
391/// order's filled quantity lies between 0 and its quantity, as [`Broker`](crate::services::Broker)
392/// requires; the terminal skips events that break this.
393fn valid_event(event: &AccountEvent) -> bool {
394    let finite = |value: Option<f64>| value.is_none_or(f64::is_finite);
395    let positive = |value: f64| value.is_finite() && value > 0.0;
396    match event {
397        AccountEvent::Account(account) => {
398            account.balance.is_finite()
399                && [
400                    account.equity,
401                    account.realized_pnl,
402                    account.margin,
403                    account.orders_margin,
404                    account.available_funds,
405                ]
406                .into_iter()
407                .all(finite)
408        }
409        AccountEvent::Order(order) => {
410            let request = &order.request;
411            positive(request.quantity)
412                && order.filled.is_finite()
413                && (0.0..=request.quantity).contains(&order.filled)
414                && finite(order.fill_price)
415                && finite(request.take_profit)
416                && request
417                    .take_profit_levels
418                    .iter()
419                    .all(|level| level.price.is_finite() && positive(level.quantity))
420                && finite(request.stop_loss)
421                && match request.order_type {
422                    OrderType::Market => true,
423                    OrderType::Limit(price) | OrderType::Stop(price) => price.is_finite(),
424                    OrderType::StopLimit { stop, limit } => stop.is_finite() && limit.is_finite(),
425                }
426        }
427        AccountEvent::Position(position) => {
428            position.quantity.is_finite()
429                && position.average_price.is_finite()
430                && [
431                    position.unrealized_pnl,
432                    position.last_price,
433                    position.trade_value,
434                    position.market_value,
435                    position.leverage,
436                    position.margin,
437                ]
438                .into_iter()
439                .all(finite)
440        }
441        AccountEvent::Execution(fill) => positive(fill.quantity) && fill.price.is_finite(),
442        AccountEvent::Balance(change) => change.balance.is_finite() && change.amount.is_finite(),
443        _ => true,
444    }
445}
446
447fn order_name(request: &OrderRequest) -> &'static str {
448    use crate::model::PositionBracket;
449    match request.position_bracket {
450        Some(PositionBracket::TakeProfit) => "Take profit",
451        Some(PositionBracket::StopLoss) => "Stop loss",
452        None => match request.order_type {
453            OrderType::Market => "Market",
454            OrderType::Limit(_) => "Limit",
455            OrderType::Stop(_) => "Stop",
456            OrderType::StopLimit { .. } => "Stop limit",
457        },
458    }
459}
460
461fn order_toast(
462    ctx: &egui::Context,
463    request: &OrderRequest,
464    price: f64,
465    quantity: f64,
466    decimals: usize,
467    executed: bool,
468    logo: Option<&str>,
469) -> Toast {
470    let side = l10n::text(ctx, side_name(request.side));
471    let fill = l10n::format_text(
472        ctx,
473        "{side} {quantity} {symbol} at {price}",
474        &[
475            ("side", side.as_str()),
476            ("quantity", quantity.to_string().as_str()),
477            ("symbol", request.symbol.as_str()),
478            ("price", format!("{price:.decimals$}").as_str()),
479        ],
480    );
481    let mut toast = Toast::new(
482        if executed {
483            ToastKind::Success
484        } else {
485            ToastKind::Info
486        },
487        l10n::format_text(
488            ctx,
489            if executed {
490                "Filled: {fill}"
491            } else {
492                "Placed: {fill}"
493            },
494            &[("fill", fill.as_str())],
495        ),
496    )
497    .with_title(l10n::format_text(
498        ctx,
499        if executed {
500            "{order} order executed on"
501        } else {
502            "{order} order placed on"
503        },
504        &[("order", l10n::text(ctx, order_name(request)).as_str())],
505    ));
506    toast.trading = Some(crate::ui_kit::notifications::TradingToast {
507        symbol: request.symbol.clone(),
508        logo: logo.map(str::to_owned),
509        side: request.side,
510        quantity,
511        price: format!("{price:.decimals$}"),
512        executed,
513    });
514    toast
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::chart::selection::SeriesId;
521    use crate::model::{Account, Capabilities, Execution, Order, OrderSide, Position};
522    use crate::ui::trading::tests::Recorder;
523    use crate::widget::Chart;
524    use crate::widget::settings::TradingSettings;
525    use std::sync::Arc;
526
527    /// The fills of one side in one bar are one mark: filled triangles stacked outward just under
528    /// the bar's low for buys and just over its high for sells, up to three and then a "+N" line;
529    /// a fill of another symbol is not drawn. Labels, on by default, give the side and total
530    /// quantity; hovering a mark lists up to 20 of its fills; the position's label is drawn over
531    /// the marks. A partial fill, the fill that completes an order and a rejection each raise a
532    /// notification, the rejection with the broker's reason. The display options hide the fills,
533    /// the orders and the positions.
534    #[test]
535    fn fills_are_drawn_on_their_bar_and_announced() {
536        use crate::theme::ThemeExt;
537        let mut chart = crate::widget::test_frames::chart(&[90.0], &[110.0]);
538        chart.symbol = "SYM".into();
539        let time = chart.bars(SeriesId::MAIN)[95].time + chrono::Duration::hours(12);
540        let order = |id, symbol, status, filled| {
541            let request = OrderRequest::new(symbol, OrderSide::Buy, 3.0, OrderType::Limit(95.0));
542            let mut order = Order::new(OrderId::new(id), request, status);
543            order.filled = filled;
544            order
545        };
546        let fill = |id, side, price, quantity| {
547            AccountEvent::Execution(Execution::new(
548                OrderId::new(id),
549                time,
550                side,
551                price,
552                quantity,
553            ))
554        };
555        let broker = Arc::new(Recorder {
556            state: vec![
557                AccountEvent::Order(order("1", "SYM", OrderStatus::Working, 0.0)),
558                fill("1", OrderSide::Buy, 95.0, 1.0),
559                AccountEvent::Order(order("1", "SYM", OrderStatus::Filled, 3.0)),
560                fill("1", OrderSide::Buy, 98.0, 2.0),
561                AccountEvent::Order(order("2", "OTHER", OrderStatus::Filled, 3.0)),
562                fill("2", OrderSide::Sell, 95.0, 3.0),
563                AccountEvent::Order(order("5", "SYM", OrderStatus::Filled, 3.0)),
564                fill("5", OrderSide::Sell, 95.0, 3.0),
565                AccountEvent::Order(order(
566                    "3",
567                    "SYM",
568                    OrderStatus::Rejected("Insufficient margin".into()),
569                    0.0,
570                )),
571                AccountEvent::Order(order("4", "SYM", OrderStatus::Working, 0.0)),
572                AccountEvent::Position(Position::new("SYM", 3.0, 97.0)),
573            ],
574            ..Recorder::default()
575        });
576        // Each notification level shows its share of the events; fills ask for the chosen sound
577        // once, and only when the user turned it on.
578        let all = [
579            "Placed: Buy 3 SYM at 95.00",
580            "Partially filled: Buy 1 SYM at 95.00 (1 of 3)",
581            "Filled: Buy 2 SYM at 98.00",
582            "Filled: Sell 3 OTHER at 95.00",
583            "Filled: Sell 3 SYM at 95.00",
584            "Order rejected: Insufficient margin",
585            "Placed: Buy 3 SYM at 95.00",
586        ];
587        for (level, expected, sound) in [
588            (TradingNotifications::Errors, &all[5..6], true),
589            (TradingNotifications::Off, &[][..], false),
590            (TradingNotifications::All, &all[..], false),
591        ] {
592            let mut trading = Trading::default();
593            trading.display.notifications = level;
594            trading.display.play_execution_sound = sound;
595            trading.display.execution_sound = "Bell".into();
596            trading.set_service(Arc::new(Recorder::default()));
597            trading.poll(&egui::Context::default(), &mut Toasts::new());
598            let mut toasts = Toasts::new();
599            for event in broker.state.clone() {
600                trading.apply(&egui::Context::default(), event, &mut toasts);
601            }
602            let messages: Vec<_> = toasts.toasts().iter().map(|t| t.message.as_str()).collect();
603            assert_eq!(messages, expected, "{level:?}");
604            assert_eq!(
605                trading.take_execution_sound(),
606                sound.then(|| "Bell".to_owned())
607            );
608            assert_eq!(trading.take_execution_sound(), None);
609        }
610        let mut trading = Trading::default();
611        trading.set_service(broker);
612        trading.poll(&egui::Context::default(), &mut Toasts::new());
613
614        let ctx = egui::Context::default();
615        let hidden = TradingSettings {
616            show_positions: false,
617            show_orders: false,
618            show_executions: false,
619            ..TradingSettings::default()
620        };
621        let unlabelled = TradingSettings {
622            show_execution_labels: false,
623            ..TradingSettings::default()
624        };
625        let mut clock = 0.0;
626        let crowd = trading.account_panel.fills.executions[0].clone();
627        let default = TradingSettings::default;
628        for (display, drawn, labels, neighbouring, inverted, crowded) in [
629            (default(), true, true, false, false, false),
630            (unlabelled.clone(), true, false, false, false, false),
631            (default(), true, true, true, false, false),
632            (default(), true, true, false, true, false),
633            (default(), true, true, false, false, true),
634            (unlabelled, true, false, false, false, true),
635            (hidden, false, false, false, false, false),
636        ] {
637            let clones = if crowded { 20 } else { 0 };
638            let kept = trading.account_panel.fills.executions.len();
639            trading
640                .account_panel
641                .fills
642                .executions
643                .extend(std::iter::repeat_n(crowd.clone(), clones));
644            trading.display = display;
645            chart.price_scale_mut().options_mut().invert_scale = inverted;
646            trading.account_panel.fills.executions[0].time =
647                time - chrono::Duration::days(i64::from(neighbouring));
648            let (mut tips, mut texts, mut hovered, mut covered) =
649                (Vec::new(), Vec::new(), false, false);
650            for frame in 0..4 {
651                clock += 1.0;
652                let (x, low) = (
653                    chart.index_to_x(95.0),
654                    chart
655                        .get_chart_mapping()
656                        .price_to_y(if inverted { 110.0 } else { 90.0 }),
657                );
658                // The pointer rests on the buy mark once it has been drawn.
659                let events = x
660                    .filter(|_| frame == 1)
661                    .map(|x| egui::Event::PointerMoved(egui::pos2(x, low + 10.0)));
662                let input = egui::RawInput {
663                    time: Some(clock),
664                    events: events.into_iter().collect(),
665                    ..Default::default()
666                };
667                let output = ctx.run_ui(input, |ui| {
668                    chart.show(ui);
669                    trading.show(ui, &mut chart);
670                });
671                let (buy, sell) = (
672                    ctx.active_theme().ui.accent,
673                    ctx.active_theme().chart.candle_down,
674                );
675                let triangles: Vec<_> = output
676                    .shapes
677                    .iter()
678                    .enumerate()
679                    .filter_map(|(at, shape)| match &shape.shape {
680                        egui::Shape::Path(path)
681                            if path.closed
682                                && path.points.len() == 3
683                                && (path.fill == buy || path.fill == sell) =>
684                        {
685                            Some((at, path.points.clone()))
686                        }
687                        _ => None,
688                    })
689                    .collect();
690                tips = triangles.iter().map(|(_, points)| points.clone()).collect();
691                texts = output
692                    .shapes
693                    .iter()
694                    .filter_map(|shape| match &shape.shape {
695                        egui::Shape::Text(text)
696                            if !text.galley.text().contains(" at ")
697                                && (text.galley.text().starts_with("Buy ")
698                                    || text.galley.text().starts_with("Sell ")
699                                    || (text.galley.text().strip_prefix('+'))
700                                        .is_some_and(|more| more.parse::<usize>().is_ok())) =>
701                        {
702                            Some(text.galley.text().to_owned())
703                        }
704                        _ => None,
705                    })
706                    .collect();
707                // 1_700_000_000 is 2023-11-14 22:13:20 UTC; 95 days and 12 hours later. The fill
708                // moved to bar 94 is listed by its own mark.
709                let mut tooltip = "Buy 2 at 98.00, Sun 18 Feb '24 10:13:20".to_owned();
710                if !neighbouring {
711                    tooltip.insert_str(0, "Buy 1 at 95.00, Sun 18 Feb '24 10:13:20\n");
712                }
713                if crowded {
714                    tooltip += &"\nBuy 1 at 95.00, Sun 18 Feb '24 10:13:20".repeat(18);
715                    tooltip += "\nand 2 more";
716                }
717                hovered = output.shapes.iter().any(|shape| {
718                    matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == tooltip)
719                });
720                // The last shape filled in the buy colour is a quantity box of the lines; the
721                // price axis tags come before the marks.
722                let label = output.shapes.iter().rposition(
723                    |shape| matches!(&shape.shape, egui::Shape::Rect(rect) if rect.fill == buy),
724                );
725                covered = triangles
726                    .last()
727                    .zip(label)
728                    .is_some_and(|((mark, _), label)| *mark > label);
729                output.drop_without_applying_deltas();
730            }
731            let x = chart.index_to_x(95.0).unwrap();
732            let mapping = chart.get_chart_mapping();
733            let (a, b) = (mapping.price_to_y(90.0), mapping.price_to_y(110.0));
734            let (low, high) = (a.max(b), a.min(b));
735            // Tip, then the base's corners: 14 wide and 17 tall, 4 points from the bar. The buys
736            // stack 19 points apart, leaving 2 points between them; the sell mirrors over the high.
737            // The newest marks come first, a bar's sells after its buys.
738            let triangle = |x: f32, tip: f32, base: f32| {
739                vec![
740                    egui::pos2(x, tip),
741                    egui::pos2(x + 7.0, base),
742                    egui::pos2(x - 7.0, base),
743                ]
744            };
745            let mut expected = vec![
746                triangle(x, high - 4.0, high - 21.0),
747                triangle(x, low + 4.0, low + 21.0),
748            ];
749            if neighbouring {
750                // The moved fill stays under its own bar, 94.
751                let x = chart.index_to_x(94.0).unwrap();
752                expected.push(triangle(x, low + 4.0, low + 21.0));
753            } else {
754                expected.push(triangle(x, low + 23.0, low + 40.0));
755            }
756            if crowded {
757                expected.push(triangle(x, low + 42.0, low + 59.0));
758            }
759            assert_eq!(tips, if drawn { expected } else { Vec::new() });
760            // 22 buys with a total quantity of 23, 3 of them drawn.
761            let labels_expected: &[&str] = match (labels, crowded, neighbouring) {
762                (true, true, _) => &["Sell 3", "+19", "Buy 23"],
763                (false, true, _) => &["+19"],
764                (true, false, true) => &["Sell 3", "Buy 2", "Buy 1"],
765                (true, false, false) => &["Sell 3", "Buy 3"],
766                (false, false, _) => &[],
767            };
768            assert_eq!(texts, labels_expected);
769            assert_eq!(hovered, drawn, "hovering a mark lists its fills");
770            assert!(!covered, "the position's label is drawn over the marks");
771            assert_eq!(chart.trading_tags.is_empty(), !drawn);
772            trading.account_panel.fills.executions.truncate(kept);
773        }
774
775        // A finer fill stays on the only revealed candle, with an exclusive replay cutoff.
776        let time = chrono::DateTime::from_timestamp(1_704_067_200, 0).unwrap();
777        trading.display.show_executions = true;
778        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
779            for width in [1440.0, 320.0] {
780                let ctx = egui::Context::default();
781                crate::theme::apply_to_egui(&ctx, &theme);
782                let mut chart = Chart::new(crate::model::BarData::from_bars(vec![
783                    crate::model::Bar::new(time, 100.0, 110.0, 90.0, 100.0, 1.0),
784                ]));
785                chart.symbol = "SYM".into();
786                chart.set_timeframe(crate::model::Timeframe::Min1);
787                chart.set_replay_time(Some(time + chrono::Duration::minutes(1)));
788                for (seconds, expected) in [(0, true), (30, true), (-1, false), (60, false)] {
789                    for fill in &mut trading.account_panel.fills.executions {
790                        fill.time = time + chrono::Duration::seconds(seconds);
791                    }
792                    let mut marked = false;
793                    for _ in 0..3 {
794                        let output = ctx.run_ui(
795                            egui::RawInput {
796                                screen_rect: Some(egui::Rect::from_min_size(
797                                    egui::Pos2::ZERO,
798                                    egui::vec2(width, 600.0),
799                                )),
800                                ..Default::default()
801                            },
802                            |ui| {
803                                chart.show(ui);
804                                trading.show(ui, &mut chart);
805                            },
806                        );
807                        marked |= output.shapes.iter().any(|shape| {
808                            matches!(&shape.shape, egui::Shape::Text(text)
809                                if text.galley.text() == "Buy 3")
810                        });
811                        output.drop_without_applying_deltas();
812                        chart.fit_content();
813                    }
814                    assert_eq!(marked, expected, "fill at {seconds} seconds, width {width}");
815                }
816            }
817        }
818    }
819
820    /// What a broker pushes as the account's state when a stream starts, on connecting, on
821    /// connecting again after a disconnect and after the stream lagged, raises no notification
822    /// and asks for no sound; a fill, a working order and a rejection pushed later are each
823    /// announced once.
824    #[test]
825    fn a_new_stream_does_not_announce_the_accounts_state() {
826        use crate::data::inbox::Inbox;
827        use crate::model::TimeInForce;
828        use crate::services::{Broker, PaperAccount};
829        let ctx = egui::Context::default();
830        let paper = Arc::new(PaperAccount::new("USD", 100_000.0));
831        paper.set_price("SYM", 100.0);
832        let buy = |quantity, order_type| {
833            let mut request = OrderRequest::new("SYM", OrderSide::Buy, quantity, order_type);
834            request.time_in_force = TimeInForce::GoodTillCancelled;
835            paper.place(request, Arc::new(Inbox::default()).reply(0));
836        };
837        let announced = |trading: &mut Trading| {
838            let mut toasts = Toasts::new();
839            trading.poll(&ctx, &mut toasts);
840            let messages: Vec<_> = (toasts.toasts().iter())
841                .map(|toast| toast.message.clone())
842                .collect();
843            (messages, trading.take_execution_sound())
844        };
845        let mut trading = Trading::default();
846        trading.display.play_execution_sound = true;
847        trading.display.execution_sound = "Bell".into();
848        trading.set_service(paper.clone());
849        assert_eq!(announced(&mut trading), (vec![], None));
850        buy(1.0, OrderType::Market);
851        buy(1.0, OrderType::Limit(90.0));
852        buy(10_000.0, OrderType::Market);
853        let (messages, sound) = announced(&mut trading);
854        assert_eq!(
855            messages[..2],
856            ["Filled: Buy 1 SYM at 100.00", "Placed: Buy 1 SYM at 90.00"]
857        );
858        assert!(messages[2].starts_with("Order rejected: Insufficient buying power"));
859        assert_eq!((messages.len(), sound.as_deref()), (3, Some("Bell")));
860
861        trading.disconnect();
862        trading.set_service(paper.clone());
863        assert_eq!(announced(&mut trading), (vec![], None), "connected again");
864        // Revaluing the open position pushes it and the account twice per price, which overflows
865        // the stream between two frames.
866        let generation = trading.generation;
867        for price in [101.0, 100.0].repeat(2_501) {
868            paper.set_price("SYM", price);
869        }
870        assert_eq!(announced(&mut trading), (vec![], None), "lagged");
871        assert_ne!(trading.generation, generation);
872
873        paper.set_price("SYM", 90.0);
874        assert_eq!(
875            announced(&mut trading),
876            (
877                vec!["Filled: Buy 1 SYM at 90.00".to_owned()],
878                Some("Bell".to_owned())
879            )
880        );
881    }
882
883    /// Each fill's triangles sit under its own bar, also among fills on the bars beside it, with
884    /// labels on or off: a run of one-lot buys on ten bars in a row, and four buys on each of two
885    /// neighbouring bars, three triangles and "+1" each.
886    #[test]
887    fn fills_on_nearby_bars_stay_on_their_bars() {
888        use crate::theme::ThemeExt;
889        let rows: [(bool, Vec<(usize, usize)>); 2] = [
890            (true, (90..100).map(|bar| (bar, 1)).collect()),
891            (false, vec![(95, 4), (96, 4)]),
892        ];
893        for (labels, bars) in rows {
894            let mut chart = crate::widget::test_frames::chart(&[90.0], &[110.0]);
895            chart.symbol = "SYM".into();
896            let request = OrderRequest::new("SYM", OrderSide::Buy, 100.0, OrderType::Limit(95.0));
897            let mut state = vec![AccountEvent::Order(Order::new(
898                OrderId::new("1"),
899                request,
900                OrderStatus::Filled,
901            ))];
902            for &(bar, count) in &bars {
903                let time = chart.bars(SeriesId::MAIN)[bar].time + chrono::Duration::hours(12);
904                state.extend((0..count).map(|_| {
905                    AccountEvent::Execution(Execution::new(
906                        OrderId::new("1"),
907                        time,
908                        OrderSide::Buy,
909                        95.0,
910                        1.0,
911                    ))
912                }));
913            }
914            let mut trading = Trading::default();
915            trading.display.show_execution_labels = labels;
916            trading.set_service(Arc::new(Recorder {
917                state,
918                ..Recorder::default()
919            }));
920            trading.poll(&egui::Context::default(), &mut Toasts::new());
921            let ctx = egui::Context::default();
922            let mut tips = Vec::new();
923            for frame in 0..3 {
924                let input = egui::RawInput {
925                    time: Some(f64::from(frame)),
926                    ..Default::default()
927                };
928                let output = ctx.run_ui(input, |ui| {
929                    chart.show(ui);
930                    trading.show(ui, &mut chart);
931                });
932                let buy = ctx.active_theme().ui.accent;
933                tips = output
934                    .shapes
935                    .iter()
936                    .filter_map(|shape| match &shape.shape {
937                        egui::Shape::Path(path)
938                            if path.closed && path.points.len() == 3 && path.fill == buy =>
939                        {
940                            Some(path.points[0])
941                        }
942                        _ => None,
943                    })
944                    .collect();
945                output.drop_without_applying_deltas();
946            }
947            let low = chart.get_chart_mapping().price_to_y(90.0);
948            // Under each bar its own stack: tips 4 points under the low, 19 points apart.
949            let mut expected: Vec<_> = bars
950                .iter()
951                .flat_map(|&(bar, count)| {
952                    let x = chart.index_to_x(bar as f64).unwrap();
953                    (0..count.min(3)).map(move |i| egui::pos2(x, low + 4.0 + 19.0 * i as f32))
954                })
955                .collect();
956            let by_place =
957                |a: &egui::Pos2, b: &egui::Pos2| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y));
958            tips.sort_by(by_place);
959            expected.sort_by(by_place);
960            assert_eq!(tips, expected, "labels {labels}, bars {bars:?}");
961        }
962    }
963
964    #[test]
965    fn order_notifications_keep_roles_and_do_not_repeat_updates() {
966        use crate::model::PositionBracket;
967        for (kind, role, name) in [
968            (OrderType::Limit(101.0), None, "Limit"),
969            (OrderType::Stop(99.0), None, "Stop"),
970            (
971                OrderType::StopLimit {
972                    stop: 101.0,
973                    limit: 102.0,
974                },
975                None,
976                "Stop limit",
977            ),
978            (
979                OrderType::Limit(105.0),
980                Some(PositionBracket::TakeProfit),
981                "Take profit",
982            ),
983            (
984                OrderType::Stop(95.0),
985                Some(PositionBracket::StopLoss),
986                "Stop loss",
987            ),
988        ] {
989            let mut trading = Trading::default();
990            trading
991                .set_instrument(crate::model::Instrument::new("SYM").with_logo("bytes://sym.svg"));
992            let mut toasts = Toasts::new();
993            let mut request = OrderRequest::new("SYM", OrderSide::Sell, 10.0, kind);
994            request.position_bracket = role;
995            let order = Order::new(OrderId::new("one"), request, OrderStatus::Working);
996            trading.apply(
997                &egui::Context::default(),
998                AccountEvent::Order(order.clone()),
999                &mut toasts,
1000            );
1001            trading.apply(
1002                &egui::Context::default(),
1003                AccountEvent::Order(order),
1004                &mut toasts,
1005            );
1006            assert_eq!(toasts.toasts().len(), 1);
1007            let toast = &toasts.toasts()[0];
1008            assert_eq!(
1009                toast.title.as_deref(),
1010                Some(format!("{name} order placed on").as_str())
1011            );
1012            let details = toast.trading.as_ref().unwrap();
1013            assert_eq!(details.logo.as_deref(), Some("bytes://sym.svg"));
1014            assert_eq!(details.side, OrderSide::Sell);
1015            assert_eq!(details.quantity, 10.0);
1016            assert!(!details.executed);
1017        }
1018    }
1019
1020    #[test]
1021    fn invalid_broker_numbers_do_not_replace_account_state() {
1022        let mut trading = Trading::default();
1023        let mut toasts = Toasts::new();
1024        let account = Account::new("USD", 1000.0, Capabilities::default());
1025        let order = Order::new(
1026            OrderId::new("7"),
1027            OrderRequest::new("SYM", OrderSide::Buy, 2.0, OrderType::Limit(100.0)),
1028            OrderStatus::Working,
1029        );
1030        let position = Position::new("SYM", 2.0, 100.0);
1031        for event in [
1032            AccountEvent::Account(account.clone()),
1033            AccountEvent::Order(order.clone()),
1034            AccountEvent::Position(position.clone()),
1035        ] {
1036            trading.apply(&egui::Context::default(), event, &mut toasts);
1037        }
1038        for bad in [f64::NAN, f64::INFINITY] {
1039            let mut invalid_order = order.clone();
1040            invalid_order.request.stop_loss = Some(bad);
1041            let mut invalid_level = order.clone();
1042            invalid_level.request.take_profit_levels =
1043                vec![crate::model::TakeProfitLevel::new(bad, 2.0)];
1044            for event in [
1045                AccountEvent::Account(Account::new("USD", bad, Capabilities::default())),
1046                AccountEvent::Order(invalid_order),
1047                AccountEvent::Order(invalid_level),
1048                AccountEvent::Position(Position::new("SYM", bad, 100.0)),
1049            ] {
1050                trading.apply(&egui::Context::default(), event, &mut toasts);
1051                assert_eq!(trading.account, Some(account.clone()));
1052                assert_eq!(trading.orders.as_slice(), std::slice::from_ref(&order));
1053                assert_eq!(
1054                    trading.positions.as_slice(),
1055                    std::slice::from_ref(&position)
1056                );
1057            }
1058        }
1059    }
1060
1061    #[test]
1062    fn account_changes_discard_stale_confirmations_and_feedback() {
1063        let mut trading = Trading::default();
1064        let position = Position::new("SYM", 2.0, 100.0);
1065        let order = Order::new(
1066            OrderId::new("7"),
1067            OrderRequest::new("SYM", OrderSide::Buy, 2.0, OrderType::Limit(100.0)),
1068            OrderStatus::Working,
1069        );
1070        for action in [
1071            TradingLineAction::Close(position.clone()),
1072            TradingLineAction::Reverse(position.clone()),
1073            TradingLineAction::Cancel(order.clone()),
1074            TradingLineAction::Modify(order.clone(), order.request.clone()),
1075        ] {
1076            trading.load_rules("SYM");
1077            trading.pending = Some(action.clone());
1078            let mut marked = position.clone();
1079            marked.unrealized_pnl = Some(5.0);
1080            trading.apply(
1081                &egui::Context::default(),
1082                AccountEvent::Position(marked),
1083                &mut Toasts::new(),
1084            );
1085            assert!(
1086                trading.pending.is_some(),
1087                "a price tick preserves confirmation"
1088            );
1089            trading.apply(
1090                &egui::Context::default(),
1091                AccountEvent::Position(Position::new("SYM", 0.0, 100.0)),
1092                &mut Toasts::new(),
1093            );
1094            let mut filled = order.clone();
1095            filled.filled = 1.0;
1096            trading.apply(
1097                &egui::Context::default(),
1098                AccountEvent::Order(filled),
1099                &mut Toasts::new(),
1100            );
1101            assert!(
1102                trading.pending.is_none(),
1103                "changed exposure invalidates confirmation"
1104            );
1105            trading.pending = Some(action);
1106            trading.load_rules("OTHER");
1107            assert!(
1108                trading.pending.is_none(),
1109                "a symbol change invalidates confirmation"
1110            );
1111        }
1112        trading.sound = Some("Bell".into());
1113        trading.calculator = Some((egui::Pos2::ZERO, "3".into()));
1114        trading.set_service(Arc::new(Recorder::default()));
1115        assert_eq!(trading.take_execution_sound(), None);
1116        assert!(trading.calculator.is_none());
1117    }
1118
1119    /// The account panel's tab marks a broker that reports that it is connecting or
1120    /// disconnected until it reports that it is live, also at the demo's minimized panel height,
1121    /// and `is_connected` is false meanwhile. A broker that never reports a status counts as
1122    /// connected, and a new stream starts without the old one's status. A regression keeps
1123    /// dropping the status, keeps reporting a dropped broker as connected, carries a status into
1124    /// the next stream or hides the mark in a minimized panel.
1125    #[test]
1126    fn the_account_panel_and_is_connected_follow_the_brokers_status() {
1127        use crate::data::FeedStatus;
1128        // The connection labels the account panel draws in a panel `height` points high.
1129        let marks = |trading: &mut Trading, height: f32| {
1130            let ctx = egui::Context::default();
1131            let input = egui::RawInput {
1132                screen_rect: Some(egui::Rect::from_min_size(
1133                    egui::Pos2::ZERO,
1134                    egui::vec2(800.0, height),
1135                )),
1136                ..Default::default()
1137            };
1138            let output = ctx.run_ui(input, |ui| {
1139                trading.show_account_panel(ui, "SYM");
1140            });
1141            let marks: Vec<String> = (output.shapes.iter())
1142                .filter_map(|shape| match &shape.shape {
1143                    egui::Shape::Text(text)
1144                        if ["Live", "Connecting...", "Offline"].contains(&text.galley.text()) =>
1145                    {
1146                        Some(text.galley.text().to_owned())
1147                    }
1148                    _ => None,
1149                })
1150                .collect();
1151            output.drop_without_applying_deltas();
1152            marks
1153        };
1154        let check = |trading: &mut Trading,
1155                     step: &str,
1156                     broker: bool,
1157                     connected: bool,
1158                     mark: Option<&str>| {
1159            assert_eq!(
1160                (trading.has_broker(), trading.is_connected()),
1161                (broker, connected),
1162                "{step}"
1163            );
1164            // 42 points is the demo's minimized panel, which draws only the tab strip.
1165            for height in [42.0, 320.0] {
1166                assert_eq!(
1167                    marks(trading, height),
1168                    Vec::from_iter(mark),
1169                    "{step} at {height}"
1170                );
1171            }
1172        };
1173        let mut trading = Trading::default();
1174        check(&mut trading, "no broker", false, false, None);
1175        trading.set_service(Arc::new(Recorder::default()));
1176        trading.poll(&egui::Context::default(), &mut Toasts::new());
1177        check(&mut trading, "no status", true, true, None);
1178        for (status, connected, mark) in [
1179            (FeedStatus::Connecting, false, Some("Connecting...")),
1180            (FeedStatus::Live, true, None),
1181            (FeedStatus::Disconnected, false, Some("Offline")),
1182            (FeedStatus::Live, true, None),
1183            (FeedStatus::Disconnected, false, Some("Offline")),
1184        ] {
1185            trading.apply(
1186                &egui::Context::default(),
1187                AccountEvent::Status(status),
1188                &mut Toasts::new(),
1189            );
1190            check(&mut trading, &format!("{status:?}"), true, connected, mark);
1191        }
1192        // A new stream, as after a lag, starts without the old stream's status.
1193        trading.connect();
1194        check(&mut trading, "new stream", true, true, None);
1195        trading.disconnect();
1196        check(&mut trading, "disconnected", false, false, None);
1197    }
1198
1199    /// Rules and older history wait while the broker reports that it is not live; one that was
1200    /// on its way when it stopped being live, or failed meanwhile or with an error of the
1201    /// connection, is asked again once when it is live again, and a refused or unsupported one
1202    /// keeps its handling. A regression leaves the ticket waiting and the history stopped after
1203    /// an outage, asks a broker that is not live, asks again on every frame or status, or asks
1204    /// again for what the broker refused.
1205    #[test]
1206    fn rules_and_history_are_asked_again_once_the_broker_is_live() {
1207        use crate::data::FeedStatus;
1208        use crate::services::HistoryRequest;
1209        let ctx = egui::Context::default();
1210        let first = HistoryRequest::new(HistoryKind::Journal, 100);
1211        let report = |trading: &mut Trading, status| {
1212            trading.apply(
1213                &egui::Context::default(),
1214                AccountEvent::Status(status),
1215                &mut Toasts::new(),
1216            );
1217        };
1218        for (name, error, again) in [
1219            ("asked while offline", None, true),
1220            (
1221                "failed while offline",
1222                Some(FeedError::Other("not connected".into())),
1223                true,
1224            ),
1225            (
1226                "failed after reconnecting",
1227                Some(FeedError::Unanswered),
1228                true,
1229            ),
1230            (
1231                "network error",
1232                Some(FeedError::Network("reset".into())),
1233                true,
1234            ),
1235            ("refused", Some(FeedError::Other("refused".into())), false),
1236            ("unsupported", Some(FeedError::NotSupported), false),
1237        ] {
1238            let broker = Arc::new(Recorder::default());
1239            let mut trading = Trading::default();
1240            trading.set_service(broker.clone());
1241            trading.poll(&ctx, &mut Toasts::new());
1242            let ask = |trading: &mut Trading| {
1243                trading.load_rules("ES");
1244                trading.load_older(HistoryKind::Journal);
1245            };
1246            // Answers the oldest rules and history requests with `error`.
1247            let fail = |trading: &mut Trading, error: FeedError| {
1248                (broker.rules.lock().unwrap().remove(0).1).send(Err(error.clone()));
1249                let (asked, page) = broker.histories.lock().unwrap().remove(0);
1250                assert_eq!(asked, first, "{name}");
1251                page.send(Err(error));
1252                trading.poll(&ctx, &mut Toasts::new());
1253            };
1254            let counts = || {
1255                (
1256                    broker.rules.lock().unwrap().len(),
1257                    broker.histories.lock().unwrap().len(),
1258                )
1259            };
1260            match name {
1261                "asked while offline" => {
1262                    report(&mut trading, FeedStatus::Disconnected);
1263                    ask(&mut trading);
1264                    assert_eq!(counts(), (0, 0), "{name}");
1265                }
1266                "failed while offline" => {
1267                    ask(&mut trading);
1268                    report(&mut trading, FeedStatus::Disconnected);
1269                    fail(&mut trading, error.clone().unwrap());
1270                }
1271                "failed after reconnecting" => {
1272                    ask(&mut trading);
1273                    report(&mut trading, FeedStatus::Disconnected);
1274                }
1275                _ => {
1276                    ask(&mut trading);
1277                    fail(&mut trading, error.clone().unwrap());
1278                    report(&mut trading, FeedStatus::Disconnected);
1279                }
1280            }
1281            // The view asks every frame; nothing goes out while the broker is not live.
1282            let before = counts();
1283            trading.load_older(HistoryKind::Journal);
1284            assert_eq!(counts(), before, "{name}");
1285            report(&mut trading, FeedStatus::Live);
1286            if name == "failed after reconnecting" {
1287                fail(&mut trading, error.clone().unwrap());
1288            }
1289            for _ in 0..2 {
1290                trading.load_older(HistoryKind::Journal);
1291                report(&mut trading, FeedStatus::Live);
1292            }
1293            let rules: Vec<_> = broker.rules.lock().unwrap().drain(..).collect();
1294            let pages: Vec<_> = (broker.histories.lock().unwrap().drain(..))
1295                .map(|(request, _)| request)
1296                .collect();
1297            let symbols: Vec<_> = rules.iter().map(|(symbol, _)| symbol.as_str()).collect();
1298            assert_eq!(symbols, if again { vec!["ES"] } else { vec![] }, "{name}");
1299            assert_eq!(
1300                pages,
1301                if again { vec![first.clone()] } else { vec![] },
1302                "{name}"
1303            );
1304            for (_, reply) in rules {
1305                reply.send(Ok(TradingRules::new("ES")));
1306            }
1307            trading.poll(&ctx, &mut Toasts::new());
1308            assert_eq!(
1309                trading.rules_ready,
1310                name != "refused",
1311                "{name}: the ticket's rules"
1312            );
1313        }
1314    }
1315}