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