1pub mod chart;
2pub mod dashboard;
3pub mod app_state_v2;
4
5use std::collections::HashMap;
6
7use ratatui::layout::{Constraint, Direction, Layout, Rect};
8use ratatui::style::{Color, Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
11use ratatui::Frame;
12
13use crate::event::{AppEvent, WsConnectionStatus};
14use crate::model::candle::{Candle, CandleBuilder};
15use crate::model::order::{Fill, OrderSide};
16use crate::model::position::Position;
17use crate::model::signal::Signal;
18use crate::order_manager::{OrderHistoryFill, OrderHistoryStats, OrderUpdate};
19use crate::order_store;
20use crate::risk_module::RateBudgetSnapshot;
21
22use app_state_v2::AppStateV2;
23use chart::{FillMarker, PriceChart};
24use dashboard::{KeybindBar, LogPanel, OrderHistoryPanel, OrderLogPanel, PositionPanel, StatusBar};
25
26const MAX_LOG_MESSAGES: usize = 200;
27const MAX_FILL_MARKERS: usize = 200;
28
29pub struct AppState {
30 pub symbol: String,
31 pub strategy_label: String,
32 pub candles: Vec<Candle>,
33 pub current_candle: Option<CandleBuilder>,
34 pub candle_interval_ms: u64,
35 pub timeframe: String,
36 pub price_history_len: usize,
37 pub position: Position,
38 pub last_signal: Option<Signal>,
39 pub last_order: Option<OrderUpdate>,
40 pub open_order_history: Vec<String>,
41 pub filled_order_history: Vec<String>,
42 pub fast_sma: Option<f64>,
43 pub slow_sma: Option<f64>,
44 pub ws_connected: bool,
45 pub paused: bool,
46 pub tick_count: u64,
47 pub log_messages: Vec<String>,
48 pub balances: HashMap<String, f64>,
49 pub initial_equity_usdt: Option<f64>,
50 pub current_equity_usdt: Option<f64>,
51 pub history_estimated_total_pnl_usdt: Option<f64>,
52 pub fill_markers: Vec<FillMarker>,
53 pub history_trade_count: u32,
54 pub history_win_count: u32,
55 pub history_lose_count: u32,
56 pub history_realized_pnl: f64,
57 pub strategy_stats: HashMap<String, OrderHistoryStats>,
58 pub history_fills: Vec<OrderHistoryFill>,
59 pub last_price_update_ms: Option<u64>,
60 pub last_price_event_ms: Option<u64>,
61 pub last_price_latency_ms: Option<u64>,
62 pub last_order_history_update_ms: Option<u64>,
63 pub last_order_history_event_ms: Option<u64>,
64 pub last_order_history_latency_ms: Option<u64>,
65 pub trade_stats_reset_warned: bool,
66 pub symbol_selector_open: bool,
67 pub symbol_selector_index: usize,
68 pub symbol_items: Vec<String>,
69 pub strategy_selector_open: bool,
70 pub strategy_selector_index: usize,
71 pub strategy_items: Vec<String>,
72 pub account_popup_open: bool,
73 pub history_popup_open: bool,
74 pub focus_popup_open: bool,
75 pub strategy_editor_open: bool,
76 pub strategy_editor_index: usize,
77 pub strategy_editor_field: usize,
78 pub strategy_editor_symbol_index: usize,
79 pub strategy_editor_fast: usize,
80 pub strategy_editor_slow: usize,
81 pub strategy_editor_cooldown: u64,
82 pub v2_grid_symbol_index: usize,
83 pub v2_grid_strategy_index: usize,
84 pub history_rows: Vec<String>,
85 pub history_bucket: order_store::HistoryBucket,
86 pub last_applied_fee: String,
87 pub v2_grid_open: bool,
88 pub v2_state: AppStateV2,
89 pub rate_budget_global: RateBudgetSnapshot,
90 pub rate_budget_orders: RateBudgetSnapshot,
91 pub rate_budget_account: RateBudgetSnapshot,
92 pub rate_budget_market_data: RateBudgetSnapshot,
93}
94
95impl AppState {
96 pub fn new(
97 symbol: &str,
98 strategy_label: &str,
99 price_history_len: usize,
100 candle_interval_ms: u64,
101 timeframe: &str,
102 ) -> Self {
103 Self {
104 symbol: symbol.to_string(),
105 strategy_label: strategy_label.to_string(),
106 candles: Vec::with_capacity(price_history_len),
107 current_candle: None,
108 candle_interval_ms,
109 timeframe: timeframe.to_string(),
110 price_history_len,
111 position: Position::new(symbol.to_string()),
112 last_signal: None,
113 last_order: None,
114 open_order_history: Vec::new(),
115 filled_order_history: Vec::new(),
116 fast_sma: None,
117 slow_sma: None,
118 ws_connected: false,
119 paused: false,
120 tick_count: 0,
121 log_messages: Vec::new(),
122 balances: HashMap::new(),
123 initial_equity_usdt: None,
124 current_equity_usdt: None,
125 history_estimated_total_pnl_usdt: None,
126 fill_markers: Vec::new(),
127 history_trade_count: 0,
128 history_win_count: 0,
129 history_lose_count: 0,
130 history_realized_pnl: 0.0,
131 strategy_stats: HashMap::new(),
132 history_fills: Vec::new(),
133 last_price_update_ms: None,
134 last_price_event_ms: None,
135 last_price_latency_ms: None,
136 last_order_history_update_ms: None,
137 last_order_history_event_ms: None,
138 last_order_history_latency_ms: None,
139 trade_stats_reset_warned: false,
140 symbol_selector_open: false,
141 symbol_selector_index: 0,
142 symbol_items: Vec::new(),
143 strategy_selector_open: false,
144 strategy_selector_index: 0,
145 strategy_items: vec![
146 "MA(Config)".to_string(),
147 "MA(Fast 5/20)".to_string(),
148 "MA(Slow 20/60)".to_string(),
149 ],
150 account_popup_open: false,
151 history_popup_open: false,
152 focus_popup_open: false,
153 strategy_editor_open: false,
154 strategy_editor_index: 0,
155 strategy_editor_field: 0,
156 strategy_editor_symbol_index: 0,
157 strategy_editor_fast: 5,
158 strategy_editor_slow: 20,
159 strategy_editor_cooldown: 1,
160 v2_grid_symbol_index: 0,
161 v2_grid_strategy_index: 0,
162 history_rows: Vec::new(),
163 history_bucket: order_store::HistoryBucket::Day,
164 last_applied_fee: "---".to_string(),
165 v2_grid_open: false,
166 v2_state: AppStateV2::new(),
167 rate_budget_global: RateBudgetSnapshot {
168 used: 0,
169 limit: 0,
170 reset_in_ms: 0,
171 },
172 rate_budget_orders: RateBudgetSnapshot {
173 used: 0,
174 limit: 0,
175 reset_in_ms: 0,
176 },
177 rate_budget_account: RateBudgetSnapshot {
178 used: 0,
179 limit: 0,
180 reset_in_ms: 0,
181 },
182 rate_budget_market_data: RateBudgetSnapshot {
183 used: 0,
184 limit: 0,
185 reset_in_ms: 0,
186 },
187 }
188 }
189
190 pub fn last_price(&self) -> Option<f64> {
192 self.current_candle
193 .as_ref()
194 .map(|cb| cb.close)
195 .or_else(|| self.candles.last().map(|c| c.close))
196 }
197
198 pub fn push_log(&mut self, msg: String) {
199 self.log_messages.push(msg);
200 if self.log_messages.len() > MAX_LOG_MESSAGES {
201 self.log_messages.remove(0);
202 }
203 }
204
205 pub fn refresh_history_rows(&mut self) {
206 match order_store::load_realized_returns_by_bucket(self.history_bucket, 400) {
207 Ok(rows) => {
208 use std::collections::{BTreeMap, BTreeSet};
209
210 let mut date_set: BTreeSet<String> = BTreeSet::new();
211 let mut ticker_map: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
212 for row in rows {
213 date_set.insert(row.date.clone());
214 ticker_map
215 .entry(row.symbol.clone())
216 .or_default()
217 .insert(row.date, row.realized_return_pct);
218 }
219
220 let mut dates: Vec<String> = date_set.into_iter().collect();
222 dates.sort();
223 const MAX_DATE_COLS: usize = 6;
224 if dates.len() > MAX_DATE_COLS {
225 dates = dates[dates.len() - MAX_DATE_COLS..].to_vec();
226 }
227
228 let mut lines = Vec::new();
229 if dates.is_empty() {
230 lines.push("Ticker (no daily realized roi data)".to_string());
231 self.history_rows = lines;
232 return;
233 }
234
235 let mut header = format!("{:<14}", "Ticker");
236 for d in &dates {
237 header.push_str(&format!(" {:>10}", d));
238 }
239 lines.push(header);
240
241 for (ticker, by_date) in ticker_map {
242 let mut line = format!("{:<14}", ticker);
243 for d in &dates {
244 let cell = by_date
245 .get(d)
246 .map(|v| format!("{:.2}%", v))
247 .unwrap_or_else(|| "-".to_string());
248 line.push_str(&format!(" {:>10}", cell));
249 }
250 lines.push(line);
251 }
252 self.history_rows = lines;
253 }
254 Err(e) => {
255 self.history_rows = vec![
256 "Ticker Date RealizedROI RealizedPnL".to_string(),
257 format!("(failed to load history: {})", e),
258 ];
259 }
260 }
261 }
262
263 fn refresh_equity_usdt(&mut self) {
264 let usdt = self.balances.get("USDT").copied().unwrap_or(0.0);
265 let btc = self.balances.get("BTC").copied().unwrap_or(0.0);
266 let mark_price = self
267 .last_price()
268 .or_else(|| (self.position.entry_price > 0.0).then_some(self.position.entry_price));
269 if let Some(price) = mark_price {
270 let total = usdt + btc * price;
271 self.current_equity_usdt = Some(total);
272 self.recompute_initial_equity_from_history();
273 }
274 }
275
276 fn recompute_initial_equity_from_history(&mut self) {
277 if let Some(current) = self.current_equity_usdt {
278 if let Some(total_pnl) = self.history_estimated_total_pnl_usdt {
279 self.initial_equity_usdt = Some(current - total_pnl);
280 } else if self.history_trade_count == 0 && self.initial_equity_usdt.is_none() {
281 self.initial_equity_usdt = Some(current);
282 }
283 }
284 }
285
286 fn candle_index_for_timestamp(&self, timestamp_ms: u64) -> Option<usize> {
287 if let Some((idx, _)) = self
288 .candles
289 .iter()
290 .enumerate()
291 .find(|(_, c)| timestamp_ms >= c.open_time && timestamp_ms < c.close_time)
292 {
293 return Some(idx);
294 }
295 if let Some(cb) = &self.current_candle {
296 if cb.contains(timestamp_ms) {
297 return Some(self.candles.len());
298 }
299 }
300 if let Some((idx, _)) = self
303 .candles
304 .iter()
305 .enumerate()
306 .rev()
307 .find(|(_, c)| c.open_time <= timestamp_ms)
308 {
309 return Some(idx);
310 }
311 None
312 }
313
314 fn rebuild_fill_markers_from_history(&mut self, fills: &[OrderHistoryFill]) {
315 self.fill_markers.clear();
316 for fill in fills {
317 if let Some(candle_index) = self.candle_index_for_timestamp(fill.timestamp_ms) {
318 self.fill_markers.push(FillMarker {
319 candle_index,
320 price: fill.price,
321 side: fill.side,
322 });
323 }
324 }
325 if self.fill_markers.len() > MAX_FILL_MARKERS {
326 let excess = self.fill_markers.len() - MAX_FILL_MARKERS;
327 self.fill_markers.drain(..excess);
328 }
329 }
330
331 pub fn apply(&mut self, event: AppEvent) {
332 let prev_focus = self.v2_state.focus.clone();
333 match event {
334 AppEvent::MarketTick(tick) => {
335 self.tick_count += 1;
336 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
337 self.last_price_update_ms = Some(now_ms);
338 self.last_price_event_ms = Some(tick.timestamp_ms);
339 self.last_price_latency_ms = Some(now_ms.saturating_sub(tick.timestamp_ms));
340
341 let should_new = match &self.current_candle {
343 Some(cb) => !cb.contains(tick.timestamp_ms),
344 None => true,
345 };
346 if should_new {
347 if let Some(cb) = self.current_candle.take() {
348 self.candles.push(cb.finish());
349 if self.candles.len() > self.price_history_len {
350 self.candles.remove(0);
351 self.fill_markers.retain_mut(|m| {
353 if m.candle_index == 0 {
354 false
355 } else {
356 m.candle_index -= 1;
357 true
358 }
359 });
360 }
361 }
362 self.current_candle = Some(CandleBuilder::new(
363 tick.price,
364 tick.timestamp_ms,
365 self.candle_interval_ms,
366 ));
367 } else if let Some(cb) = self.current_candle.as_mut() {
368 cb.update(tick.price);
369 } else {
370 self.current_candle = Some(CandleBuilder::new(
372 tick.price,
373 tick.timestamp_ms,
374 self.candle_interval_ms,
375 ));
376 self.push_log("[WARN] Recovered missing current candle state".to_string());
377 }
378
379 self.position.update_unrealized_pnl(tick.price);
380 self.refresh_equity_usdt();
381 }
382 AppEvent::StrategySignal(ref signal) => {
383 self.last_signal = Some(signal.clone());
384 match signal {
385 Signal::Buy { .. } => {
386 self.push_log("Signal: BUY".to_string());
387 }
388 Signal::Sell { .. } => {
389 self.push_log("Signal: SELL".to_string());
390 }
391 Signal::Hold => {}
392 }
393 }
394 AppEvent::StrategyState { fast_sma, slow_sma } => {
395 self.fast_sma = fast_sma;
396 self.slow_sma = slow_sma;
397 }
398 AppEvent::OrderUpdate(ref update) => {
399 match update {
400 OrderUpdate::Filled {
401 intent_id,
402 client_order_id,
403 side,
404 fills,
405 avg_price,
406 } => {
407 if let Some(summary) = format_last_applied_fee(&self.symbol, fills) {
408 self.last_applied_fee = summary;
409 }
410 self.position.apply_fill(*side, fills);
411 self.refresh_equity_usdt();
412 let candle_index = if self.current_candle.is_some() {
413 self.candles.len()
414 } else {
415 self.candles.len().saturating_sub(1)
416 };
417 self.fill_markers.push(FillMarker {
418 candle_index,
419 price: *avg_price,
420 side: *side,
421 });
422 if self.fill_markers.len() > MAX_FILL_MARKERS {
423 self.fill_markers.remove(0);
424 }
425 self.push_log(format!(
426 "FILLED {} {} ({}) @ {:.2}",
427 side, client_order_id, intent_id, avg_price
428 ));
429 }
430 OrderUpdate::Submitted {
431 intent_id,
432 client_order_id,
433 server_order_id,
434 } => {
435 self.refresh_equity_usdt();
436 self.push_log(format!(
437 "Submitted {} (id: {}, {})",
438 client_order_id, server_order_id, intent_id
439 ));
440 }
441 OrderUpdate::Rejected {
442 intent_id,
443 client_order_id,
444 reason_code,
445 reason,
446 } => {
447 self.push_log(format!(
448 "[ERR] Rejected {} ({}) [{}]: {}",
449 client_order_id, intent_id, reason_code, reason
450 ));
451 }
452 }
453 self.last_order = Some(update.clone());
454 }
455 AppEvent::WsStatus(ref status) => match status {
456 WsConnectionStatus::Connected => {
457 self.ws_connected = true;
458 self.push_log("WebSocket Connected".to_string());
459 }
460 WsConnectionStatus::Disconnected => {
461 self.ws_connected = false;
462 self.push_log("[WARN] WebSocket Disconnected".to_string());
463 }
464 WsConnectionStatus::Reconnecting { attempt, delay_ms } => {
465 self.ws_connected = false;
466 self.push_log(format!(
467 "[WARN] Reconnecting (attempt {}, wait {}ms)",
468 attempt, delay_ms
469 ));
470 }
471 },
472 AppEvent::HistoricalCandles {
473 candles,
474 interval_ms,
475 interval,
476 } => {
477 self.candles = candles;
478 if self.candles.len() > self.price_history_len {
479 let excess = self.candles.len() - self.price_history_len;
480 self.candles.drain(..excess);
481 }
482 self.candle_interval_ms = interval_ms;
483 self.timeframe = interval;
484 self.current_candle = None;
485 let fills = self.history_fills.clone();
486 self.rebuild_fill_markers_from_history(&fills);
487 self.push_log(format!(
488 "Switched to {} ({} candles)",
489 self.timeframe,
490 self.candles.len()
491 ));
492 }
493 AppEvent::BalanceUpdate(balances) => {
494 self.balances = balances;
495 self.refresh_equity_usdt();
496 }
497 AppEvent::OrderHistoryUpdate(snapshot) => {
498 let mut open = Vec::new();
499 let mut filled = Vec::new();
500
501 for row in snapshot.rows {
502 let status = row.split_whitespace().nth(1).unwrap_or_default();
503 if status == "FILLED" {
504 filled.push(row);
505 } else {
506 open.push(row);
507 }
508 }
509
510 if open.len() > MAX_LOG_MESSAGES {
511 let excess = open.len() - MAX_LOG_MESSAGES;
512 open.drain(..excess);
513 }
514 if filled.len() > MAX_LOG_MESSAGES {
515 let excess = filled.len() - MAX_LOG_MESSAGES;
516 filled.drain(..excess);
517 }
518
519 self.open_order_history = open;
520 self.filled_order_history = filled;
521 if snapshot.trade_data_complete {
522 let stats_looks_reset = snapshot.stats.trade_count == 0
523 && (self.history_trade_count > 0 || !self.history_fills.is_empty());
524 if stats_looks_reset {
525 if !self.trade_stats_reset_warned {
526 self.push_log(
527 "[WARN] Ignored transient trade stats reset from order-history sync"
528 .to_string(),
529 );
530 self.trade_stats_reset_warned = true;
531 }
532 } else {
533 self.trade_stats_reset_warned = false;
534 self.history_trade_count = snapshot.stats.trade_count;
535 self.history_win_count = snapshot.stats.win_count;
536 self.history_lose_count = snapshot.stats.lose_count;
537 self.history_realized_pnl = snapshot.stats.realized_pnl;
538 self.strategy_stats = snapshot.strategy_stats;
539 if snapshot.open_qty > f64::EPSILON {
542 self.position.side = Some(OrderSide::Buy);
543 self.position.qty = snapshot.open_qty;
544 self.position.entry_price = snapshot.open_entry_price;
545 if let Some(px) = self.last_price() {
546 self.position.unrealized_pnl =
547 (px - snapshot.open_entry_price) * snapshot.open_qty;
548 }
549 } else {
550 self.position.side = None;
551 self.position.qty = 0.0;
552 self.position.entry_price = 0.0;
553 self.position.unrealized_pnl = 0.0;
554 }
555 }
556 if !snapshot.fills.is_empty() || self.history_fills.is_empty() {
557 self.history_fills = snapshot.fills.clone();
558 self.rebuild_fill_markers_from_history(&snapshot.fills);
559 }
560 self.history_estimated_total_pnl_usdt = snapshot.estimated_total_pnl_usdt;
561 self.recompute_initial_equity_from_history();
562 }
563 self.last_order_history_update_ms = Some(snapshot.fetched_at_ms);
564 self.last_order_history_event_ms = snapshot.latest_event_ms;
565 self.last_order_history_latency_ms = Some(snapshot.fetch_latency_ms);
566 self.refresh_history_rows();
567 }
568 AppEvent::RiskRateSnapshot {
569 global,
570 orders,
571 account,
572 market_data,
573 } => {
574 self.rate_budget_global = global;
575 self.rate_budget_orders = orders;
576 self.rate_budget_account = account;
577 self.rate_budget_market_data = market_data;
578 }
579 AppEvent::LogMessage(msg) => {
580 self.push_log(msg);
581 }
582 AppEvent::Error(msg) => {
583 self.push_log(format!("[ERR] {}", msg));
584 }
585 }
586 let mut next = AppStateV2::from_legacy(self);
587 if prev_focus.symbol.is_some() {
588 next.focus.symbol = prev_focus.symbol;
589 }
590 if prev_focus.strategy_id.is_some() {
591 next.focus.strategy_id = prev_focus.strategy_id;
592 }
593 self.v2_state = next;
594 }
595}
596
597pub fn render(frame: &mut Frame, state: &AppState) {
598 let outer = Layout::default()
599 .direction(Direction::Vertical)
600 .constraints([
601 Constraint::Length(1), Constraint::Min(8), Constraint::Length(5), Constraint::Length(6), Constraint::Length(8), Constraint::Length(1), ])
608 .split(frame.area());
609
610 frame.render_widget(
612 StatusBar {
613 symbol: &state.symbol,
614 strategy_label: &state.strategy_label,
615 ws_connected: state.ws_connected,
616 paused: state.paused,
617 timeframe: &state.timeframe,
618 last_price_update_ms: state.last_price_update_ms,
619 last_price_latency_ms: state.last_price_latency_ms,
620 last_order_history_update_ms: state.last_order_history_update_ms,
621 last_order_history_latency_ms: state.last_order_history_latency_ms,
622 },
623 outer[0],
624 );
625
626 let main_area = Layout::default()
628 .direction(Direction::Horizontal)
629 .constraints([Constraint::Min(40), Constraint::Length(24)])
630 .split(outer[1]);
631
632 let current_price = state.last_price();
634 frame.render_widget(
635 PriceChart::new(&state.candles, &state.symbol)
636 .current_candle(state.current_candle.as_ref())
637 .fill_markers(&state.fill_markers)
638 .fast_sma(state.fast_sma)
639 .slow_sma(state.slow_sma),
640 main_area[0],
641 );
642
643 frame.render_widget(
645 PositionPanel::new(
646 &state.position,
647 current_price,
648 &state.balances,
649 state.initial_equity_usdt,
650 state.current_equity_usdt,
651 state.history_trade_count,
652 state.history_realized_pnl,
653 &state.last_applied_fee,
654 ),
655 main_area[1],
656 );
657
658 frame.render_widget(
660 OrderLogPanel::new(
661 &state.last_signal,
662 &state.last_order,
663 state.fast_sma,
664 state.slow_sma,
665 state.history_trade_count,
666 state.history_win_count,
667 state.history_lose_count,
668 state.history_realized_pnl,
669 ),
670 outer[2],
671 );
672
673 frame.render_widget(
675 OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
676 outer[3],
677 );
678
679 frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
681
682 frame.render_widget(KeybindBar, outer[5]);
684
685 if state.symbol_selector_open {
686 render_selector_popup(
687 frame,
688 " Select Symbol ",
689 &state.symbol_items,
690 state.symbol_selector_index,
691 None,
692 None,
693 None,
694 );
695 } else if state.strategy_selector_open {
696 render_selector_popup(
697 frame,
698 " Select Strategy ",
699 &state.strategy_items,
700 state.strategy_selector_index,
701 Some(&state.strategy_stats),
702 Some(OrderHistoryStats {
703 trade_count: state.history_trade_count,
704 win_count: state.history_win_count,
705 lose_count: state.history_lose_count,
706 realized_pnl: state.history_realized_pnl,
707 }),
708 Some(state.symbol.as_str()),
709 );
710 } else if state.account_popup_open {
711 render_account_popup(frame, &state.balances);
712 } else if state.history_popup_open {
713 render_history_popup(frame, &state.history_rows, state.history_bucket);
714 } else if state.focus_popup_open {
715 render_focus_popup(frame, state);
716 } else if state.strategy_editor_open {
717 render_strategy_editor_popup(frame, state);
718 } else if state.v2_grid_open {
719 render_v2_grid_popup(frame, state);
720 }
721}
722
723fn render_focus_popup(frame: &mut Frame, state: &AppState) {
724 let area = frame.area();
725 let popup = Rect {
726 x: area.x + 1,
727 y: area.y + 1,
728 width: area.width.saturating_sub(2).max(70),
729 height: area.height.saturating_sub(2).max(22),
730 };
731 frame.render_widget(Clear, popup);
732 let block = Block::default()
733 .title(" Focus View (V2 Drill-down) ")
734 .borders(Borders::ALL)
735 .border_style(Style::default().fg(Color::Green));
736 let inner = block.inner(popup);
737 frame.render_widget(block, popup);
738
739 let rows = Layout::default()
740 .direction(Direction::Vertical)
741 .constraints([
742 Constraint::Length(2),
743 Constraint::Min(8),
744 Constraint::Length(7),
745 ])
746 .split(inner);
747
748 let focus_symbol = state
749 .v2_state
750 .focus
751 .symbol
752 .as_deref()
753 .unwrap_or(&state.symbol);
754 let focus_strategy = state
755 .v2_state
756 .focus
757 .strategy_id
758 .as_deref()
759 .unwrap_or(&state.strategy_label);
760 frame.render_widget(
761 Paragraph::new(vec![
762 Line::from(vec![
763 Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
764 Span::styled(
765 focus_symbol,
766 Style::default()
767 .fg(Color::Cyan)
768 .add_modifier(Modifier::BOLD),
769 ),
770 Span::styled(" Strategy: ", Style::default().fg(Color::DarkGray)),
771 Span::styled(
772 focus_strategy,
773 Style::default()
774 .fg(Color::Magenta)
775 .add_modifier(Modifier::BOLD),
776 ),
777 ]),
778 Line::from(Span::styled(
779 "Reuse legacy chart/position/history widgets. Press [F]/[Esc] to close.",
780 Style::default().fg(Color::DarkGray),
781 )),
782 ]),
783 rows[0],
784 );
785
786 let main_cols = Layout::default()
787 .direction(Direction::Horizontal)
788 .constraints([Constraint::Min(48), Constraint::Length(28)])
789 .split(rows[1]);
790
791 frame.render_widget(
792 PriceChart::new(&state.candles, focus_symbol)
793 .current_candle(state.current_candle.as_ref())
794 .fill_markers(&state.fill_markers)
795 .fast_sma(state.fast_sma)
796 .slow_sma(state.slow_sma),
797 main_cols[0],
798 );
799 frame.render_widget(
800 PositionPanel::new(
801 &state.position,
802 state.last_price(),
803 &state.balances,
804 state.initial_equity_usdt,
805 state.current_equity_usdt,
806 state.history_trade_count,
807 state.history_realized_pnl,
808 &state.last_applied_fee,
809 ),
810 main_cols[1],
811 );
812
813 frame.render_widget(
814 OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
815 rows[2],
816 );
817}
818
819fn render_v2_grid_popup(frame: &mut Frame, state: &AppState) {
820 let area = frame.area();
821 let popup = Rect {
822 x: area.x + 1,
823 y: area.y + 1,
824 width: area.width.saturating_sub(2).max(60),
825 height: area.height.saturating_sub(2).max(20),
826 };
827 frame.render_widget(Clear, popup);
828 let block = Block::default()
829 .title(" Portfolio Grid (V2) ")
830 .borders(Borders::ALL)
831 .border_style(Style::default().fg(Color::Cyan));
832 let inner = block.inner(popup);
833 frame.render_widget(block, popup);
834
835 let chunks = Layout::default()
836 .direction(Direction::Vertical)
837 .constraints([
838 Constraint::Length(4),
839 Constraint::Min(10),
840 Constraint::Length(3),
841 Constraint::Min(3),
842 ])
843 .split(inner);
844
845 let mut asset_lines = vec![Line::from(Span::styled(
846 "Asset Table",
847 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
848 ))];
849 for a in &state.v2_state.assets {
850 asset_lines.push(Line::from(format!(
851 "{} px={} qty={:.5} rlz={:+.4} unrlz={:+.4}",
852 a.symbol,
853 a.last_price
854 .map(|v| format!("{:.2}", v))
855 .unwrap_or_else(|| "---".to_string()),
856 a.position_qty,
857 a.realized_pnl_usdt,
858 a.unrealized_pnl_usdt
859 )));
860 }
861 frame.render_widget(Paragraph::new(asset_lines), chunks[0]);
862
863 let selected_symbol = state
864 .symbol_items
865 .get(state.v2_grid_symbol_index)
866 .map(String::as_str)
867 .unwrap_or(state.symbol.as_str());
868 let strategy_chunks = Layout::default()
869 .direction(Direction::Vertical)
870 .constraints([Constraint::Min(3), Constraint::Length(2)])
871 .split(chunks[1]);
872 let header = Row::new(vec![
873 Cell::from(" "),
874 Cell::from("Symbol"),
875 Cell::from("Strategy"),
876 Cell::from("W"),
877 Cell::from("L"),
878 Cell::from("T"),
879 Cell::from("PnL"),
880 ])
881 .style(Style::default().fg(Color::DarkGray));
882 let mut rows: Vec<Row> = state
883 .strategy_items
884 .iter()
885 .enumerate()
886 .map(|(idx, item)| {
887 let stats = strategy_stats_for_item(&state.strategy_stats, item);
888 let (w, l, t, pnl) = if let Some(s) = stats {
889 (
890 s.win_count.to_string(),
891 s.lose_count.to_string(),
892 s.trade_count.to_string(),
893 format!("{:+.4}", s.realized_pnl),
894 )
895 } else {
896 ("0".to_string(), "0".to_string(), "0".to_string(), "+0.0000".to_string())
897 };
898 let marker = if idx == state.v2_grid_strategy_index {
899 "▶"
900 } else {
901 " "
902 };
903 let mut row = Row::new(vec![
904 Cell::from(marker),
905 Cell::from(selected_symbol.to_string()),
906 Cell::from(item.clone()),
907 Cell::from(w),
908 Cell::from(l),
909 Cell::from(t),
910 Cell::from(pnl),
911 ]);
912 if idx == state.v2_grid_strategy_index {
913 row = row.style(
914 Style::default()
915 .fg(Color::Yellow)
916 .add_modifier(Modifier::BOLD),
917 );
918 }
919 row
920 })
921 .collect();
922 if rows.is_empty() {
923 rows.push(
924 Row::new(vec![
925 Cell::from(" "),
926 Cell::from("-"),
927 Cell::from("(no strategies configured)"),
928 Cell::from("-"),
929 Cell::from("-"),
930 Cell::from("-"),
931 Cell::from("-"),
932 ])
933 .style(Style::default().fg(Color::DarkGray)),
934 );
935 }
936 let table = Table::new(
937 rows,
938 [
939 Constraint::Length(2),
940 Constraint::Length(16),
941 Constraint::Min(28),
942 Constraint::Length(3),
943 Constraint::Length(3),
944 Constraint::Length(4),
945 Constraint::Length(11),
946 ],
947 )
948 .header(header)
949 .column_spacing(1);
950 frame.render_widget(table, strategy_chunks[0]);
951 frame.render_widget(
952 Paragraph::new(vec![
953 Line::from(vec![
954 Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
955 Span::styled(
956 selected_symbol,
957 Style::default()
958 .fg(Color::Green)
959 .add_modifier(Modifier::BOLD),
960 ),
961 Span::styled(" ([H/L] or [←/→])", Style::default().fg(Color::DarkGray)),
962 ]),
963 Line::from(Span::styled(
964 "Use [N] new, [C] config, [J/K] strategy, [H/L] symbol, [Enter/F] run, [G/Esc] close.",
965 Style::default().fg(Color::DarkGray),
966 )),
967 ]),
968 strategy_chunks[1],
969 );
970
971 let heat = format!(
972 "Risk/Rate Heatmap global {}/{} | orders {}/{} | account {}/{} | mkt {}/{}",
973 state.rate_budget_global.used,
974 state.rate_budget_global.limit,
975 state.rate_budget_orders.used,
976 state.rate_budget_orders.limit,
977 state.rate_budget_account.used,
978 state.rate_budget_account.limit,
979 state.rate_budget_market_data.used,
980 state.rate_budget_market_data.limit
981 );
982 frame.render_widget(
983 Paragraph::new(vec![
984 Line::from(Span::styled(
985 "Risk/Rate Heatmap",
986 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
987 )),
988 Line::from(heat),
989 ]),
990 chunks[2],
991 );
992
993 let mut rejection_lines = vec![Line::from(Span::styled(
994 "Rejection Stream",
995 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
996 ))];
997 let recent_rejections: Vec<&String> = state
998 .log_messages
999 .iter()
1000 .filter(|m| m.contains("[ERR] Rejected"))
1001 .rev()
1002 .take(20)
1003 .collect();
1004 for msg in recent_rejections.into_iter().rev() {
1005 rejection_lines.push(Line::from(Span::styled(
1006 msg.as_str(),
1007 Style::default().fg(Color::Red),
1008 )));
1009 }
1010 if rejection_lines.len() == 1 {
1011 rejection_lines.push(Line::from(Span::styled(
1012 "(no rejections yet)",
1013 Style::default().fg(Color::DarkGray),
1014 )));
1015 }
1016 frame.render_widget(Paragraph::new(rejection_lines), chunks[3]);
1017}
1018
1019fn render_strategy_editor_popup(frame: &mut Frame, state: &AppState) {
1020 let area = frame.area();
1021 let popup = Rect {
1022 x: area.x + 8,
1023 y: area.y + 4,
1024 width: area.width.saturating_sub(16).max(50),
1025 height: area.height.saturating_sub(8).max(12),
1026 };
1027 frame.render_widget(Clear, popup);
1028 let block = Block::default()
1029 .title(" Strategy Config ")
1030 .borders(Borders::ALL)
1031 .border_style(Style::default().fg(Color::Yellow));
1032 let inner = block.inner(popup);
1033 frame.render_widget(block, popup);
1034 let selected_name = state
1035 .strategy_items
1036 .get(state.strategy_editor_index)
1037 .map(String::as_str)
1038 .unwrap_or("Unknown");
1039 let rows = [
1040 (
1041 "Symbol",
1042 state
1043 .symbol_items
1044 .get(state.strategy_editor_symbol_index)
1045 .cloned()
1046 .unwrap_or_else(|| state.symbol.clone()),
1047 ),
1048 ("Fast Period", state.strategy_editor_fast.to_string()),
1049 ("Slow Period", state.strategy_editor_slow.to_string()),
1050 ("Cooldown Tick", state.strategy_editor_cooldown.to_string()),
1051 ];
1052 let mut lines = vec![
1053 Line::from(vec![
1054 Span::styled("Target: ", Style::default().fg(Color::DarkGray)),
1055 Span::styled(
1056 selected_name,
1057 Style::default()
1058 .fg(Color::White)
1059 .add_modifier(Modifier::BOLD),
1060 ),
1061 ]),
1062 Line::from(Span::styled(
1063 "Use [J/K] field, [H/L] value, [Enter] save+apply symbol, [Esc] cancel",
1064 Style::default().fg(Color::DarkGray),
1065 )),
1066 ];
1067 for (idx, (name, value)) in rows.iter().enumerate() {
1068 let marker = if idx == state.strategy_editor_field {
1069 "▶ "
1070 } else {
1071 " "
1072 };
1073 let style = if idx == state.strategy_editor_field {
1074 Style::default()
1075 .fg(Color::Yellow)
1076 .add_modifier(Modifier::BOLD)
1077 } else {
1078 Style::default().fg(Color::White)
1079 };
1080 lines.push(Line::from(vec![
1081 Span::styled(marker, Style::default().fg(Color::Yellow)),
1082 Span::styled(format!("{:<14}", name), style),
1083 Span::styled(value, style),
1084 ]));
1085 }
1086 frame.render_widget(Paragraph::new(lines), inner);
1087}
1088
1089fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
1090 let area = frame.area();
1091 let popup = Rect {
1092 x: area.x + 4,
1093 y: area.y + 2,
1094 width: area.width.saturating_sub(8).max(30),
1095 height: area.height.saturating_sub(4).max(10),
1096 };
1097 frame.render_widget(Clear, popup);
1098 let block = Block::default()
1099 .title(" Account Assets ")
1100 .borders(Borders::ALL)
1101 .border_style(Style::default().fg(Color::Cyan));
1102 let inner = block.inner(popup);
1103 frame.render_widget(block, popup);
1104
1105 let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
1106 assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
1107
1108 let mut lines = Vec::with_capacity(assets.len() + 2);
1109 lines.push(Line::from(vec![
1110 Span::styled(
1111 "Asset",
1112 Style::default()
1113 .fg(Color::Cyan)
1114 .add_modifier(Modifier::BOLD),
1115 ),
1116 Span::styled(
1117 " Free",
1118 Style::default()
1119 .fg(Color::Cyan)
1120 .add_modifier(Modifier::BOLD),
1121 ),
1122 ]));
1123 for (asset, qty) in assets {
1124 lines.push(Line::from(vec![
1125 Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
1126 Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
1127 ]));
1128 }
1129 if lines.len() == 1 {
1130 lines.push(Line::from(Span::styled(
1131 "No assets",
1132 Style::default().fg(Color::DarkGray),
1133 )));
1134 }
1135
1136 frame.render_widget(Paragraph::new(lines), inner);
1137}
1138
1139fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
1140 let area = frame.area();
1141 let popup = Rect {
1142 x: area.x + 2,
1143 y: area.y + 1,
1144 width: area.width.saturating_sub(4).max(40),
1145 height: area.height.saturating_sub(2).max(12),
1146 };
1147 frame.render_widget(Clear, popup);
1148 let block = Block::default()
1149 .title(match bucket {
1150 order_store::HistoryBucket::Day => " History (Day ROI) ",
1151 order_store::HistoryBucket::Hour => " History (Hour ROI) ",
1152 order_store::HistoryBucket::Month => " History (Month ROI) ",
1153 })
1154 .borders(Borders::ALL)
1155 .border_style(Style::default().fg(Color::Cyan));
1156 let inner = block.inner(popup);
1157 frame.render_widget(block, popup);
1158
1159 let max_rows = inner.height.saturating_sub(1) as usize;
1160 let mut visible: Vec<Line> = Vec::new();
1161 for (idx, row) in rows.iter().take(max_rows).enumerate() {
1162 let color = if idx == 0 {
1163 Color::Cyan
1164 } else if row.contains('-') && row.contains('%') {
1165 Color::White
1166 } else {
1167 Color::DarkGray
1168 };
1169 visible.push(Line::from(Span::styled(
1170 row.clone(),
1171 Style::default().fg(color),
1172 )));
1173 }
1174 if visible.is_empty() {
1175 visible.push(Line::from(Span::styled(
1176 "No history rows",
1177 Style::default().fg(Color::DarkGray),
1178 )));
1179 }
1180 frame.render_widget(Paragraph::new(visible), inner);
1181}
1182
1183fn render_selector_popup(
1184 frame: &mut Frame,
1185 title: &str,
1186 items: &[String],
1187 selected: usize,
1188 stats: Option<&HashMap<String, OrderHistoryStats>>,
1189 total_stats: Option<OrderHistoryStats>,
1190 selected_symbol: Option<&str>,
1191) {
1192 let area = frame.area();
1193 let available_width = area.width.saturating_sub(2).max(1);
1194 let width = if stats.is_some() {
1195 let min_width = 44;
1196 let preferred = 84;
1197 preferred
1198 .min(available_width)
1199 .max(min_width.min(available_width))
1200 } else {
1201 let min_width = 24;
1202 let preferred = 48;
1203 preferred
1204 .min(available_width)
1205 .max(min_width.min(available_width))
1206 };
1207 let available_height = area.height.saturating_sub(2).max(1);
1208 let desired_height = if stats.is_some() {
1209 items.len() as u16 + 7
1210 } else {
1211 items.len() as u16 + 4
1212 };
1213 let height = desired_height
1214 .min(available_height)
1215 .max(6.min(available_height));
1216 let popup = Rect {
1217 x: area.x + (area.width.saturating_sub(width)) / 2,
1218 y: area.y + (area.height.saturating_sub(height)) / 2,
1219 width,
1220 height,
1221 };
1222
1223 frame.render_widget(Clear, popup);
1224 let block = Block::default()
1225 .title(title)
1226 .borders(Borders::ALL)
1227 .border_style(Style::default().fg(Color::Cyan));
1228 let inner = block.inner(popup);
1229 frame.render_widget(block, popup);
1230
1231 let mut lines: Vec<Line> = Vec::new();
1232 if stats.is_some() {
1233 if let Some(symbol) = selected_symbol {
1234 lines.push(Line::from(vec![
1235 Span::styled(" Symbol: ", Style::default().fg(Color::DarkGray)),
1236 Span::styled(
1237 symbol,
1238 Style::default()
1239 .fg(Color::Green)
1240 .add_modifier(Modifier::BOLD),
1241 ),
1242 ]));
1243 }
1244 lines.push(Line::from(vec![Span::styled(
1245 " Strategy W L T PnL",
1246 Style::default()
1247 .fg(Color::Cyan)
1248 .add_modifier(Modifier::BOLD),
1249 )]));
1250 }
1251
1252 let mut item_lines: Vec<Line> = items
1253 .iter()
1254 .enumerate()
1255 .map(|(idx, item)| {
1256 let item_text = if let Some(stats_map) = stats {
1257 if let Some(s) = strategy_stats_for_item(stats_map, item) {
1258 format!(
1259 "{:<16} W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
1260 item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
1261 )
1262 } else {
1263 format!("{:<16} W:0 L:0 T:0 PnL:0.0000", item)
1264 }
1265 } else {
1266 item.clone()
1267 };
1268 if idx == selected {
1269 Line::from(vec![
1270 Span::styled("▶ ", Style::default().fg(Color::Yellow)),
1271 Span::styled(
1272 item_text,
1273 Style::default()
1274 .fg(Color::White)
1275 .add_modifier(Modifier::BOLD),
1276 ),
1277 ])
1278 } else {
1279 Line::from(vec![
1280 Span::styled(" ", Style::default()),
1281 Span::styled(item_text, Style::default().fg(Color::DarkGray)),
1282 ])
1283 }
1284 })
1285 .collect();
1286 lines.append(&mut item_lines);
1287 if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
1288 let mut strategy_sum = OrderHistoryStats::default();
1289 for item in items {
1290 if let Some(s) = strategy_stats_for_item(stats_map, item) {
1291 strategy_sum.trade_count += s.trade_count;
1292 strategy_sum.win_count += s.win_count;
1293 strategy_sum.lose_count += s.lose_count;
1294 strategy_sum.realized_pnl += s.realized_pnl;
1295 }
1296 }
1297 let manual = subtract_stats(t, &strategy_sum);
1298 lines.push(Line::from(vec![Span::styled(
1299 format!(
1300 " MANUAL(rest) W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
1301 manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
1302 ),
1303 Style::default().fg(Color::LightBlue),
1304 )]));
1305 }
1306 if let Some(t) = total_stats {
1307 lines.push(Line::from(vec![Span::styled(
1308 format!(
1309 " TOTAL W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
1310 t.win_count, t.lose_count, t.trade_count, t.realized_pnl
1311 ),
1312 Style::default()
1313 .fg(Color::Yellow)
1314 .add_modifier(Modifier::BOLD),
1315 )]));
1316 }
1317
1318 frame.render_widget(
1319 Paragraph::new(lines).style(Style::default().fg(Color::White)),
1320 inner,
1321 );
1322}
1323
1324fn strategy_stats_for_item<'a>(
1325 stats_map: &'a HashMap<String, OrderHistoryStats>,
1326 item: &str,
1327) -> Option<&'a OrderHistoryStats> {
1328 if let Some(s) = stats_map.get(item) {
1329 return Some(s);
1330 }
1331 let source_tag = source_tag_for_strategy_item(item);
1332 source_tag.and_then(|tag| {
1333 stats_map
1334 .get(&tag)
1335 .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
1336 })
1337}
1338
1339fn source_tag_for_strategy_item(item: &str) -> Option<String> {
1340 match item {
1341 "MA(Config)" => return Some("cfg".to_string()),
1342 "MA(Fast 5/20)" => return Some("fst".to_string()),
1343 "MA(Slow 20/60)" => return Some("slw".to_string()),
1344 _ => {}
1345 }
1346 if let Some((_, tail)) = item.rsplit_once('[') {
1347 if let Some(tag) = tail.strip_suffix(']') {
1348 let tag = tag.trim();
1349 if !tag.is_empty() {
1350 return Some(tag.to_ascii_lowercase());
1351 }
1352 }
1353 }
1354 None
1355}
1356
1357fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
1358 OrderHistoryStats {
1359 trade_count: total.trade_count.saturating_sub(used.trade_count),
1360 win_count: total.win_count.saturating_sub(used.win_count),
1361 lose_count: total.lose_count.saturating_sub(used.lose_count),
1362 realized_pnl: total.realized_pnl - used.realized_pnl,
1363 }
1364}
1365
1366fn split_symbol_assets(symbol: &str) -> (String, String) {
1367 const QUOTE_SUFFIXES: [&str; 10] = [
1368 "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
1369 ];
1370 for q in QUOTE_SUFFIXES {
1371 if let Some(base) = symbol.strip_suffix(q) {
1372 if !base.is_empty() {
1373 return (base.to_string(), q.to_string());
1374 }
1375 }
1376 }
1377 (symbol.to_string(), String::new())
1378}
1379
1380fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
1381 if fills.is_empty() {
1382 return None;
1383 }
1384 let (base_asset, quote_asset) = split_symbol_assets(symbol);
1385 let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
1386 let mut notional_quote = 0.0;
1387 let mut fee_quote_equiv = 0.0;
1388 let mut quote_convertible = !quote_asset.is_empty();
1389
1390 for f in fills {
1391 if f.qty > 0.0 && f.price > 0.0 {
1392 notional_quote += f.qty * f.price;
1393 }
1394 if f.commission <= 0.0 {
1395 continue;
1396 }
1397 *fee_by_asset.entry(f.commission_asset.clone()).or_insert(0.0) += f.commission;
1398 if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case("e_asset) {
1399 fee_quote_equiv += f.commission;
1400 } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
1401 fee_quote_equiv += f.commission * f.price.max(0.0);
1402 } else {
1403 quote_convertible = false;
1404 }
1405 }
1406
1407 if fee_by_asset.is_empty() {
1408 return Some("0".to_string());
1409 }
1410
1411 if quote_convertible && notional_quote > f64::EPSILON {
1412 let fee_pct = fee_quote_equiv / notional_quote * 100.0;
1413 return Some(format!(
1414 "{:.3}% ({:.4} {})",
1415 fee_pct, fee_quote_equiv, quote_asset
1416 ));
1417 }
1418
1419 let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
1420 items.sort_by(|a, b| a.0.cmp(&b.0));
1421 if items.len() == 1 {
1422 let (asset, amount) = &items[0];
1423 Some(format!("{:.6} {}", amount, asset))
1424 } else {
1425 Some(format!("mixed fees ({})", items.len()))
1426 }
1427}
1428
1429#[cfg(test)]
1430mod tests {
1431 use super::format_last_applied_fee;
1432 use crate::model::order::Fill;
1433
1434 #[test]
1435 fn fee_summary_from_quote_asset_commission() {
1436 let fills = vec![Fill {
1437 price: 2000.0,
1438 qty: 0.5,
1439 commission: 1.0,
1440 commission_asset: "USDT".to_string(),
1441 }];
1442 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
1443 assert_eq!(summary, "0.100% (1.0000 USDT)");
1444 }
1445
1446 #[test]
1447 fn fee_summary_from_base_asset_commission() {
1448 let fills = vec![Fill {
1449 price: 2000.0,
1450 qty: 0.5,
1451 commission: 0.0005,
1452 commission_asset: "ETH".to_string(),
1453 }];
1454 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
1455 assert_eq!(summary, "0.100% (1.0000 USDT)");
1456 }
1457}