Skip to main content

egui_charts/ui/trading/
view.rs

1//! The trading controller's chart view: the order ticket's panel, the orders, positions and fills
2//! drawn on the chart, the image exports, and the confirmations of what the user changes there.
3
4use super::format::Numbers;
5use super::ticket::{self, Action, Market};
6use super::{Preview, Trading, executions};
7use crate::chart::selection::SeriesId;
8use crate::chart::{TradingLineAction, TradingLineOptions, render_trading_lines};
9use crate::localization as l10n;
10use crate::model::{
11    Order, OrderId, OrderRequest, OrderSide, OrderStatus, OrderType, Position, TradingRules,
12};
13use crate::scales::to_step;
14use crate::theme::ThemeExt;
15use crate::ui_kit::modal::dialogs::ConfirmDialog;
16use crate::widget::Chart;
17
18impl Trading {
19    /// What the order ticket needs from `chart` and the account this frame. Order tiers take
20    /// `colors` unless the chart's book view sets its own.
21    fn market<'a>(&'a self, chart: &'a Chart, colors: &crate::theme::ChartColors) -> Market<'a> {
22        let symbol = chart.symbol();
23        let instrument = chart
24            .instrument()
25            .filter(|instrument| instrument.symbol == symbol)
26            .or_else(|| self.instruments.get(symbol));
27        let decimals = instrument
28            .and_then(crate::scales::instrument_price_precision)
29            .map_or(2, usize::from);
30        let tick = instrument
31            .and_then(|instrument| instrument.tick_size)
32            .filter(|tick| tick.is_finite() && *tick > 0.0)
33            .unwrap_or(10f64.powi(-(decimals as i32)));
34        let replay = chart.replay_time().is_some();
35        let quote = (self.quote.as_ref())
36            .filter(|(quoted, _)| quoted == symbol && !replay)
37            .map(|(_, quote)| quote);
38        let book = chart.order_book(SeriesId::MAIN).filter(|_| !replay);
39        let view = chart.order_book_view();
40        let queue = chart.order_queue(SeriesId::MAIN).filter(|_| !replay);
41        let filtered = view.filter != crate::data::OrderFilter::default();
42        let ladder = match (queue, filtered) {
43            (Some(queue), true) => queue
44                .filtered(symbol, &view.filter)
45                .map(|b| Some(std::borrow::Cow::Owned(b))),
46            (None, true) if !replay => Err(crate::data::OrderFilterUnavailable::AggregateDepth),
47            _ => Ok(book.map(std::borrow::Cow::Borrowed)),
48        };
49        let highlights = queue
50            .zip(view.tiers)
51            .map(|(queue, tiers)| {
52                (queue.largest_orders(&view.filter).into_iter())
53                    .filter_map(|o| {
54                        let tier = tiers.tier(o.size)?;
55                        Some((
56                            o.side == OrderSide::Buy,
57                            o.price,
58                            view.tier_color(tier, colors),
59                        ))
60                    })
61                    .collect()
62            })
63            .unwrap_or_default();
64        Market {
65            symbol,
66            formatter: self.formatter.as_deref(),
67            pip_value: self.rules.pip_value.or_else(|| {
68                let pip = instrument.and_then(|instrument| instrument.pip_size);
69                pip.filter(|pip| pip.is_finite() && *pip > 0.0)
70                    .map(|pip| pip * self.rules.point_value)
71            }),
72            last: crate::model::last_with_value(chart.bars(SeriesId::MAIN)).map(|bar| bar.close),
73            bid: book
74                .and_then(|book| book.best_bid().map(|level| level.price))
75                .or_else(|| quote.and_then(|quote| quote.bid)),
76            ask: book
77                .and_then(|book| book.best_ask().map(|level| level.price))
78                .or_else(|| quote.and_then(|quote| quote.ask)),
79            tick,
80            decimals,
81            currency: self
82                .account
83                .as_ref()
84                .map_or("", |account| account.currency.as_str()),
85            balance: self.account.as_ref().map(|account| account.balance),
86            capabilities: self.capabilities(),
87            rules: &self.rules,
88            rules_ready: self.rules_ready && self.rules.symbol == symbol,
89            offline: self.offline(),
90            ladder_unavailable: ladder.as_ref().err().copied(),
91            ladder: ladder.unwrap_or(None),
92            highlights,
93            orders: &self.orders,
94            positions: &self.positions,
95            trades: &[],
96            session_open: None,
97            last_fill: self.account_panel.fills.price(symbol),
98            logo: instrument.and_then(|instrument| instrument.logo.as_deref()),
99        }
100    }
101
102    /// Draws the order ticket, while it is open, as a panel at the right of `ui` for the chart's
103    /// symbol. Call it before laying out the chart, so the chart takes the space left. Sends the
104    /// order the user submits, and the orders the user places from the depth ladder; a change to a
105    /// working order or the position waits for the confirmation [`show`](Self::show) asks for.
106    pub fn show_ticket(&mut self, ui: &mut egui::Ui, chart: &Chart) {
107        if !self.ticket.open {
108            return;
109        }
110        let symbol = chart.symbol();
111        if self.ticket.symbol != symbol {
112            // The ticket follows the chart; prices of the previous symbol mean nothing here.
113            self.ticket.change_symbol(symbol);
114        }
115        if self.account_panel.editing.is_none() {
116            self.load_rules(symbol);
117        }
118        let mut ticket = std::mem::take(&mut self.ticket);
119        let mut presets = std::mem::take(&mut self.presets);
120        let mut one_click = self.display.instant_orders;
121        let trades: Vec<_> = if chart.replay_time().is_some() {
122            Vec::new()
123        } else {
124            chart.trades(SeriesId::MAIN).collect()
125        };
126        let theme = ui.active_theme();
127        let mut market = self.market(chart, &theme.chart);
128        market.last = trades.last().map(|trade| trade.price).or(market.last);
129        market.trades = &trades;
130        market.session_open = trades
131            .last()
132            .and_then(|trade| session_open(chart, trade.ts));
133        let action = egui::Panel::right("order_ticket")
134            .resizable(false)
135            .exact_size(ui.available_width().min(340.0))
136            .frame(
137                egui::Frame::new()
138                    .fill(theme.ui.panel_bg)
139                    .inner_margin(egui::Margin::symmetric(16, 8)),
140            )
141            .show(ui, |ui| {
142                ticket.show(ui, &market, &mut presets, &mut one_click)
143            })
144            .inner;
145        // Later panels may grow to their contents, but must not cover the docked ticket.
146        ui.shrink_clip_rect(ui.available_rect_before_wrap());
147        (self.ticket, self.presets, self.display.instant_orders) = (ticket, presets, one_click);
148        self.perform(action);
149    }
150
151    /// Sends what the ticket asked for.
152    fn perform(&mut self, action: Option<Action>) {
153        match action {
154            Some(Action::Place(request)) => self.place(request),
155            Some(Action::Preview(request)) => {
156                if let Some(service) = self
157                    .service
158                    .as_ref()
159                    .filter(|_| self.capabilities().preview)
160                {
161                    self.request_key += 2;
162                    service.preview(request.clone(), self.inbox.reply(self.request_key));
163                    self.preview = Some(Preview {
164                        key: self.request_key,
165                        request,
166                        estimate: None,
167                    });
168                }
169            }
170            Some(Action::Cancel(ids)) => {
171                if let Some(service) = self.live_service() {
172                    for id in ids {
173                        service.cancel(id, self.inbox.reply(0));
174                    }
175                }
176            }
177            Some(Action::Confirm(change)) => self.pending = Some(*change),
178            None => {}
179        }
180    }
181
182    fn draw_overlays(&self, ui: &mut egui::Ui, chart: &Chart) -> crate::chart::TradingLines {
183        let symbol = chart.symbol();
184        let market = self.market(chart, &ui.active_theme().chart);
185        let (decimals, tick, last) = (market.decimals, market.tick, market.last);
186        let capabilities = self.capabilities();
187        let mapping = chart.get_chart_mapping();
188        let display = &self.display;
189        let options = TradingLineOptions {
190            show_orders: display.show_orders,
191            extend_lines: display.extend_lines,
192            label_alignment: display.label_alignment,
193            show_positions: display.show_positions,
194            submit: false,
195            modify: capabilities.modify,
196            close: capabilities.close,
197            reverse: display.reverse_button && capabilities.reverse,
198            take_profit: capabilities.limit,
199            stop_loss: capabilities.stop,
200            order_exits: capabilities.brackets && capabilities.modify,
201            pnl: display.position_pnl.then_some(display.position_pnl_unit),
202            bracket_pnl: display.bracket_pnl.then_some(display.bracket_pnl_unit),
203            last_price: last,
204            point_value: if self.rules.symbol == symbol {
205                self.rules.point_value
206            } else {
207                1.0
208            },
209            currency: self
210                .account
211                .as_ref()
212                .map_or("", |account| account.currency.as_str()),
213            tick_size: tick,
214        };
215        let mut shown_orders = self.orders.clone();
216        if let Some(TradingLineAction::Modify(old, request)) = &self.pending
217            && let Some(order) = shown_orders.iter_mut().find(|order| order.id == old.id)
218        {
219            order.request = request.clone();
220        }
221        if let Some(TradingLineAction::Exit(_, request)) = &self.pending {
222            shown_orders.push(Order::new(
223                OrderId::new("pending-exit"),
224                request.clone(),
225                OrderStatus::Working,
226            ));
227        }
228        {
229            let _theme = chart
230                .theme_override
231                .as_ref()
232                .map(|theme| crate::theme::ThemeScope::new(ui.ctx(), theme));
233            // Under the lines, so that no mark covers an order's or a position's label.
234            if display.show_executions {
235                let fills: Vec<_> = self
236                    .account_panel
237                    .fills
238                    .executions
239                    .iter()
240                    .filter(|fill| {
241                        self.account_panel
242                            .fills
243                            .orders
244                            .get(&fill.order)
245                            .is_some_and(|request| request.symbol == symbol)
246                    })
247                    .collect();
248                // `Trading::numbers` takes the ticket's decimals; prices here keep the market's.
249                let numbers = Numbers {
250                    symbol,
251                    decimals,
252                    formatter: self.formatter.as_deref(),
253                };
254                executions::show(ui, chart, &fills, display.show_execution_labels, numbers);
255            }
256            render_trading_lines(
257                ui,
258                &mapping,
259                shown_orders.iter().filter(|o| o.request.symbol == symbol),
260                self.positions.iter().filter(|p| p.symbol == symbol),
261                &options,
262            )
263        }
264    }
265
266    /// Exports the chart with its visible trading overlays when
267    /// [`TradingSettings::include_in_image`](crate::widget::settings::TradingSettings::include_in_image) is enabled. Account data and display settings
268    /// stay unchanged; the host delivers the returned frame.
269    pub fn export_image(
270        &self,
271        chart: &Chart,
272        ctx: &egui::Context,
273        options: &crate::widget::ImageExportOptions,
274    ) -> Result<crate::widget::ChartImage, crate::widget::ExportError> {
275        chart.export_layout_image(ctx, options, |ui, chart| self.draw_export(ui, chart))
276    }
277
278    /// Encodes [`Self::export_image`] as PNG bytes for host file, clipboard or sharing delivery.
279    pub fn export_png(
280        &self,
281        chart: &Chart,
282        ctx: &egui::Context,
283        options: &crate::widget::ImageExportOptions,
284    ) -> Result<Vec<u8>, crate::widget::ExportError> {
285        chart.export_layout_png(ctx, options, |ui, chart| self.draw_export(ui, chart))
286    }
287
288    /// Writes [`Self::export_image`] as SVG, as [`Chart::export_svg`] does.
289    pub fn export_svg(
290        &self,
291        chart: &Chart,
292        ctx: &egui::Context,
293        options: &crate::widget::ImageExportOptions,
294    ) -> Result<String, crate::widget::ExportError> {
295        chart.export_layout_svg(ctx, options, |ui, chart| self.draw_export(ui, chart))
296    }
297
298    /// Draws a frozen chart and its opted-in trading overlays inside an image layout callback.
299    /// Use with [`Chart::export_layout_image`] and [`Chart::export_snapshot`] for several charts.
300    pub fn draw_export(&self, ui: &mut egui::Ui, chart: &mut Chart) {
301        chart.show(ui);
302        if self.display.include_in_image {
303            chart.trading_bid_ask = self.display.bid_ask_labels;
304            let lines = self.draw_overlays(ui, chart);
305            chart.trading_tags = lines.tags;
306            chart.trading_outlined = lines.outlined;
307        }
308    }
309
310    /// Draws the chart symbol's working orders, brackets, positions and execution marks,
311    /// respecting [`display`](Self::display). Hovering a mark lists the fills of its bar.
312    /// Asks the user to confirm a change made on a line or in the depth ladder before sending it.
313    pub fn show(&mut self, ui: &mut egui::Ui, chart: &mut Chart) {
314        chart.trading_bid_ask = self.display.bid_ask_labels;
315        let colors = ui.active_theme().chart;
316        let symbol = chart.symbol().to_owned();
317        let symbol = symbol.as_str();
318        let market = self.market(chart, &colors);
319        let (decimals, tick, last) = (market.decimals, market.tick, market.last);
320        let logo = market.logo.map(str::to_owned);
321        self.chart_logo = Some((symbol.to_owned(), logo));
322        self.decimals = decimals;
323        if self.account_panel.editing.is_none() {
324            self.load_rules(symbol);
325        }
326
327        if self.service.is_some() {
328            let lines = self.draw_overlays(ui, chart);
329            chart.trading_tags = lines.tags;
330            chart.trading_outlined = lines.outlined;
331            if self.display.instant_orders {
332                self.execute(lines.action);
333            } else {
334                self.pending = self.pending.take().or(lines.action);
335            }
336        }
337        if let Some(action) = &self.pending {
338            let numbers = self.numbers(symbol, symbol);
339            let money = |value: f64| numbers.price(value);
340            let (title, message, confirm) = describe(ui.ctx(), action, &money);
341            let mut open = true;
342            let result = ConfirmDialog::new("trading_confirm", title, message)
343                .confirm_text(confirm)
344                .cancel_text("Discard")
345                .show(ui.ctx(), &mut open);
346            if result.is_confirmed() {
347                let action = self.pending.take();
348                self.execute(action);
349            } else if result.is_cancelled() || !open {
350                self.pending = None;
351            }
352        }
353
354        if let Some(Preview {
355            request,
356            estimate: Some(estimate),
357            ..
358        }) = &self.preview
359        {
360            let mut open = true;
361            let result = ConfirmDialog::new(
362                "order_preview",
363                "Order preview",
364                ticket::preview_message(
365                    request,
366                    estimate,
367                    &self.rules,
368                    &self.numbers(&request.symbol, symbol),
369                    self.account
370                        .as_ref()
371                        .map_or("", |account| &account.currency),
372                ),
373            )
374            .confirm_text("Place order")
375            .cancel_text("Back")
376            .show(ui.ctx(), &mut open);
377            if result.is_confirmed() {
378                if let Some(preview) = self.preview.take() {
379                    self.place(preview.request);
380                }
381            } else if result.is_cancelled() || !open {
382                self.preview = None;
383            }
384        }
385
386        // The legend shows the order quantity under the spread; clicking it toggles the calculator.
387        chart.order_quantity = self.service.is_some().then_some(self.quantity);
388        let toggled = chart.quantity_clicked.take();
389        if let Some(anchor) = toggled {
390            self.calculator = match self.calculator {
391                Some(_) => None,
392                None => Some((
393                    anchor.left_bottom() + egui::vec2(0.0, 4.0),
394                    self.quantity.to_string(),
395                )),
396            };
397        }
398        if let Some((at, text)) = &mut self.calculator {
399            let area = egui::Area::new(ui.scope_id().with("order_quantity"))
400                .order(egui::Order::Foreground)
401                .fixed_pos(*at)
402                .show(ui.ctx(), |ui| {
403                    egui::Frame::popup(ui.style()).show(ui, |ui| {
404                        calculator(ui, text, &mut self.quantity, &self.rules);
405                    });
406                });
407            let escape = ui.input(|input| input.key_pressed(egui::Key::Escape));
408            if escape || toggled.is_none() && area.response.clicked_elsewhere() {
409                self.calculator = None;
410            }
411        }
412
413        if !self.ticket.open {
414            return;
415        }
416        let market = self.market(chart, &colors);
417        if let Ok(request) = self.ticket.request(&market)
418            && request.order_type != OrderType::Market
419        {
420            let _theme = chart
421                .theme_override
422                .as_ref()
423                .map(|theme| crate::theme::ThemeScope::new(ui.ctx(), theme));
424            let mapping = chart.get_chart_mapping();
425            let options = TradingLineOptions {
426                show_orders: true,
427                extend_lines: self.display.extend_lines,
428                label_alignment: self.display.label_alignment,
429                show_positions: false,
430                submit: true,
431                modify: true,
432                close: false,
433                reverse: false,
434                take_profit: false,
435                stop_loss: false,
436                order_exits: self.capabilities().brackets,
437                pnl: None,
438                bracket_pnl: self
439                    .display
440                    .bracket_pnl
441                    .then_some(self.display.bracket_pnl_unit),
442                last_price: last,
443                point_value: self.rules.point_value,
444                currency: self
445                    .account
446                    .as_ref()
447                    .map_or("", |account| account.currency.as_str()),
448                tick_size: tick,
449            };
450            let order = Order::new(
451                OrderId::new("projected"),
452                request.clone(),
453                OrderStatus::Working,
454            );
455            let lines = ui
456                .push_id("projected_order", |ui| {
457                    render_trading_lines(ui, &mapping, [&order], [], &options)
458                })
459                .inner;
460            let offset = chart.trading_tags.len();
461            chart.trading_tags.extend(lines.tags);
462            chart
463                .trading_outlined
464                .extend(lines.outlined.into_iter().map(|index| offset + index));
465            match lines.action {
466                Some(TradingLineAction::Cancel(_)) => self.ticket.open = false,
467                Some(TradingLineAction::Modify(_, changed)) => self.ticket.open(&changed),
468                _ => {}
469            }
470            if let Some(request) = lines.submitted {
471                self.ticket.open = false;
472                self.place(request);
473            }
474        }
475    }
476
477    /// Sends a confirmed change to the broker, or reports it as refused while the broker is not
478    /// live. Closing and reversing a position are market orders on the other side, for the
479    /// position's size and for twice it.
480    pub(super) fn execute(&mut self, action: Option<TradingLineAction>) {
481        // With instant orders `show` passes the lines' action of every frame, mostly none.
482        if action.is_none() {
483            return;
484        }
485        let Some(service) = self.live_service() else {
486            return;
487        };
488        let reverse = matches!(action, Some(TradingLineAction::Reverse(_)));
489        let (position, mut order) = match action {
490            Some(TradingLineAction::Cancel(order)) => {
491                return service.cancel(order.id, self.inbox.reply(0));
492            }
493            Some(TradingLineAction::Modify(order, request)) => {
494                return service.modify(order.id, request, self.inbox.reply(0));
495            }
496            Some(TradingLineAction::Exit(position, exit)) => (position, exit),
497            Some(TradingLineAction::Close(position) | TradingLineAction::Reverse(position))
498                if position.quantity != 0.0
499                    && if reverse {
500                        self.capabilities().reverse
501                    } else {
502                        self.capabilities().close
503                    } =>
504            {
505                let side = if position.quantity > 0.0 {
506                    OrderSide::Sell
507                } else {
508                    OrderSide::Buy
509                };
510                let quantity = position.quantity.abs() * if reverse { 2.0 } else { 1.0 };
511                let order = OrderRequest::new(&position.symbol, side, quantity, OrderType::Market);
512                (position, order)
513            }
514            _ => return,
515        };
516        if self.rules_ready && self.rules.symbol == order.symbol {
517            order.time_in_force = self.rules.time_in_force[0];
518            self.place(order);
519        } else {
520            self.request_key += 2;
521            service.rules(&order.symbol, self.inbox.reply(self.request_key));
522            self.placing.push((self.request_key, position, order));
523        }
524    }
525}
526
527/// The open of the exchange session containing `time`, by the chart's trading hours and zone.
528fn session_open(
529    chart: &Chart,
530    time: chrono::DateTime<chrono::Utc>,
531) -> Option<chrono::DateTime<chrono::Utc>> {
532    let sessions = (chart.bar_clock().sessions()).with_timeframe(crate::model::Timeframe::Min1);
533    (sessions.opens([time]).into_iter().rev())
534        .find(|(open, _)| *open <= time)
535        .map(|(open, _)| open)
536}
537
538/// The quantity calculator: a field for the quantity, minus and plus by the quantity step, preset
539/// quantities, a button that clears the field and one that resets the quantity to the minimum.
540/// A positive number in the field becomes the order quantity.
541fn calculator(ui: &mut egui::Ui, text: &mut String, quantity: &mut f64, rules: &TradingRules) {
542    use crate::icons::icons;
543    use crate::theme::ThemeExt;
544    let theme = ui.ctx().active_theme();
545    ui.set_width(200.0);
546    ui.label(egui::RichText::new(l10n::text(ui.ctx(), "Quantity")).color(theme.ui.text_secondary));
547    if ui
548        .add(egui::TextEdit::singleline(text).desired_width(f32::INFINITY))
549        .changed()
550        && let Ok(value) = text.trim().parse::<f64>()
551        && value.is_finite()
552        && value > 0.0
553    {
554        *quantity = value;
555    }
556    let step = rules.quantity_step;
557    let icon =
558        |icon: &crate::icons::Icon| icon.as_image_tinted(egui::vec2(14.0, 14.0), theme.ui.text);
559    let mut set = None;
560    egui::Grid::new("order_quantity_buttons")
561        .num_columns(2)
562        .spacing([6.0, 6.0])
563        .show(ui, |ui| {
564            let button = |ui: &mut egui::Ui, button: egui::Button<'_>| {
565                ui.add_sized([97.0, 28.0], button).clicked()
566            };
567            if button(ui, egui::Button::image(icon(&icons::MINUS))) {
568                set = Some(to_step(*quantity - step, step, f64::round).max(rules.min_quantity));
569            }
570            if button(ui, egui::Button::image(icon(&icons::PLUS))) {
571                set = Some(to_step(*quantity + step, step, f64::round));
572            }
573            ui.end_row();
574            for pair in [[1.0, 5.0], [25.0, 100.0], [500.0, 1000.0]] {
575                for preset in pair {
576                    if button(ui, egui::Button::new(format!("{preset}"))) {
577                        set = Some(preset);
578                    }
579                }
580                ui.end_row();
581            }
582            if button(ui, egui::Button::new(l10n::text(ui.ctx(), "C"))) {
583                text.clear();
584            }
585            if button(ui, egui::Button::image(icon(&icons::ROTATE_CCW))) {
586                set = Some(rules.min_quantity);
587            }
588            ui.end_row();
589        });
590    if let Some(value) = set.filter(|value| value.is_finite() && *value > 0.0) {
591        *quantity = value;
592        *text = value.to_string();
593    }
594}
595
596/// The title, question and confirm button text of the confirmation for `action`.
597fn describe(
598    ctx: &egui::Context,
599    action: &TradingLineAction,
600    price: &dyn Fn(f64) -> String,
601) -> (&'static str, String, &'static str) {
602    let order = |request: &OrderRequest| {
603        let quantity = request.quantity.to_string();
604        let values = [
605            ("quantity", quantity.as_str()),
606            ("symbol", request.symbol.as_str()),
607        ];
608        let key = match (request.side, request.order_type) {
609            (OrderSide::Buy, OrderType::Limit(_)) => "BUY {quantity} {symbol} limit {price}",
610            (OrderSide::Sell, OrderType::Limit(_)) => "SELL {quantity} {symbol} limit {price}",
611            (OrderSide::Buy, OrderType::Stop(_)) => "BUY {quantity} {symbol} stop {price}",
612            (OrderSide::Sell, OrderType::Stop(_)) => "SELL {quantity} {symbol} stop {price}",
613            (OrderSide::Buy, OrderType::StopLimit { .. }) => {
614                "BUY {quantity} {symbol} stop {stop} limit {limit}"
615            }
616            (OrderSide::Sell, OrderType::StopLimit { .. }) => {
617                "SELL {quantity} {symbol} stop {stop} limit {limit}"
618            }
619            (OrderSide::Buy, _) => "BUY {quantity} {symbol} at market",
620            (OrderSide::Sell, _) => "SELL {quantity} {symbol} at market",
621        };
622        let mut text = match request.order_type {
623            OrderType::Limit(limit) | OrderType::Stop(limit) => l10n::format_text(
624                ctx,
625                key,
626                &[values[0], values[1], ("price", price(limit).as_str())],
627            ),
628            OrderType::StopLimit { stop, limit } => l10n::format_text(
629                ctx,
630                key,
631                &[
632                    values[0],
633                    values[1],
634                    ("stop", price(stop).as_str()),
635                    ("limit", price(limit).as_str()),
636                ],
637            ),
638            _ => l10n::format_text(ctx, key, &values),
639        };
640        if let Some(take_profit) = request.take_profit {
641            text.push_str(&l10n::format_text(
642                ctx,
643                ", take profit {price}",
644                &[("price", price(take_profit).as_str())],
645            ));
646        }
647        for level in &request.take_profit_levels {
648            text.push_str(&l10n::format_text(
649                ctx,
650                ", take profit {price} for {quantity}",
651                &[
652                    ("price", price(level.price).as_str()),
653                    ("quantity", level.quantity.to_string().as_str()),
654                ],
655            ));
656        }
657        if let Some(stop_loss) = request.stop_loss {
658            text.push_str(&l10n::format_text(
659                ctx,
660                ", stop loss {price}",
661                &[("price", price(stop_loss).as_str())],
662            ));
663        }
664        text
665    };
666    let position = |position: &Position| {
667        l10n::format_text(
668            ctx,
669            if position.quantity > 0.0 {
670                "the long position of {quantity} {symbol}"
671            } else {
672                "the short position of {quantity} {symbol}"
673            },
674            &[
675                ("quantity", position.quantity.abs().to_string().as_str()),
676                ("symbol", position.symbol.as_str()),
677            ],
678        )
679    };
680    match action {
681        TradingLineAction::Cancel(working) => (
682            "Cancel order",
683            l10n::format_text(
684                ctx,
685                "Cancel the order to {order}?",
686                &[("order", order(&working.request).as_str())],
687            ),
688            "Cancel order",
689        ),
690        TradingLineAction::Modify(_, changed) => (
691            "Modify order",
692            l10n::format_text(
693                ctx,
694                "Change the order to {order}?",
695                &[("order", order(changed).as_str())],
696            ),
697            "Modify order",
698        ),
699        TradingLineAction::Close(open) => (
700            "Close position",
701            l10n::format_text(
702                ctx,
703                "Close {position} at market?",
704                &[("position", position(open).as_str())],
705            ),
706            "Close position",
707        ),
708        TradingLineAction::Reverse(open) => (
709            "Reverse position",
710            l10n::format_text(
711                ctx,
712                "Reverse {position} at market?",
713                &[("position", position(open).as_str())],
714            ),
715            "Reverse position",
716        ),
717        TradingLineAction::Exit(open, exit) => {
718            let (title, key) = match exit.order_type {
719                OrderType::Limit(_) => (
720                    "Place take profit",
721                    "Protect {position} with a take profit: {order}?",
722                ),
723                _ => (
724                    "Place stop loss",
725                    "Protect {position} with a stop loss: {order}?",
726                ),
727            };
728            (
729                title,
730                l10n::format_text(
731                    ctx,
732                    key,
733                    &[
734                        ("position", position(open).as_str()),
735                        ("order", order(exit).as_str()),
736                    ],
737                ),
738                title,
739            )
740        }
741    }
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use crate::data::FeedError;
748    use crate::model::{Account, Capabilities, Instrument, OrderPreview, TakeProfitLevel};
749    use crate::services::AccountEvent;
750    use crate::ui::trading::TradingFormatter;
751    use crate::ui::trading::tests::Recorder;
752    use crate::ui::trading::widgets::side_name;
753    use crate::ui_kit::notifications::Toasts;
754    use crate::widget::settings::TradingSettings;
755    use std::sync::Arc;
756
757    /// Confirmation sentences and their side and order descriptions use template keys.
758    #[test]
759    fn confirmations_reach_the_translator() {
760        let ctx = egui::Context::default();
761        l10n::mark_keys(&ctx);
762        let request = |side, kind| OrderRequest::new("1301", side, 2.0, kind);
763        let mut bracketed = request(OrderSide::Buy, OrderType::Limit(101.0));
764        bracketed.take_profit = Some(110.0);
765        bracketed.stop_loss = Some(95.0);
766        let working = Order::new(OrderId::new("7"), bracketed, OrderStatus::Working);
767        let long = Position::new("1301", 2.0, 100.0);
768        for (action, expected) in [
769            (
770                TradingLineAction::Cancel(working.clone()),
771                "~~2~1301~101.00~~110.00~~95.00~~",
772            ),
773            (
774                TradingLineAction::Modify(
775                    working,
776                    request(
777                        OrderSide::Sell,
778                        OrderType::StopLimit {
779                            stop: 99.0,
780                            limit: 98.0,
781                        },
782                    ),
783                ),
784                "~~2~1301~99.00~98.00~~",
785            ),
786            (TradingLineAction::Close(long.clone()), "~~2~1301~~"),
787            (
788                TradingLineAction::Reverse(Position::new("1301", -3.0, 100.0)),
789                "~~3~1301~~",
790            ),
791            (
792                TradingLineAction::Exit(
793                    long.clone(),
794                    request(OrderSide::Sell, OrderType::Limit(110.0)),
795                ),
796                "~~2~1301~~~2~1301~110.00~~",
797            ),
798            (
799                TradingLineAction::Exit(long, request(OrderSide::Sell, OrderType::Stop(95.0))),
800                "~~2~1301~~~2~1301~95.00~~",
801            ),
802        ] {
803            let (_, message, _) = describe(&ctx, &action, &|v| format!("{v:.2}"));
804            assert_eq!(message, expected, "{action:?}");
805        }
806    }
807
808    /// Rows of differently formatted symbols keep their own price decimals, host text and pip
809    /// value instead of the chart symbol's decimals or a fixed two. A regression writes every
810    /// symbol with the chart's decimals, ignores the host's formatter, loses the pip value or shows
811    /// another symbol's logo.
812    #[test]
813    fn symbols_keep_their_own_decimals_host_text_pip_values_and_logos() {
814        struct Host;
815        impl TradingFormatter for Host {
816            fn quantity(&self, symbol: &str, quantity: f64) -> Option<String> {
817                (symbol == "BTC").then(|| format!("{quantity} coins"))
818            }
819        }
820        let mut trading = Trading {
821            decimals: 5,
822            chart_logo: Some(("EURUSD".into(), Some("bytes://chart.svg".into()))),
823            ..Trading::default()
824        };
825        trading.set_instrument(
826            Instrument::new("USDJPY")
827                .with_price_decimals(3)
828                .with_pip_size(0.01)
829                .with_logo("bytes://usdjpy.svg"),
830        );
831        trading.set_instrument(Instrument::new("EURUSD").with_logo("bytes://host.svg"));
832        trading.set_formatter(Some(Arc::new(Host)));
833        assert_eq!(
834            ["EURUSD", "USDJPY", "UNKNOWN"].map(|symbol| trading.logo(symbol)),
835            [Some("bytes://chart.svg"), Some("bytes://usdjpy.svg"), None]
836        );
837        let price = |trading: &Trading, symbol| trading.numbers(symbol, "EURUSD").price(1234.5);
838        assert_eq!(price(&trading, "EURUSD"), "1,234.50000");
839        assert_eq!(price(&trading, "USDJPY"), "1,234.500");
840        assert_eq!(price(&trading, "UNKNOWN"), "1,234.50");
841        let quantity = |symbol| trading.numbers(symbol, "EURUSD").quantity(2.0, 0);
842        assert_eq!(
843            (quantity("BTC"), quantity("USDJPY")),
844            ("2 coins".into(), "2".into())
845        );
846
847        let mut chart = crate::widget::test_frames::chart(&[90.0], &[110.0]);
848        chart.set_symbol("USDJPY");
849        trading.rules = TradingRules::new("USDJPY");
850        trading.rules.point_value = 1000.0;
851        let colors = crate::theme::Theme::dark().chart;
852        assert_eq!(
853            trading.market(&chart, &colors).logo,
854            Some("bytes://usdjpy.svg")
855        );
856        assert_eq!(trading.market(&chart, &colors).pip_value, Some(10.0));
857        trading.rules.pip_value = Some(0.5);
858        assert_eq!(trading.market(&chart, &colors).pip_value, Some(0.5));
859        chart.set_symbol("UNKNOWN");
860        trading.rules = TradingRules::new("UNKNOWN");
861        assert_eq!(trading.market(&chart, &colors).pip_value, None);
862    }
863
864    #[test]
865    fn ladder_changes_still_wait_for_confirmation_with_instant_orders() {
866        let broker = Arc::new(Recorder::default());
867        let mut trading = Trading::default();
868        trading.set_service(broker.clone());
869        let ctx = egui::Context::default();
870        trading.load_rules("SYM");
871        trading.poll(&ctx, &mut Toasts::new());
872        trading.display.instant_orders = true;
873        let order = Order::new(
874            OrderId::new("working"),
875            OrderRequest::new("SYM", OrderSide::Buy, 1.0, OrderType::Limit(95.0)),
876            OrderStatus::Working,
877        );
878        let mut changed = order.request.clone();
879        changed.order_type = OrderType::Limit(96.0);
880        let action = TradingLineAction::Modify(order, changed);
881        trading.perform(Some(Action::Confirm(Box::new(action.clone()))));
882        let mut chart = crate::widget::test_frames::chart(&[90.0], &[110.0]);
883        chart.symbol = "SYM".into();
884        ctx.run_ui(Default::default(), |ui| {
885            chart.show(ui);
886            trading.show(ui, &mut chart);
887        })
888        .drop_without_applying_deltas();
889        assert_eq!(trading.pending, Some(action));
890        assert!(broker.modified.lock().unwrap().is_empty());
891    }
892
893    /// Line gestures wait for the actual confirmation button and send one broker call.
894    #[test]
895    fn line_changes_wait_for_confirmation_and_send_one_call() {
896        use crate::chart::TradingLabelAlignment::{Center, Left, Right};
897        use crate::theme::{Theme, apply_to_egui};
898        for theme in [Theme::light(), Theme::dark()] {
899            for (width, alignment) in [800.0, 320.0, 260.0]
900                .into_iter()
901                .flat_map(|width| [Left, Center, Right].map(|alignment| (width, alignment)))
902            {
903                for instant in [false, true] {
904                    for (operation, edge, handle, gesture) in [
905                        ("Cancel order", false, false, ""),
906                        ("Modify order", false, false, ""),
907                        ("Modify order", false, true, ""),
908                        ("Modify order", true, false, ""),
909                        ("Close position", false, false, ""),
910                        ("Reverse position", false, false, ""),
911                        ("Place take profit", false, false, ""),
912                        ("Place stop loss", false, false, ""),
913                        ("Modify order", false, false, "levels entry"),
914                        ("Modify order", false, true, "levels drag"),
915                        ("Modify order", false, false, "levels remove"),
916                        ("Modify order", false, false, "add TP"),
917                        ("Modify order", false, false, "add SL"),
918                    ] {
919                        // The gestures behave alike in both themes; one keeps the test's time down.
920                        if !gesture.is_empty() && theme != Theme::light() {
921                            continue;
922                        }
923                        let mut request =
924                            OrderRequest::new("SYM", OrderSide::Sell, 1.0, OrderType::Limit(105.0));
925                        let add = gesture.starts_with("add");
926                        if gesture.starts_with("levels") {
927                            request.take_profit_levels = vec![
928                                TakeProfitLevel::new(102.0, 0.5),
929                                TakeProfitLevel::new(101.0, 0.5),
930                            ];
931                        } else if !add {
932                            request.take_profit = Some(102.0);
933                        }
934                        if !add {
935                            request.stop_loss = Some(108.0);
936                        }
937                        let order = Order::new(OrderId::new("7"), request, OrderStatus::Working);
938                        let position = Position::new("SYM", -2.0, 100.0);
939                        let broker = Arc::new(Recorder {
940                            state: vec![
941                                AccountEvent::Order(order.clone()),
942                                AccountEvent::Position(position.clone()),
943                            ],
944                            brackets: true,
945                            ..Recorder::default()
946                        });
947                        let mut trading = Trading::default();
948                        trading.set_service(broker.clone());
949                        trading.display.instant_orders = instant;
950                        trading.display.label_alignment = alignment;
951                        let mut chart = crate::widget::test_frames::chart(&[90.0], &[110.0]);
952                        chart.symbol = "SYM".into();
953                        if edge {
954                            let mut instrument = crate::model::Instrument::new("SYM");
955                            instrument.tick_size = Some(64.0);
956                            chart.set_instrument(Some(instrument));
957                        }
958                        let ctx = egui::Context::default();
959                        apply_to_egui(
960                            &ctx,
961                            &if theme == Theme::light() {
962                                Theme::dark()
963                            } else {
964                                Theme::light()
965                            },
966                        );
967                        chart.set_theme_override(Some(theme.clone()));
968                        let frame = |trading: &mut Trading, chart: &mut Chart, events| {
969                            for (_, reply) in std::mem::take(&mut *broker.rules.lock().unwrap()) {
970                                reply.send(Err(FeedError::NotSupported));
971                            }
972                            let output = ctx.run_ui(
973                                egui::RawInput {
974                                    screen_rect: Some(egui::Rect::from_min_size(
975                                        egui::Pos2::ZERO,
976                                        egui::vec2(width, 600.0),
977                                    )),
978                                    events,
979                                    ..Default::default()
980                                },
981                                |ui| {
982                                    trading.poll(ui.ctx(), &mut Toasts::new());
983                                    chart.show(ui);
984                                    trading.show(ui, chart);
985                                },
986                            );
987                            let texts: Vec<_> = output
988                                .shapes
989                                .iter()
990                                .filter_map(|shape| match &shape.shape {
991                                    egui::Shape::Text(text) => Some((
992                                        text.galley.text().to_owned(),
993                                        text.galley.rect.translate(text.pos.to_vec2()),
994                                    )),
995                                    egui::Shape::Rect(rect)
996                                        if rect.rect.size() == egui::vec2(20.0, 20.0)
997                                            && rect.stroke.width == 1.0
998                                            && rect.stroke.color == theme.chart.candle_down =>
999                                    {
1000                                        Some(("Reverse position".into(), rect.rect))
1001                                    }
1002                                    _ => None,
1003                                })
1004                                .collect();
1005                            output.drop_without_applying_deltas();
1006                            texts
1007                        };
1008                        let find = |texts: Vec<(String, egui::Rect)>, wanted: &str| {
1009                            texts
1010                                .into_iter()
1011                                .rev()
1012                                .find(|(text, _)| text == wanted)
1013                                .map(|(_, rect)| rect)
1014                        };
1015                        let click = |pos, pressed| egui::Event::PointerButton {
1016                            pos,
1017                            button: egui::PointerButton::Primary,
1018                            pressed,
1019                            modifiers: Default::default(),
1020                        };
1021                        frame(&mut trading, &mut chart, vec![]);
1022                        frame(&mut trading, &mut chart, vec![]);
1023                        assert!(
1024                            chart
1025                                .trading_tags
1026                                .iter()
1027                                .any(|(_, color)| *color == theme.chart.candle_down)
1028                        );
1029                        let mapping = chart.get_chart_mapping();
1030                        let y = mapping.price_to_y(
1031                            if matches!(gesture, "levels drag" | "levels remove") {
1032                                101.0
1033                            } else if operation.ends_with("order") {
1034                                105.0
1035                            } else {
1036                                100.0
1037                            },
1038                        );
1039                        // Hovering the label shows the reverse button.
1040                        let texts = frame(&mut trading, &mut chart, vec![]);
1041                        let right = texts
1042                            .iter()
1043                            .filter(|(_, rect)| {
1044                                (rect.center().y - y).abs() < 2.0
1045                                    && rect.right() < mapping.rect.right() - 20.0
1046                            })
1047                            .map(|(_, rect)| rect.right())
1048                            .max_by(f32::total_cmp)
1049                            .unwrap();
1050                        let close = egui::pos2(right + 17.0, y);
1051                        let texts = frame(
1052                            &mut trading,
1053                            &mut chart,
1054                            vec![egui::Event::PointerMoved(close)],
1055                        );
1056                        let take_profit = find(texts.clone(), "TP").expect("a TP button");
1057                        // The position has its own buttons on another row.
1058                        let on_row = |wanted: &str| {
1059                            texts
1060                                .iter()
1061                                .find(|(text, rect)| {
1062                                    text == wanted && (rect.center().y - y).abs() < 2.0
1063                                })
1064                                .map(|(_, rect)| rect.center().x)
1065                        };
1066                        if gesture.is_empty() && operation.ends_with("order") {
1067                            assert_eq!(
1068                                (on_row("TP"), on_row("SL")),
1069                                (None, None),
1070                                "an order with both exits offers no button"
1071                            );
1072                        }
1073                        let x = match operation {
1074                            _ if gesture == "levels remove" => close.x,
1075                            _ if gesture == "add TP" => on_row("TP").expect("the order's TP"),
1076                            _ if gesture == "add SL" => on_row("SL").expect("the order's SL"),
1077                            "Modify order" if handle => mapping.rect.right() - 42.0,
1078                            "Modify order" => find(texts.clone(), "Limit").unwrap().center().x,
1079                            "Place take profit" => take_profit.center().x,
1080                            "Place stop loss" => {
1081                                find(texts.clone(), "SL").expect("an SL button").center().x
1082                            }
1083                            _ => close.x,
1084                        };
1085                        let start = if operation == "Reverse position" {
1086                            find(texts.clone(), "Reverse position")
1087                                .expect("reverse button")
1088                                .center()
1089                        } else {
1090                            egui::pos2(x, y)
1091                        };
1092                        assert!(mapping.rect.contains(start), "{operation} at {width}");
1093                        frame(
1094                            &mut trading,
1095                            &mut chart,
1096                            vec![egui::Event::PointerMoved(start)],
1097                        );
1098                        frame(&mut trading, &mut chart, vec![click(start, true)]);
1099                        let end = if edge {
1100                            egui::pos2(x, mapping.rect.top())
1101                        } else if gesture == "levels remove" {
1102                            start
1103                        } else if gesture == "levels drag" {
1104                            egui::pos2(x, mapping.price_to_y(100.5))
1105                        } else if gesture == "add TP" {
1106                            egui::pos2(x, mapping.price_to_y(101.5))
1107                        } else if gesture == "add SL" {
1108                            egui::pos2(x, mapping.price_to_y(108.5))
1109                        } else if operation == "Modify order" {
1110                            egui::pos2(x, mapping.price_to_y(103.07))
1111                        } else if operation == "Place take profit" {
1112                            egui::pos2(x, mapping.price_to_y(95.0))
1113                        } else if operation == "Place stop loss" {
1114                            egui::pos2(x, mapping.price_to_y(104.0))
1115                        } else {
1116                            start
1117                        };
1118                        frame(
1119                            &mut trading,
1120                            &mut chart,
1121                            vec![egui::Event::PointerMoved(end)],
1122                        );
1123                        frame(&mut trading, &mut chart, vec![click(end, false)]);
1124                        if !instant {
1125                            assert!(
1126                                trading.pending.is_some(),
1127                                "{operation} at {width}, {alignment:?}, handle={handle}, instant={instant}, start={start:?}, TP={take_profit:?}, pane={:?}",
1128                                mapping.rect
1129                            );
1130                            assert!(broker.placed.lock().unwrap().is_empty());
1131                            assert!(broker.cancelled.lock().unwrap().is_empty());
1132                            assert!(broker.modified.lock().unwrap().is_empty());
1133                            if operation == "Modify order" && !edge && !handle && gesture.is_empty()
1134                            {
1135                                let discard =
1136                                    find(frame(&mut trading, &mut chart, vec![]), "Discard")
1137                                        .unwrap()
1138                                        .center();
1139                                frame(
1140                                    &mut trading,
1141                                    &mut chart,
1142                                    vec![egui::Event::PointerMoved(discard)],
1143                                );
1144                                frame(&mut trading, &mut chart, vec![click(discard, true)]);
1145                                frame(&mut trading, &mut chart, vec![click(discard, false)]);
1146                                frame(&mut trading, &mut chart, vec![]);
1147                                assert!(trading.pending.is_none());
1148                                assert!(broker.modified.lock().unwrap().is_empty());
1149                                assert!(
1150                                    chart.trading_tags.iter().any(|(price, _)| *price == 105.0),
1151                                    "discard restores the broker's price"
1152                                );
1153                                for events in [
1154                                    vec![egui::Event::PointerMoved(start)],
1155                                    vec![click(start, true)],
1156                                    vec![egui::Event::PointerMoved(end)],
1157                                    vec![click(end, false)],
1158                                ] {
1159                                    frame(&mut trading, &mut chart, events);
1160                                }
1161                            }
1162                            frame(&mut trading, &mut chart, vec![]);
1163                            let confirm = find(frame(&mut trading, &mut chart, vec![]), operation)
1164                                .expect(operation)
1165                                .center();
1166                            assert!(
1167                                confirm.x >= 0.0 && confirm.x <= width,
1168                                "confirmation stays reachable"
1169                            );
1170                            frame(
1171                                &mut trading,
1172                                &mut chart,
1173                                vec![egui::Event::PointerMoved(confirm)],
1174                            );
1175                            frame(&mut trading, &mut chart, vec![click(confirm, true)]);
1176                            frame(&mut trading, &mut chart, vec![click(confirm, false)]);
1177                            frame(&mut trading, &mut chart, vec![]);
1178                            assert!(trading.pending.is_none());
1179                        } else {
1180                            assert!(trading.pending.is_none());
1181                        }
1182                        match operation {
1183                            "Cancel order" => {
1184                                assert_eq!(*broker.cancelled.lock().unwrap(), [order.id])
1185                            }
1186                            "Modify order" => {
1187                                let changed = broker.modified.lock().unwrap();
1188                                assert_eq!(changed.len(), 1);
1189                                assert_eq!(changed[0].0, order.id);
1190                                // Prices are written at the tick's decimals, brackets included.
1191                                let (price, take_profit, stop_loss) = if edge {
1192                                    (128.0, 125.0, 131.0)
1193                                } else {
1194                                    (103.07, 100.07, 106.07)
1195                                };
1196                                let mut expected = order.request.clone();
1197                                match gesture {
1198                                    "levels entry" => {
1199                                        expected.order_type = OrderType::Limit(price);
1200                                        expected.take_profit_levels = vec![
1201                                            TakeProfitLevel::new(100.07, 0.5),
1202                                            TakeProfitLevel::new(99.07, 0.5),
1203                                        ];
1204                                        expected.stop_loss = Some(stop_loss);
1205                                    }
1206                                    "levels drag" => expected.take_profit_levels[1].price = 100.5,
1207                                    "levels remove" => {
1208                                        expected.take_profit = Some(102.0);
1209                                        expected.take_profit_levels.clear();
1210                                    }
1211                                    "add TP" => expected.take_profit = Some(101.5),
1212                                    "add SL" => expected.stop_loss = Some(108.5),
1213                                    _ => {
1214                                        expected.order_type = OrderType::Limit(price);
1215                                        expected.take_profit = Some(take_profit);
1216                                        expected.stop_loss = Some(stop_loss);
1217                                    }
1218                                }
1219                                assert_eq!(changed[0].1, expected, "{gesture}");
1220                            }
1221                            _ => {
1222                                // Closing and the exits are for the position's size, reversing for
1223                                // twice it.
1224                                let (quantity, order_type) = match operation {
1225                                    "Reverse position" => (4.0, OrderType::Market),
1226                                    "Place take profit" => (2.0, OrderType::Limit(95.0)),
1227                                    "Place stop loss" => (2.0, OrderType::Stop(104.0)),
1228                                    _ => (2.0, OrderType::Market),
1229                                };
1230                                let mut expected =
1231                                    OrderRequest::new("SYM", OrderSide::Buy, quantity, order_type);
1232                                expected.position_bracket = match operation {
1233                                    "Place take profit" => {
1234                                        Some(crate::model::PositionBracket::TakeProfit)
1235                                    }
1236                                    "Place stop loss" => {
1237                                        Some(crate::model::PositionBracket::StopLoss)
1238                                    }
1239                                    _ => None,
1240                                };
1241                                assert_eq!(*broker.placed.lock().unwrap(), [expected]);
1242                            }
1243                        }
1244                    }
1245                }
1246            }
1247        }
1248    }
1249
1250    #[test]
1251    fn trading_display_preferences_position_lines_and_control_image_exports() {
1252        use crate::chart::TradingLabelAlignment::{Center, Left, Right};
1253        let defaults: TradingSettings = serde_json::from_str("{}").unwrap();
1254        assert!(
1255            !defaults.show_execution_labels,
1256            "settings saved before labels were on by default keep them off"
1257        );
1258        assert!(TradingSettings::default().show_execution_labels);
1259        assert!(defaults.extend_lines);
1260        assert_eq!(defaults.label_alignment, Right);
1261        assert!(!defaults.include_in_image);
1262        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
1263            let ctx = egui::Context::default();
1264            crate::theme::apply_to_egui(&ctx, &theme);
1265            let mut chart = crate::widget::test_frames::chart(&[90.0], &[110.0]);
1266            chart.symbol = "SYM".into();
1267            let broker = Arc::new(Recorder {
1268                state: vec![AccountEvent::Order(Order::new(
1269                    OrderId::new("working"),
1270                    OrderRequest::new("SYM", OrderSide::Buy, 1.0, OrderType::Limit(95.0)),
1271                    OrderStatus::Working,
1272                ))],
1273                ..Default::default()
1274            });
1275            let mut trading = Trading::default();
1276            trading.set_service(broker.clone());
1277            trading.poll(&ctx, &mut Toasts::new());
1278            let mut centres = Vec::new();
1279            for alignment in [Left, Center, Right] {
1280                for extended in [false, true] {
1281                    trading.display.label_alignment = alignment;
1282                    trading.display.extend_lines = extended;
1283                    let json = serde_json::to_string(&trading.display).unwrap();
1284                    assert_eq!(
1285                        serde_json::from_str::<TradingSettings>(&json).unwrap(),
1286                        trading.display
1287                    );
1288                    let mut label = egui::Rect::NOTHING;
1289                    let mut line_start = f32::INFINITY;
1290                    for frame in 0..3 {
1291                        let output = ctx.run_ui(
1292                            egui::RawInput {
1293                                screen_rect: Some(egui::Rect::from_min_size(
1294                                    egui::Pos2::ZERO,
1295                                    egui::vec2(1440.0, 900.0),
1296                                )),
1297                                ..Default::default()
1298                            },
1299                            |ui| {
1300                                chart.show(ui);
1301                                trading.show(ui, &mut chart);
1302                            },
1303                        );
1304                        if frame < 2 {
1305                            output.drop_without_applying_deltas();
1306                            continue;
1307                        }
1308                        let y = chart.get_chart_mapping().price_to_y(95.0);
1309                        for shape in &output.shapes {
1310                            match &shape.shape {
1311                                egui::Shape::Rect(rect)
1312                                    if rect.stroke.color == theme.ui.accent
1313                                        && rect.rect.height() == 20.0
1314                                        && rect.rect.width() > 40.0 =>
1315                                {
1316                                    label = rect.rect
1317                                }
1318                                egui::Shape::LineSegment { points, stroke }
1319                                    if stroke.color == theme.ui.accent
1320                                        && (points[0].y - y).abs() < 0.1
1321                                        && (points[1].y - y).abs() < 0.1 =>
1322                                {
1323                                    line_start = line_start.min(points[0].x.min(points[1].x));
1324                                }
1325                                _ => {}
1326                            }
1327                        }
1328                        output.drop_without_applying_deltas();
1329                    }
1330                    assert!(label.is_positive(), "order label");
1331                    let pane = chart.get_chart_mapping().rect;
1332                    assert!(pane.contains_rect(label));
1333                    let expected = if extended { pane.left() } else { label.left() };
1334                    assert!(
1335                        (line_start - expected).abs() < 0.1,
1336                        "line starts at {line_start}, expected {expected}"
1337                    );
1338                    if !extended {
1339                        centres.push(label.center().x);
1340                        match alignment {
1341                            Left => assert!((label.left() - pane.left() - 8.0).abs() < 0.1),
1342                            Center => assert!((label.center().x - pane.center().x).abs() < 0.1),
1343                            Right => assert!(label.right() < pane.right()),
1344                        }
1345                    }
1346                }
1347            }
1348            assert!(centres.windows(2).all(|pair| pair[0] < pair[1]));
1349            let options = crate::widget::ImageExportOptions {
1350                size: [480, 320],
1351                include_timestamp: false,
1352                ..Default::default()
1353            };
1354            let before = chart.get_time_scale_state();
1355            let plain = chart.export_png(&ctx, &options).unwrap();
1356            assert_eq!(trading.export_png(&chart, &ctx, &options).unwrap(), plain);
1357            trading.display.include_in_image = true;
1358            let with_orders = trading.export_png(&chart, &ctx, &options).unwrap();
1359            assert_ne!(with_orders, plain, "visible broker orders are exported");
1360            trading.display.show_orders = false;
1361            assert_eq!(
1362                trading.export_png(&chart, &ctx, &options).unwrap(),
1363                plain,
1364                "hidden orders stay hidden"
1365            );
1366            assert_eq!(chart.get_time_scale_state(), before);
1367            assert!(broker.placed.lock().unwrap().is_empty());
1368            trading.disconnect();
1369            trading.display.bid_ask_labels = true;
1370            chart.set_quote(Some(crate::model::Level1Quote {
1371                symbol: "SYM".into(),
1372                ts: chart.bars(SeriesId::MAIN)[99].time,
1373                bid: 99.0,
1374                ask: 101.0,
1375                bid_size: 1.0,
1376                ask_size: 1.0,
1377            }));
1378            let plain = chart.export_png(&ctx, &options).unwrap();
1379            for include in [false, true] {
1380                trading.display.include_in_image = include;
1381                assert_eq!(
1382                    trading.export_png(&chart, &ctx, &options).unwrap() != plain,
1383                    include,
1384                    "bid and ask labels do not require a broker connection"
1385                );
1386            }
1387        }
1388    }
1389
1390    /// The legend shows the order quantity under the spread; clicking it opens the calculator,
1391    /// whose presets set the quantity that Buy and Sell use, and a click elsewhere closes it.
1392    #[test]
1393    fn the_quantity_box_opens_a_calculator_that_sets_the_order_quantity() {
1394        let mut chart = crate::widget::test_frames::chart(&[90.0], &[110.0]);
1395        chart.symbol = "SYM".into();
1396        chart.set_quote(Some(crate::model::Level1Quote {
1397            symbol: "SYM".into(),
1398            ts: chart.bars(SeriesId::MAIN)[99].time,
1399            bid: 99.0,
1400            ask: 101.0,
1401            bid_size: 1.0,
1402            ask_size: 1.0,
1403        }));
1404        let mut trading = Trading::default();
1405        trading.set_service(Arc::new(Recorder::default()));
1406        trading.quantity = 3.25;
1407        let ctx = egui::Context::default();
1408        let frame = |trading: &mut Trading, chart: &mut Chart, events: Vec<egui::Event>| {
1409            let output = ctx.run_ui(
1410                egui::RawInput {
1411                    events,
1412                    ..Default::default()
1413                },
1414                |ui| {
1415                    trading.poll(ui.ctx(), &mut Toasts::new());
1416                    chart.show(ui);
1417                    trading.show(ui, chart);
1418                },
1419            );
1420            let texts: Vec<_> = output
1421                .shapes
1422                .iter()
1423                .filter_map(|shape| match &shape.shape {
1424                    egui::Shape::Text(text) => Some((
1425                        text.galley.text().to_owned(),
1426                        text.galley.rect.translate(text.pos.to_vec2()).center(),
1427                    )),
1428                    _ => None,
1429                })
1430                .collect();
1431            output.drop_without_applying_deltas();
1432            texts
1433        };
1434        // A click at `at` and the frame after it.
1435        let click = |at| {
1436            let button = |pressed| egui::Event::PointerButton {
1437                pos: at,
1438                button: egui::PointerButton::Primary,
1439                pressed,
1440                modifiers: Default::default(),
1441            };
1442            [
1443                vec![egui::Event::PointerMoved(at)],
1444                vec![button(true)],
1445                vec![button(false)],
1446                vec![],
1447            ]
1448        };
1449        let find = |texts: &[(String, egui::Pos2)], wanted: &str| {
1450            texts
1451                .iter()
1452                .find(|(text, _)| text == wanted)
1453                .map(|(_, at)| *at)
1454        };
1455        frame(&mut trading, &mut chart, vec![]);
1456        let texts = frame(&mut trading, &mut chart, vec![]);
1457        let quantity = find(&texts, "3.25").expect("the quantity under the spread");
1458        assert!(find(&texts, "1000").is_none());
1459
1460        let mut texts = Vec::new();
1461        for events in click(quantity) {
1462            texts = frame(&mut trading, &mut chart, events);
1463        }
1464        let preset = find(&texts, "25").expect("the calculator's presets");
1465        for events in click(preset) {
1466            texts = frame(&mut trading, &mut chart, events);
1467        }
1468        assert_eq!(trading.quantity, 25.0);
1469        assert!(find(&texts, "1000").is_some(), "a preset keeps it open");
1470
1471        for events in click(egui::pos2(300.0, 300.0)) {
1472            texts = frame(&mut trading, &mut chart, events);
1473        }
1474        assert!(
1475            find(&texts, "1000").is_none(),
1476            "a click elsewhere closes it"
1477        );
1478        assert!(
1479            find(&texts, "25").is_some(),
1480            "the legend shows the new quantity"
1481        );
1482    }
1483
1484    #[test]
1485    fn coincident_order_and_position_axis_tags_keep_their_own_style() {
1486        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
1487            for (show_positions, show_orders) in
1488                [(true, true), (false, true), (true, false), (false, false)]
1489            {
1490                let mut exit =
1491                    OrderRequest::new("SYM", OrderSide::Sell, 2.0, OrderType::Limit(105.0));
1492                exit.position_bracket = Some(crate::model::PositionBracket::TakeProfit);
1493                let broker = Arc::new(Recorder {
1494                    state: vec![
1495                        AccountEvent::Order(Order::new(
1496                            OrderId::new("order"),
1497                            OrderRequest::new("SYM", OrderSide::Buy, 1.0, OrderType::Limit(100.0)),
1498                            OrderStatus::Working,
1499                        )),
1500                        AccountEvent::Position(Position::new("SYM", 2.0, 100.0)),
1501                        AccountEvent::Order(Order::new(
1502                            OrderId::new("exit"),
1503                            exit,
1504                            OrderStatus::Working,
1505                        )),
1506                    ],
1507                    ..Recorder::default()
1508                });
1509                let mut trading = Trading::default();
1510                trading.set_service(broker);
1511                trading.display.show_positions = show_positions;
1512                trading.display.show_orders = show_orders;
1513                let mut chart = crate::widget::test_frames::chart(&[90.0], &[110.0]);
1514                chart.symbol = "SYM".into();
1515                chart.set_theme_override(Some(theme.clone()));
1516                let ctx = egui::Context::default();
1517                crate::theme::apply_to_egui(&ctx, &theme);
1518                for pass in 0..3 {
1519                    let output = ctx.run_ui(egui::RawInput::default(), |ui| {
1520                        trading.poll(ui.ctx(), &mut Toasts::new());
1521                        chart.show(ui);
1522                        trading.show(ui, &mut chart);
1523                    });
1524                    if pass == 2 {
1525                        let edge = chart.get_chart_mapping().rect.right();
1526                        let tags: Vec<_> = output
1527                            .shapes
1528                            .iter()
1529                            .filter_map(|shape| match &shape.shape {
1530                                egui::Shape::Rect(rect) if rect.rect.center().x > edge => {
1531                                    Some(rect)
1532                                }
1533                                _ => None,
1534                            })
1535                            .collect();
1536                        assert_eq!(
1537                            tags.iter().any(|rect| rect.fill == theme.ui.accent),
1538                            show_positions,
1539                            "position tag stays filled"
1540                        );
1541                        assert_eq!(
1542                            tags.iter().any(|rect| rect.stroke.color == theme.ui.accent
1543                                && rect.stroke.width == 1.0),
1544                            show_orders,
1545                            "order tag stays outlined"
1546                        );
1547                    }
1548                    if pass == 2 {
1549                        let has_text = |wanted: &str| {
1550                            output.shapes.iter().any(|shape| {
1551                        matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == wanted)
1552                    })
1553                        };
1554                        assert_eq!(
1555                            has_text("+10.00 USD"),
1556                            show_orders,
1557                            "brackets retain the hidden position's entry price"
1558                        );
1559                        assert!(
1560                            !has_text("TP"),
1561                            "hiding orders cannot offer a duplicate exit"
1562                        );
1563                        assert_eq!(has_text("SL"), show_positions);
1564                    }
1565                    output.drop_without_applying_deltas();
1566                }
1567            }
1568        }
1569    }
1570
1571    #[test]
1572    fn projected_orders_only_send_from_their_submit_button() {
1573        for (width, quantity) in [(1000.0, 20.0), (320.0, 1_000_000.0)] {
1574            for side in [OrderSide::Buy, OrderSide::Sell] {
1575                for submit in [false, true] {
1576                    let broker = Arc::new(Recorder::default());
1577                    let mut trading = Trading::default();
1578                    trading.set_service(broker.clone());
1579                    trading.poll(&egui::Context::default(), &mut Toasts::new());
1580                    trading.display.instant_orders = true;
1581                    let request = OrderRequest::new("SYM", side, quantity, OrderType::Limit(94.0));
1582                    trading.open_ticket(request.clone());
1583                    trading.rules_ready = true;
1584                    let mut chart = crate::widget::test_frames::chart(&[90.0], &[110.0]);
1585                    chart.symbol = "SYM".into();
1586                    let ctx = egui::Context::default();
1587                    let frame = |trading: &mut Trading, chart: &mut Chart, events| {
1588                        let output = ctx.run_ui(
1589                            egui::RawInput {
1590                                screen_rect: Some(egui::Rect::from_min_size(
1591                                    egui::Pos2::ZERO,
1592                                    egui::vec2(width, 800.0),
1593                                )),
1594                                events,
1595                                ..Default::default()
1596                            },
1597                            |ui| {
1598                                chart.show(ui);
1599                                trading.show(ui, chart);
1600                            },
1601                        );
1602                        let y = chart.get_chart_mapping().price_to_y(94.0);
1603                        let at = output.shapes.iter().find_map(|shape| match &shape.shape {
1604                            egui::Shape::Text(text)
1605                                if text.galley.text() == side_name(side)
1606                                    && (y - 12.0..y + 50.0).contains(&text.pos.y) =>
1607                            {
1608                                Some(text.pos + text.galley.size() / 2.0)
1609                            }
1610                            _ => None,
1611                        });
1612                        output.drop_without_applying_deltas();
1613                        at
1614                    };
1615                    frame(&mut trading, &mut chart, vec![]);
1616                    let button =
1617                        frame(&mut trading, &mut chart, vec![]).expect("projected submit button");
1618                    assert!(
1619                        chart.get_chart_mapping().rect.contains(button),
1620                        "submit remains inside the plot at {width}"
1621                    );
1622                    assert!(broker.placed.lock().unwrap().is_empty());
1623                    let map = chart.get_chart_mapping();
1624                    let at = if submit {
1625                        button
1626                    } else {
1627                        egui::pos2(map.rect.right() - 95.0, map.price_to_y(94.0))
1628                    };
1629                    frame(
1630                        &mut trading,
1631                        &mut chart,
1632                        vec![egui::Event::PointerMoved(at)],
1633                    );
1634                    for pressed in [true, false] {
1635                        frame(
1636                            &mut trading,
1637                            &mut chart,
1638                            vec![egui::Event::PointerButton {
1639                                pos: at,
1640                                button: egui::PointerButton::Primary,
1641                                pressed,
1642                                modifiers: Default::default(),
1643                            }],
1644                        );
1645                    }
1646                    frame(&mut trading, &mut chart, vec![]);
1647                    assert!(!trading.ticket.open);
1648                    assert_eq!(
1649                        *broker.placed.lock().unwrap(),
1650                        if submit { vec![request] } else { vec![] }
1651                    );
1652                    assert!(
1653                        broker.cancelled.lock().unwrap().is_empty(),
1654                        "a projected cancel never reaches the broker"
1655                    );
1656                }
1657            }
1658        }
1659    }
1660
1661    #[test]
1662    fn chart_frames_keep_the_edited_orders_symbol_rules() {
1663        let broker = Arc::new(Recorder::default());
1664        let mut trading = Trading::default();
1665        trading.set_service(broker.clone());
1666        let order = Order::new(
1667            OrderId::new("1"),
1668            OrderRequest::new("OTHER", OrderSide::Buy, 2.0, OrderType::Limit(100.0)),
1669            OrderStatus::Working,
1670        );
1671        trading.account_panel.editing = Some((order.clone(), order.request));
1672        trading.load_rules("OTHER");
1673        let (_, reply) = broker.rules.lock().unwrap().pop().unwrap();
1674        let mut rules = TradingRules::new("OTHER");
1675        rules.quantity_step = 2.0;
1676        reply.send(Ok(rules));
1677        let mut chart = crate::ChartBuilder::new().with_symbol("MAIN").build();
1678        let ctx = egui::Context::default();
1679        trading.poll(&ctx, &mut Toasts::new());
1680        egui::__run_test_ui(|ui| trading.show(ui, &mut chart));
1681        assert_eq!(trading.rules.symbol, "OTHER");
1682        assert!(trading.rules_ready);
1683        assert!(broker.rules.lock().unwrap().is_empty());
1684    }
1685
1686    /// Guards the capability gates: an account that does not report closing, reversing or
1687    /// previewing never gets those requests, and a preview alone never places an order. A
1688    /// regression sends a market order the broker said it cannot take, or places an order the
1689    /// user only asked to price.
1690    #[test]
1691    fn close_reverse_and_preview_follow_the_accounts_capabilities() {
1692        let broker = Arc::new(Recorder::default());
1693        let mut trading = Trading::default();
1694        trading.set_service(broker.clone());
1695        let ctx = egui::Context::default();
1696        let mut toasts = Toasts::new();
1697        trading.poll(&ctx, &mut toasts);
1698        trading.rules = TradingRules::new("ES");
1699        trading.rules_ready = true;
1700        let position = Position::new("ES", 2.0, 100.0);
1701        let order = OrderRequest::new("ES", OrderSide::Buy, 1.0, OrderType::Limit(99.0));
1702        // (close, reverse, preview) -> the market quantities placed and the previews asked for.
1703        for (close, reverse, preview, placed, previewed) in [
1704            (true, true, true, vec![2.0, 4.0], 1),
1705            (true, false, false, vec![2.0], 0),
1706            (false, true, false, vec![4.0], 0),
1707            (false, false, false, vec![], 0),
1708        ] {
1709            let capabilities = Capabilities {
1710                market: true,
1711                close,
1712                reverse,
1713                preview,
1714                ..Capabilities::default()
1715            };
1716            trading.apply(
1717                &egui::Context::default(),
1718                AccountEvent::Account(Account::new("USD", 0.0, capabilities)),
1719                &mut toasts,
1720            );
1721            trading.execute(Some(TradingLineAction::Close(position.clone())));
1722            trading.execute(Some(TradingLineAction::Reverse(position.clone())));
1723            trading.perform(Some(Action::Preview(order.clone())));
1724            let sent: Vec<f64> = std::mem::take(&mut *broker.placed.lock().unwrap())
1725                .iter()
1726                .map(|order| order.quantity)
1727                .collect();
1728            assert_eq!(sent, placed, "{close} {reverse} {preview}");
1729            let asked = std::mem::take(&mut *broker.previews.lock().unwrap());
1730            assert_eq!(asked.len(), previewed, "{close} {reverse} {preview}");
1731            for (request, reply) in asked {
1732                assert_eq!(request, order);
1733                reply.send(Ok(OrderPreview::default()));
1734                trading.poll(&ctx, &mut toasts);
1735                assert_eq!(
1736                    trading.preview.as_ref().map(|p| p.estimate.is_some()),
1737                    Some(true)
1738                );
1739            }
1740        }
1741        // A refused preview clears the pending one and tells the user.
1742        trading.apply(
1743            &egui::Context::default(),
1744            AccountEvent::Account(Account::new(
1745                "USD",
1746                0.0,
1747                Capabilities {
1748                    preview: true,
1749                    ..Capabilities::default()
1750                },
1751            )),
1752            &mut toasts,
1753        );
1754        trading.perform(Some(Action::Preview(order)));
1755        let (_, reply) = broker.previews.lock().unwrap().pop().unwrap();
1756        reply.send(Err(FeedError::NotSupported));
1757        trading.poll(&ctx, &mut toasts);
1758        assert!(trading.preview.is_none());
1759        assert!(
1760            toasts
1761                .toasts()
1762                .iter()
1763                .any(|t| t.message.contains("preview"))
1764        );
1765        assert!(broker.placed.lock().unwrap().is_empty());
1766    }
1767
1768    #[test]
1769    fn docked_tickets_keep_later_panels_out_of_their_bounds() {
1770        for width in [320.0, 480.0, 800.0] {
1771            let ctx = egui::Context::default();
1772            let mut chart = crate::widget::test_frames::chart(&[90.0], &[110.0]);
1773            chart.symbol = "SYM".into();
1774            let mut trading = Trading::default();
1775            trading.open_ticket(OrderRequest::new(
1776                "SYM",
1777                OrderSide::Buy,
1778                1.0,
1779                OrderType::Market,
1780            ));
1781            let mut remaining = egui::Rect::NOTHING;
1782            let output = ctx.run_ui(
1783                egui::RawInput {
1784                    screen_rect: Some(egui::Rect::from_min_size(
1785                        egui::Pos2::ZERO,
1786                        egui::vec2(width, 360.0),
1787                    )),
1788                    ..Default::default()
1789                },
1790                |ui| {
1791                    trading.show_ticket(ui, &chart);
1792                    remaining = ui.clip_rect();
1793                },
1794            );
1795            let symbol = output.shapes.iter().find_map(|shape| match &shape.shape {
1796                egui::Shape::Text(text) if text.galley.text() == "SYM" => Some(text.pos.x),
1797                _ => None,
1798            });
1799            output.drop_without_applying_deltas();
1800            assert!(
1801                remaining.right() <= symbol.expect("the ticket's symbol"),
1802                "later panels stay beside the ticket at {width}"
1803            );
1804        }
1805    }
1806
1807    /// While the broker reports that it is not live the ticket shows its status and why it
1808    /// cannot send, and an order, cancellation or change the user confirms is reported as
1809    /// refused, once each, instead of sent; a close confirmed before the drop that waits for its
1810    /// symbol's rules is refused too and not sent when they arrive after the broker is back. Once
1811    /// the broker is live the same actions send one call each. A regression sends to a broker
1812    /// that is not live, sends a confirmation from before the drop after it, refuses without a
1813    /// word or leaves the ticket looking ready.
1814    #[test]
1815    fn nothing_is_sent_while_the_broker_is_not_live() {
1816        use crate::data::FeedStatus;
1817        let ctx = egui::Context::default();
1818        let mut chart = crate::widget::test_frames::chart(&[90.0], &[110.0]);
1819        chart.symbol = "SYM".into();
1820        let working = Order::new(
1821            OrderId::new("7"),
1822            OrderRequest::new("SYM", OrderSide::Buy, 1.0, OrderType::Limit(95.0)),
1823            OrderStatus::Working,
1824        );
1825        let mut moved = working.request.clone();
1826        moved.order_type = OrderType::Limit(96.0);
1827        let place = OrderRequest::new("SYM", OrderSide::Buy, 1.0, OrderType::Market);
1828        let broker = Arc::new(Recorder {
1829            state: vec![AccountEvent::Order(working.clone())],
1830            ..Recorder::default()
1831        });
1832        let mut trading = Trading::default();
1833        trading.set_service(broker.clone());
1834        trading.poll(&ctx, &mut Toasts::new());
1835        trading.open_ticket(place.clone());
1836        let (_, reply) = broker.rules.lock().unwrap().pop().unwrap();
1837        reply.send(Ok(TradingRules::new("SYM")));
1838        trading.poll(&ctx, &mut Toasts::new());
1839        // Confirmed while live, the close waits for the rules of its own symbol.
1840        trading.execute(Some(TradingLineAction::Close(Position::new(
1841            "OTHER", 2.0, 100.0,
1842        ))));
1843        let (symbol, other) = broker.rules.lock().unwrap().pop().unwrap();
1844        assert_eq!(symbol, "OTHER");
1845        // The ticket's status and its reason not to send, as drawn.
1846        let shown = |trading: &mut Trading| {
1847            let input = egui::RawInput {
1848                screen_rect: Some(egui::Rect::from_min_size(
1849                    egui::Pos2::ZERO,
1850                    egui::vec2(800.0, 600.0),
1851                )),
1852                ..Default::default()
1853            };
1854            let output = ctx.run_ui(input, |ui| trading.show_ticket(ui, &chart));
1855            let mut texts: Vec<String> = (output.shapes.iter())
1856                .filter_map(|shape| match &shape.shape {
1857                    egui::Shape::Text(text)
1858                        if [
1859                            "Connecting...",
1860                            "Offline",
1861                            "Waiting for the broker to connect",
1862                        ]
1863                        .contains(&text.galley.text()) =>
1864                    {
1865                        Some(text.galley.text().to_owned())
1866                    }
1867                    _ => None,
1868                })
1869                .collect();
1870            output.drop_without_applying_deltas();
1871            texts.sort();
1872            texts
1873        };
1874        let confirm = |trading: &mut Trading| {
1875            trading.perform(Some(Action::Place(place.clone())));
1876            trading.perform(Some(Action::Cancel(vec![working.id.clone()])));
1877            trading.execute(Some(TradingLineAction::Modify(
1878                working.clone(),
1879                moved.clone(),
1880            )));
1881        };
1882
1883        let mut toasts = Toasts::new();
1884        trading.apply(
1885            &egui::Context::default(),
1886            AccountEvent::Status(FeedStatus::Disconnected),
1887            &mut toasts,
1888        );
1889        assert_eq!(
1890            shown(&mut trading),
1891            ["Offline", "Waiting for the broker to connect"]
1892        );
1893        confirm(&mut trading);
1894        // With instant orders `show` passes the lines' action every frame, mostly none.
1895        trading.execute(None);
1896        trading.poll(&ctx, &mut toasts);
1897        let messages: Vec<_> = (toasts.toasts().iter())
1898            .map(|toast| toast.message.as_str())
1899            .collect();
1900        assert_eq!(messages, ["Order refused: the broker is not connected"; 4]);
1901
1902        trading.apply(
1903            &egui::Context::default(),
1904            AccountEvent::Status(FeedStatus::Live),
1905            &mut toasts,
1906        );
1907        other.send(Ok(TradingRules::new("OTHER")));
1908        trading.poll(&ctx, &mut toasts);
1909        assert!(shown(&mut trading).is_empty());
1910        assert!(
1911            broker.placed.lock().unwrap().is_empty(),
1912            "the close confirmed before the drop"
1913        );
1914        assert!(broker.cancelled.lock().unwrap().is_empty());
1915        assert!(broker.modified.lock().unwrap().is_empty());
1916        confirm(&mut trading);
1917        trading.poll(&ctx, &mut toasts);
1918        assert_eq!(*broker.placed.lock().unwrap(), [place]);
1919        assert_eq!(
1920            *broker.cancelled.lock().unwrap(),
1921            std::slice::from_ref(&working.id)
1922        );
1923        assert_eq!(*broker.modified.lock().unwrap(), [(working.id, moved)]);
1924        assert_eq!(toasts.toasts().len(), 4, "the broker accepted them");
1925    }
1926
1927    /// At the narrowest ticket a long symbol gives way, so that the broker's status stays clear of
1928    /// the More and Close buttons. A regression covers the status with the buttons, the one sign
1929    /// in the header of why the ticket does not send, or draws it twice.
1930    #[test]
1931    fn a_long_symbol_gives_way_to_the_tickets_status_and_buttons() {
1932        use crate::data::FeedStatus;
1933        let width = 320.0;
1934        let ctx = egui::Context::default();
1935        let mut chart = crate::widget::test_frames::chart(&[90.0], &[110.0]);
1936        chart.symbol = "EXCHANGE:A.VERY.LONG.SYMBOL.NAME.INDEED".into();
1937        let mut trading = Trading::default();
1938        trading.set_service(Arc::new(Recorder::default()));
1939        trading.open_ticket(OrderRequest::new(
1940            chart.symbol(),
1941            OrderSide::Buy,
1942            1.0,
1943            OrderType::Market,
1944        ));
1945        trading.poll(&ctx, &mut Toasts::new());
1946        trading.apply(
1947            &egui::Context::default(),
1948            AccountEvent::Status(FeedStatus::Disconnected),
1949            &mut Toasts::new(),
1950        );
1951        let input = egui::RawInput {
1952            screen_rect: Some(egui::Rect::from_min_size(
1953                egui::Pos2::ZERO,
1954                egui::vec2(width, 600.0),
1955            )),
1956            ..Default::default()
1957        };
1958        let output = ctx.run_ui(input, |ui| trading.show_ticket(ui, &chart));
1959        let status: Vec<f32> = (output.shapes.iter())
1960            .filter_map(|shape| match &shape.shape {
1961                egui::Shape::Text(text) if text.galley.text() == "Offline" => {
1962                    Some(text.visual_bounding_rect().right())
1963                }
1964                _ => None,
1965            })
1966            .collect();
1967        output.drop_without_applying_deltas();
1968        assert_eq!(status.len(), 1, "{status:?}");
1969        // The panel's margin of 16 points, then the two buttons of the header.
1970        assert!(status[0] <= width - 16.0 - 50.0, "{status:?}");
1971    }
1972}