Skip to main content

sandbox_quant/ui/
mod.rs

1pub mod ui_projection;
2pub mod chart;
3pub mod dashboard;
4pub mod network_metrics;
5
6use std::collections::HashMap;
7
8use ratatui::layout::{Constraint, Direction, Layout, Rect};
9use ratatui::style::{Color, Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
12use ratatui::Frame;
13
14use crate::event::{AppEvent, AssetPnlEntry, LogDomain, LogLevel, LogRecord, WsConnectionStatus};
15use crate::model::candle::{Candle, CandleBuilder};
16use crate::model::order::{Fill, OrderSide};
17use crate::model::position::Position;
18use crate::model::signal::Signal;
19use crate::order_manager::{OrderHistoryFill, OrderHistoryStats, OrderUpdate};
20use crate::order_store;
21use crate::risk_module::RateBudgetSnapshot;
22use crate::ui::network_metrics::{classify_health, count_since, percentile, rate_per_sec, ratio_pct, NetworkHealth};
23
24use ui_projection::UiProjection;
25use ui_projection::AssetEntry;
26use chart::{FillMarker, PriceChart};
27use dashboard::{KeybindBar, LogPanel, OrderHistoryPanel, OrderLogPanel, PositionPanel, StatusBar, StrategyMetricsPanel};
28
29const MAX_LOG_MESSAGES: usize = 200;
30const MAX_FILL_MARKERS: usize = 200;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum GridTab {
34    Assets,
35    Strategies,
36    Risk,
37    Network,
38    SystemLog,
39}
40
41#[derive(Debug, Clone)]
42pub struct StrategyLastEvent {
43    pub side: OrderSide,
44    pub price: Option<f64>,
45    pub timestamp_ms: u64,
46    pub is_filled: bool,
47}
48
49#[derive(Debug, Clone)]
50pub struct ViewState {
51    pub is_grid_open: bool,
52    pub selected_grid_tab: GridTab,
53    pub selected_symbol_index: usize,
54    pub selected_strategy_index: usize,
55    pub is_on_panel_selected: bool,
56    pub is_symbol_selector_open: bool,
57    pub selected_symbol_selector_index: usize,
58    pub is_strategy_selector_open: bool,
59    pub selected_strategy_selector_index: usize,
60    pub is_account_popup_open: bool,
61    pub is_history_popup_open: bool,
62    pub is_focus_popup_open: bool,
63    pub is_strategy_editor_open: bool,
64}
65
66pub struct AppState {
67    pub symbol: String,
68    pub strategy_label: String,
69    pub candles: Vec<Candle>,
70    pub current_candle: Option<CandleBuilder>,
71    pub candle_interval_ms: u64,
72    pub timeframe: String,
73    pub price_history_len: usize,
74    pub position: Position,
75    pub last_signal: Option<Signal>,
76    pub last_order: Option<OrderUpdate>,
77    pub open_order_history: Vec<String>,
78    pub filled_order_history: Vec<String>,
79    pub fast_sma: Option<f64>,
80    pub slow_sma: Option<f64>,
81    pub ws_connected: bool,
82    pub paused: bool,
83    pub tick_count: u64,
84    pub log_messages: Vec<String>,
85    pub log_records: Vec<LogRecord>,
86    pub balances: HashMap<String, f64>,
87    pub initial_equity_usdt: Option<f64>,
88    pub current_equity_usdt: Option<f64>,
89    pub history_estimated_total_pnl_usdt: Option<f64>,
90    pub fill_markers: Vec<FillMarker>,
91    pub history_trade_count: u32,
92    pub history_win_count: u32,
93    pub history_lose_count: u32,
94    pub history_realized_pnl: f64,
95    pub asset_pnl_by_symbol: HashMap<String, AssetPnlEntry>,
96    pub strategy_stats: HashMap<String, OrderHistoryStats>,
97    pub history_fills: Vec<OrderHistoryFill>,
98    pub last_price_update_ms: Option<u64>,
99    pub last_price_event_ms: Option<u64>,
100    pub last_price_latency_ms: Option<u64>,
101    pub last_order_history_update_ms: Option<u64>,
102    pub last_order_history_event_ms: Option<u64>,
103    pub last_order_history_latency_ms: Option<u64>,
104    pub trade_stats_reset_warned: bool,
105    pub symbol_selector_open: bool,
106    pub symbol_selector_index: usize,
107    pub symbol_items: Vec<String>,
108    pub strategy_selector_open: bool,
109    pub strategy_selector_index: usize,
110    pub strategy_items: Vec<String>,
111    pub strategy_item_symbols: Vec<String>,
112    pub strategy_item_active: Vec<bool>,
113    pub strategy_item_created_at_ms: Vec<i64>,
114    pub strategy_item_total_running_ms: Vec<u64>,
115    pub account_popup_open: bool,
116    pub history_popup_open: bool,
117    pub focus_popup_open: bool,
118    pub strategy_editor_open: bool,
119    pub strategy_editor_index: usize,
120    pub strategy_editor_field: usize,
121    pub strategy_editor_symbol_index: usize,
122    pub strategy_editor_fast: usize,
123    pub strategy_editor_slow: usize,
124    pub strategy_editor_cooldown: u64,
125    pub grid_symbol_index: usize,
126    pub grid_strategy_index: usize,
127    pub grid_select_on_panel: bool,
128    pub grid_tab: GridTab,
129    pub strategy_last_event_by_tag: HashMap<String, StrategyLastEvent>,
130    pub network_tick_drop_count: u64,
131    pub network_reconnect_count: u64,
132    pub network_tick_latencies_ms: Vec<u64>,
133    pub network_fill_latencies_ms: Vec<u64>,
134    pub network_order_sync_latencies_ms: Vec<u64>,
135    pub network_tick_in_timestamps_ms: Vec<u64>,
136    pub network_tick_drop_timestamps_ms: Vec<u64>,
137    pub network_reconnect_timestamps_ms: Vec<u64>,
138    pub network_disconnect_timestamps_ms: Vec<u64>,
139    pub network_last_fill_ms: Option<u64>,
140    pub network_pending_submit_ms_by_intent: HashMap<String, u64>,
141    pub history_rows: Vec<String>,
142    pub history_bucket: order_store::HistoryBucket,
143    pub last_applied_fee: String,
144    pub grid_open: bool,
145    pub ui_projection: UiProjection,
146    pub rate_budget_global: RateBudgetSnapshot,
147    pub rate_budget_orders: RateBudgetSnapshot,
148    pub rate_budget_account: RateBudgetSnapshot,
149    pub rate_budget_market_data: RateBudgetSnapshot,
150}
151
152impl AppState {
153    pub fn new(
154        symbol: &str,
155        strategy_label: &str,
156        price_history_len: usize,
157        candle_interval_ms: u64,
158        timeframe: &str,
159    ) -> Self {
160        Self {
161            symbol: symbol.to_string(),
162            strategy_label: strategy_label.to_string(),
163            candles: Vec::with_capacity(price_history_len),
164            current_candle: None,
165            candle_interval_ms,
166            timeframe: timeframe.to_string(),
167            price_history_len,
168            position: Position::new(symbol.to_string()),
169            last_signal: None,
170            last_order: None,
171            open_order_history: Vec::new(),
172            filled_order_history: Vec::new(),
173            fast_sma: None,
174            slow_sma: None,
175            ws_connected: false,
176            paused: false,
177            tick_count: 0,
178            log_messages: Vec::new(),
179            log_records: Vec::new(),
180            balances: HashMap::new(),
181            initial_equity_usdt: None,
182            current_equity_usdt: None,
183            history_estimated_total_pnl_usdt: None,
184            fill_markers: Vec::new(),
185            history_trade_count: 0,
186            history_win_count: 0,
187            history_lose_count: 0,
188            history_realized_pnl: 0.0,
189            asset_pnl_by_symbol: HashMap::new(),
190            strategy_stats: HashMap::new(),
191            history_fills: Vec::new(),
192            last_price_update_ms: None,
193            last_price_event_ms: None,
194            last_price_latency_ms: None,
195            last_order_history_update_ms: None,
196            last_order_history_event_ms: None,
197            last_order_history_latency_ms: None,
198            trade_stats_reset_warned: false,
199            symbol_selector_open: false,
200            symbol_selector_index: 0,
201            symbol_items: Vec::new(),
202            strategy_selector_open: false,
203            strategy_selector_index: 0,
204            strategy_items: vec![
205                "MA(Config)".to_string(),
206                "MA(Fast 5/20)".to_string(),
207                "MA(Slow 20/60)".to_string(),
208            ],
209            strategy_item_symbols: vec![
210                symbol.to_ascii_uppercase(),
211                symbol.to_ascii_uppercase(),
212                symbol.to_ascii_uppercase(),
213            ],
214            strategy_item_active: vec![false, false, false],
215            strategy_item_created_at_ms: vec![0, 0, 0],
216            strategy_item_total_running_ms: vec![0, 0, 0],
217            account_popup_open: false,
218            history_popup_open: false,
219            focus_popup_open: false,
220            strategy_editor_open: false,
221            strategy_editor_index: 0,
222            strategy_editor_field: 0,
223            strategy_editor_symbol_index: 0,
224            strategy_editor_fast: 5,
225            strategy_editor_slow: 20,
226            strategy_editor_cooldown: 1,
227            grid_symbol_index: 0,
228            grid_strategy_index: 0,
229            grid_select_on_panel: true,
230            grid_tab: GridTab::Strategies,
231            strategy_last_event_by_tag: HashMap::new(),
232            network_tick_drop_count: 0,
233            network_reconnect_count: 0,
234            network_tick_latencies_ms: Vec::new(),
235            network_fill_latencies_ms: Vec::new(),
236            network_order_sync_latencies_ms: Vec::new(),
237            network_tick_in_timestamps_ms: Vec::new(),
238            network_tick_drop_timestamps_ms: Vec::new(),
239            network_reconnect_timestamps_ms: Vec::new(),
240            network_disconnect_timestamps_ms: Vec::new(),
241            network_last_fill_ms: None,
242            network_pending_submit_ms_by_intent: HashMap::new(),
243            history_rows: Vec::new(),
244            history_bucket: order_store::HistoryBucket::Day,
245            last_applied_fee: "---".to_string(),
246            grid_open: false,
247            ui_projection: UiProjection::new(),
248            rate_budget_global: RateBudgetSnapshot {
249                used: 0,
250                limit: 0,
251                reset_in_ms: 0,
252            },
253            rate_budget_orders: RateBudgetSnapshot {
254                used: 0,
255                limit: 0,
256                reset_in_ms: 0,
257            },
258            rate_budget_account: RateBudgetSnapshot {
259                used: 0,
260                limit: 0,
261                reset_in_ms: 0,
262            },
263            rate_budget_market_data: RateBudgetSnapshot {
264                used: 0,
265                limit: 0,
266                reset_in_ms: 0,
267            },
268        }
269    }
270
271    /// Get the latest price (from current candle or last finalized candle).
272    pub fn last_price(&self) -> Option<f64> {
273        self.current_candle
274            .as_ref()
275            .map(|cb| cb.close)
276            .or_else(|| self.candles.last().map(|c| c.close))
277    }
278
279    pub fn push_log(&mut self, msg: String) {
280        self.log_messages.push(msg);
281        if self.log_messages.len() > MAX_LOG_MESSAGES {
282            self.log_messages.remove(0);
283        }
284    }
285
286    pub fn push_log_record(&mut self, record: LogRecord) {
287        self.log_records.push(record.clone());
288        if self.log_records.len() > MAX_LOG_MESSAGES {
289            self.log_records.remove(0);
290        }
291        self.push_log(format_log_record_compact(&record));
292    }
293
294    fn push_latency_sample(samples: &mut Vec<u64>, value: u64) {
295        const MAX_SAMPLES: usize = 200;
296        samples.push(value);
297        if samples.len() > MAX_SAMPLES {
298            let drop_n = samples.len() - MAX_SAMPLES;
299            samples.drain(..drop_n);
300        }
301    }
302
303    fn push_network_event_sample(samples: &mut Vec<u64>, ts_ms: u64) {
304        samples.push(ts_ms);
305        let lower = ts_ms.saturating_sub(60_000);
306        samples.retain(|&v| v >= lower);
307    }
308
309    fn prune_network_event_windows(&mut self, now_ms: u64) {
310        let lower = now_ms.saturating_sub(60_000);
311        self.network_tick_in_timestamps_ms.retain(|&v| v >= lower);
312        self.network_tick_drop_timestamps_ms.retain(|&v| v >= lower);
313        self.network_reconnect_timestamps_ms.retain(|&v| v >= lower);
314        self.network_disconnect_timestamps_ms.retain(|&v| v >= lower);
315    }
316
317    /// Transitional projection for RFC-0016 Phase 2.
318    /// Keeps runtime behavior unchanged while exposing normalized naming.
319    pub fn view_state(&self) -> ViewState {
320        ViewState {
321            is_grid_open: self.grid_open,
322            selected_grid_tab: self.grid_tab,
323            selected_symbol_index: self.grid_symbol_index,
324            selected_strategy_index: self.grid_strategy_index,
325            is_on_panel_selected: self.grid_select_on_panel,
326            is_symbol_selector_open: self.symbol_selector_open,
327            selected_symbol_selector_index: self.symbol_selector_index,
328            is_strategy_selector_open: self.strategy_selector_open,
329            selected_strategy_selector_index: self.strategy_selector_index,
330            is_account_popup_open: self.account_popup_open,
331            is_history_popup_open: self.history_popup_open,
332            is_focus_popup_open: self.focus_popup_open,
333            is_strategy_editor_open: self.strategy_editor_open,
334        }
335    }
336
337    pub fn is_grid_open(&self) -> bool {
338        self.grid_open
339    }
340    pub fn set_grid_open(&mut self, open: bool) {
341        self.grid_open = open;
342    }
343    pub fn grid_tab(&self) -> GridTab {
344        self.grid_tab
345    }
346    pub fn set_grid_tab(&mut self, tab: GridTab) {
347        self.grid_tab = tab;
348    }
349    pub fn selected_grid_symbol_index(&self) -> usize {
350        self.grid_symbol_index
351    }
352    pub fn set_selected_grid_symbol_index(&mut self, idx: usize) {
353        self.grid_symbol_index = idx;
354    }
355    pub fn selected_grid_strategy_index(&self) -> usize {
356        self.grid_strategy_index
357    }
358    pub fn set_selected_grid_strategy_index(&mut self, idx: usize) {
359        self.grid_strategy_index = idx;
360    }
361    pub fn is_on_panel_selected(&self) -> bool {
362        self.grid_select_on_panel
363    }
364    pub fn set_on_panel_selected(&mut self, selected: bool) {
365        self.grid_select_on_panel = selected;
366    }
367    pub fn is_symbol_selector_open(&self) -> bool {
368        self.symbol_selector_open
369    }
370    pub fn set_symbol_selector_open(&mut self, open: bool) {
371        self.symbol_selector_open = open;
372    }
373    pub fn symbol_selector_index(&self) -> usize {
374        self.symbol_selector_index
375    }
376    pub fn set_symbol_selector_index(&mut self, idx: usize) {
377        self.symbol_selector_index = idx;
378    }
379    pub fn is_strategy_selector_open(&self) -> bool {
380        self.strategy_selector_open
381    }
382    pub fn set_strategy_selector_open(&mut self, open: bool) {
383        self.strategy_selector_open = open;
384    }
385    pub fn strategy_selector_index(&self) -> usize {
386        self.strategy_selector_index
387    }
388    pub fn set_strategy_selector_index(&mut self, idx: usize) {
389        self.strategy_selector_index = idx;
390    }
391    pub fn is_account_popup_open(&self) -> bool {
392        self.account_popup_open
393    }
394    pub fn set_account_popup_open(&mut self, open: bool) {
395        self.account_popup_open = open;
396    }
397    pub fn is_history_popup_open(&self) -> bool {
398        self.history_popup_open
399    }
400    pub fn set_history_popup_open(&mut self, open: bool) {
401        self.history_popup_open = open;
402    }
403    pub fn is_focus_popup_open(&self) -> bool {
404        self.focus_popup_open
405    }
406    pub fn set_focus_popup_open(&mut self, open: bool) {
407        self.focus_popup_open = open;
408    }
409    pub fn is_strategy_editor_open(&self) -> bool {
410        self.strategy_editor_open
411    }
412    pub fn set_strategy_editor_open(&mut self, open: bool) {
413        self.strategy_editor_open = open;
414    }
415    pub fn focus_symbol(&self) -> Option<&str> {
416        self.ui_projection.focus.symbol.as_deref()
417    }
418    pub fn focus_strategy_id(&self) -> Option<&str> {
419        self.ui_projection.focus.strategy_id.as_deref()
420    }
421    pub fn set_focus_symbol(&mut self, symbol: Option<String>) {
422        self.ui_projection.focus.symbol = symbol;
423    }
424    pub fn set_focus_strategy_id(&mut self, strategy_id: Option<String>) {
425        self.ui_projection.focus.strategy_id = strategy_id;
426    }
427    pub fn focus_pair(&self) -> (Option<String>, Option<String>) {
428        (
429            self.ui_projection.focus.symbol.clone(),
430            self.ui_projection.focus.strategy_id.clone(),
431        )
432    }
433    pub fn assets_view(&self) -> &[AssetEntry] {
434        &self.ui_projection.assets
435    }
436
437    pub fn refresh_history_rows(&mut self) {
438        match order_store::load_realized_returns_by_bucket(self.history_bucket, 400) {
439            Ok(rows) => {
440                use std::collections::{BTreeMap, BTreeSet};
441
442                let mut date_set: BTreeSet<String> = BTreeSet::new();
443                let mut ticker_map: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
444                for row in rows {
445                    date_set.insert(row.date.clone());
446                    ticker_map
447                        .entry(row.symbol.clone())
448                        .or_default()
449                        .insert(row.date, row.realized_return_pct);
450                }
451
452                // Keep recent dates only to avoid horizontal overflow in terminal.
453                let mut dates: Vec<String> = date_set.into_iter().collect();
454                dates.sort();
455                const MAX_DATE_COLS: usize = 6;
456                if dates.len() > MAX_DATE_COLS {
457                    dates = dates[dates.len() - MAX_DATE_COLS..].to_vec();
458                }
459
460                let mut lines = Vec::new();
461                if dates.is_empty() {
462                    lines.push("Ticker            (no daily realized roi data)".to_string());
463                    self.history_rows = lines;
464                    return;
465                }
466
467                let mut header = format!("{:<14}", "Ticker");
468                for d in &dates {
469                    header.push_str(&format!(" {:>10}", d));
470                }
471                lines.push(header);
472
473                for (ticker, by_date) in ticker_map {
474                    let mut line = format!("{:<14}", ticker);
475                    for d in &dates {
476                        let cell = by_date
477                            .get(d)
478                            .map(|v| format!("{:.2}%", v))
479                            .unwrap_or_else(|| "-".to_string());
480                        line.push_str(&format!(" {:>10}", cell));
481                    }
482                    lines.push(line);
483                }
484                self.history_rows = lines;
485            }
486            Err(e) => {
487                self.history_rows = vec![
488                    "Ticker           Date         RealizedROI   RealizedPnL".to_string(),
489                    format!("(failed to load history: {})", e),
490                ];
491            }
492        }
493    }
494
495    fn refresh_equity_usdt(&mut self) {
496        let usdt = self.balances.get("USDT").copied().unwrap_or(0.0);
497        let btc = self.balances.get("BTC").copied().unwrap_or(0.0);
498        let mark_price = self
499            .last_price()
500            .or_else(|| (self.position.entry_price > 0.0).then_some(self.position.entry_price));
501        if let Some(price) = mark_price {
502            let total = usdt + btc * price;
503            self.current_equity_usdt = Some(total);
504            self.recompute_initial_equity_from_history();
505        }
506    }
507
508    fn recompute_initial_equity_from_history(&mut self) {
509        if let Some(current) = self.current_equity_usdt {
510            if let Some(total_pnl) = self.history_estimated_total_pnl_usdt {
511                self.initial_equity_usdt = Some(current - total_pnl);
512            } else if self.history_trade_count == 0 && self.initial_equity_usdt.is_none() {
513                self.initial_equity_usdt = Some(current);
514            }
515        }
516    }
517
518    fn candle_index_for_timestamp(&self, timestamp_ms: u64) -> Option<usize> {
519        if let Some((idx, _)) = self
520            .candles
521            .iter()
522            .enumerate()
523            .find(|(_, c)| timestamp_ms >= c.open_time && timestamp_ms < c.close_time)
524        {
525            return Some(idx);
526        }
527        if let Some(cb) = &self.current_candle {
528            if cb.contains(timestamp_ms) {
529                return Some(self.candles.len());
530            }
531        }
532        // Fallback: if timestamp is newer than the latest finalized candle range
533        // (e.g. coarse timeframe like 1M and no in-progress bucket), pin to nearest past candle.
534        if let Some((idx, _)) = self
535            .candles
536            .iter()
537            .enumerate()
538            .rev()
539            .find(|(_, c)| c.open_time <= timestamp_ms)
540        {
541            return Some(idx);
542        }
543        None
544    }
545
546    fn rebuild_fill_markers_from_history(&mut self, fills: &[OrderHistoryFill]) {
547        self.fill_markers.clear();
548        for fill in fills {
549            if let Some(candle_index) = self.candle_index_for_timestamp(fill.timestamp_ms) {
550                self.fill_markers.push(FillMarker {
551                    candle_index,
552                    price: fill.price,
553                    side: fill.side,
554                });
555            }
556        }
557        if self.fill_markers.len() > MAX_FILL_MARKERS {
558            let excess = self.fill_markers.len() - MAX_FILL_MARKERS;
559            self.fill_markers.drain(..excess);
560        }
561    }
562
563    fn sync_projection_portfolio_summary(&mut self) {
564        self.ui_projection.portfolio.total_equity_usdt = self.current_equity_usdt;
565        self.ui_projection.portfolio.total_realized_pnl_usdt = self.history_realized_pnl;
566        self.ui_projection.portfolio.total_unrealized_pnl_usdt = self.position.unrealized_pnl;
567        self.ui_projection.portfolio.ws_connected = self.ws_connected;
568    }
569
570    fn ensure_projection_focus_defaults(&mut self) {
571        if self.ui_projection.focus.symbol.is_none() {
572            self.ui_projection.focus.symbol = Some(self.symbol.clone());
573        }
574        if self.ui_projection.focus.strategy_id.is_none() {
575            self.ui_projection.focus.strategy_id = Some(self.strategy_label.clone());
576        }
577    }
578
579    fn rebuild_projection_preserve_focus(&mut self, prev_focus: (Option<String>, Option<String>)) {
580        let mut next = UiProjection::from_legacy(self);
581        if prev_focus.0.is_some() {
582            next.focus.symbol = prev_focus.0;
583        }
584        if prev_focus.1.is_some() {
585            next.focus.strategy_id = prev_focus.1;
586        }
587        self.ui_projection = next;
588        self.ensure_projection_focus_defaults();
589    }
590
591    pub fn apply(&mut self, event: AppEvent) {
592        let prev_focus = self.focus_pair();
593        let mut rebuild_projection = false;
594        match event {
595            AppEvent::MarketTick(tick) => {
596                rebuild_projection = true;
597                self.tick_count += 1;
598                let now_ms = chrono::Utc::now().timestamp_millis() as u64;
599                self.last_price_update_ms = Some(now_ms);
600                self.last_price_event_ms = Some(tick.timestamp_ms);
601                self.last_price_latency_ms = Some(now_ms.saturating_sub(tick.timestamp_ms));
602                Self::push_network_event_sample(&mut self.network_tick_in_timestamps_ms, now_ms);
603                if let Some(lat) = self.last_price_latency_ms {
604                    Self::push_latency_sample(&mut self.network_tick_latencies_ms, lat);
605                }
606
607                // Aggregate tick into candles
608                let should_new = match &self.current_candle {
609                    Some(cb) => !cb.contains(tick.timestamp_ms),
610                    None => true,
611                };
612                if should_new {
613                    if let Some(cb) = self.current_candle.take() {
614                        self.candles.push(cb.finish());
615                        if self.candles.len() > self.price_history_len {
616                            self.candles.remove(0);
617                            // Shift marker indices when oldest candle is trimmed.
618                            self.fill_markers.retain_mut(|m| {
619                                if m.candle_index == 0 {
620                                    false
621                                } else {
622                                    m.candle_index -= 1;
623                                    true
624                                }
625                            });
626                        }
627                    }
628                    self.current_candle = Some(CandleBuilder::new(
629                        tick.price,
630                        tick.timestamp_ms,
631                        self.candle_interval_ms,
632                    ));
633                } else if let Some(cb) = self.current_candle.as_mut() {
634                    cb.update(tick.price);
635                } else {
636                    // Defensive fallback: avoid panic if tick ordering/state gets out of sync.
637                    self.current_candle = Some(CandleBuilder::new(
638                        tick.price,
639                        tick.timestamp_ms,
640                        self.candle_interval_ms,
641                    ));
642                    self.push_log("[WARN] Recovered missing current candle state".to_string());
643                }
644
645                self.position.update_unrealized_pnl(tick.price);
646                self.refresh_equity_usdt();
647            }
648            AppEvent::StrategySignal {
649                ref signal,
650                symbol,
651                source_tag,
652                price,
653                timestamp_ms,
654            } => {
655                self.last_signal = Some(signal.clone());
656                let source_tag = source_tag.to_ascii_lowercase();
657                match signal {
658                    Signal::Buy { .. } => {
659                        let should_emit = self
660                            .strategy_last_event_by_tag
661                            .get(&source_tag)
662                            .map(|e| e.side != OrderSide::Buy || timestamp_ms.saturating_sub(e.timestamp_ms) >= 1000)
663                            .unwrap_or(true);
664                        if should_emit {
665                            let mut record = LogRecord::new(
666                                LogLevel::Info,
667                                LogDomain::Strategy,
668                                "signal.emit",
669                                format!(
670                                    "side=BUY price={}",
671                                    price
672                                        .map(|v| format!("{:.4}", v))
673                                        .unwrap_or_else(|| "-".to_string())
674                                ),
675                            );
676                            record.symbol = Some(symbol.clone());
677                            record.strategy_tag = Some(source_tag.clone());
678                            self.push_log_record(record);
679                        }
680                        self.strategy_last_event_by_tag.insert(
681                            source_tag.clone(),
682                            StrategyLastEvent {
683                                side: OrderSide::Buy,
684                                price,
685                                timestamp_ms,
686                                is_filled: false,
687                            },
688                        );
689                    }
690                    Signal::Sell { .. } => {
691                        let should_emit = self
692                            .strategy_last_event_by_tag
693                            .get(&source_tag)
694                            .map(|e| e.side != OrderSide::Sell || timestamp_ms.saturating_sub(e.timestamp_ms) >= 1000)
695                            .unwrap_or(true);
696                        if should_emit {
697                            let mut record = LogRecord::new(
698                                LogLevel::Info,
699                                LogDomain::Strategy,
700                                "signal.emit",
701                                format!(
702                                    "side=SELL price={}",
703                                    price
704                                        .map(|v| format!("{:.4}", v))
705                                        .unwrap_or_else(|| "-".to_string())
706                                ),
707                            );
708                            record.symbol = Some(symbol.clone());
709                            record.strategy_tag = Some(source_tag.clone());
710                            self.push_log_record(record);
711                        }
712                        self.strategy_last_event_by_tag.insert(
713                            source_tag.clone(),
714                            StrategyLastEvent {
715                                side: OrderSide::Sell,
716                                price,
717                                timestamp_ms,
718                                is_filled: false,
719                            },
720                        );
721                    }
722                    Signal::Hold => {}
723                }
724            }
725            AppEvent::StrategyState { fast_sma, slow_sma } => {
726                self.fast_sma = fast_sma;
727                self.slow_sma = slow_sma;
728            }
729            AppEvent::OrderUpdate(ref update) => {
730                rebuild_projection = true;
731                match update {
732                    OrderUpdate::Filled {
733                        intent_id,
734                        client_order_id,
735                        side,
736                        fills,
737                        avg_price,
738                    } => {
739                        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
740                        let source_tag = parse_source_tag_from_client_order_id(client_order_id)
741                            .map(|s| s.to_ascii_lowercase());
742                        if let Some(submit_ms) = self.network_pending_submit_ms_by_intent.remove(intent_id)
743                        {
744                            Self::push_latency_sample(
745                                &mut self.network_fill_latencies_ms,
746                                now_ms.saturating_sub(submit_ms),
747                            );
748                        } else if let Some(signal_ms) = source_tag
749                            .as_deref()
750                            .and_then(|tag| self.strategy_last_event_by_tag.get(tag))
751                            .map(|e| e.timestamp_ms)
752                        {
753                            // Fallback for immediate-fill paths where Submitted is not emitted.
754                            Self::push_latency_sample(
755                                &mut self.network_fill_latencies_ms,
756                                now_ms.saturating_sub(signal_ms),
757                            );
758                        }
759                        self.network_last_fill_ms = Some(now_ms);
760                        if let Some(source_tag) = source_tag {
761                            self.strategy_last_event_by_tag.insert(
762                                source_tag,
763                                StrategyLastEvent {
764                                    side: *side,
765                                    price: Some(*avg_price),
766                                    timestamp_ms: now_ms,
767                                    is_filled: true,
768                                },
769                            );
770                        }
771                        if let Some(summary) = format_last_applied_fee(&self.symbol, fills) {
772                            self.last_applied_fee = summary;
773                        }
774                        self.position.apply_fill(*side, fills);
775                        self.refresh_equity_usdt();
776                        let candle_index = if self.current_candle.is_some() {
777                            self.candles.len()
778                        } else {
779                            self.candles.len().saturating_sub(1)
780                        };
781                        self.fill_markers.push(FillMarker {
782                            candle_index,
783                            price: *avg_price,
784                            side: *side,
785                        });
786                        if self.fill_markers.len() > MAX_FILL_MARKERS {
787                            self.fill_markers.remove(0);
788                        }
789                        let mut record = LogRecord::new(
790                            LogLevel::Info,
791                            LogDomain::Order,
792                            "fill.received",
793                            format!(
794                                "side={} client_order_id={} intent_id={} avg_price={:.2}",
795                                side, client_order_id, intent_id, avg_price
796                            ),
797                        );
798                        record.symbol = Some(self.symbol.clone());
799                        record.strategy_tag =
800                            parse_source_tag_from_client_order_id(client_order_id).map(|s| s.to_ascii_lowercase());
801                        self.push_log_record(record);
802                    }
803                    OrderUpdate::Submitted {
804                        intent_id,
805                        client_order_id,
806                        server_order_id,
807                    } => {
808                        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
809                        self.network_pending_submit_ms_by_intent
810                            .insert(intent_id.clone(), now_ms);
811                        self.refresh_equity_usdt();
812                        let mut record = LogRecord::new(
813                            LogLevel::Info,
814                            LogDomain::Order,
815                            "submit.accepted",
816                            format!(
817                                "client_order_id={} server_order_id={} intent_id={}",
818                                client_order_id, server_order_id, intent_id
819                            ),
820                        );
821                        record.symbol = Some(self.symbol.clone());
822                        record.strategy_tag =
823                            parse_source_tag_from_client_order_id(client_order_id).map(|s| s.to_ascii_lowercase());
824                        self.push_log_record(record);
825                    }
826                    OrderUpdate::Rejected {
827                        intent_id,
828                        client_order_id,
829                        reason_code,
830                        reason,
831                    } => {
832                        let mut record = LogRecord::new(
833                            LogLevel::Error,
834                            LogDomain::Order,
835                            "reject.received",
836                            format!(
837                                "client_order_id={} intent_id={} reason_code={} reason={}",
838                                client_order_id, intent_id, reason_code, reason
839                            ),
840                        );
841                        record.symbol = Some(self.symbol.clone());
842                        record.strategy_tag =
843                            parse_source_tag_from_client_order_id(client_order_id).map(|s| s.to_ascii_lowercase());
844                        self.push_log_record(record);
845                    }
846                }
847                self.last_order = Some(update.clone());
848            }
849            AppEvent::WsStatus(ref status) => match status {
850                WsConnectionStatus::Connected => {
851                    self.ws_connected = true;
852                }
853                WsConnectionStatus::Disconnected => {
854                    self.ws_connected = false;
855                    let now_ms = chrono::Utc::now().timestamp_millis() as u64;
856                    Self::push_network_event_sample(&mut self.network_disconnect_timestamps_ms, now_ms);
857                    self.push_log("[WARN] WebSocket Disconnected".to_string());
858                }
859                WsConnectionStatus::Reconnecting { attempt, delay_ms } => {
860                    self.ws_connected = false;
861                    self.network_reconnect_count += 1;
862                    let now_ms = chrono::Utc::now().timestamp_millis() as u64;
863                    Self::push_network_event_sample(&mut self.network_reconnect_timestamps_ms, now_ms);
864                    self.push_log(format!(
865                        "[WARN] Reconnecting (attempt {}, wait {}ms)",
866                        attempt, delay_ms
867                    ));
868                }
869            },
870            AppEvent::HistoricalCandles {
871                candles,
872                interval_ms,
873                interval,
874            } => {
875                rebuild_projection = true;
876                self.candles = candles;
877                if self.candles.len() > self.price_history_len {
878                    let excess = self.candles.len() - self.price_history_len;
879                    self.candles.drain(..excess);
880                }
881                self.candle_interval_ms = interval_ms;
882                self.timeframe = interval;
883                self.current_candle = None;
884                let fills = self.history_fills.clone();
885                self.rebuild_fill_markers_from_history(&fills);
886                self.push_log(format!(
887                    "Switched to {} ({} candles)",
888                    self.timeframe,
889                    self.candles.len()
890                ));
891            }
892            AppEvent::BalanceUpdate(balances) => {
893                rebuild_projection = true;
894                self.balances = balances;
895                self.refresh_equity_usdt();
896            }
897            AppEvent::OrderHistoryUpdate(snapshot) => {
898                rebuild_projection = true;
899                let mut open = Vec::new();
900                let mut filled = Vec::new();
901
902                for row in snapshot.rows {
903                    let status = row.split_whitespace().nth(1).unwrap_or_default();
904                    if status == "FILLED" {
905                        filled.push(row);
906                    } else {
907                        open.push(row);
908                    }
909                }
910
911                if open.len() > MAX_LOG_MESSAGES {
912                    let excess = open.len() - MAX_LOG_MESSAGES;
913                    open.drain(..excess);
914                }
915                if filled.len() > MAX_LOG_MESSAGES {
916                    let excess = filled.len() - MAX_LOG_MESSAGES;
917                    filled.drain(..excess);
918                }
919
920                self.open_order_history = open;
921                self.filled_order_history = filled;
922                if snapshot.trade_data_complete {
923                    let stats_looks_reset = snapshot.stats.trade_count == 0
924                        && (self.history_trade_count > 0 || !self.history_fills.is_empty());
925                    if stats_looks_reset {
926                        if !self.trade_stats_reset_warned {
927                            self.push_log(
928                                "[WARN] Ignored transient trade stats reset from order-history sync"
929                                    .to_string(),
930                            );
931                            self.trade_stats_reset_warned = true;
932                        }
933                    } else {
934                        self.trade_stats_reset_warned = false;
935                        self.history_trade_count = snapshot.stats.trade_count;
936                        self.history_win_count = snapshot.stats.win_count;
937                        self.history_lose_count = snapshot.stats.lose_count;
938                        self.history_realized_pnl = snapshot.stats.realized_pnl;
939                        // Keep position panel aligned with exchange history state
940                        // so Qty/Entry/UnrPL reflect actual holdings, not only session fills.
941                        if snapshot.open_qty > f64::EPSILON {
942                            self.position.side = Some(OrderSide::Buy);
943                            self.position.qty = snapshot.open_qty;
944                            self.position.entry_price = snapshot.open_entry_price;
945                            if let Some(px) = self.last_price() {
946                                self.position.unrealized_pnl =
947                                    (px - snapshot.open_entry_price) * snapshot.open_qty;
948                            }
949                        } else {
950                            self.position.side = None;
951                            self.position.qty = 0.0;
952                            self.position.entry_price = 0.0;
953                            self.position.unrealized_pnl = 0.0;
954                        }
955                    }
956                    if !snapshot.fills.is_empty() || self.history_fills.is_empty() {
957                        self.history_fills = snapshot.fills.clone();
958                        self.rebuild_fill_markers_from_history(&snapshot.fills);
959                    }
960                    self.history_estimated_total_pnl_usdt = snapshot.estimated_total_pnl_usdt;
961                    self.recompute_initial_equity_from_history();
962                }
963                self.last_order_history_update_ms = Some(snapshot.fetched_at_ms);
964                self.last_order_history_event_ms = snapshot.latest_event_ms;
965                self.last_order_history_latency_ms = Some(snapshot.fetch_latency_ms);
966                Self::push_latency_sample(
967                    &mut self.network_order_sync_latencies_ms,
968                    snapshot.fetch_latency_ms,
969                );
970                self.refresh_history_rows();
971            }
972            AppEvent::StrategyStatsUpdate { strategy_stats } => {
973                rebuild_projection = true;
974                self.strategy_stats = strategy_stats;
975            }
976            AppEvent::AssetPnlUpdate { by_symbol } => {
977                rebuild_projection = true;
978                self.asset_pnl_by_symbol = by_symbol;
979            }
980            AppEvent::RiskRateSnapshot {
981                global,
982                orders,
983                account,
984                market_data,
985            } => {
986                self.rate_budget_global = global;
987                self.rate_budget_orders = orders;
988                self.rate_budget_account = account;
989                self.rate_budget_market_data = market_data;
990            }
991            AppEvent::TickDropped => {
992                self.network_tick_drop_count = self.network_tick_drop_count.saturating_add(1);
993                let now_ms = chrono::Utc::now().timestamp_millis() as u64;
994                Self::push_network_event_sample(&mut self.network_tick_drop_timestamps_ms, now_ms);
995            }
996            AppEvent::LogRecord(record) => {
997                self.push_log_record(record);
998            }
999            AppEvent::LogMessage(msg) => {
1000                self.push_log(msg);
1001            }
1002            AppEvent::Error(msg) => {
1003                self.push_log(format!("[ERR] {}", msg));
1004            }
1005        }
1006        self.prune_network_event_windows(chrono::Utc::now().timestamp_millis() as u64);
1007        self.sync_projection_portfolio_summary();
1008        if rebuild_projection {
1009            self.rebuild_projection_preserve_focus(prev_focus);
1010        } else {
1011            self.ensure_projection_focus_defaults();
1012        }
1013    }
1014}
1015
1016pub fn render(frame: &mut Frame, state: &AppState) {
1017    let view = state.view_state();
1018    if view.is_grid_open {
1019        render_grid_popup(frame, state);
1020        if view.is_strategy_editor_open {
1021            render_strategy_editor_popup(frame, state);
1022        }
1023        return;
1024    }
1025
1026    let outer = Layout::default()
1027        .direction(Direction::Vertical)
1028        .constraints([
1029            Constraint::Length(1), // status bar
1030            Constraint::Min(8),    // main area (chart + position)
1031            Constraint::Length(5), // order log
1032            Constraint::Length(6), // order history
1033            Constraint::Length(8), // system log
1034            Constraint::Length(1), // keybinds
1035        ])
1036        .split(frame.area());
1037
1038    // Status bar
1039    frame.render_widget(
1040        StatusBar {
1041            symbol: &state.symbol,
1042            strategy_label: &state.strategy_label,
1043            ws_connected: state.ws_connected,
1044            paused: state.paused,
1045            timeframe: &state.timeframe,
1046            last_price_update_ms: state.last_price_update_ms,
1047            last_price_latency_ms: state.last_price_latency_ms,
1048            last_order_history_update_ms: state.last_order_history_update_ms,
1049            last_order_history_latency_ms: state.last_order_history_latency_ms,
1050        },
1051        outer[0],
1052    );
1053
1054    // Main area: chart + position panel
1055    let main_area = Layout::default()
1056        .direction(Direction::Horizontal)
1057        .constraints([Constraint::Min(40), Constraint::Length(24)])
1058        .split(outer[1]);
1059    let selected_strategy_stats =
1060        strategy_stats_for_item(&state.strategy_stats, &state.strategy_label, &state.symbol)
1061        .cloned()
1062        .unwrap_or_default();
1063
1064    // Price chart (candles + in-progress candle)
1065    let current_price = state.last_price();
1066    frame.render_widget(
1067        PriceChart::new(&state.candles, &state.symbol)
1068            .current_candle(state.current_candle.as_ref())
1069            .fill_markers(&state.fill_markers)
1070            .fast_sma(state.fast_sma)
1071            .slow_sma(state.slow_sma),
1072        main_area[0],
1073    );
1074
1075    // Right panels: Position (symbol scope) + Strategy metrics (strategy scope).
1076    let right_panels = Layout::default()
1077        .direction(Direction::Vertical)
1078        .constraints([Constraint::Min(9), Constraint::Length(8)])
1079        .split(main_area[1]);
1080    frame.render_widget(
1081        PositionPanel::new(
1082            &state.position,
1083            current_price,
1084            &state.last_applied_fee,
1085        ),
1086        right_panels[0],
1087    );
1088    frame.render_widget(
1089        StrategyMetricsPanel::new(
1090            &state.strategy_label,
1091            selected_strategy_stats.trade_count,
1092            selected_strategy_stats.win_count,
1093            selected_strategy_stats.lose_count,
1094            selected_strategy_stats.realized_pnl,
1095        ),
1096        right_panels[1],
1097    );
1098
1099    // Order log
1100    frame.render_widget(
1101        OrderLogPanel::new(
1102            &state.last_signal,
1103            &state.last_order,
1104            state.fast_sma,
1105            state.slow_sma,
1106            selected_strategy_stats.trade_count,
1107            selected_strategy_stats.win_count,
1108            selected_strategy_stats.lose_count,
1109            selected_strategy_stats.realized_pnl,
1110        ),
1111        outer[2],
1112    );
1113
1114    // Order history panel
1115    frame.render_widget(
1116        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1117        outer[3],
1118    );
1119
1120    // System log panel
1121    frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
1122
1123    // Keybind bar
1124    frame.render_widget(KeybindBar, outer[5]);
1125
1126    if view.is_symbol_selector_open {
1127        render_selector_popup(
1128            frame,
1129            " Select Symbol ",
1130            &state.symbol_items,
1131            view.selected_symbol_selector_index,
1132            None,
1133            None,
1134            None,
1135        );
1136    } else if view.is_strategy_selector_open {
1137        let selected_strategy_symbol = state
1138            .strategy_item_symbols
1139            .get(view.selected_strategy_selector_index)
1140            .map(String::as_str)
1141            .unwrap_or(state.symbol.as_str());
1142        render_selector_popup(
1143            frame,
1144            " Select Strategy ",
1145            &state.strategy_items,
1146            view.selected_strategy_selector_index,
1147            Some(&state.strategy_stats),
1148            Some(OrderHistoryStats {
1149                trade_count: state.history_trade_count,
1150                win_count: state.history_win_count,
1151                lose_count: state.history_lose_count,
1152                realized_pnl: state.history_realized_pnl,
1153            }),
1154            Some(selected_strategy_symbol),
1155        );
1156    } else if view.is_account_popup_open {
1157        render_account_popup(frame, &state.balances);
1158    } else if view.is_history_popup_open {
1159        render_history_popup(frame, &state.history_rows, state.history_bucket);
1160    } else if view.is_focus_popup_open {
1161        render_focus_popup(frame, state);
1162    } else if view.is_strategy_editor_open {
1163        render_strategy_editor_popup(frame, state);
1164    }
1165}
1166
1167fn render_focus_popup(frame: &mut Frame, state: &AppState) {
1168    let area = frame.area();
1169    let popup = Rect {
1170        x: area.x + 1,
1171        y: area.y + 1,
1172        width: area.width.saturating_sub(2).max(70),
1173        height: area.height.saturating_sub(2).max(22),
1174    };
1175    frame.render_widget(Clear, popup);
1176    let block = Block::default()
1177        .title(" Focus View (Drill-down) ")
1178        .borders(Borders::ALL)
1179        .border_style(Style::default().fg(Color::Green));
1180    let inner = block.inner(popup);
1181    frame.render_widget(block, popup);
1182
1183    let rows = Layout::default()
1184        .direction(Direction::Vertical)
1185        .constraints([
1186            Constraint::Length(2),
1187            Constraint::Min(8),
1188            Constraint::Length(7),
1189        ])
1190        .split(inner);
1191
1192    let focus_symbol = state.focus_symbol().unwrap_or(&state.symbol);
1193    let focus_strategy = state.focus_strategy_id().unwrap_or(&state.strategy_label);
1194    let focus_strategy_stats = strategy_stats_for_item(
1195        &state.strategy_stats,
1196        focus_strategy,
1197        focus_symbol,
1198    )
1199        .cloned()
1200        .unwrap_or_default();
1201    frame.render_widget(
1202        Paragraph::new(vec![
1203            Line::from(vec![
1204                Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
1205                Span::styled(
1206                    focus_symbol,
1207                    Style::default()
1208                        .fg(Color::Cyan)
1209                        .add_modifier(Modifier::BOLD),
1210                ),
1211                Span::styled("  Strategy: ", Style::default().fg(Color::DarkGray)),
1212                Span::styled(
1213                    focus_strategy,
1214                    Style::default()
1215                        .fg(Color::Magenta)
1216                        .add_modifier(Modifier::BOLD),
1217                ),
1218            ]),
1219            Line::from(Span::styled(
1220                "Reuse legacy chart/position/history widgets. Press [F]/[Esc] to close.",
1221                Style::default().fg(Color::DarkGray),
1222            )),
1223        ]),
1224        rows[0],
1225    );
1226
1227    let main_cols = Layout::default()
1228        .direction(Direction::Horizontal)
1229        .constraints([Constraint::Min(48), Constraint::Length(28)])
1230        .split(rows[1]);
1231
1232    frame.render_widget(
1233        PriceChart::new(&state.candles, focus_symbol)
1234            .current_candle(state.current_candle.as_ref())
1235            .fill_markers(&state.fill_markers)
1236            .fast_sma(state.fast_sma)
1237            .slow_sma(state.slow_sma),
1238        main_cols[0],
1239    );
1240    let focus_right = Layout::default()
1241        .direction(Direction::Vertical)
1242        .constraints([Constraint::Min(8), Constraint::Length(8)])
1243        .split(main_cols[1]);
1244    frame.render_widget(
1245        PositionPanel::new(
1246            &state.position,
1247            state.last_price(),
1248            &state.last_applied_fee,
1249        ),
1250        focus_right[0],
1251    );
1252    frame.render_widget(
1253        StrategyMetricsPanel::new(
1254            focus_strategy,
1255            focus_strategy_stats.trade_count,
1256            focus_strategy_stats.win_count,
1257            focus_strategy_stats.lose_count,
1258            focus_strategy_stats.realized_pnl,
1259        ),
1260        focus_right[1],
1261    );
1262
1263    frame.render_widget(
1264        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1265        rows[2],
1266    );
1267}
1268
1269fn render_grid_popup(frame: &mut Frame, state: &AppState) {
1270    let view = state.view_state();
1271    let area = frame.area();
1272    let popup = area;
1273    frame.render_widget(Clear, popup);
1274    let block = Block::default()
1275        .title(" Portfolio Grid ")
1276        .borders(Borders::ALL)
1277        .border_style(Style::default().fg(Color::Cyan));
1278    let inner = block.inner(popup);
1279    frame.render_widget(block, popup);
1280
1281    let root = Layout::default()
1282        .direction(Direction::Vertical)
1283        .constraints([Constraint::Length(2), Constraint::Min(1)])
1284        .split(inner);
1285    let tab_area = root[0];
1286    let body_area = root[1];
1287
1288    let tab_span = |tab: GridTab, key: &str, label: &str| -> Span<'_> {
1289        let selected = view.selected_grid_tab == tab;
1290        Span::styled(
1291            format!("[{} {}]", key, label),
1292            if selected {
1293                Style::default()
1294                    .fg(Color::Yellow)
1295                    .add_modifier(Modifier::BOLD)
1296            } else {
1297                Style::default().fg(Color::DarkGray)
1298            },
1299        )
1300    };
1301    frame.render_widget(
1302        Paragraph::new(Line::from(vec![
1303            tab_span(GridTab::Assets, "1", "Assets"),
1304            Span::raw(" "),
1305            tab_span(GridTab::Strategies, "2", "Strategies"),
1306            Span::raw(" "),
1307            tab_span(GridTab::Risk, "3", "Risk"),
1308            Span::raw(" "),
1309            tab_span(GridTab::Network, "4", "Network"),
1310            Span::raw(" "),
1311            tab_span(GridTab::SystemLog, "5", "SystemLog"),
1312        ])),
1313        tab_area,
1314    );
1315
1316    let global_pressure =
1317        state.rate_budget_global.used as f64 / (state.rate_budget_global.limit.max(1) as f64);
1318    let orders_pressure =
1319        state.rate_budget_orders.used as f64 / (state.rate_budget_orders.limit.max(1) as f64);
1320    let account_pressure =
1321        state.rate_budget_account.used as f64 / (state.rate_budget_account.limit.max(1) as f64);
1322    let market_pressure = state.rate_budget_market_data.used as f64
1323        / (state.rate_budget_market_data.limit.max(1) as f64);
1324    let max_pressure = global_pressure
1325        .max(orders_pressure)
1326        .max(account_pressure)
1327        .max(market_pressure);
1328    let (risk_label, risk_color) = if max_pressure >= 0.90 {
1329        ("CRIT", Color::Red)
1330    } else if max_pressure >= 0.70 {
1331        ("WARN", Color::Yellow)
1332    } else {
1333        ("OK", Color::Green)
1334    };
1335
1336    if view.selected_grid_tab == GridTab::Assets {
1337        let chunks = Layout::default()
1338            .direction(Direction::Vertical)
1339            .constraints([Constraint::Min(3), Constraint::Length(1)])
1340            .split(body_area);
1341        let asset_header = Row::new(vec![
1342            Cell::from("Symbol"),
1343            Cell::from("Qty"),
1344            Cell::from("Price"),
1345            Cell::from("RlzPnL"),
1346            Cell::from("UnrPnL"),
1347        ])
1348        .style(Style::default().fg(Color::DarkGray));
1349        let mut asset_rows: Vec<Row> = state
1350            .assets_view()
1351            .iter()
1352            .map(|a| {
1353                let price = a
1354                    .last_price
1355                    .map(|v| format!("{:.2}", v))
1356                    .unwrap_or_else(|| "---".to_string());
1357                Row::new(vec![
1358                    Cell::from(a.symbol.clone()),
1359                    Cell::from(format!("{:.5}", a.position_qty)),
1360                    Cell::from(price),
1361                    Cell::from(format!("{:+.4}", a.realized_pnl_usdt)),
1362                    Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
1363                ])
1364            })
1365            .collect();
1366        if asset_rows.is_empty() {
1367            asset_rows.push(
1368                Row::new(vec![
1369                    Cell::from("(no assets)"),
1370                    Cell::from("-"),
1371                    Cell::from("-"),
1372                    Cell::from("-"),
1373                    Cell::from("-"),
1374                ])
1375                .style(Style::default().fg(Color::DarkGray)),
1376            );
1377        }
1378        frame.render_widget(
1379            Table::new(
1380                asset_rows,
1381                [
1382                    Constraint::Length(16),
1383                    Constraint::Length(12),
1384                    Constraint::Length(10),
1385                    Constraint::Length(10),
1386                    Constraint::Length(10),
1387                ],
1388            )
1389            .header(asset_header)
1390            .column_spacing(1)
1391            .block(
1392                Block::default()
1393                    .title(format!(" Assets | Total {} ", state.assets_view().len()))
1394                    .borders(Borders::ALL)
1395                    .border_style(Style::default().fg(Color::DarkGray)),
1396            ),
1397            chunks[0],
1398        );
1399        frame.render_widget(Paragraph::new("[1/2/3] tab  [G/Esc] close"), chunks[1]);
1400        return;
1401    }
1402
1403    if view.selected_grid_tab == GridTab::Risk {
1404        let chunks = Layout::default()
1405            .direction(Direction::Vertical)
1406            .constraints([
1407                Constraint::Length(2),
1408                Constraint::Length(4),
1409                Constraint::Min(3),
1410                Constraint::Length(1),
1411            ])
1412            .split(body_area);
1413        frame.render_widget(
1414            Paragraph::new(Line::from(vec![
1415                Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
1416                Span::styled(
1417                    risk_label,
1418                    Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
1419                ),
1420                Span::styled(
1421                    "  (70%=WARN, 90%=CRIT)",
1422                    Style::default().fg(Color::DarkGray),
1423                ),
1424            ])),
1425            chunks[0],
1426        );
1427        let risk_rows = vec![
1428            Row::new(vec![
1429                Cell::from("GLOBAL"),
1430                Cell::from(format!(
1431                    "{}/{}",
1432                    state.rate_budget_global.used, state.rate_budget_global.limit
1433                )),
1434                Cell::from(format!("{}ms", state.rate_budget_global.reset_in_ms)),
1435            ]),
1436            Row::new(vec![
1437                Cell::from("ORDERS"),
1438                Cell::from(format!(
1439                    "{}/{}",
1440                    state.rate_budget_orders.used, state.rate_budget_orders.limit
1441                )),
1442                Cell::from(format!("{}ms", state.rate_budget_orders.reset_in_ms)),
1443            ]),
1444            Row::new(vec![
1445                Cell::from("ACCOUNT"),
1446                Cell::from(format!(
1447                    "{}/{}",
1448                    state.rate_budget_account.used, state.rate_budget_account.limit
1449                )),
1450                Cell::from(format!("{}ms", state.rate_budget_account.reset_in_ms)),
1451            ]),
1452            Row::new(vec![
1453                Cell::from("MARKET"),
1454                Cell::from(format!(
1455                    "{}/{}",
1456                    state.rate_budget_market_data.used, state.rate_budget_market_data.limit
1457                )),
1458                Cell::from(format!("{}ms", state.rate_budget_market_data.reset_in_ms)),
1459            ]),
1460        ];
1461        frame.render_widget(
1462            Table::new(
1463                risk_rows,
1464                [
1465                    Constraint::Length(10),
1466                    Constraint::Length(16),
1467                    Constraint::Length(12),
1468                ],
1469            )
1470            .header(Row::new(vec![
1471                Cell::from("Group"),
1472                Cell::from("Used/Limit"),
1473                Cell::from("Reset In"),
1474            ]))
1475            .column_spacing(1)
1476            .block(
1477                Block::default()
1478                    .title(" Risk Budgets ")
1479                    .borders(Borders::ALL)
1480                    .border_style(Style::default().fg(Color::DarkGray)),
1481            ),
1482            chunks[1],
1483        );
1484        let recent_rejections: Vec<&String> = state
1485            .log_messages
1486            .iter()
1487            .filter(|m| m.contains("order.reject.received"))
1488            .rev()
1489            .take(20)
1490            .collect();
1491        let mut lines = vec![Line::from(Span::styled(
1492            "Recent Rejections",
1493            Style::default()
1494                .fg(Color::Cyan)
1495                .add_modifier(Modifier::BOLD),
1496        ))];
1497        for msg in recent_rejections.into_iter().rev() {
1498            lines.push(Line::from(Span::styled(
1499                msg.as_str(),
1500                Style::default().fg(Color::Red),
1501            )));
1502        }
1503        if lines.len() == 1 {
1504            lines.push(Line::from(Span::styled(
1505                "(no rejections yet)",
1506                Style::default().fg(Color::DarkGray),
1507            )));
1508        }
1509        frame.render_widget(
1510            Paragraph::new(lines).block(
1511                Block::default()
1512                    .borders(Borders::ALL)
1513                    .border_style(Style::default().fg(Color::DarkGray)),
1514            ),
1515            chunks[2],
1516        );
1517        frame.render_widget(Paragraph::new("[1/2/3/4/5] tab  [G/Esc] close"), chunks[3]);
1518        return;
1519    }
1520
1521    if view.selected_grid_tab == GridTab::Network {
1522        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1523        let tick_in_1s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 1_000);
1524        let tick_in_10s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 10_000);
1525        let tick_in_60s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 60_000);
1526        let tick_drop_1s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 1_000);
1527        let tick_drop_10s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 10_000);
1528        let tick_drop_60s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 60_000);
1529        let reconnect_60s = count_since(&state.network_reconnect_timestamps_ms, now_ms, 60_000);
1530        let disconnect_60s = count_since(&state.network_disconnect_timestamps_ms, now_ms, 60_000);
1531
1532        let tick_in_rate_1s = rate_per_sec(tick_in_1s, 1.0);
1533        let tick_drop_rate_1s = rate_per_sec(tick_drop_1s, 1.0);
1534        let tick_drop_rate_10s = rate_per_sec(tick_drop_10s, 10.0);
1535        let tick_drop_rate_60s = rate_per_sec(tick_drop_60s, 60.0);
1536        let tick_drop_ratio_10s = ratio_pct(tick_drop_10s, tick_in_10s.saturating_add(tick_drop_10s));
1537        let tick_drop_ratio_60s = ratio_pct(tick_drop_60s, tick_in_60s.saturating_add(tick_drop_60s));
1538        let reconnect_rate_60s = reconnect_60s as f64;
1539        let disconnect_rate_60s = disconnect_60s as f64;
1540        let heartbeat_gap_ms = state.last_price_update_ms.map(|ts| now_ms.saturating_sub(ts));
1541        let tick_p95_ms = percentile(&state.network_tick_latencies_ms, 95);
1542        let health = classify_health(
1543            state.ws_connected,
1544            tick_drop_ratio_10s,
1545            reconnect_rate_60s,
1546            tick_p95_ms,
1547            heartbeat_gap_ms,
1548        );
1549        let (health_label, health_color) = match health {
1550            NetworkHealth::Ok => ("OK", Color::Green),
1551            NetworkHealth::Warn => ("WARN", Color::Yellow),
1552            NetworkHealth::Crit => ("CRIT", Color::Red),
1553        };
1554
1555        let chunks = Layout::default()
1556            .direction(Direction::Vertical)
1557            .constraints([
1558                Constraint::Length(2),
1559                Constraint::Min(6),
1560                Constraint::Length(6),
1561                Constraint::Length(1),
1562            ])
1563            .split(body_area);
1564        frame.render_widget(
1565            Paragraph::new(Line::from(vec![
1566                Span::styled("Health: ", Style::default().fg(Color::DarkGray)),
1567                Span::styled(
1568                    health_label,
1569                    Style::default()
1570                        .fg(health_color)
1571                        .add_modifier(Modifier::BOLD),
1572                ),
1573                Span::styled("  WS: ", Style::default().fg(Color::DarkGray)),
1574                Span::styled(
1575                    if state.ws_connected {
1576                        "CONNECTED"
1577                    } else {
1578                        "DISCONNECTED"
1579                    },
1580                    Style::default().fg(if state.ws_connected {
1581                        Color::Green
1582                    } else {
1583                        Color::Red
1584                    }),
1585                ),
1586                Span::styled(
1587                    format!(
1588                        "  in1s={:.1}/s  drop10s={:.2}/s  ratio10s={:.2}%  reconn60s={:.0}/min",
1589                        tick_in_rate_1s, tick_drop_rate_10s, tick_drop_ratio_10s, reconnect_rate_60s
1590                    ),
1591                    Style::default().fg(Color::DarkGray),
1592                ),
1593            ])),
1594            chunks[0],
1595        );
1596
1597        let tick_stats = latency_stats(&state.network_tick_latencies_ms);
1598        let fill_stats = latency_stats(&state.network_fill_latencies_ms);
1599        let sync_stats = latency_stats(&state.network_order_sync_latencies_ms);
1600        let last_fill_age = state
1601            .network_last_fill_ms
1602            .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
1603            .unwrap_or_else(|| "-".to_string());
1604        let rows = vec![
1605            Row::new(vec![
1606                Cell::from("Tick Latency"),
1607                Cell::from(tick_stats.0),
1608                Cell::from(tick_stats.1),
1609                Cell::from(tick_stats.2),
1610                Cell::from(
1611                    state
1612                        .last_price_latency_ms
1613                        .map(|v| format!("{}ms", v))
1614                        .unwrap_or_else(|| "-".to_string()),
1615                ),
1616            ]),
1617            Row::new(vec![
1618                Cell::from("Fill Latency"),
1619                Cell::from(fill_stats.0),
1620                Cell::from(fill_stats.1),
1621                Cell::from(fill_stats.2),
1622                Cell::from(last_fill_age),
1623            ]),
1624            Row::new(vec![
1625                Cell::from("Order Sync"),
1626                Cell::from(sync_stats.0),
1627                Cell::from(sync_stats.1),
1628                Cell::from(sync_stats.2),
1629                Cell::from(
1630                    state
1631                        .last_order_history_latency_ms
1632                        .map(|v| format!("{}ms", v))
1633                        .unwrap_or_else(|| "-".to_string()),
1634                ),
1635            ]),
1636        ];
1637        frame.render_widget(
1638            Table::new(
1639                rows,
1640                [
1641                    Constraint::Length(14),
1642                    Constraint::Length(12),
1643                    Constraint::Length(12),
1644                    Constraint::Length(12),
1645                    Constraint::Length(14),
1646                ],
1647            )
1648            .header(Row::new(vec![
1649                Cell::from("Metric"),
1650                Cell::from("p50"),
1651                Cell::from("p95"),
1652                Cell::from("p99"),
1653                Cell::from("last/age"),
1654            ]))
1655            .column_spacing(1)
1656            .block(
1657                Block::default()
1658                    .title(" Network Metrics ")
1659                    .borders(Borders::ALL)
1660                    .border_style(Style::default().fg(Color::DarkGray)),
1661            ),
1662            chunks[1],
1663        );
1664
1665        let summary_rows = vec![
1666            Row::new(vec![
1667                Cell::from("tick_drop_rate_1s"),
1668                Cell::from(format!("{:.2}/s", tick_drop_rate_1s)),
1669                Cell::from("tick_drop_rate_60s"),
1670                Cell::from(format!("{:.2}/s", tick_drop_rate_60s)),
1671            ]),
1672            Row::new(vec![
1673                Cell::from("drop_ratio_60s"),
1674                Cell::from(format!("{:.2}%", tick_drop_ratio_60s)),
1675                Cell::from("disconnect_rate_60s"),
1676                Cell::from(format!("{:.0}/min", disconnect_rate_60s)),
1677            ]),
1678            Row::new(vec![
1679                Cell::from("last_tick_age"),
1680                Cell::from(
1681                    heartbeat_gap_ms
1682                        .map(format_age_ms)
1683                        .unwrap_or_else(|| "-".to_string()),
1684                ),
1685                Cell::from("last_order_update_age"),
1686                Cell::from(
1687                    state
1688                        .last_order_history_update_ms
1689                        .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
1690                        .unwrap_or_else(|| "-".to_string()),
1691                ),
1692            ]),
1693            Row::new(vec![
1694                Cell::from("tick_drop_total"),
1695                Cell::from(state.network_tick_drop_count.to_string()),
1696                Cell::from("reconnect_total"),
1697                Cell::from(state.network_reconnect_count.to_string()),
1698            ]),
1699        ];
1700        frame.render_widget(
1701            Table::new(
1702                summary_rows,
1703                [
1704                    Constraint::Length(20),
1705                    Constraint::Length(18),
1706                    Constraint::Length(20),
1707                    Constraint::Length(18),
1708                ],
1709            )
1710            .column_spacing(1)
1711            .block(
1712                Block::default()
1713                    .title(" Network Summary ")
1714                    .borders(Borders::ALL)
1715                    .border_style(Style::default().fg(Color::DarkGray)),
1716            ),
1717            chunks[2],
1718        );
1719        frame.render_widget(Paragraph::new("[1/2/3/4/5] tab  [G/Esc] close"), chunks[3]);
1720        return;
1721    }
1722
1723    if view.selected_grid_tab == GridTab::SystemLog {
1724        let chunks = Layout::default()
1725            .direction(Direction::Vertical)
1726            .constraints([Constraint::Min(6), Constraint::Length(1)])
1727            .split(body_area);
1728        let max_rows = chunks[0].height.saturating_sub(2) as usize;
1729        let mut log_rows: Vec<Row> = state
1730            .log_messages
1731            .iter()
1732            .rev()
1733            .take(max_rows.max(1))
1734            .rev()
1735            .map(|line| Row::new(vec![Cell::from(line.clone())]))
1736            .collect();
1737        if log_rows.is_empty() {
1738            log_rows.push(
1739                Row::new(vec![Cell::from("(no system logs yet)")])
1740                    .style(Style::default().fg(Color::DarkGray)),
1741            );
1742        }
1743        frame.render_widget(
1744            Table::new(log_rows, [Constraint::Min(1)])
1745                .header(Row::new(vec![Cell::from("Message")]).style(Style::default().fg(Color::DarkGray)))
1746                .column_spacing(1)
1747                .block(
1748                    Block::default()
1749                        .title(" System Log ")
1750                        .borders(Borders::ALL)
1751                        .border_style(Style::default().fg(Color::DarkGray)),
1752                ),
1753            chunks[0],
1754        );
1755        frame.render_widget(Paragraph::new("[1/2/3/4/5] tab  [G/Esc] close"), chunks[1]);
1756        return;
1757    }
1758
1759    let selected_symbol = state
1760        .symbol_items
1761        .get(view.selected_symbol_index)
1762        .map(String::as_str)
1763        .unwrap_or(state.symbol.as_str());
1764    let strategy_chunks = Layout::default()
1765        .direction(Direction::Vertical)
1766        .constraints([
1767            Constraint::Length(2),
1768            Constraint::Length(3),
1769            Constraint::Min(12),
1770            Constraint::Length(1),
1771        ])
1772        .split(body_area);
1773
1774    let mut on_indices: Vec<usize> = Vec::new();
1775    let mut off_indices: Vec<usize> = Vec::new();
1776    for idx in 0..state.strategy_items.len() {
1777        if state
1778            .strategy_item_active
1779            .get(idx)
1780            .copied()
1781            .unwrap_or(false)
1782        {
1783            on_indices.push(idx);
1784        } else {
1785            off_indices.push(idx);
1786        }
1787    }
1788    let on_weight = on_indices.len().max(1) as u32;
1789    let off_weight = off_indices.len().max(1) as u32;
1790
1791    frame.render_widget(
1792        Paragraph::new(Line::from(vec![
1793            Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
1794            Span::styled(
1795                risk_label,
1796                Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
1797            ),
1798            Span::styled("  GLOBAL ", Style::default().fg(Color::DarkGray)),
1799            Span::styled(
1800                format!(
1801                    "{}/{}",
1802                    state.rate_budget_global.used, state.rate_budget_global.limit
1803                ),
1804                Style::default().fg(if global_pressure >= 0.9 {
1805                    Color::Red
1806                } else if global_pressure >= 0.7 {
1807                    Color::Yellow
1808                } else {
1809                    Color::Cyan
1810                }),
1811            ),
1812            Span::styled("  ORD ", Style::default().fg(Color::DarkGray)),
1813            Span::styled(
1814                format!(
1815                    "{}/{}",
1816                    state.rate_budget_orders.used, state.rate_budget_orders.limit
1817                ),
1818                Style::default().fg(if orders_pressure >= 0.9 {
1819                    Color::Red
1820                } else if orders_pressure >= 0.7 {
1821                    Color::Yellow
1822                } else {
1823                    Color::Cyan
1824                }),
1825            ),
1826            Span::styled("  ACC ", Style::default().fg(Color::DarkGray)),
1827            Span::styled(
1828                format!(
1829                    "{}/{}",
1830                    state.rate_budget_account.used, state.rate_budget_account.limit
1831                ),
1832                Style::default().fg(if account_pressure >= 0.9 {
1833                    Color::Red
1834                } else if account_pressure >= 0.7 {
1835                    Color::Yellow
1836                } else {
1837                    Color::Cyan
1838                }),
1839            ),
1840            Span::styled("  MKT ", Style::default().fg(Color::DarkGray)),
1841            Span::styled(
1842                format!(
1843                    "{}/{}",
1844                    state.rate_budget_market_data.used, state.rate_budget_market_data.limit
1845                ),
1846                Style::default().fg(if market_pressure >= 0.9 {
1847                    Color::Red
1848                } else if market_pressure >= 0.7 {
1849                    Color::Yellow
1850                } else {
1851                    Color::Cyan
1852                }),
1853            ),
1854        ])),
1855        strategy_chunks[0],
1856    );
1857
1858    let strategy_area = strategy_chunks[2];
1859    let min_panel_height: u16 = 6;
1860    let total_height = strategy_area.height;
1861    let (on_height, off_height) = if total_height >= min_panel_height.saturating_mul(2) {
1862        let total_weight = on_weight + off_weight;
1863        let mut on_h =
1864            ((total_height as u32 * on_weight) / total_weight).max(min_panel_height as u32) as u16;
1865        let max_on_h = total_height.saturating_sub(min_panel_height);
1866        if on_h > max_on_h {
1867            on_h = max_on_h;
1868        }
1869        let off_h = total_height.saturating_sub(on_h);
1870        (on_h, off_h)
1871    } else {
1872        let on_h = (total_height / 2).max(1);
1873        let off_h = total_height.saturating_sub(on_h).max(1);
1874        (on_h, off_h)
1875    };
1876    let on_area = Rect {
1877        x: strategy_area.x,
1878        y: strategy_area.y,
1879        width: strategy_area.width,
1880        height: on_height,
1881    };
1882    let off_area = Rect {
1883        x: strategy_area.x,
1884        y: strategy_area.y.saturating_add(on_height),
1885        width: strategy_area.width,
1886        height: off_height,
1887    };
1888
1889    let pnl_sum_for_indices = |indices: &[usize], state: &AppState| -> f64 {
1890        indices
1891            .iter()
1892            .map(|idx| {
1893                let item = state
1894                    .strategy_items
1895                    .get(*idx)
1896                    .map(String::as_str)
1897                    .unwrap_or("-");
1898                let row_symbol = state
1899                    .strategy_item_symbols
1900                    .get(*idx)
1901                    .map(String::as_str)
1902                    .unwrap_or(state.symbol.as_str());
1903                strategy_stats_for_item(&state.strategy_stats, item, row_symbol)
1904                    .map(|s| s.realized_pnl)
1905                    .unwrap_or(0.0)
1906            })
1907            .sum()
1908    };
1909    let on_pnl_sum = pnl_sum_for_indices(&on_indices, state);
1910    let off_pnl_sum = pnl_sum_for_indices(&off_indices, state);
1911    let total_pnl_sum = on_pnl_sum + off_pnl_sum;
1912
1913    let total_row = Row::new(vec![
1914        Cell::from("ON Total"),
1915        Cell::from(on_indices.len().to_string()),
1916        Cell::from(format!("{:+.4}", on_pnl_sum)),
1917        Cell::from("OFF Total"),
1918        Cell::from(off_indices.len().to_string()),
1919        Cell::from(format!("{:+.4}", off_pnl_sum)),
1920        Cell::from("All Total"),
1921        Cell::from(format!("{:+.4}", total_pnl_sum)),
1922    ]);
1923    let total_table = Table::new(
1924        vec![total_row],
1925        [
1926            Constraint::Length(10),
1927            Constraint::Length(5),
1928            Constraint::Length(12),
1929            Constraint::Length(10),
1930            Constraint::Length(5),
1931            Constraint::Length(12),
1932            Constraint::Length(10),
1933            Constraint::Length(12),
1934        ],
1935    )
1936    .column_spacing(1)
1937    .block(
1938        Block::default()
1939            .title(" Total ")
1940            .borders(Borders::ALL)
1941            .border_style(Style::default().fg(Color::DarkGray)),
1942    );
1943    frame.render_widget(total_table, strategy_chunks[1]);
1944
1945    let render_strategy_window = |frame: &mut Frame,
1946                                  area: Rect,
1947                                  title: &str,
1948                                  indices: &[usize],
1949                                  state: &AppState,
1950                                  pnl_sum: f64,
1951                                  selected_panel: bool| {
1952        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1953        let inner_height = area.height.saturating_sub(2);
1954        let row_capacity = inner_height.saturating_sub(1) as usize;
1955        let selected_pos = indices
1956            .iter()
1957            .position(|idx| *idx == view.selected_strategy_index);
1958        let window_start = if row_capacity == 0 {
1959            0
1960        } else if let Some(pos) = selected_pos {
1961            pos.saturating_sub(row_capacity.saturating_sub(1))
1962        } else {
1963            0
1964        };
1965        let window_end = if row_capacity == 0 {
1966            0
1967        } else {
1968            (window_start + row_capacity).min(indices.len())
1969        };
1970        let visible_indices = if indices.is_empty() || row_capacity == 0 {
1971            &indices[0..0]
1972        } else {
1973            &indices[window_start..window_end]
1974        };
1975        let header = Row::new(vec![
1976            Cell::from(" "),
1977            Cell::from("Symbol"),
1978            Cell::from("Strategy"),
1979            Cell::from("Run"),
1980            Cell::from("Last"),
1981            Cell::from("Px"),
1982            Cell::from("Age"),
1983            Cell::from("W"),
1984            Cell::from("L"),
1985            Cell::from("T"),
1986            Cell::from("PnL"),
1987        ])
1988        .style(Style::default().fg(Color::DarkGray));
1989        let mut rows: Vec<Row> = visible_indices
1990            .iter()
1991            .map(|idx| {
1992                let row_symbol = state
1993                    .strategy_item_symbols
1994                    .get(*idx)
1995                    .map(String::as_str)
1996                    .unwrap_or("-");
1997                let item = state
1998                    .strategy_items
1999                    .get(*idx)
2000                    .cloned()
2001                    .unwrap_or_else(|| "-".to_string());
2002                let running = state
2003                    .strategy_item_total_running_ms
2004                    .get(*idx)
2005                    .copied()
2006                    .map(format_running_time)
2007                    .unwrap_or_else(|| "-".to_string());
2008                let stats = strategy_stats_for_item(&state.strategy_stats, &item, row_symbol);
2009                let source_tag = source_tag_for_strategy_item(&item);
2010                let last_evt = source_tag
2011                    .as_ref()
2012                    .and_then(|tag| state.strategy_last_event_by_tag.get(tag));
2013                let (last_label, last_px, last_age, last_style) = if let Some(evt) = last_evt {
2014                    let age = now_ms.saturating_sub(evt.timestamp_ms);
2015                    let age_txt = if age < 1_000 {
2016                        format!("{}ms", age)
2017                    } else if age < 60_000 {
2018                        format!("{}s", age / 1_000)
2019                    } else {
2020                        format!("{}m", age / 60_000)
2021                    };
2022                    let side_txt = match evt.side {
2023                        OrderSide::Buy => "BUY",
2024                        OrderSide::Sell => "SELL",
2025                    };
2026                    let px_txt = evt
2027                        .price
2028                        .map(|v| format!("{:.2}", v))
2029                        .unwrap_or_else(|| "-".to_string());
2030                    let style = match evt.side {
2031                        OrderSide::Buy => Style::default()
2032                            .fg(Color::Green)
2033                            .add_modifier(Modifier::BOLD),
2034                        OrderSide::Sell => {
2035                            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
2036                        }
2037                    };
2038                    (side_txt.to_string(), px_txt, age_txt, style)
2039                } else {
2040                    (
2041                        "-".to_string(),
2042                        "-".to_string(),
2043                        "-".to_string(),
2044                        Style::default().fg(Color::DarkGray),
2045                    )
2046                };
2047                let (w, l, t, pnl) = if let Some(s) = stats {
2048                    (
2049                        s.win_count.to_string(),
2050                        s.lose_count.to_string(),
2051                        s.trade_count.to_string(),
2052                        format!("{:+.4}", s.realized_pnl),
2053                    )
2054                } else {
2055                    (
2056                        "0".to_string(),
2057                        "0".to_string(),
2058                        "0".to_string(),
2059                        "+0.0000".to_string(),
2060                    )
2061                };
2062                let marker = if *idx == view.selected_strategy_index {
2063                    "▶"
2064                } else {
2065                    " "
2066                };
2067                let mut row = Row::new(vec![
2068                    Cell::from(marker),
2069                    Cell::from(row_symbol.to_string()),
2070                    Cell::from(item),
2071                    Cell::from(running),
2072                    Cell::from(last_label).style(last_style),
2073                    Cell::from(last_px),
2074                    Cell::from(last_age),
2075                    Cell::from(w),
2076                    Cell::from(l),
2077                    Cell::from(t),
2078                    Cell::from(pnl),
2079                ]);
2080                if *idx == view.selected_strategy_index {
2081                    row = row.style(
2082                        Style::default()
2083                            .fg(Color::Yellow)
2084                            .add_modifier(Modifier::BOLD),
2085                    );
2086                }
2087                row
2088            })
2089            .collect();
2090
2091        if rows.is_empty() {
2092            rows.push(
2093                Row::new(vec![
2094                    Cell::from(" "),
2095                    Cell::from("-"),
2096                    Cell::from("(empty)"),
2097                    Cell::from("-"),
2098                    Cell::from("-"),
2099                    Cell::from("-"),
2100                    Cell::from("-"),
2101                    Cell::from("-"),
2102                    Cell::from("-"),
2103                    Cell::from("-"),
2104                    Cell::from("-"),
2105                ])
2106                .style(Style::default().fg(Color::DarkGray)),
2107            );
2108        }
2109
2110        let table = Table::new(
2111            rows,
2112            [
2113                Constraint::Length(2),
2114                Constraint::Length(12),
2115                Constraint::Min(14),
2116                Constraint::Length(9),
2117                Constraint::Length(5),
2118                Constraint::Length(9),
2119                Constraint::Length(6),
2120                Constraint::Length(3),
2121                Constraint::Length(3),
2122                Constraint::Length(4),
2123                Constraint::Length(11),
2124            ],
2125        )
2126        .header(header)
2127        .column_spacing(1)
2128        .block(
2129            Block::default()
2130                .title(format!(
2131                    "{} | Total {:+.4} | {}/{}",
2132                    title,
2133                    pnl_sum,
2134                    visible_indices.len(),
2135                    indices.len()
2136                ))
2137                .borders(Borders::ALL)
2138                .border_style(if selected_panel {
2139                    Style::default().fg(Color::Yellow)
2140                } else if risk_label == "CRIT" {
2141                    Style::default().fg(Color::Red)
2142                } else if risk_label == "WARN" {
2143                    Style::default().fg(Color::Yellow)
2144                } else {
2145                    Style::default().fg(Color::DarkGray)
2146                }),
2147        );
2148        frame.render_widget(table, area);
2149    };
2150
2151    render_strategy_window(
2152        frame,
2153        on_area,
2154        " ON Strategies ",
2155        &on_indices,
2156        state,
2157        on_pnl_sum,
2158        view.is_on_panel_selected,
2159    );
2160    render_strategy_window(
2161        frame,
2162        off_area,
2163        " OFF Strategies ",
2164        &off_indices,
2165        state,
2166        off_pnl_sum,
2167        !view.is_on_panel_selected,
2168    );
2169    frame.render_widget(
2170        Paragraph::new(Line::from(vec![
2171            Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
2172            Span::styled(
2173                selected_symbol,
2174                Style::default()
2175                    .fg(Color::Green)
2176                    .add_modifier(Modifier::BOLD),
2177            ),
2178            Span::styled(
2179                "  [1/2/3/4]tab [Tab]panel [N]new [C]cfg [O]on/off [X]del [J/K]strategy [H/L]symbol [Enter/F]run [G/Esc]close",
2180                Style::default().fg(Color::DarkGray),
2181            ),
2182        ])),
2183        strategy_chunks[3],
2184    );
2185}
2186
2187fn format_running_time(total_running_ms: u64) -> String {
2188    let total_sec = total_running_ms / 1000;
2189    let days = total_sec / 86_400;
2190    let hours = (total_sec % 86_400) / 3_600;
2191    let minutes = (total_sec % 3_600) / 60;
2192    if days > 0 {
2193        format!("{}d {:02}h", days, hours)
2194    } else {
2195        format!("{:02}h {:02}m", hours, minutes)
2196    }
2197}
2198
2199fn format_age_ms(age_ms: u64) -> String {
2200    if age_ms < 1_000 {
2201        format!("{}ms", age_ms)
2202    } else if age_ms < 60_000 {
2203        format!("{}s", age_ms / 1_000)
2204    } else {
2205        format!("{}m", age_ms / 60_000)
2206    }
2207}
2208
2209fn latency_stats(samples: &[u64]) -> (String, String, String) {
2210    let p50 = percentile(samples, 50);
2211    let p95 = percentile(samples, 95);
2212    let p99 = percentile(samples, 99);
2213    (
2214        p50.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2215        p95.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2216        p99.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2217    )
2218}
2219
2220fn render_strategy_editor_popup(frame: &mut Frame, state: &AppState) {
2221    let area = frame.area();
2222    let popup = Rect {
2223        x: area.x + 8,
2224        y: area.y + 4,
2225        width: area.width.saturating_sub(16).max(50),
2226        height: area.height.saturating_sub(8).max(12),
2227    };
2228    frame.render_widget(Clear, popup);
2229    let block = Block::default()
2230        .title(" Strategy Config ")
2231        .borders(Borders::ALL)
2232        .border_style(Style::default().fg(Color::Yellow));
2233    let inner = block.inner(popup);
2234    frame.render_widget(block, popup);
2235    let selected_name = state
2236        .strategy_items
2237        .get(state.strategy_editor_index)
2238        .map(String::as_str)
2239        .unwrap_or("Unknown");
2240    let rows = [
2241        (
2242            "Symbol",
2243            state
2244                .symbol_items
2245                .get(state.strategy_editor_symbol_index)
2246                .cloned()
2247                .unwrap_or_else(|| state.symbol.clone()),
2248        ),
2249        ("Fast Period", state.strategy_editor_fast.to_string()),
2250        ("Slow Period", state.strategy_editor_slow.to_string()),
2251        ("Cooldown Tick", state.strategy_editor_cooldown.to_string()),
2252    ];
2253    let mut lines = vec![
2254        Line::from(vec![
2255            Span::styled("Target: ", Style::default().fg(Color::DarkGray)),
2256            Span::styled(
2257                selected_name,
2258                Style::default()
2259                    .fg(Color::White)
2260                    .add_modifier(Modifier::BOLD),
2261            ),
2262        ]),
2263        Line::from(Span::styled(
2264            "Use [J/K] field, [H/L] value, [Enter] save+apply symbol, [Esc] cancel",
2265            Style::default().fg(Color::DarkGray),
2266        )),
2267    ];
2268    for (idx, (name, value)) in rows.iter().enumerate() {
2269        let marker = if idx == state.strategy_editor_field {
2270            "▶ "
2271        } else {
2272            "  "
2273        };
2274        let style = if idx == state.strategy_editor_field {
2275            Style::default()
2276                .fg(Color::Yellow)
2277                .add_modifier(Modifier::BOLD)
2278        } else {
2279            Style::default().fg(Color::White)
2280        };
2281        lines.push(Line::from(vec![
2282            Span::styled(marker, Style::default().fg(Color::Yellow)),
2283            Span::styled(format!("{:<14}", name), style),
2284            Span::styled(value, style),
2285        ]));
2286    }
2287    frame.render_widget(Paragraph::new(lines), inner);
2288}
2289
2290fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
2291    let area = frame.area();
2292    let popup = Rect {
2293        x: area.x + 4,
2294        y: area.y + 2,
2295        width: area.width.saturating_sub(8).max(30),
2296        height: area.height.saturating_sub(4).max(10),
2297    };
2298    frame.render_widget(Clear, popup);
2299    let block = Block::default()
2300        .title(" Account Assets ")
2301        .borders(Borders::ALL)
2302        .border_style(Style::default().fg(Color::Cyan));
2303    let inner = block.inner(popup);
2304    frame.render_widget(block, popup);
2305
2306    let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
2307    assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
2308
2309    let mut lines = Vec::with_capacity(assets.len() + 2);
2310    lines.push(Line::from(vec![
2311        Span::styled(
2312            "Asset",
2313            Style::default()
2314                .fg(Color::Cyan)
2315                .add_modifier(Modifier::BOLD),
2316        ),
2317        Span::styled(
2318            "      Free",
2319            Style::default()
2320                .fg(Color::Cyan)
2321                .add_modifier(Modifier::BOLD),
2322        ),
2323    ]));
2324    for (asset, qty) in assets {
2325        lines.push(Line::from(vec![
2326            Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
2327            Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
2328        ]));
2329    }
2330    if lines.len() == 1 {
2331        lines.push(Line::from(Span::styled(
2332            "No assets",
2333            Style::default().fg(Color::DarkGray),
2334        )));
2335    }
2336
2337    frame.render_widget(Paragraph::new(lines), inner);
2338}
2339
2340fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
2341    let area = frame.area();
2342    let popup = Rect {
2343        x: area.x + 2,
2344        y: area.y + 1,
2345        width: area.width.saturating_sub(4).max(40),
2346        height: area.height.saturating_sub(2).max(12),
2347    };
2348    frame.render_widget(Clear, popup);
2349    let block = Block::default()
2350        .title(match bucket {
2351            order_store::HistoryBucket::Day => " History (Day ROI) ",
2352            order_store::HistoryBucket::Hour => " History (Hour ROI) ",
2353            order_store::HistoryBucket::Month => " History (Month ROI) ",
2354        })
2355        .borders(Borders::ALL)
2356        .border_style(Style::default().fg(Color::Cyan));
2357    let inner = block.inner(popup);
2358    frame.render_widget(block, popup);
2359
2360    let max_rows = inner.height.saturating_sub(1) as usize;
2361    let mut visible: Vec<Line> = Vec::new();
2362    for (idx, row) in rows.iter().take(max_rows).enumerate() {
2363        let color = if idx == 0 {
2364            Color::Cyan
2365        } else if row.contains('-') && row.contains('%') {
2366            Color::White
2367        } else {
2368            Color::DarkGray
2369        };
2370        visible.push(Line::from(Span::styled(
2371            row.clone(),
2372            Style::default().fg(color),
2373        )));
2374    }
2375    if visible.is_empty() {
2376        visible.push(Line::from(Span::styled(
2377            "No history rows",
2378            Style::default().fg(Color::DarkGray),
2379        )));
2380    }
2381    frame.render_widget(Paragraph::new(visible), inner);
2382}
2383
2384fn render_selector_popup(
2385    frame: &mut Frame,
2386    title: &str,
2387    items: &[String],
2388    selected: usize,
2389    stats: Option<&HashMap<String, OrderHistoryStats>>,
2390    total_stats: Option<OrderHistoryStats>,
2391    selected_symbol: Option<&str>,
2392) {
2393    let area = frame.area();
2394    let available_width = area.width.saturating_sub(2).max(1);
2395    let width = if stats.is_some() {
2396        let min_width = 44;
2397        let preferred = 84;
2398        preferred
2399            .min(available_width)
2400            .max(min_width.min(available_width))
2401    } else {
2402        let min_width = 24;
2403        let preferred = 48;
2404        preferred
2405            .min(available_width)
2406            .max(min_width.min(available_width))
2407    };
2408    let available_height = area.height.saturating_sub(2).max(1);
2409    let desired_height = if stats.is_some() {
2410        items.len() as u16 + 7
2411    } else {
2412        items.len() as u16 + 4
2413    };
2414    let height = desired_height
2415        .min(available_height)
2416        .max(6.min(available_height));
2417    let popup = Rect {
2418        x: area.x + (area.width.saturating_sub(width)) / 2,
2419        y: area.y + (area.height.saturating_sub(height)) / 2,
2420        width,
2421        height,
2422    };
2423
2424    frame.render_widget(Clear, popup);
2425    let block = Block::default()
2426        .title(title)
2427        .borders(Borders::ALL)
2428        .border_style(Style::default().fg(Color::Cyan));
2429    let inner = block.inner(popup);
2430    frame.render_widget(block, popup);
2431
2432    let mut lines: Vec<Line> = Vec::new();
2433    if stats.is_some() {
2434        if let Some(symbol) = selected_symbol {
2435            lines.push(Line::from(vec![
2436                Span::styled("  Symbol: ", Style::default().fg(Color::DarkGray)),
2437                Span::styled(
2438                    symbol,
2439                    Style::default()
2440                        .fg(Color::Green)
2441                        .add_modifier(Modifier::BOLD),
2442                ),
2443            ]));
2444        }
2445        lines.push(Line::from(vec![Span::styled(
2446            "  Strategy           W    L    T    PnL",
2447            Style::default()
2448                .fg(Color::Cyan)
2449                .add_modifier(Modifier::BOLD),
2450        )]));
2451    }
2452
2453    let mut item_lines: Vec<Line> = items
2454        .iter()
2455        .enumerate()
2456        .map(|(idx, item)| {
2457            let item_text = if let Some(stats_map) = stats {
2458                let symbol = selected_symbol.unwrap_or("-");
2459                if let Some(s) = strategy_stats_for_item(stats_map, item, symbol) {
2460                    format!(
2461                        "{:<16}  W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
2462                        item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
2463                    )
2464                } else {
2465                    format!("{:<16}  W:0   L:0   T:0   PnL:0.0000", item)
2466                }
2467            } else {
2468                item.clone()
2469            };
2470            if idx == selected {
2471                Line::from(vec![
2472                    Span::styled("▶ ", Style::default().fg(Color::Yellow)),
2473                    Span::styled(
2474                        item_text,
2475                        Style::default()
2476                            .fg(Color::White)
2477                            .add_modifier(Modifier::BOLD),
2478                    ),
2479                ])
2480            } else {
2481                Line::from(vec![
2482                    Span::styled("  ", Style::default()),
2483                    Span::styled(item_text, Style::default().fg(Color::DarkGray)),
2484                ])
2485            }
2486        })
2487        .collect();
2488    lines.append(&mut item_lines);
2489    if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
2490        let mut strategy_sum = OrderHistoryStats::default();
2491        for item in items {
2492            let symbol = selected_symbol.unwrap_or("-");
2493            if let Some(s) = strategy_stats_for_item(stats_map, item, symbol) {
2494                strategy_sum.trade_count += s.trade_count;
2495                strategy_sum.win_count += s.win_count;
2496                strategy_sum.lose_count += s.lose_count;
2497                strategy_sum.realized_pnl += s.realized_pnl;
2498            }
2499        }
2500        let manual = subtract_stats(t, &strategy_sum);
2501        lines.push(Line::from(vec![Span::styled(
2502            format!(
2503                "  MANUAL(rest)       W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
2504                manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
2505            ),
2506            Style::default().fg(Color::LightBlue),
2507        )]));
2508    }
2509    if let Some(t) = total_stats {
2510        lines.push(Line::from(vec![Span::styled(
2511            format!(
2512                "  TOTAL              W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
2513                t.win_count, t.lose_count, t.trade_count, t.realized_pnl
2514            ),
2515            Style::default()
2516                .fg(Color::Yellow)
2517                .add_modifier(Modifier::BOLD),
2518        )]));
2519    }
2520
2521    frame.render_widget(
2522        Paragraph::new(lines).style(Style::default().fg(Color::White)),
2523        inner,
2524    );
2525}
2526
2527fn strategy_stats_for_item<'a>(
2528    stats_map: &'a HashMap<String, OrderHistoryStats>,
2529    item: &str,
2530    symbol: &str,
2531) -> Option<&'a OrderHistoryStats> {
2532    if let Some(source_tag) = source_tag_for_strategy_item(item) {
2533        let scoped = strategy_stats_scope_key(symbol, &source_tag);
2534        if let Some(s) = stats_map.get(&scoped) {
2535            return Some(s);
2536        }
2537    }
2538    if let Some(s) = stats_map.get(item) {
2539        return Some(s);
2540    }
2541    let source_tag = source_tag_for_strategy_item(item);
2542    source_tag.and_then(|tag| {
2543        stats_map
2544            .get(&tag)
2545            .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
2546    })
2547}
2548
2549fn strategy_stats_scope_key(symbol: &str, source_tag: &str) -> String {
2550    format!(
2551        "{}::{}",
2552        symbol.trim().to_ascii_uppercase(),
2553        source_tag.trim().to_ascii_lowercase()
2554    )
2555}
2556
2557fn source_tag_for_strategy_item(item: &str) -> Option<String> {
2558    match item {
2559        "MA(Config)" => return Some("cfg".to_string()),
2560        "MA(Fast 5/20)" => return Some("fst".to_string()),
2561        "MA(Slow 20/60)" => return Some("slw".to_string()),
2562        _ => {}
2563    }
2564    if let Some((_, tail)) = item.rsplit_once('[') {
2565        if let Some(tag) = tail.strip_suffix(']') {
2566            let tag = tag.trim();
2567            if !tag.is_empty() {
2568                return Some(tag.to_ascii_lowercase());
2569            }
2570        }
2571    }
2572    None
2573}
2574
2575fn parse_source_tag_from_client_order_id(client_order_id: &str) -> Option<&str> {
2576    let body = client_order_id.strip_prefix("sq-")?;
2577    let (source_tag, _) = body.split_once('-')?;
2578    if source_tag.is_empty() {
2579        None
2580    } else {
2581        Some(source_tag)
2582    }
2583}
2584
2585fn format_log_record_compact(record: &LogRecord) -> String {
2586    let level = match record.level {
2587        LogLevel::Debug => "DEBUG",
2588        LogLevel::Info => "INFO",
2589        LogLevel::Warn => "WARN",
2590        LogLevel::Error => "ERR",
2591    };
2592    let domain = match record.domain {
2593        LogDomain::Ws => "ws",
2594        LogDomain::Strategy => "strategy",
2595        LogDomain::Risk => "risk",
2596        LogDomain::Order => "order",
2597        LogDomain::Portfolio => "portfolio",
2598        LogDomain::Ui => "ui",
2599        LogDomain::System => "system",
2600    };
2601    let symbol = record.symbol.as_deref().unwrap_or("-");
2602    let strategy = record.strategy_tag.as_deref().unwrap_or("-");
2603    format!(
2604        "[{}] {}.{} {} {} {}",
2605        level, domain, record.event, symbol, strategy, record.msg
2606    )
2607}
2608
2609fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
2610    OrderHistoryStats {
2611        trade_count: total.trade_count.saturating_sub(used.trade_count),
2612        win_count: total.win_count.saturating_sub(used.win_count),
2613        lose_count: total.lose_count.saturating_sub(used.lose_count),
2614        realized_pnl: total.realized_pnl - used.realized_pnl,
2615    }
2616}
2617
2618fn split_symbol_assets(symbol: &str) -> (String, String) {
2619    const QUOTE_SUFFIXES: [&str; 10] = [
2620        "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
2621    ];
2622    for q in QUOTE_SUFFIXES {
2623        if let Some(base) = symbol.strip_suffix(q) {
2624            if !base.is_empty() {
2625                return (base.to_string(), q.to_string());
2626            }
2627        }
2628    }
2629    (symbol.to_string(), String::new())
2630}
2631
2632fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
2633    if fills.is_empty() {
2634        return None;
2635    }
2636    let (base_asset, quote_asset) = split_symbol_assets(symbol);
2637    let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
2638    let mut notional_quote = 0.0;
2639    let mut fee_quote_equiv = 0.0;
2640    let mut quote_convertible = !quote_asset.is_empty();
2641
2642    for f in fills {
2643        if f.qty > 0.0 && f.price > 0.0 {
2644            notional_quote += f.qty * f.price;
2645        }
2646        if f.commission <= 0.0 {
2647            continue;
2648        }
2649        *fee_by_asset
2650            .entry(f.commission_asset.clone())
2651            .or_insert(0.0) += f.commission;
2652        if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&quote_asset) {
2653            fee_quote_equiv += f.commission;
2654        } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
2655            fee_quote_equiv += f.commission * f.price.max(0.0);
2656        } else {
2657            quote_convertible = false;
2658        }
2659    }
2660
2661    if fee_by_asset.is_empty() {
2662        return Some("0".to_string());
2663    }
2664
2665    if quote_convertible && notional_quote > f64::EPSILON {
2666        let fee_pct = fee_quote_equiv / notional_quote * 100.0;
2667        return Some(format!(
2668            "{:.3}% ({:.4} {})",
2669            fee_pct, fee_quote_equiv, quote_asset
2670        ));
2671    }
2672
2673    let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
2674    items.sort_by(|a, b| a.0.cmp(&b.0));
2675    if items.len() == 1 {
2676        let (asset, amount) = &items[0];
2677        Some(format!("{:.6} {}", amount, asset))
2678    } else {
2679        Some(format!("mixed fees ({})", items.len()))
2680    }
2681}
2682
2683#[cfg(test)]
2684mod tests {
2685    use super::format_last_applied_fee;
2686    use crate::model::order::Fill;
2687
2688    #[test]
2689    fn fee_summary_from_quote_asset_commission() {
2690        let fills = vec![Fill {
2691            price: 2000.0,
2692            qty: 0.5,
2693            commission: 1.0,
2694            commission_asset: "USDT".to_string(),
2695        }];
2696        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
2697        assert_eq!(summary, "0.100% (1.0000 USDT)");
2698    }
2699
2700    #[test]
2701    fn fee_summary_from_base_asset_commission() {
2702        let fills = vec![Fill {
2703            price: 2000.0,
2704            qty: 0.5,
2705            commission: 0.0005,
2706            commission_asset: "ETH".to_string(),
2707        }];
2708        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
2709        assert_eq!(summary, "0.100% (1.0000 USDT)");
2710    }
2711}