1use 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub(super) fn execute(&mut self, action: Option<TradingLineAction>) {
481 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
527fn 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
538fn 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
596fn 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 #[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 #[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 #[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 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 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 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 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 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 #[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 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 #[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 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 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 #[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 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 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 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 #[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 assert!(status[0] <= width - 16.0 - 50.0, "{status:?}");
1971 }
1972}