1pub mod chart;
2pub mod dashboard;
3
4use std::collections::HashMap;
5
6use ratatui::layout::{Constraint, Direction, Layout, Rect};
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, Clear, Paragraph};
10use ratatui::Frame;
11
12use crate::event::{AppEvent, WsConnectionStatus};
13use crate::model::candle::{Candle, CandleBuilder};
14use crate::model::order::{Fill, OrderSide};
15use crate::model::position::Position;
16use crate::model::signal::Signal;
17use crate::order_manager::{OrderHistoryFill, OrderHistoryStats, OrderUpdate};
18use crate::order_store;
19
20use chart::{FillMarker, PriceChart};
21use dashboard::{KeybindBar, LogPanel, OrderHistoryPanel, OrderLogPanel, PositionPanel, StatusBar};
22
23const MAX_LOG_MESSAGES: usize = 200;
24const MAX_FILL_MARKERS: usize = 200;
25
26pub struct AppState {
27 pub symbol: String,
28 pub strategy_label: String,
29 pub candles: Vec<Candle>,
30 pub current_candle: Option<CandleBuilder>,
31 pub candle_interval_ms: u64,
32 pub timeframe: String,
33 pub price_history_len: usize,
34 pub position: Position,
35 pub last_signal: Option<Signal>,
36 pub last_order: Option<OrderUpdate>,
37 pub open_order_history: Vec<String>,
38 pub filled_order_history: Vec<String>,
39 pub fast_sma: Option<f64>,
40 pub slow_sma: Option<f64>,
41 pub ws_connected: bool,
42 pub paused: bool,
43 pub tick_count: u64,
44 pub log_messages: Vec<String>,
45 pub balances: HashMap<String, f64>,
46 pub initial_equity_usdt: Option<f64>,
47 pub current_equity_usdt: Option<f64>,
48 pub history_estimated_total_pnl_usdt: Option<f64>,
49 pub fill_markers: Vec<FillMarker>,
50 pub history_trade_count: u32,
51 pub history_win_count: u32,
52 pub history_lose_count: u32,
53 pub history_realized_pnl: f64,
54 pub strategy_stats: HashMap<String, OrderHistoryStats>,
55 pub history_fills: Vec<OrderHistoryFill>,
56 pub last_price_update_ms: Option<u64>,
57 pub last_price_event_ms: Option<u64>,
58 pub last_price_latency_ms: Option<u64>,
59 pub last_order_history_update_ms: Option<u64>,
60 pub last_order_history_event_ms: Option<u64>,
61 pub last_order_history_latency_ms: Option<u64>,
62 pub trade_stats_reset_warned: bool,
63 pub symbol_selector_open: bool,
64 pub symbol_selector_index: usize,
65 pub symbol_items: Vec<String>,
66 pub strategy_selector_open: bool,
67 pub strategy_selector_index: usize,
68 pub strategy_items: Vec<String>,
69 pub account_popup_open: bool,
70 pub history_popup_open: bool,
71 pub history_rows: Vec<String>,
72 pub history_bucket: order_store::HistoryBucket,
73 pub last_applied_fee: String,
74}
75
76impl AppState {
77 pub fn new(
78 symbol: &str,
79 strategy_label: &str,
80 price_history_len: usize,
81 candle_interval_ms: u64,
82 timeframe: &str,
83 ) -> Self {
84 Self {
85 symbol: symbol.to_string(),
86 strategy_label: strategy_label.to_string(),
87 candles: Vec::with_capacity(price_history_len),
88 current_candle: None,
89 candle_interval_ms,
90 timeframe: timeframe.to_string(),
91 price_history_len,
92 position: Position::new(symbol.to_string()),
93 last_signal: None,
94 last_order: None,
95 open_order_history: Vec::new(),
96 filled_order_history: Vec::new(),
97 fast_sma: None,
98 slow_sma: None,
99 ws_connected: false,
100 paused: false,
101 tick_count: 0,
102 log_messages: Vec::new(),
103 balances: HashMap::new(),
104 initial_equity_usdt: None,
105 current_equity_usdt: None,
106 history_estimated_total_pnl_usdt: None,
107 fill_markers: Vec::new(),
108 history_trade_count: 0,
109 history_win_count: 0,
110 history_lose_count: 0,
111 history_realized_pnl: 0.0,
112 strategy_stats: HashMap::new(),
113 history_fills: Vec::new(),
114 last_price_update_ms: None,
115 last_price_event_ms: None,
116 last_price_latency_ms: None,
117 last_order_history_update_ms: None,
118 last_order_history_event_ms: None,
119 last_order_history_latency_ms: None,
120 trade_stats_reset_warned: false,
121 symbol_selector_open: false,
122 symbol_selector_index: 0,
123 symbol_items: Vec::new(),
124 strategy_selector_open: false,
125 strategy_selector_index: 0,
126 strategy_items: vec![
127 "MA(Config)".to_string(),
128 "MA(Fast 5/20)".to_string(),
129 "MA(Slow 20/60)".to_string(),
130 ],
131 account_popup_open: false,
132 history_popup_open: false,
133 history_rows: Vec::new(),
134 history_bucket: order_store::HistoryBucket::Day,
135 last_applied_fee: "---".to_string(),
136 }
137 }
138
139 pub fn last_price(&self) -> Option<f64> {
141 self.current_candle
142 .as_ref()
143 .map(|cb| cb.close)
144 .or_else(|| self.candles.last().map(|c| c.close))
145 }
146
147 pub fn push_log(&mut self, msg: String) {
148 self.log_messages.push(msg);
149 if self.log_messages.len() > MAX_LOG_MESSAGES {
150 self.log_messages.remove(0);
151 }
152 }
153
154 pub fn refresh_history_rows(&mut self) {
155 match order_store::load_realized_returns_by_bucket(self.history_bucket, 400) {
156 Ok(rows) => {
157 use std::collections::{BTreeMap, BTreeSet};
158
159 let mut date_set: BTreeSet<String> = BTreeSet::new();
160 let mut ticker_map: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
161 for row in rows {
162 date_set.insert(row.date.clone());
163 ticker_map
164 .entry(row.symbol.clone())
165 .or_default()
166 .insert(row.date, row.realized_return_pct);
167 }
168
169 let mut dates: Vec<String> = date_set.into_iter().collect();
171 dates.sort();
172 const MAX_DATE_COLS: usize = 6;
173 if dates.len() > MAX_DATE_COLS {
174 dates = dates[dates.len() - MAX_DATE_COLS..].to_vec();
175 }
176
177 let mut lines = Vec::new();
178 if dates.is_empty() {
179 lines.push("Ticker (no daily realized roi data)".to_string());
180 self.history_rows = lines;
181 return;
182 }
183
184 let mut header = format!("{:<14}", "Ticker");
185 for d in &dates {
186 header.push_str(&format!(" {:>10}", d));
187 }
188 lines.push(header);
189
190 for (ticker, by_date) in ticker_map {
191 let mut line = format!("{:<14}", ticker);
192 for d in &dates {
193 let cell = by_date
194 .get(d)
195 .map(|v| format!("{:.2}%", v))
196 .unwrap_or_else(|| "-".to_string());
197 line.push_str(&format!(" {:>10}", cell));
198 }
199 lines.push(line);
200 }
201 self.history_rows = lines;
202 }
203 Err(e) => {
204 self.history_rows = vec![
205 "Ticker Date RealizedROI RealizedPnL".to_string(),
206 format!("(failed to load history: {})", e),
207 ];
208 }
209 }
210 }
211
212 fn refresh_equity_usdt(&mut self) {
213 let usdt = self.balances.get("USDT").copied().unwrap_or(0.0);
214 let btc = self.balances.get("BTC").copied().unwrap_or(0.0);
215 let mark_price = self
216 .last_price()
217 .or_else(|| (self.position.entry_price > 0.0).then_some(self.position.entry_price));
218 if let Some(price) = mark_price {
219 let total = usdt + btc * price;
220 self.current_equity_usdt = Some(total);
221 self.recompute_initial_equity_from_history();
222 }
223 }
224
225 fn recompute_initial_equity_from_history(&mut self) {
226 if let Some(current) = self.current_equity_usdt {
227 if let Some(total_pnl) = self.history_estimated_total_pnl_usdt {
228 self.initial_equity_usdt = Some(current - total_pnl);
229 } else if self.history_trade_count == 0 && self.initial_equity_usdt.is_none() {
230 self.initial_equity_usdt = Some(current);
231 }
232 }
233 }
234
235 fn candle_index_for_timestamp(&self, timestamp_ms: u64) -> Option<usize> {
236 if let Some((idx, _)) = self
237 .candles
238 .iter()
239 .enumerate()
240 .find(|(_, c)| timestamp_ms >= c.open_time && timestamp_ms < c.close_time)
241 {
242 return Some(idx);
243 }
244 if let Some(cb) = &self.current_candle {
245 if cb.contains(timestamp_ms) {
246 return Some(self.candles.len());
247 }
248 }
249 if let Some((idx, _)) = self
252 .candles
253 .iter()
254 .enumerate()
255 .rev()
256 .find(|(_, c)| c.open_time <= timestamp_ms)
257 {
258 return Some(idx);
259 }
260 None
261 }
262
263 fn rebuild_fill_markers_from_history(&mut self, fills: &[OrderHistoryFill]) {
264 self.fill_markers.clear();
265 for fill in fills {
266 if let Some(candle_index) = self.candle_index_for_timestamp(fill.timestamp_ms) {
267 self.fill_markers.push(FillMarker {
268 candle_index,
269 price: fill.price,
270 side: fill.side,
271 });
272 }
273 }
274 if self.fill_markers.len() > MAX_FILL_MARKERS {
275 let excess = self.fill_markers.len() - MAX_FILL_MARKERS;
276 self.fill_markers.drain(..excess);
277 }
278 }
279
280 pub fn apply(&mut self, event: AppEvent) {
281 match event {
282 AppEvent::MarketTick(tick) => {
283 self.tick_count += 1;
284 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
285 self.last_price_update_ms = Some(now_ms);
286 self.last_price_event_ms = Some(tick.timestamp_ms);
287 self.last_price_latency_ms = Some(now_ms.saturating_sub(tick.timestamp_ms));
288
289 let should_new = match &self.current_candle {
291 Some(cb) => !cb.contains(tick.timestamp_ms),
292 None => true,
293 };
294 if should_new {
295 if let Some(cb) = self.current_candle.take() {
296 self.candles.push(cb.finish());
297 if self.candles.len() > self.price_history_len {
298 self.candles.remove(0);
299 self.fill_markers.retain_mut(|m| {
301 if m.candle_index == 0 {
302 false
303 } else {
304 m.candle_index -= 1;
305 true
306 }
307 });
308 }
309 }
310 self.current_candle = Some(CandleBuilder::new(
311 tick.price,
312 tick.timestamp_ms,
313 self.candle_interval_ms,
314 ));
315 } else if let Some(cb) = self.current_candle.as_mut() {
316 cb.update(tick.price);
317 } else {
318 self.current_candle = Some(CandleBuilder::new(
320 tick.price,
321 tick.timestamp_ms,
322 self.candle_interval_ms,
323 ));
324 self.push_log("[WARN] Recovered missing current candle state".to_string());
325 }
326
327 self.position.update_unrealized_pnl(tick.price);
328 self.refresh_equity_usdt();
329 }
330 AppEvent::StrategySignal(ref signal) => {
331 self.last_signal = Some(signal.clone());
332 match signal {
333 Signal::Buy { .. } => {
334 self.push_log("Signal: BUY".to_string());
335 }
336 Signal::Sell { .. } => {
337 self.push_log("Signal: SELL".to_string());
338 }
339 Signal::Hold => {}
340 }
341 }
342 AppEvent::StrategyState { fast_sma, slow_sma } => {
343 self.fast_sma = fast_sma;
344 self.slow_sma = slow_sma;
345 }
346 AppEvent::OrderUpdate(ref update) => {
347 match update {
348 OrderUpdate::Filled {
349 intent_id,
350 client_order_id,
351 side,
352 fills,
353 avg_price,
354 } => {
355 if let Some(summary) = format_last_applied_fee(&self.symbol, fills) {
356 self.last_applied_fee = summary;
357 }
358 self.position.apply_fill(*side, fills);
359 self.refresh_equity_usdt();
360 let candle_index = if self.current_candle.is_some() {
361 self.candles.len()
362 } else {
363 self.candles.len().saturating_sub(1)
364 };
365 self.fill_markers.push(FillMarker {
366 candle_index,
367 price: *avg_price,
368 side: *side,
369 });
370 if self.fill_markers.len() > MAX_FILL_MARKERS {
371 self.fill_markers.remove(0);
372 }
373 self.push_log(format!(
374 "FILLED {} {} ({}) @ {:.2}",
375 side, client_order_id, intent_id, avg_price
376 ));
377 }
378 OrderUpdate::Submitted {
379 intent_id,
380 client_order_id,
381 server_order_id,
382 } => {
383 self.refresh_equity_usdt();
384 self.push_log(format!(
385 "Submitted {} (id: {}, {})",
386 client_order_id, server_order_id, intent_id
387 ));
388 }
389 OrderUpdate::Rejected {
390 intent_id,
391 client_order_id,
392 reason_code,
393 reason,
394 } => {
395 self.push_log(format!(
396 "[ERR] Rejected {} ({}) [{}]: {}",
397 client_order_id, intent_id, reason_code, reason
398 ));
399 }
400 }
401 self.last_order = Some(update.clone());
402 }
403 AppEvent::WsStatus(ref status) => match status {
404 WsConnectionStatus::Connected => {
405 self.ws_connected = true;
406 self.push_log("WebSocket Connected".to_string());
407 }
408 WsConnectionStatus::Disconnected => {
409 self.ws_connected = false;
410 self.push_log("[WARN] WebSocket Disconnected".to_string());
411 }
412 WsConnectionStatus::Reconnecting { attempt, delay_ms } => {
413 self.ws_connected = false;
414 self.push_log(format!(
415 "[WARN] Reconnecting (attempt {}, wait {}ms)",
416 attempt, delay_ms
417 ));
418 }
419 },
420 AppEvent::HistoricalCandles {
421 candles,
422 interval_ms,
423 interval,
424 } => {
425 self.candles = candles;
426 if self.candles.len() > self.price_history_len {
427 let excess = self.candles.len() - self.price_history_len;
428 self.candles.drain(..excess);
429 }
430 self.candle_interval_ms = interval_ms;
431 self.timeframe = interval;
432 self.current_candle = None;
433 let fills = self.history_fills.clone();
434 self.rebuild_fill_markers_from_history(&fills);
435 self.push_log(format!(
436 "Switched to {} ({} candles)",
437 self.timeframe,
438 self.candles.len()
439 ));
440 }
441 AppEvent::BalanceUpdate(balances) => {
442 self.balances = balances;
443 self.refresh_equity_usdt();
444 }
445 AppEvent::OrderHistoryUpdate(snapshot) => {
446 let mut open = Vec::new();
447 let mut filled = Vec::new();
448
449 for row in snapshot.rows {
450 let status = row.split_whitespace().nth(1).unwrap_or_default();
451 if status == "FILLED" {
452 filled.push(row);
453 } else {
454 open.push(row);
455 }
456 }
457
458 if open.len() > MAX_LOG_MESSAGES {
459 let excess = open.len() - MAX_LOG_MESSAGES;
460 open.drain(..excess);
461 }
462 if filled.len() > MAX_LOG_MESSAGES {
463 let excess = filled.len() - MAX_LOG_MESSAGES;
464 filled.drain(..excess);
465 }
466
467 self.open_order_history = open;
468 self.filled_order_history = filled;
469 if snapshot.trade_data_complete {
470 let stats_looks_reset = snapshot.stats.trade_count == 0
471 && (self.history_trade_count > 0 || !self.history_fills.is_empty());
472 if stats_looks_reset {
473 if !self.trade_stats_reset_warned {
474 self.push_log(
475 "[WARN] Ignored transient trade stats reset from order-history sync"
476 .to_string(),
477 );
478 self.trade_stats_reset_warned = true;
479 }
480 } else {
481 self.trade_stats_reset_warned = false;
482 self.history_trade_count = snapshot.stats.trade_count;
483 self.history_win_count = snapshot.stats.win_count;
484 self.history_lose_count = snapshot.stats.lose_count;
485 self.history_realized_pnl = snapshot.stats.realized_pnl;
486 self.strategy_stats = snapshot.strategy_stats;
487 if snapshot.open_qty > f64::EPSILON {
490 self.position.side = Some(OrderSide::Buy);
491 self.position.qty = snapshot.open_qty;
492 self.position.entry_price = snapshot.open_entry_price;
493 if let Some(px) = self.last_price() {
494 self.position.unrealized_pnl =
495 (px - snapshot.open_entry_price) * snapshot.open_qty;
496 }
497 } else {
498 self.position.side = None;
499 self.position.qty = 0.0;
500 self.position.entry_price = 0.0;
501 self.position.unrealized_pnl = 0.0;
502 }
503 }
504 if !snapshot.fills.is_empty() || self.history_fills.is_empty() {
505 self.history_fills = snapshot.fills.clone();
506 self.rebuild_fill_markers_from_history(&snapshot.fills);
507 }
508 self.history_estimated_total_pnl_usdt = snapshot.estimated_total_pnl_usdt;
509 self.recompute_initial_equity_from_history();
510 }
511 self.last_order_history_update_ms = Some(snapshot.fetched_at_ms);
512 self.last_order_history_event_ms = snapshot.latest_event_ms;
513 self.last_order_history_latency_ms = Some(snapshot.fetch_latency_ms);
514 self.refresh_history_rows();
515 }
516 AppEvent::LogMessage(msg) => {
517 self.push_log(msg);
518 }
519 AppEvent::Error(msg) => {
520 self.push_log(format!("[ERR] {}", msg));
521 }
522 }
523 }
524}
525
526pub fn render(frame: &mut Frame, state: &AppState) {
527 let outer = Layout::default()
528 .direction(Direction::Vertical)
529 .constraints([
530 Constraint::Length(1), Constraint::Min(8), Constraint::Length(5), Constraint::Length(6), Constraint::Length(8), Constraint::Length(1), ])
537 .split(frame.area());
538
539 frame.render_widget(
541 StatusBar {
542 symbol: &state.symbol,
543 strategy_label: &state.strategy_label,
544 ws_connected: state.ws_connected,
545 paused: state.paused,
546 timeframe: &state.timeframe,
547 last_price_update_ms: state.last_price_update_ms,
548 last_price_latency_ms: state.last_price_latency_ms,
549 last_order_history_update_ms: state.last_order_history_update_ms,
550 last_order_history_latency_ms: state.last_order_history_latency_ms,
551 },
552 outer[0],
553 );
554
555 let main_area = Layout::default()
557 .direction(Direction::Horizontal)
558 .constraints([Constraint::Min(40), Constraint::Length(24)])
559 .split(outer[1]);
560
561 let current_price = state.last_price();
563 frame.render_widget(
564 PriceChart::new(&state.candles, &state.symbol)
565 .current_candle(state.current_candle.as_ref())
566 .fill_markers(&state.fill_markers)
567 .fast_sma(state.fast_sma)
568 .slow_sma(state.slow_sma),
569 main_area[0],
570 );
571
572 frame.render_widget(
574 PositionPanel::new(
575 &state.position,
576 current_price,
577 &state.balances,
578 state.initial_equity_usdt,
579 state.current_equity_usdt,
580 state.history_trade_count,
581 state.history_realized_pnl,
582 &state.last_applied_fee,
583 ),
584 main_area[1],
585 );
586
587 frame.render_widget(
589 OrderLogPanel::new(
590 &state.last_signal,
591 &state.last_order,
592 state.fast_sma,
593 state.slow_sma,
594 state.history_trade_count,
595 state.history_win_count,
596 state.history_lose_count,
597 state.history_realized_pnl,
598 ),
599 outer[2],
600 );
601
602 frame.render_widget(
604 OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
605 outer[3],
606 );
607
608 frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
610
611 frame.render_widget(KeybindBar, outer[5]);
613
614 if state.symbol_selector_open {
615 render_selector_popup(
616 frame,
617 " Select Symbol ",
618 &state.symbol_items,
619 state.symbol_selector_index,
620 None,
621 None,
622 );
623 } else if state.strategy_selector_open {
624 render_selector_popup(
625 frame,
626 " Select Strategy ",
627 &state.strategy_items,
628 state.strategy_selector_index,
629 Some(&state.strategy_stats),
630 Some(OrderHistoryStats {
631 trade_count: state.history_trade_count,
632 win_count: state.history_win_count,
633 lose_count: state.history_lose_count,
634 realized_pnl: state.history_realized_pnl,
635 }),
636 );
637 } else if state.account_popup_open {
638 render_account_popup(frame, &state.balances);
639 } else if state.history_popup_open {
640 render_history_popup(frame, &state.history_rows, state.history_bucket);
641 }
642}
643
644fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
645 let area = frame.area();
646 let popup = Rect {
647 x: area.x + 4,
648 y: area.y + 2,
649 width: area.width.saturating_sub(8).max(30),
650 height: area.height.saturating_sub(4).max(10),
651 };
652 frame.render_widget(Clear, popup);
653 let block = Block::default()
654 .title(" Account Assets ")
655 .borders(Borders::ALL)
656 .border_style(Style::default().fg(Color::Cyan));
657 let inner = block.inner(popup);
658 frame.render_widget(block, popup);
659
660 let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
661 assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
662
663 let mut lines = Vec::with_capacity(assets.len() + 2);
664 lines.push(Line::from(vec![
665 Span::styled(
666 "Asset",
667 Style::default()
668 .fg(Color::Cyan)
669 .add_modifier(Modifier::BOLD),
670 ),
671 Span::styled(
672 " Free",
673 Style::default()
674 .fg(Color::Cyan)
675 .add_modifier(Modifier::BOLD),
676 ),
677 ]));
678 for (asset, qty) in assets {
679 lines.push(Line::from(vec![
680 Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
681 Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
682 ]));
683 }
684 if lines.len() == 1 {
685 lines.push(Line::from(Span::styled(
686 "No assets",
687 Style::default().fg(Color::DarkGray),
688 )));
689 }
690
691 frame.render_widget(Paragraph::new(lines), inner);
692}
693
694fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
695 let area = frame.area();
696 let popup = Rect {
697 x: area.x + 2,
698 y: area.y + 1,
699 width: area.width.saturating_sub(4).max(40),
700 height: area.height.saturating_sub(2).max(12),
701 };
702 frame.render_widget(Clear, popup);
703 let block = Block::default()
704 .title(match bucket {
705 order_store::HistoryBucket::Day => " History (Day ROI) ",
706 order_store::HistoryBucket::Hour => " History (Hour ROI) ",
707 order_store::HistoryBucket::Month => " History (Month ROI) ",
708 })
709 .borders(Borders::ALL)
710 .border_style(Style::default().fg(Color::Cyan));
711 let inner = block.inner(popup);
712 frame.render_widget(block, popup);
713
714 let max_rows = inner.height.saturating_sub(1) as usize;
715 let mut visible: Vec<Line> = Vec::new();
716 for (idx, row) in rows.iter().take(max_rows).enumerate() {
717 let color = if idx == 0 {
718 Color::Cyan
719 } else if row.contains('-') && row.contains('%') {
720 Color::White
721 } else {
722 Color::DarkGray
723 };
724 visible.push(Line::from(Span::styled(
725 row.clone(),
726 Style::default().fg(color),
727 )));
728 }
729 if visible.is_empty() {
730 visible.push(Line::from(Span::styled(
731 "No history rows",
732 Style::default().fg(Color::DarkGray),
733 )));
734 }
735 frame.render_widget(Paragraph::new(visible), inner);
736}
737
738fn render_selector_popup(
739 frame: &mut Frame,
740 title: &str,
741 items: &[String],
742 selected: usize,
743 stats: Option<&HashMap<String, OrderHistoryStats>>,
744 total_stats: Option<OrderHistoryStats>,
745) {
746 let area = frame.area();
747 let available_width = area.width.saturating_sub(2).max(1);
748 let width = if stats.is_some() {
749 let min_width = 44;
750 let preferred = 84;
751 preferred
752 .min(available_width)
753 .max(min_width.min(available_width))
754 } else {
755 let min_width = 24;
756 let preferred = 48;
757 preferred
758 .min(available_width)
759 .max(min_width.min(available_width))
760 };
761 let available_height = area.height.saturating_sub(2).max(1);
762 let desired_height = if stats.is_some() {
763 items.len() as u16 + 7
764 } else {
765 items.len() as u16 + 4
766 };
767 let height = desired_height
768 .min(available_height)
769 .max(6.min(available_height));
770 let popup = Rect {
771 x: area.x + (area.width.saturating_sub(width)) / 2,
772 y: area.y + (area.height.saturating_sub(height)) / 2,
773 width,
774 height,
775 };
776
777 frame.render_widget(Clear, popup);
778 let block = Block::default()
779 .title(title)
780 .borders(Borders::ALL)
781 .border_style(Style::default().fg(Color::Cyan));
782 let inner = block.inner(popup);
783 frame.render_widget(block, popup);
784
785 let mut lines: Vec<Line> = Vec::new();
786 if stats.is_some() {
787 lines.push(Line::from(vec![Span::styled(
788 " Strategy W L T PnL",
789 Style::default()
790 .fg(Color::Cyan)
791 .add_modifier(Modifier::BOLD),
792 )]));
793 }
794
795 let mut item_lines: Vec<Line> = items
796 .iter()
797 .enumerate()
798 .map(|(idx, item)| {
799 let item_text = if let Some(stats_map) = stats {
800 if let Some(s) = strategy_stats_for_item(stats_map, item) {
801 format!(
802 "{:<16} W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
803 item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
804 )
805 } else {
806 format!("{:<16} W:0 L:0 T:0 PnL:0.0000", item)
807 }
808 } else {
809 item.clone()
810 };
811 if idx == selected {
812 Line::from(vec![
813 Span::styled("▶ ", Style::default().fg(Color::Yellow)),
814 Span::styled(
815 item_text,
816 Style::default()
817 .fg(Color::White)
818 .add_modifier(Modifier::BOLD),
819 ),
820 ])
821 } else {
822 Line::from(vec![
823 Span::styled(" ", Style::default()),
824 Span::styled(item_text, Style::default().fg(Color::DarkGray)),
825 ])
826 }
827 })
828 .collect();
829 lines.append(&mut item_lines);
830 if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
831 let mut strategy_sum = OrderHistoryStats::default();
832 for item in items {
833 if let Some(s) = strategy_stats_for_item(stats_map, item) {
834 strategy_sum.trade_count += s.trade_count;
835 strategy_sum.win_count += s.win_count;
836 strategy_sum.lose_count += s.lose_count;
837 strategy_sum.realized_pnl += s.realized_pnl;
838 }
839 }
840 let manual = subtract_stats(t, &strategy_sum);
841 lines.push(Line::from(vec![Span::styled(
842 format!(
843 " MANUAL(rest) W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
844 manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
845 ),
846 Style::default().fg(Color::LightBlue),
847 )]));
848 }
849 if let Some(t) = total_stats {
850 lines.push(Line::from(vec![Span::styled(
851 format!(
852 " TOTAL W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
853 t.win_count, t.lose_count, t.trade_count, t.realized_pnl
854 ),
855 Style::default()
856 .fg(Color::Yellow)
857 .add_modifier(Modifier::BOLD),
858 )]));
859 }
860
861 frame.render_widget(
862 Paragraph::new(lines).style(Style::default().fg(Color::White)),
863 inner,
864 );
865}
866
867fn strategy_stats_for_item<'a>(
868 stats_map: &'a HashMap<String, OrderHistoryStats>,
869 item: &str,
870) -> Option<&'a OrderHistoryStats> {
871 if let Some(s) = stats_map.get(item) {
872 return Some(s);
873 }
874 let source_tag = match item {
875 "MA(Config)" => Some("cfg"),
876 "MA(Fast 5/20)" => Some("fst"),
877 "MA(Slow 20/60)" => Some("slw"),
878 _ => None,
879 };
880 source_tag.and_then(|tag| {
881 stats_map
882 .get(tag)
883 .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
884 })
885}
886
887fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
888 OrderHistoryStats {
889 trade_count: total.trade_count.saturating_sub(used.trade_count),
890 win_count: total.win_count.saturating_sub(used.win_count),
891 lose_count: total.lose_count.saturating_sub(used.lose_count),
892 realized_pnl: total.realized_pnl - used.realized_pnl,
893 }
894}
895
896fn split_symbol_assets(symbol: &str) -> (String, String) {
897 const QUOTE_SUFFIXES: [&str; 10] = [
898 "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
899 ];
900 for q in QUOTE_SUFFIXES {
901 if let Some(base) = symbol.strip_suffix(q) {
902 if !base.is_empty() {
903 return (base.to_string(), q.to_string());
904 }
905 }
906 }
907 (symbol.to_string(), String::new())
908}
909
910fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
911 if fills.is_empty() {
912 return None;
913 }
914 let (base_asset, quote_asset) = split_symbol_assets(symbol);
915 let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
916 let mut notional_quote = 0.0;
917 let mut fee_quote_equiv = 0.0;
918 let mut quote_convertible = !quote_asset.is_empty();
919
920 for f in fills {
921 if f.qty > 0.0 && f.price > 0.0 {
922 notional_quote += f.qty * f.price;
923 }
924 if f.commission <= 0.0 {
925 continue;
926 }
927 *fee_by_asset.entry(f.commission_asset.clone()).or_insert(0.0) += f.commission;
928 if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case("e_asset) {
929 fee_quote_equiv += f.commission;
930 } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
931 fee_quote_equiv += f.commission * f.price.max(0.0);
932 } else {
933 quote_convertible = false;
934 }
935 }
936
937 if fee_by_asset.is_empty() {
938 return Some("0".to_string());
939 }
940
941 if quote_convertible && notional_quote > f64::EPSILON {
942 let fee_pct = fee_quote_equiv / notional_quote * 100.0;
943 return Some(format!(
944 "{:.3}% ({:.4} {})",
945 fee_pct, fee_quote_equiv, quote_asset
946 ));
947 }
948
949 let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
950 items.sort_by(|a, b| a.0.cmp(&b.0));
951 if items.len() == 1 {
952 let (asset, amount) = &items[0];
953 Some(format!("{:.6} {}", amount, asset))
954 } else {
955 Some(format!("mixed fees ({})", items.len()))
956 }
957}
958
959#[cfg(test)]
960mod tests {
961 use super::format_last_applied_fee;
962 use crate::model::order::Fill;
963
964 #[test]
965 fn fee_summary_from_quote_asset_commission() {
966 let fills = vec![Fill {
967 price: 2000.0,
968 qty: 0.5,
969 commission: 1.0,
970 commission_asset: "USDT".to_string(),
971 }];
972 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
973 assert_eq!(summary, "0.100% (1.0000 USDT)");
974 }
975
976 #[test]
977 fn fee_summary_from_base_asset_commission() {
978 let fills = vec![Fill {
979 price: 2000.0,
980 qty: 0.5,
981 commission: 0.0005,
982 commission_asset: "ETH".to_string(),
983 }];
984 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
985 assert_eq!(summary, "0.100% (1.0000 USDT)");
986 }
987}