Skip to main content

sandbox_quant/ui/
mod.rs

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    /// Get the latest price (from current candle or last finalized candle).
197    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                // Keep recent dates only to avoid horizontal overflow in terminal.
227                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        // Fallback: if timestamp is newer than the latest finalized candle range
307        // (e.g. coarse timeframe like 1M and no in-progress bucket), pin to nearest past candle.
308        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                // Aggregate tick into candles
348                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                            // Shift marker indices when oldest candle is trimmed.
358                            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                    // Defensive fallback: avoid panic if tick ordering/state gets out of sync.
377                    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                        // Keep position panel aligned with exchange history state
546                        // so Qty/Entry/UnrPL reflect actual holdings, not only session fills.
547                        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), // status bar
608            Constraint::Min(8),    // main area (chart + position)
609            Constraint::Length(5), // order log
610            Constraint::Length(6), // order history
611            Constraint::Length(8), // system log
612            Constraint::Length(1), // keybinds
613        ])
614        .split(frame.area());
615
616    // Status bar
617    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    // Main area: chart + position panel
633    let main_area = Layout::default()
634        .direction(Direction::Horizontal)
635        .constraints([Constraint::Min(40), Constraint::Length(24)])
636        .split(outer[1]);
637
638    // Price chart (candles + in-progress candle)
639    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    // Position panel (with current price and balances)
650    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    // Order log
665    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    // Order history panel
680    frame.render_widget(
681        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
682        outer[3],
683    );
684
685    // System log panel
686    frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
687
688    // Keybind bar
689    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(&quote_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}