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