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