1mod history;
5mod view;
6
7use std::collections::{HashMap, HashSet};
8
9use chrono::{DateTime, Utc};
10use egui::Color32;
11
12use history::Paging;
13
14use super::Trading;
15use crate::model::{
16 BalanceChange, Execution, JournalEntry, Order, OrderId, OrderRequest, OrderStatus, OrderType,
17 Position,
18};
19use crate::services::{AccountEvent, HistoryKind};
20
21const TAB_HEIGHT: f32 = 38.0;
22const HEADER_HEIGHT: f32 = 52.0;
23const ROW_HEIGHT: f32 = 48.0;
24const ACTIONS_WIDTH: f32 = 64.0;
26pub(super) const HISTORY_LIMIT: usize = 1_000;
28
29#[derive(Default)]
31pub(super) struct Fills {
32 pub(super) orders: HashMap<OrderId, OrderRequest>,
33 pub(super) executions: Vec<Execution>,
34 paged: usize,
36 working: HashSet<OrderId>,
37 filled: HashMap<OrderId, f64>,
38 latest: HashMap<String, Execution>,
40}
41
42impl Fills {
43 pub(super) fn order(&mut self, order: &Order) {
44 self.orders.insert(order.id.clone(), order.request.clone());
45 if order.status == OrderStatus::Working {
46 self.working.insert(order.id.clone());
47 } else {
48 self.working.remove(&order.id);
49 }
50 if let Some(fill) = self
51 .executions
52 .iter()
53 .filter(|fill| fill.order == order.id)
54 .max_by_key(|fill| fill.time)
55 .cloned()
56 {
57 self.remember(&order.request.symbol, &fill);
58 }
59 }
60
61 pub(super) fn execution(&mut self, fill: &Execution) {
62 let (repeated, dropped) = push_live(&mut self.executions, &mut self.paged, fill);
63 if !repeated {
65 *self.filled.entry(fill.order.clone()).or_default() += fill.quantity;
66 }
67 if let Some(order) = self.orders.get(&fill.order) {
68 let symbol = order.symbol.clone();
69 self.remember(&symbol, fill);
70 }
71 if let Some(old) = dropped {
72 self.forget(&old.order);
73 }
74 }
75
76 pub(super) fn forget(&mut self, id: &OrderId) {
77 if !self.working.contains(id) && !self.executions.iter().any(|fill| &fill.order == id) {
78 self.orders.remove(id);
79 self.filled.remove(id);
80 }
81 }
82
83 fn remember(&mut self, symbol: &str, fill: &Execution) {
84 if self
85 .latest
86 .get(symbol)
87 .is_none_or(|old| old.time <= fill.time)
88 {
89 self.latest.insert(symbol.to_owned(), fill.clone());
90 }
91 }
92
93 pub(super) fn price(&self, symbol: &str) -> Option<f64> {
94 self.latest.get(symbol).map(|fill| fill.price)
95 }
96
97 pub(super) fn filled_quantity(&self, id: &OrderId) -> f64 {
98 self.filled.get(id).copied().unwrap_or_default()
99 }
100}
101
102fn push_live<T: PartialEq + Clone>(
107 list: &mut Vec<T>,
108 paged: &mut usize,
109 entry: &T,
110) -> (bool, Option<T>) {
111 let repeated = list[..*paged].iter().position(|listed| listed == entry);
112 if let Some(at) = repeated {
113 list.remove(at);
114 *paged -= 1;
115 }
116 list.push(entry.clone());
117 let dropped = if list.len() > HISTORY_LIMIT {
118 *paged = paged.saturating_sub(1);
119 Some(list.remove(0))
120 } else {
121 None
122 };
123 (repeated.is_some(), dropped)
124}
125
126#[derive(Clone, Debug, PartialEq)]
129#[non_exhaustive]
130pub enum AccountPanelAction {
131 SelectSymbol(String),
133 SelectAccount(usize),
135 Disconnect,
137 Settings,
140 Minimize,
142 Maximize,
144 Export {
146 name: String,
148 csv: String,
150 },
151}
152
153#[derive(Default)]
170pub struct AccountPanel {
171 pub broker: String,
173 pub accounts: Vec<String>,
176 pub active: usize,
178 tab: Tab,
179 hidden: HashSet<(Tab, &'static str)>,
180 history: Vec<Order>,
181 pub(super) fills: Fills,
182 show_executions: bool,
183 balance: Vec<BalanceChange>,
184 balance_paged: usize,
186 journal: Vec<JournalEntry>,
187 journal_paged: usize,
189 paging: HashMap<HistoryKind, Paging>,
191 pub(super) editing: Option<(Order, OrderRequest)>,
193}
194
195#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
196enum Tab {
197 #[default]
198 Positions,
199 Orders,
200 History,
201 Balance,
202 Journal,
203 Executions,
204}
205
206impl Tab {
207 const ALL: [Tab; 5] = [
208 Tab::Positions,
209 Tab::Orders,
210 Tab::History,
211 Tab::Balance,
212 Tab::Journal,
213 ];
214
215 fn title(self) -> &'static str {
216 match self {
217 Tab::Positions => "Positions",
218 Tab::Orders => "Orders",
219 Tab::History => "Order History",
220 Tab::Balance => "Balance History",
221 Tab::Journal => "Trading Journal",
222 Tab::Executions => "Executions",
223 }
224 }
225
226 fn history(self) -> Option<HistoryKind> {
228 match self {
229 Tab::History => Some(HistoryKind::Orders),
230 Tab::Executions => Some(HistoryKind::Executions),
231 Tab::Balance => Some(HistoryKind::Balance),
232 Tab::Journal => Some(HistoryKind::Journal),
233 Tab::Positions | Tab::Orders => None,
234 }
235 }
236
237 fn columns(self) -> &'static [Column] {
238 const fn left(title: &'static str, width: f32) -> Column {
239 Column {
240 title,
241 right: false,
242 width,
243 }
244 }
245 const fn right(title: &'static str, width: f32) -> Column {
246 Column {
247 title,
248 right: true,
249 width,
250 }
251 }
252 match self {
253 Tab::Positions => {
254 const COLUMNS: &[Column] = &[
255 left("Symbol", 134.0),
256 left("Side", 44.0),
257 right("Qty", 44.0),
258 right("Avg Fill Price", 84.0),
259 right("Last Price", 76.0),
260 right("Unrealized P&L", 96.0),
261 right("Unrealized P&L %", 96.0),
262 right("Trade Value", 96.0),
263 right("Market Value", 96.0),
264 right("Leverage", 56.0),
265 right("Margin", 80.0),
266 left("Expiration Date", 96.0),
267 ];
268 COLUMNS
269 }
270 Tab::Orders => {
271 const COLUMNS: &[Column] = &[
272 left("Symbol", 134.0),
273 left("Side", 44.0),
274 left("Type", 60.0),
275 right("Qty", 44.0),
276 right("Limit Price", 80.0),
277 right("Stop Price", 80.0),
278 right("Take Profit", 80.0),
279 right("Stop Loss", 80.0),
280 left("Status", 76.0),
281 left("Placing Time", 130.0),
282 left("Order ID", 60.0),
283 ];
284 COLUMNS
285 }
286 Tab::History => {
287 const COLUMNS: &[Column] = &[
288 left("Symbol", 134.0),
289 left("Side", 44.0),
290 left("Type", 60.0),
291 right("Qty", 44.0),
292 right("Limit Price", 80.0),
293 right("Stop Price", 80.0),
294 right("Fill Price", 80.0),
295 left("Status", 76.0),
296 left("Placing Time", 130.0),
297 left("Closing Time", 130.0),
298 left("Order ID", 60.0),
299 ];
300 COLUMNS
301 }
302 Tab::Balance => {
303 const COLUMNS: &[Column] = &[
304 left("Time", 130.0),
305 right("Balance Before", 110.0),
306 right("Balance After", 110.0),
307 right("Realized P&L", 110.0),
308 left("Action", 300.0),
309 ];
310 COLUMNS
311 }
312 Tab::Executions => {
313 const COLUMNS: &[Column] = &[
314 left("Symbol", 134.0),
315 left("Side", 44.0),
316 right("Qty", 44.0),
317 right("Fill Price", 80.0),
318 left("Time", 130.0),
319 left("Order ID", 60.0),
320 ];
321 COLUMNS
322 }
323 Tab::Journal => {
324 const COLUMNS: &[Column] = &[left("Time", 130.0), left("Text", 500.0)];
325 COLUMNS
326 }
327 }
328 }
329}
330
331struct Column {
333 title: &'static str,
334 right: bool,
335 width: f32,
336}
337
338struct Cell {
341 text: String,
342 color: Color32,
343 unit: String,
344 symbol: bool,
345 logo: Option<String>,
347}
348
349struct Row {
352 cells: Vec<Cell>,
353 symbol: Option<String>,
354 edit: Option<RowAction>,
355 close: Option<RowAction>,
356}
357
358enum RowAction {
359 Position(Position),
360 Order(Order),
361}
362
363fn pnl_color(theme: &crate::theme::Theme, value: f64, text: &str) -> Color32 {
366 if !text.bytes().any(|b| matches!(b, b'1'..=b'9')) {
367 theme.ui.text
368 } else if value > 0.0 {
369 theme.chart.candle_up
370 } else {
371 theme.chart.candle_down
372 }
373}
374
375fn time(time: Option<DateTime<Utc>>) -> String {
376 time.map_or_else(String::new, |time| {
377 time.format("%Y-%m-%d %H:%M:%S").to_string()
378 })
379}
380
381fn kind(order_type: &OrderType) -> &'static str {
382 match order_type {
383 OrderType::Market => "Market",
384 OrderType::Limit(_) => "Limit",
385 OrderType::Stop(_) => "Stop",
386 OrderType::StopLimit { .. } => "Stop Limit",
387 }
388}
389
390fn status(ctx: &egui::Context, status: &OrderStatus) -> String {
391 match status {
392 OrderStatus::Working => "Working".to_owned(),
393 OrderStatus::Filled => "Filled".to_owned(),
394 OrderStatus::Cancelled => "Cancelled".to_owned(),
395 OrderStatus::Rejected(reason) => crate::localization::format_text(
396 ctx,
397 "Rejected: {reason}",
398 &[("reason", reason.as_str())],
399 ),
400 }
401}
402
403impl AccountPanel {
404 pub(super) fn record(&mut self, event: &AccountEvent) {
406 if let AccountEvent::Order(order) = event
407 && self
408 .editing
409 .as_ref()
410 .is_some_and(|(old, _)| old.id == order.id && old != order)
411 {
412 self.editing = None;
413 }
414 if let AccountEvent::Account(account) = event
415 && !account.capabilities.modify
416 {
417 self.editing = None;
418 }
419 if let AccountEvent::Order(order) = event {
420 self.fills.order(order);
421 }
422 match event {
423 AccountEvent::Order(order) if order.status != OrderStatus::Working => {
424 self.history.retain(|finished| finished.id != order.id);
425 self.history.push(order.clone());
426 if self.history.len() > HISTORY_LIMIT {
427 let old = self.history.remove(0);
428 self.fills.forget(&old.id);
429 }
430 }
431 AccountEvent::Execution(fill) => self.fills.execution(fill),
432 AccountEvent::Balance(change) => {
433 push_live(&mut self.balance, &mut self.balance_paged, change);
434 }
435 AccountEvent::Journal(entry) => {
436 push_live(&mut self.journal, &mut self.journal_paged, entry);
437 }
438 _ => {}
439 }
440 }
441
442 pub(super) fn clear(&mut self) {
444 self.history.clear();
445 self.fills = Fills::default();
446 self.balance.clear();
447 self.balance_paged = 0;
448 self.journal.clear();
449 self.journal_paged = 0;
450 self.paging.clear();
451 self.editing = None;
452 }
453}
454
455pub(super) fn quantity_decimals(quantity: f64) -> usize {
457 (0..8)
458 .find(|&decimals| {
459 let scaled = quantity * 10f64.powi(decimals as i32);
460 (scaled - scaled.round()).abs() < 1e-6
461 })
462 .unwrap_or(8)
463}
464
465fn csv(columns: &[(usize, &Column)], rows: &[Row]) -> String {
468 let field = |text: &str| {
469 if text.contains([',', '"', '\n', '\r']) {
470 format!("\"{}\"", text.replace('"', "\"\""))
471 } else {
472 text.to_owned()
473 }
474 };
475 let mut out = columns
476 .iter()
477 .map(|(_, column)| field(column.title))
478 .collect::<Vec<_>>()
479 .join(",");
480 for row in rows {
481 out.push('\n');
482 out += &columns
483 .iter()
484 .map(|&(index, _)| {
485 let cell = &row.cells[index];
486 let text = if cell.unit.is_empty() {
487 cell.text.clone()
488 } else {
489 format!("{} {}", cell.text, cell.unit)
490 };
491 field(&text)
492 })
493 .collect::<Vec<_>>()
494 .join(",");
495 }
496 out
497}
498
499#[cfg(test)]
500mod tests {
501 use std::sync::Arc;
502
503 use egui_kittest::{Harness, kittest::Queryable};
504
505 use super::*;
506 use crate::model::OrderSide;
507 use crate::ui::trading::tests::Recorder;
508 use crate::ui_kit::notifications::Toasts;
509
510 #[test]
512 fn row_actions_show_at_rest() {
513 let broker = Arc::new(Recorder {
514 state: vec![AccountEvent::Position(Position::new("SYM", 1.0, 100.0))],
515 ..Default::default()
516 });
517 let mut trading = Trading::default();
518 trading.set_service(broker);
519 trading.poll(&egui::Context::default(), &mut Toasts::new());
520 trading.account_panel.broker = "Broker".into();
521 let mut harness = Harness::builder()
522 .with_size(egui::vec2(1000.0, 400.0))
523 .with_context_setup(egui_extras::install_image_loaders)
524 .build_ui_state(
525 |ui, trading: &mut Trading| {
526 trading.show_account_panel(ui, "SYM");
527 },
528 trading,
529 );
530 harness.run();
531 harness.get_by_label("Close position");
532 }
533
534 #[test]
538 fn tab_strip_buttons_stay_apart_in_narrow_panels() {
539 use crate::data::FeedStatus;
540 for width in [264.0, 337.0] {
541 for status in [
542 None,
543 Some(FeedStatus::Connecting),
544 Some(FeedStatus::Disconnected),
545 ] {
546 let mut trading = Trading::default();
547 trading.set_service(Arc::new(Recorder::default()));
548 trading.poll(&egui::Context::default(), &mut Toasts::new());
549 if let Some(status) = status {
550 trading.apply(
551 &egui::Context::default(),
552 AccountEvent::Status(status),
553 &mut Toasts::new(),
554 );
555 }
556 trading.account_panel.broker = "Paper Trading".into();
557 let mut harness = Harness::builder()
559 .with_size(egui::vec2(width + 16.0, 320.0))
560 .with_context_setup(egui_extras::install_image_loaders)
561 .build_ui_state(
562 |ui, trading: &mut Trading| {
563 trading.show_account_panel(ui, "SYM");
564 },
565 trading,
566 );
567 harness.run();
568 let rects = ["Trade", "Minimize panel", "Maximize panel"]
569 .map(|label| harness.get_by_label(label).rect());
570 for (index, rect) in rects.iter().enumerate() {
571 let case = format!("{status:?} at {width}: {index}");
572 assert!(rect.min.x >= 8.0 && rect.max.x <= 8.0 + width, "{case}");
573 for other in &rects[index + 1..] {
574 assert!(!rect.intersect(*other).is_positive(), "{case}");
575 }
576 }
577 }
578 }
579 }
580
581 #[test]
582 fn history_keeps_each_partial_fill_and_discards_stale_edits() {
583 let mut panel = AccountPanel::default();
584 let order = Order::new(
585 crate::model::OrderId::new("7"),
586 OrderRequest::new("SYM", OrderSide::Buy, 2.0, OrderType::Limit(100.0)),
587 OrderStatus::Working,
588 );
589 let fill = Execution::new(order.id.clone(), Utc::now(), OrderSide::Buy, 99.0, 1.0);
590 panel.record(&AccountEvent::Execution(fill.clone()));
591 panel.record(&AccountEvent::Execution(fill.clone()));
592 assert_eq!(panel.fills.executions, vec![fill.clone(), fill]);
593 for event in [
594 AccountEvent::Order({
595 let mut partial = order.clone();
596 partial.filled = 1.0;
597 partial
598 }),
599 AccountEvent::Account(crate::model::Account::new(
600 "USD",
601 1000.0,
602 Default::default(),
603 )),
604 ] {
605 panel.editing = Some((order.clone(), order.request.clone()));
606 panel.record(&event);
607 assert!(panel.editing.is_none());
608 }
609 panel.clear();
610 assert!(panel.fills.executions.is_empty());
611 }
612
613 #[test]
614 fn history_retention_preserves_working_orders_and_latest_fills() {
615 let mut panel = AccountPanel::default();
616 let mut order = Order::new(
617 crate::model::OrderId::new("active"),
618 OrderRequest::new("OLD", OrderSide::Buy, 5_000.0, OrderType::Market),
619 OrderStatus::Working,
620 );
621 let time = DateTime::from_timestamp(1_704_067_200, 0).unwrap();
622 panel.record(&AccountEvent::Order(order.clone()));
623 panel.record(&AccountEvent::Execution(Execution::new(
624 order.id.clone(),
625 time,
626 OrderSide::Buy,
627 99.0,
628 1.0,
629 )));
630 order.status = OrderStatus::Filled;
631 for i in 0..2_005 {
632 order.id = crate::model::OrderId::new(i.to_string());
633 order.request.symbol = format!("NEW-{i}");
634 let time = time + chrono::Duration::seconds(i);
635 panel.record(&AccountEvent::Order(order.clone()));
636 panel.record(&AccountEvent::Execution(Execution::new(
637 order.id.clone(),
638 time,
639 OrderSide::Buy,
640 100.0,
641 1.0,
642 )));
643 panel.record(&AccountEvent::Balance(BalanceChange::new(
644 time,
645 1000.0,
646 1.0,
647 i.to_string(),
648 )));
649 panel.record(&AccountEvent::Journal(JournalEntry::new(
650 time,
651 i.to_string(),
652 )));
653 }
654 assert_eq!(panel.history.len(), 1_000);
655 assert_eq!(panel.balance.len(), 1_000);
656 assert_eq!(panel.journal.len(), 1_000);
657 assert_eq!(panel.fills.executions.len(), 1_000);
658 assert_eq!(panel.history[0].id.0, "1005");
659 assert_eq!(panel.fills.executions[0].order.0, "1005");
660 assert_eq!(panel.fills.orders.len(), 1_001);
661 assert!(
662 panel
663 .fills
664 .orders
665 .contains_key(&crate::model::OrderId::new("active"))
666 );
667 assert_eq!(panel.fills.price("OLD"), Some(99.0));
668 assert_eq!(panel.fills.price("NEW-0"), Some(100.0));
669 assert_eq!(panel.fills.price("NEW-2004"), Some(100.0));
670 order.status = OrderStatus::Cancelled;
672 for i in 2_005..4_010 {
673 order.id = crate::model::OrderId::new(i.to_string());
674 panel.record(&AccountEvent::Order(order.clone()));
675 }
676 assert!(panel.fills.orders.len() <= 2_001);
677 let active = crate::model::OrderId::new("active");
678 for _ in 0..1_005 {
679 panel.record(&AccountEvent::Execution(Execution::new(
680 active.clone(),
681 time,
682 OrderSide::Buy,
683 99.0,
684 1.0,
685 )));
686 }
687 assert_eq!(panel.fills.executions.len(), 1_000);
688 assert_eq!(panel.fills.filled_quantity(&active), 1_006.0);
689 panel.clear();
690 assert!(panel.fills.orders.is_empty());
691 assert_eq!(panel.fills.price("OLD"), None);
692 }
693
694 #[test]
695 fn csv_round_trips_quoted_journal_text_and_visible_columns() {
696 let columns: Vec<_> = Tab::Journal.columns().iter().enumerate().collect();
697 let rows = vec![Row {
698 cells: ["time", "line\rbreak"]
699 .into_iter()
700 .map(|text| Cell {
701 text: text.into(),
702 color: Color32::WHITE,
703 unit: String::new(),
704 symbol: false,
705 logo: None,
706 })
707 .collect(),
708 symbol: None,
709 edit: None,
710 close: None,
711 }];
712 for shown in [&columns[..], &columns[1..]] {
713 let exported = csv(shown, &rows);
714 let mut reader = ::csv::Reader::from_reader(exported.as_bytes());
715 assert_eq!(reader.headers().unwrap().len(), shown.len());
716 assert_eq!(
717 reader.records().next().unwrap().unwrap().iter().next_back(),
718 Some("line\rbreak")
719 );
720 }
721 }
722
723 #[test]
724 fn last_fill_belongs_to_its_order_symbol_and_newest_execution() {
725 let mut fills = Fills::default();
726 let order = Order::new(
727 OrderId::new("a"),
728 OrderRequest::new("ES", OrderSide::Buy, 2.0, OrderType::Market),
729 crate::model::OrderStatus::Working,
730 );
731 fills.order(&order);
732 let now = chrono::Utc::now();
733 let mut fill = Execution::new(order.id.clone(), now, OrderSide::Buy, 100.0, 1.0);
734 fills.execution(&fill);
735 fill.time -= chrono::Duration::seconds(1);
736 fill.price = 99.0;
737 fills.execution(&fill);
738 fill.order = OrderId::new("unknown");
739 fills.execution(&fill);
740 assert_eq!(fills.price("ES"), Some(100.0));
741 assert_eq!(fills.price("NQ"), None);
742 let mut delayed = order.clone();
743 delayed.id = fill.order.clone();
744 delayed.request.symbol = "NQ".into();
745 fills.order(&delayed);
746 assert_eq!(fills.price("NQ"), Some(99.0));
747 assert_eq!(fills.price("ES"), Some(100.0));
748 }
749}