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 = strategy_stats_for_item(&state.strategy_stats, &state.strategy_label)
1060        .cloned()
1061        .unwrap_or_default();
1062
1063    // Price chart (candles + in-progress candle)
1064    let current_price = state.last_price();
1065    frame.render_widget(
1066        PriceChart::new(&state.candles, &state.symbol)
1067            .current_candle(state.current_candle.as_ref())
1068            .fill_markers(&state.fill_markers)
1069            .fast_sma(state.fast_sma)
1070            .slow_sma(state.slow_sma),
1071        main_area[0],
1072    );
1073
1074    // Right panels: Position (symbol scope) + Strategy metrics (strategy scope).
1075    let right_panels = Layout::default()
1076        .direction(Direction::Vertical)
1077        .constraints([Constraint::Min(9), Constraint::Length(8)])
1078        .split(main_area[1]);
1079    frame.render_widget(
1080        PositionPanel::new(
1081            &state.position,
1082            current_price,
1083            &state.last_applied_fee,
1084        ),
1085        right_panels[0],
1086    );
1087    frame.render_widget(
1088        StrategyMetricsPanel::new(
1089            &state.strategy_label,
1090            selected_strategy_stats.trade_count,
1091            selected_strategy_stats.win_count,
1092            selected_strategy_stats.lose_count,
1093            selected_strategy_stats.realized_pnl,
1094        ),
1095        right_panels[1],
1096    );
1097
1098    // Order log
1099    frame.render_widget(
1100        OrderLogPanel::new(
1101            &state.last_signal,
1102            &state.last_order,
1103            state.fast_sma,
1104            state.slow_sma,
1105            selected_strategy_stats.trade_count,
1106            selected_strategy_stats.win_count,
1107            selected_strategy_stats.lose_count,
1108            selected_strategy_stats.realized_pnl,
1109        ),
1110        outer[2],
1111    );
1112
1113    // Order history panel
1114    frame.render_widget(
1115        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1116        outer[3],
1117    );
1118
1119    // System log panel
1120    frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
1121
1122    // Keybind bar
1123    frame.render_widget(KeybindBar, outer[5]);
1124
1125    if view.is_symbol_selector_open {
1126        render_selector_popup(
1127            frame,
1128            " Select Symbol ",
1129            &state.symbol_items,
1130            view.selected_symbol_selector_index,
1131            None,
1132            None,
1133            None,
1134        );
1135    } else if view.is_strategy_selector_open {
1136        let selected_strategy_symbol = state
1137            .strategy_item_symbols
1138            .get(view.selected_strategy_selector_index)
1139            .map(String::as_str)
1140            .unwrap_or(state.symbol.as_str());
1141        render_selector_popup(
1142            frame,
1143            " Select Strategy ",
1144            &state.strategy_items,
1145            view.selected_strategy_selector_index,
1146            Some(&state.strategy_stats),
1147            Some(OrderHistoryStats {
1148                trade_count: state.history_trade_count,
1149                win_count: state.history_win_count,
1150                lose_count: state.history_lose_count,
1151                realized_pnl: state.history_realized_pnl,
1152            }),
1153            Some(selected_strategy_symbol),
1154        );
1155    } else if view.is_account_popup_open {
1156        render_account_popup(frame, &state.balances);
1157    } else if view.is_history_popup_open {
1158        render_history_popup(frame, &state.history_rows, state.history_bucket);
1159    } else if view.is_focus_popup_open {
1160        render_focus_popup(frame, state);
1161    } else if view.is_strategy_editor_open {
1162        render_strategy_editor_popup(frame, state);
1163    }
1164}
1165
1166fn render_focus_popup(frame: &mut Frame, state: &AppState) {
1167    let area = frame.area();
1168    let popup = Rect {
1169        x: area.x + 1,
1170        y: area.y + 1,
1171        width: area.width.saturating_sub(2).max(70),
1172        height: area.height.saturating_sub(2).max(22),
1173    };
1174    frame.render_widget(Clear, popup);
1175    let block = Block::default()
1176        .title(" Focus View (Drill-down) ")
1177        .borders(Borders::ALL)
1178        .border_style(Style::default().fg(Color::Green));
1179    let inner = block.inner(popup);
1180    frame.render_widget(block, popup);
1181
1182    let rows = Layout::default()
1183        .direction(Direction::Vertical)
1184        .constraints([
1185            Constraint::Length(2),
1186            Constraint::Min(8),
1187            Constraint::Length(7),
1188        ])
1189        .split(inner);
1190
1191    let focus_symbol = state.focus_symbol().unwrap_or(&state.symbol);
1192    let focus_strategy = state.focus_strategy_id().unwrap_or(&state.strategy_label);
1193    let focus_strategy_stats = strategy_stats_for_item(&state.strategy_stats, focus_strategy)
1194        .cloned()
1195        .unwrap_or_default();
1196    frame.render_widget(
1197        Paragraph::new(vec![
1198            Line::from(vec![
1199                Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
1200                Span::styled(
1201                    focus_symbol,
1202                    Style::default()
1203                        .fg(Color::Cyan)
1204                        .add_modifier(Modifier::BOLD),
1205                ),
1206                Span::styled("  Strategy: ", Style::default().fg(Color::DarkGray)),
1207                Span::styled(
1208                    focus_strategy,
1209                    Style::default()
1210                        .fg(Color::Magenta)
1211                        .add_modifier(Modifier::BOLD),
1212                ),
1213            ]),
1214            Line::from(Span::styled(
1215                "Reuse legacy chart/position/history widgets. Press [F]/[Esc] to close.",
1216                Style::default().fg(Color::DarkGray),
1217            )),
1218        ]),
1219        rows[0],
1220    );
1221
1222    let main_cols = Layout::default()
1223        .direction(Direction::Horizontal)
1224        .constraints([Constraint::Min(48), Constraint::Length(28)])
1225        .split(rows[1]);
1226
1227    frame.render_widget(
1228        PriceChart::new(&state.candles, focus_symbol)
1229            .current_candle(state.current_candle.as_ref())
1230            .fill_markers(&state.fill_markers)
1231            .fast_sma(state.fast_sma)
1232            .slow_sma(state.slow_sma),
1233        main_cols[0],
1234    );
1235    let focus_right = Layout::default()
1236        .direction(Direction::Vertical)
1237        .constraints([Constraint::Min(8), Constraint::Length(8)])
1238        .split(main_cols[1]);
1239    frame.render_widget(
1240        PositionPanel::new(
1241            &state.position,
1242            state.last_price(),
1243            &state.last_applied_fee,
1244        ),
1245        focus_right[0],
1246    );
1247    frame.render_widget(
1248        StrategyMetricsPanel::new(
1249            focus_strategy,
1250            focus_strategy_stats.trade_count,
1251            focus_strategy_stats.win_count,
1252            focus_strategy_stats.lose_count,
1253            focus_strategy_stats.realized_pnl,
1254        ),
1255        focus_right[1],
1256    );
1257
1258    frame.render_widget(
1259        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1260        rows[2],
1261    );
1262}
1263
1264fn render_grid_popup(frame: &mut Frame, state: &AppState) {
1265    let view = state.view_state();
1266    let area = frame.area();
1267    let popup = area;
1268    frame.render_widget(Clear, popup);
1269    let block = Block::default()
1270        .title(" Portfolio Grid ")
1271        .borders(Borders::ALL)
1272        .border_style(Style::default().fg(Color::Cyan));
1273    let inner = block.inner(popup);
1274    frame.render_widget(block, popup);
1275
1276    let root = Layout::default()
1277        .direction(Direction::Vertical)
1278        .constraints([Constraint::Length(2), Constraint::Min(1)])
1279        .split(inner);
1280    let tab_area = root[0];
1281    let body_area = root[1];
1282
1283    let tab_span = |tab: GridTab, key: &str, label: &str| -> Span<'_> {
1284        let selected = view.selected_grid_tab == tab;
1285        Span::styled(
1286            format!("[{} {}]", key, label),
1287            if selected {
1288                Style::default()
1289                    .fg(Color::Yellow)
1290                    .add_modifier(Modifier::BOLD)
1291            } else {
1292                Style::default().fg(Color::DarkGray)
1293            },
1294        )
1295    };
1296    frame.render_widget(
1297        Paragraph::new(Line::from(vec![
1298            tab_span(GridTab::Assets, "1", "Assets"),
1299            Span::raw(" "),
1300            tab_span(GridTab::Strategies, "2", "Strategies"),
1301            Span::raw(" "),
1302            tab_span(GridTab::Risk, "3", "Risk"),
1303            Span::raw(" "),
1304            tab_span(GridTab::Network, "4", "Network"),
1305            Span::raw(" "),
1306            tab_span(GridTab::SystemLog, "5", "SystemLog"),
1307        ])),
1308        tab_area,
1309    );
1310
1311    let global_pressure =
1312        state.rate_budget_global.used as f64 / (state.rate_budget_global.limit.max(1) as f64);
1313    let orders_pressure =
1314        state.rate_budget_orders.used as f64 / (state.rate_budget_orders.limit.max(1) as f64);
1315    let account_pressure =
1316        state.rate_budget_account.used as f64 / (state.rate_budget_account.limit.max(1) as f64);
1317    let market_pressure = state.rate_budget_market_data.used as f64
1318        / (state.rate_budget_market_data.limit.max(1) as f64);
1319    let max_pressure = global_pressure
1320        .max(orders_pressure)
1321        .max(account_pressure)
1322        .max(market_pressure);
1323    let (risk_label, risk_color) = if max_pressure >= 0.90 {
1324        ("CRIT", Color::Red)
1325    } else if max_pressure >= 0.70 {
1326        ("WARN", Color::Yellow)
1327    } else {
1328        ("OK", Color::Green)
1329    };
1330
1331    if view.selected_grid_tab == GridTab::Assets {
1332        let chunks = Layout::default()
1333            .direction(Direction::Vertical)
1334            .constraints([Constraint::Min(3), Constraint::Length(1)])
1335            .split(body_area);
1336        let asset_header = Row::new(vec![
1337            Cell::from("Symbol"),
1338            Cell::from("Qty"),
1339            Cell::from("Price"),
1340            Cell::from("RlzPnL"),
1341            Cell::from("UnrPnL"),
1342        ])
1343        .style(Style::default().fg(Color::DarkGray));
1344        let mut asset_rows: Vec<Row> = state
1345            .assets_view()
1346            .iter()
1347            .map(|a| {
1348                let price = a
1349                    .last_price
1350                    .map(|v| format!("{:.2}", v))
1351                    .unwrap_or_else(|| "---".to_string());
1352                Row::new(vec![
1353                    Cell::from(a.symbol.clone()),
1354                    Cell::from(format!("{:.5}", a.position_qty)),
1355                    Cell::from(price),
1356                    Cell::from(format!("{:+.4}", a.realized_pnl_usdt)),
1357                    Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
1358                ])
1359            })
1360            .collect();
1361        if asset_rows.is_empty() {
1362            asset_rows.push(
1363                Row::new(vec![
1364                    Cell::from("(no assets)"),
1365                    Cell::from("-"),
1366                    Cell::from("-"),
1367                    Cell::from("-"),
1368                    Cell::from("-"),
1369                ])
1370                .style(Style::default().fg(Color::DarkGray)),
1371            );
1372        }
1373        frame.render_widget(
1374            Table::new(
1375                asset_rows,
1376                [
1377                    Constraint::Length(16),
1378                    Constraint::Length(12),
1379                    Constraint::Length(10),
1380                    Constraint::Length(10),
1381                    Constraint::Length(10),
1382                ],
1383            )
1384            .header(asset_header)
1385            .column_spacing(1)
1386            .block(
1387                Block::default()
1388                    .title(format!(" Assets | Total {} ", state.assets_view().len()))
1389                    .borders(Borders::ALL)
1390                    .border_style(Style::default().fg(Color::DarkGray)),
1391            ),
1392            chunks[0],
1393        );
1394        frame.render_widget(Paragraph::new("[1/2/3] tab  [G/Esc] close"), chunks[1]);
1395        return;
1396    }
1397
1398    if view.selected_grid_tab == GridTab::Risk {
1399        let chunks = Layout::default()
1400            .direction(Direction::Vertical)
1401            .constraints([
1402                Constraint::Length(2),
1403                Constraint::Length(4),
1404                Constraint::Min(3),
1405                Constraint::Length(1),
1406            ])
1407            .split(body_area);
1408        frame.render_widget(
1409            Paragraph::new(Line::from(vec![
1410                Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
1411                Span::styled(
1412                    risk_label,
1413                    Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
1414                ),
1415                Span::styled(
1416                    "  (70%=WARN, 90%=CRIT)",
1417                    Style::default().fg(Color::DarkGray),
1418                ),
1419            ])),
1420            chunks[0],
1421        );
1422        let risk_rows = vec![
1423            Row::new(vec![
1424                Cell::from("GLOBAL"),
1425                Cell::from(format!(
1426                    "{}/{}",
1427                    state.rate_budget_global.used, state.rate_budget_global.limit
1428                )),
1429                Cell::from(format!("{}ms", state.rate_budget_global.reset_in_ms)),
1430            ]),
1431            Row::new(vec![
1432                Cell::from("ORDERS"),
1433                Cell::from(format!(
1434                    "{}/{}",
1435                    state.rate_budget_orders.used, state.rate_budget_orders.limit
1436                )),
1437                Cell::from(format!("{}ms", state.rate_budget_orders.reset_in_ms)),
1438            ]),
1439            Row::new(vec![
1440                Cell::from("ACCOUNT"),
1441                Cell::from(format!(
1442                    "{}/{}",
1443                    state.rate_budget_account.used, state.rate_budget_account.limit
1444                )),
1445                Cell::from(format!("{}ms", state.rate_budget_account.reset_in_ms)),
1446            ]),
1447            Row::new(vec![
1448                Cell::from("MARKET"),
1449                Cell::from(format!(
1450                    "{}/{}",
1451                    state.rate_budget_market_data.used, state.rate_budget_market_data.limit
1452                )),
1453                Cell::from(format!("{}ms", state.rate_budget_market_data.reset_in_ms)),
1454            ]),
1455        ];
1456        frame.render_widget(
1457            Table::new(
1458                risk_rows,
1459                [
1460                    Constraint::Length(10),
1461                    Constraint::Length(16),
1462                    Constraint::Length(12),
1463                ],
1464            )
1465            .header(Row::new(vec![
1466                Cell::from("Group"),
1467                Cell::from("Used/Limit"),
1468                Cell::from("Reset In"),
1469            ]))
1470            .column_spacing(1)
1471            .block(
1472                Block::default()
1473                    .title(" Risk Budgets ")
1474                    .borders(Borders::ALL)
1475                    .border_style(Style::default().fg(Color::DarkGray)),
1476            ),
1477            chunks[1],
1478        );
1479        let recent_rejections: Vec<&String> = state
1480            .log_messages
1481            .iter()
1482            .filter(|m| m.contains("order.reject.received"))
1483            .rev()
1484            .take(20)
1485            .collect();
1486        let mut lines = vec![Line::from(Span::styled(
1487            "Recent Rejections",
1488            Style::default()
1489                .fg(Color::Cyan)
1490                .add_modifier(Modifier::BOLD),
1491        ))];
1492        for msg in recent_rejections.into_iter().rev() {
1493            lines.push(Line::from(Span::styled(
1494                msg.as_str(),
1495                Style::default().fg(Color::Red),
1496            )));
1497        }
1498        if lines.len() == 1 {
1499            lines.push(Line::from(Span::styled(
1500                "(no rejections yet)",
1501                Style::default().fg(Color::DarkGray),
1502            )));
1503        }
1504        frame.render_widget(
1505            Paragraph::new(lines).block(
1506                Block::default()
1507                    .borders(Borders::ALL)
1508                    .border_style(Style::default().fg(Color::DarkGray)),
1509            ),
1510            chunks[2],
1511        );
1512        frame.render_widget(Paragraph::new("[1/2/3/4/5] tab  [G/Esc] close"), chunks[3]);
1513        return;
1514    }
1515
1516    if view.selected_grid_tab == GridTab::Network {
1517        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1518        let tick_in_1s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 1_000);
1519        let tick_in_10s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 10_000);
1520        let tick_in_60s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 60_000);
1521        let tick_drop_1s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 1_000);
1522        let tick_drop_10s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 10_000);
1523        let tick_drop_60s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 60_000);
1524        let reconnect_60s = count_since(&state.network_reconnect_timestamps_ms, now_ms, 60_000);
1525        let disconnect_60s = count_since(&state.network_disconnect_timestamps_ms, now_ms, 60_000);
1526
1527        let tick_in_rate_1s = rate_per_sec(tick_in_1s, 1.0);
1528        let tick_drop_rate_1s = rate_per_sec(tick_drop_1s, 1.0);
1529        let tick_drop_rate_10s = rate_per_sec(tick_drop_10s, 10.0);
1530        let tick_drop_rate_60s = rate_per_sec(tick_drop_60s, 60.0);
1531        let tick_drop_ratio_10s = ratio_pct(tick_drop_10s, tick_in_10s.saturating_add(tick_drop_10s));
1532        let tick_drop_ratio_60s = ratio_pct(tick_drop_60s, tick_in_60s.saturating_add(tick_drop_60s));
1533        let reconnect_rate_60s = reconnect_60s as f64;
1534        let disconnect_rate_60s = disconnect_60s as f64;
1535        let heartbeat_gap_ms = state.last_price_update_ms.map(|ts| now_ms.saturating_sub(ts));
1536        let tick_p95_ms = percentile(&state.network_tick_latencies_ms, 95);
1537        let health = classify_health(
1538            state.ws_connected,
1539            tick_drop_ratio_10s,
1540            reconnect_rate_60s,
1541            tick_p95_ms,
1542            heartbeat_gap_ms,
1543        );
1544        let (health_label, health_color) = match health {
1545            NetworkHealth::Ok => ("OK", Color::Green),
1546            NetworkHealth::Warn => ("WARN", Color::Yellow),
1547            NetworkHealth::Crit => ("CRIT", Color::Red),
1548        };
1549
1550        let chunks = Layout::default()
1551            .direction(Direction::Vertical)
1552            .constraints([
1553                Constraint::Length(2),
1554                Constraint::Min(6),
1555                Constraint::Length(6),
1556                Constraint::Length(1),
1557            ])
1558            .split(body_area);
1559        frame.render_widget(
1560            Paragraph::new(Line::from(vec![
1561                Span::styled("Health: ", Style::default().fg(Color::DarkGray)),
1562                Span::styled(
1563                    health_label,
1564                    Style::default()
1565                        .fg(health_color)
1566                        .add_modifier(Modifier::BOLD),
1567                ),
1568                Span::styled("  WS: ", Style::default().fg(Color::DarkGray)),
1569                Span::styled(
1570                    if state.ws_connected {
1571                        "CONNECTED"
1572                    } else {
1573                        "DISCONNECTED"
1574                    },
1575                    Style::default().fg(if state.ws_connected {
1576                        Color::Green
1577                    } else {
1578                        Color::Red
1579                    }),
1580                ),
1581                Span::styled(
1582                    format!(
1583                        "  in1s={:.1}/s  drop10s={:.2}/s  ratio10s={:.2}%  reconn60s={:.0}/min",
1584                        tick_in_rate_1s, tick_drop_rate_10s, tick_drop_ratio_10s, reconnect_rate_60s
1585                    ),
1586                    Style::default().fg(Color::DarkGray),
1587                ),
1588            ])),
1589            chunks[0],
1590        );
1591
1592        let tick_stats = latency_stats(&state.network_tick_latencies_ms);
1593        let fill_stats = latency_stats(&state.network_fill_latencies_ms);
1594        let sync_stats = latency_stats(&state.network_order_sync_latencies_ms);
1595        let last_fill_age = state
1596            .network_last_fill_ms
1597            .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
1598            .unwrap_or_else(|| "-".to_string());
1599        let rows = vec![
1600            Row::new(vec![
1601                Cell::from("Tick Latency"),
1602                Cell::from(tick_stats.0),
1603                Cell::from(tick_stats.1),
1604                Cell::from(tick_stats.2),
1605                Cell::from(
1606                    state
1607                        .last_price_latency_ms
1608                        .map(|v| format!("{}ms", v))
1609                        .unwrap_or_else(|| "-".to_string()),
1610                ),
1611            ]),
1612            Row::new(vec![
1613                Cell::from("Fill Latency"),
1614                Cell::from(fill_stats.0),
1615                Cell::from(fill_stats.1),
1616                Cell::from(fill_stats.2),
1617                Cell::from(last_fill_age),
1618            ]),
1619            Row::new(vec![
1620                Cell::from("Order Sync"),
1621                Cell::from(sync_stats.0),
1622                Cell::from(sync_stats.1),
1623                Cell::from(sync_stats.2),
1624                Cell::from(
1625                    state
1626                        .last_order_history_latency_ms
1627                        .map(|v| format!("{}ms", v))
1628                        .unwrap_or_else(|| "-".to_string()),
1629                ),
1630            ]),
1631        ];
1632        frame.render_widget(
1633            Table::new(
1634                rows,
1635                [
1636                    Constraint::Length(14),
1637                    Constraint::Length(12),
1638                    Constraint::Length(12),
1639                    Constraint::Length(12),
1640                    Constraint::Length(14),
1641                ],
1642            )
1643            .header(Row::new(vec![
1644                Cell::from("Metric"),
1645                Cell::from("p50"),
1646                Cell::from("p95"),
1647                Cell::from("p99"),
1648                Cell::from("last/age"),
1649            ]))
1650            .column_spacing(1)
1651            .block(
1652                Block::default()
1653                    .title(" Network Metrics ")
1654                    .borders(Borders::ALL)
1655                    .border_style(Style::default().fg(Color::DarkGray)),
1656            ),
1657            chunks[1],
1658        );
1659
1660        let summary_rows = vec![
1661            Row::new(vec![
1662                Cell::from("tick_drop_rate_1s"),
1663                Cell::from(format!("{:.2}/s", tick_drop_rate_1s)),
1664                Cell::from("tick_drop_rate_60s"),
1665                Cell::from(format!("{:.2}/s", tick_drop_rate_60s)),
1666            ]),
1667            Row::new(vec![
1668                Cell::from("drop_ratio_60s"),
1669                Cell::from(format!("{:.2}%", tick_drop_ratio_60s)),
1670                Cell::from("disconnect_rate_60s"),
1671                Cell::from(format!("{:.0}/min", disconnect_rate_60s)),
1672            ]),
1673            Row::new(vec![
1674                Cell::from("last_tick_age"),
1675                Cell::from(
1676                    heartbeat_gap_ms
1677                        .map(format_age_ms)
1678                        .unwrap_or_else(|| "-".to_string()),
1679                ),
1680                Cell::from("last_order_update_age"),
1681                Cell::from(
1682                    state
1683                        .last_order_history_update_ms
1684                        .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
1685                        .unwrap_or_else(|| "-".to_string()),
1686                ),
1687            ]),
1688            Row::new(vec![
1689                Cell::from("tick_drop_total"),
1690                Cell::from(state.network_tick_drop_count.to_string()),
1691                Cell::from("reconnect_total"),
1692                Cell::from(state.network_reconnect_count.to_string()),
1693            ]),
1694        ];
1695        frame.render_widget(
1696            Table::new(
1697                summary_rows,
1698                [
1699                    Constraint::Length(20),
1700                    Constraint::Length(18),
1701                    Constraint::Length(20),
1702                    Constraint::Length(18),
1703                ],
1704            )
1705            .column_spacing(1)
1706            .block(
1707                Block::default()
1708                    .title(" Network Summary ")
1709                    .borders(Borders::ALL)
1710                    .border_style(Style::default().fg(Color::DarkGray)),
1711            ),
1712            chunks[2],
1713        );
1714        frame.render_widget(Paragraph::new("[1/2/3/4/5] tab  [G/Esc] close"), chunks[3]);
1715        return;
1716    }
1717
1718    if view.selected_grid_tab == GridTab::SystemLog {
1719        let chunks = Layout::default()
1720            .direction(Direction::Vertical)
1721            .constraints([Constraint::Min(6), Constraint::Length(1)])
1722            .split(body_area);
1723        let max_rows = chunks[0].height.saturating_sub(2) as usize;
1724        let mut log_rows: Vec<Row> = state
1725            .log_messages
1726            .iter()
1727            .rev()
1728            .take(max_rows.max(1))
1729            .rev()
1730            .map(|line| Row::new(vec![Cell::from(line.clone())]))
1731            .collect();
1732        if log_rows.is_empty() {
1733            log_rows.push(
1734                Row::new(vec![Cell::from("(no system logs yet)")])
1735                    .style(Style::default().fg(Color::DarkGray)),
1736            );
1737        }
1738        frame.render_widget(
1739            Table::new(log_rows, [Constraint::Min(1)])
1740                .header(Row::new(vec![Cell::from("Message")]).style(Style::default().fg(Color::DarkGray)))
1741                .column_spacing(1)
1742                .block(
1743                    Block::default()
1744                        .title(" System Log ")
1745                        .borders(Borders::ALL)
1746                        .border_style(Style::default().fg(Color::DarkGray)),
1747                ),
1748            chunks[0],
1749        );
1750        frame.render_widget(Paragraph::new("[1/2/3/4/5] tab  [G/Esc] close"), chunks[1]);
1751        return;
1752    }
1753
1754    let selected_symbol = state
1755        .symbol_items
1756        .get(view.selected_symbol_index)
1757        .map(String::as_str)
1758        .unwrap_or(state.symbol.as_str());
1759    let strategy_chunks = Layout::default()
1760        .direction(Direction::Vertical)
1761        .constraints([
1762            Constraint::Length(2),
1763            Constraint::Length(3),
1764            Constraint::Min(12),
1765            Constraint::Length(1),
1766        ])
1767        .split(body_area);
1768
1769    let mut on_indices: Vec<usize> = Vec::new();
1770    let mut off_indices: Vec<usize> = Vec::new();
1771    for idx in 0..state.strategy_items.len() {
1772        if state
1773            .strategy_item_active
1774            .get(idx)
1775            .copied()
1776            .unwrap_or(false)
1777        {
1778            on_indices.push(idx);
1779        } else {
1780            off_indices.push(idx);
1781        }
1782    }
1783    let on_weight = on_indices.len().max(1) as u32;
1784    let off_weight = off_indices.len().max(1) as u32;
1785
1786    frame.render_widget(
1787        Paragraph::new(Line::from(vec![
1788            Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
1789            Span::styled(
1790                risk_label,
1791                Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
1792            ),
1793            Span::styled("  GLOBAL ", Style::default().fg(Color::DarkGray)),
1794            Span::styled(
1795                format!(
1796                    "{}/{}",
1797                    state.rate_budget_global.used, state.rate_budget_global.limit
1798                ),
1799                Style::default().fg(if global_pressure >= 0.9 {
1800                    Color::Red
1801                } else if global_pressure >= 0.7 {
1802                    Color::Yellow
1803                } else {
1804                    Color::Cyan
1805                }),
1806            ),
1807            Span::styled("  ORD ", Style::default().fg(Color::DarkGray)),
1808            Span::styled(
1809                format!(
1810                    "{}/{}",
1811                    state.rate_budget_orders.used, state.rate_budget_orders.limit
1812                ),
1813                Style::default().fg(if orders_pressure >= 0.9 {
1814                    Color::Red
1815                } else if orders_pressure >= 0.7 {
1816                    Color::Yellow
1817                } else {
1818                    Color::Cyan
1819                }),
1820            ),
1821            Span::styled("  ACC ", Style::default().fg(Color::DarkGray)),
1822            Span::styled(
1823                format!(
1824                    "{}/{}",
1825                    state.rate_budget_account.used, state.rate_budget_account.limit
1826                ),
1827                Style::default().fg(if account_pressure >= 0.9 {
1828                    Color::Red
1829                } else if account_pressure >= 0.7 {
1830                    Color::Yellow
1831                } else {
1832                    Color::Cyan
1833                }),
1834            ),
1835            Span::styled("  MKT ", Style::default().fg(Color::DarkGray)),
1836            Span::styled(
1837                format!(
1838                    "{}/{}",
1839                    state.rate_budget_market_data.used, state.rate_budget_market_data.limit
1840                ),
1841                Style::default().fg(if market_pressure >= 0.9 {
1842                    Color::Red
1843                } else if market_pressure >= 0.7 {
1844                    Color::Yellow
1845                } else {
1846                    Color::Cyan
1847                }),
1848            ),
1849        ])),
1850        strategy_chunks[0],
1851    );
1852
1853    let strategy_area = strategy_chunks[2];
1854    let min_panel_height: u16 = 6;
1855    let total_height = strategy_area.height;
1856    let (on_height, off_height) = if total_height >= min_panel_height.saturating_mul(2) {
1857        let total_weight = on_weight + off_weight;
1858        let mut on_h =
1859            ((total_height as u32 * on_weight) / total_weight).max(min_panel_height as u32) as u16;
1860        let max_on_h = total_height.saturating_sub(min_panel_height);
1861        if on_h > max_on_h {
1862            on_h = max_on_h;
1863        }
1864        let off_h = total_height.saturating_sub(on_h);
1865        (on_h, off_h)
1866    } else {
1867        let on_h = (total_height / 2).max(1);
1868        let off_h = total_height.saturating_sub(on_h).max(1);
1869        (on_h, off_h)
1870    };
1871    let on_area = Rect {
1872        x: strategy_area.x,
1873        y: strategy_area.y,
1874        width: strategy_area.width,
1875        height: on_height,
1876    };
1877    let off_area = Rect {
1878        x: strategy_area.x,
1879        y: strategy_area.y.saturating_add(on_height),
1880        width: strategy_area.width,
1881        height: off_height,
1882    };
1883
1884    let pnl_sum_for_indices = |indices: &[usize], state: &AppState| -> f64 {
1885        indices
1886            .iter()
1887            .map(|idx| {
1888                state
1889                    .strategy_items
1890                    .get(*idx)
1891                    .and_then(|item| strategy_stats_for_item(&state.strategy_stats, item))
1892                    .map(|s| s.realized_pnl)
1893                    .unwrap_or(0.0)
1894            })
1895            .sum()
1896    };
1897    let on_pnl_sum = pnl_sum_for_indices(&on_indices, state);
1898    let off_pnl_sum = pnl_sum_for_indices(&off_indices, state);
1899    let total_pnl_sum = on_pnl_sum + off_pnl_sum;
1900
1901    let total_row = Row::new(vec![
1902        Cell::from("ON Total"),
1903        Cell::from(on_indices.len().to_string()),
1904        Cell::from(format!("{:+.4}", on_pnl_sum)),
1905        Cell::from("OFF Total"),
1906        Cell::from(off_indices.len().to_string()),
1907        Cell::from(format!("{:+.4}", off_pnl_sum)),
1908        Cell::from("All Total"),
1909        Cell::from(format!("{:+.4}", total_pnl_sum)),
1910    ]);
1911    let total_table = Table::new(
1912        vec![total_row],
1913        [
1914            Constraint::Length(10),
1915            Constraint::Length(5),
1916            Constraint::Length(12),
1917            Constraint::Length(10),
1918            Constraint::Length(5),
1919            Constraint::Length(12),
1920            Constraint::Length(10),
1921            Constraint::Length(12),
1922        ],
1923    )
1924    .column_spacing(1)
1925    .block(
1926        Block::default()
1927            .title(" Total ")
1928            .borders(Borders::ALL)
1929            .border_style(Style::default().fg(Color::DarkGray)),
1930    );
1931    frame.render_widget(total_table, strategy_chunks[1]);
1932
1933    let render_strategy_window = |frame: &mut Frame,
1934                                  area: Rect,
1935                                  title: &str,
1936                                  indices: &[usize],
1937                                  state: &AppState,
1938                                  pnl_sum: f64,
1939                                  selected_panel: bool| {
1940        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1941        let inner_height = area.height.saturating_sub(2);
1942        let row_capacity = inner_height.saturating_sub(1) as usize;
1943        let selected_pos = indices
1944            .iter()
1945            .position(|idx| *idx == view.selected_strategy_index);
1946        let window_start = if row_capacity == 0 {
1947            0
1948        } else if let Some(pos) = selected_pos {
1949            pos.saturating_sub(row_capacity.saturating_sub(1))
1950        } else {
1951            0
1952        };
1953        let window_end = if row_capacity == 0 {
1954            0
1955        } else {
1956            (window_start + row_capacity).min(indices.len())
1957        };
1958        let visible_indices = if indices.is_empty() || row_capacity == 0 {
1959            &indices[0..0]
1960        } else {
1961            &indices[window_start..window_end]
1962        };
1963        let header = Row::new(vec![
1964            Cell::from(" "),
1965            Cell::from("Symbol"),
1966            Cell::from("Strategy"),
1967            Cell::from("Run"),
1968            Cell::from("Last"),
1969            Cell::from("Px"),
1970            Cell::from("Age"),
1971            Cell::from("W"),
1972            Cell::from("L"),
1973            Cell::from("T"),
1974            Cell::from("PnL"),
1975        ])
1976        .style(Style::default().fg(Color::DarkGray));
1977        let mut rows: Vec<Row> = visible_indices
1978            .iter()
1979            .map(|idx| {
1980                let row_symbol = state
1981                    .strategy_item_symbols
1982                    .get(*idx)
1983                    .map(String::as_str)
1984                    .unwrap_or("-");
1985                let item = state
1986                    .strategy_items
1987                    .get(*idx)
1988                    .cloned()
1989                    .unwrap_or_else(|| "-".to_string());
1990                let running = state
1991                    .strategy_item_total_running_ms
1992                    .get(*idx)
1993                    .copied()
1994                    .map(format_running_time)
1995                    .unwrap_or_else(|| "-".to_string());
1996                let stats = strategy_stats_for_item(&state.strategy_stats, &item);
1997                let source_tag = source_tag_for_strategy_item(&item);
1998                let last_evt = source_tag
1999                    .as_ref()
2000                    .and_then(|tag| state.strategy_last_event_by_tag.get(tag));
2001                let (last_label, last_px, last_age, last_style) = if let Some(evt) = last_evt {
2002                    let age = now_ms.saturating_sub(evt.timestamp_ms);
2003                    let age_txt = if age < 1_000 {
2004                        format!("{}ms", age)
2005                    } else if age < 60_000 {
2006                        format!("{}s", age / 1_000)
2007                    } else {
2008                        format!("{}m", age / 60_000)
2009                    };
2010                    let side_txt = match evt.side {
2011                        OrderSide::Buy => "BUY",
2012                        OrderSide::Sell => "SELL",
2013                    };
2014                    let px_txt = evt
2015                        .price
2016                        .map(|v| format!("{:.2}", v))
2017                        .unwrap_or_else(|| "-".to_string());
2018                    let style = match evt.side {
2019                        OrderSide::Buy => Style::default()
2020                            .fg(Color::Green)
2021                            .add_modifier(Modifier::BOLD),
2022                        OrderSide::Sell => {
2023                            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
2024                        }
2025                    };
2026                    (side_txt.to_string(), px_txt, age_txt, style)
2027                } else {
2028                    (
2029                        "-".to_string(),
2030                        "-".to_string(),
2031                        "-".to_string(),
2032                        Style::default().fg(Color::DarkGray),
2033                    )
2034                };
2035                let (w, l, t, pnl) = if let Some(s) = stats {
2036                    (
2037                        s.win_count.to_string(),
2038                        s.lose_count.to_string(),
2039                        s.trade_count.to_string(),
2040                        format!("{:+.4}", s.realized_pnl),
2041                    )
2042                } else {
2043                    (
2044                        "0".to_string(),
2045                        "0".to_string(),
2046                        "0".to_string(),
2047                        "+0.0000".to_string(),
2048                    )
2049                };
2050                let marker = if *idx == view.selected_strategy_index {
2051                    "▶"
2052                } else {
2053                    " "
2054                };
2055                let mut row = Row::new(vec![
2056                    Cell::from(marker),
2057                    Cell::from(row_symbol.to_string()),
2058                    Cell::from(item),
2059                    Cell::from(running),
2060                    Cell::from(last_label).style(last_style),
2061                    Cell::from(last_px),
2062                    Cell::from(last_age),
2063                    Cell::from(w),
2064                    Cell::from(l),
2065                    Cell::from(t),
2066                    Cell::from(pnl),
2067                ]);
2068                if *idx == view.selected_strategy_index {
2069                    row = row.style(
2070                        Style::default()
2071                            .fg(Color::Yellow)
2072                            .add_modifier(Modifier::BOLD),
2073                    );
2074                }
2075                row
2076            })
2077            .collect();
2078
2079        if rows.is_empty() {
2080            rows.push(
2081                Row::new(vec![
2082                    Cell::from(" "),
2083                    Cell::from("-"),
2084                    Cell::from("(empty)"),
2085                    Cell::from("-"),
2086                    Cell::from("-"),
2087                    Cell::from("-"),
2088                    Cell::from("-"),
2089                    Cell::from("-"),
2090                    Cell::from("-"),
2091                    Cell::from("-"),
2092                    Cell::from("-"),
2093                ])
2094                .style(Style::default().fg(Color::DarkGray)),
2095            );
2096        }
2097
2098        let table = Table::new(
2099            rows,
2100            [
2101                Constraint::Length(2),
2102                Constraint::Length(12),
2103                Constraint::Min(14),
2104                Constraint::Length(9),
2105                Constraint::Length(5),
2106                Constraint::Length(9),
2107                Constraint::Length(6),
2108                Constraint::Length(3),
2109                Constraint::Length(3),
2110                Constraint::Length(4),
2111                Constraint::Length(11),
2112            ],
2113        )
2114        .header(header)
2115        .column_spacing(1)
2116        .block(
2117            Block::default()
2118                .title(format!(
2119                    "{} | Total {:+.4} | {}/{}",
2120                    title,
2121                    pnl_sum,
2122                    visible_indices.len(),
2123                    indices.len()
2124                ))
2125                .borders(Borders::ALL)
2126                .border_style(if selected_panel {
2127                    Style::default().fg(Color::Yellow)
2128                } else if risk_label == "CRIT" {
2129                    Style::default().fg(Color::Red)
2130                } else if risk_label == "WARN" {
2131                    Style::default().fg(Color::Yellow)
2132                } else {
2133                    Style::default().fg(Color::DarkGray)
2134                }),
2135        );
2136        frame.render_widget(table, area);
2137    };
2138
2139    render_strategy_window(
2140        frame,
2141        on_area,
2142        " ON Strategies ",
2143        &on_indices,
2144        state,
2145        on_pnl_sum,
2146        view.is_on_panel_selected,
2147    );
2148    render_strategy_window(
2149        frame,
2150        off_area,
2151        " OFF Strategies ",
2152        &off_indices,
2153        state,
2154        off_pnl_sum,
2155        !view.is_on_panel_selected,
2156    );
2157    frame.render_widget(
2158        Paragraph::new(Line::from(vec![
2159            Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
2160            Span::styled(
2161                selected_symbol,
2162                Style::default()
2163                    .fg(Color::Green)
2164                    .add_modifier(Modifier::BOLD),
2165            ),
2166            Span::styled(
2167                "  [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",
2168                Style::default().fg(Color::DarkGray),
2169            ),
2170        ])),
2171        strategy_chunks[3],
2172    );
2173}
2174
2175fn format_running_time(total_running_ms: u64) -> String {
2176    let total_sec = total_running_ms / 1000;
2177    let days = total_sec / 86_400;
2178    let hours = (total_sec % 86_400) / 3_600;
2179    let minutes = (total_sec % 3_600) / 60;
2180    if days > 0 {
2181        format!("{}d {:02}h", days, hours)
2182    } else {
2183        format!("{:02}h {:02}m", hours, minutes)
2184    }
2185}
2186
2187fn format_age_ms(age_ms: u64) -> String {
2188    if age_ms < 1_000 {
2189        format!("{}ms", age_ms)
2190    } else if age_ms < 60_000 {
2191        format!("{}s", age_ms / 1_000)
2192    } else {
2193        format!("{}m", age_ms / 60_000)
2194    }
2195}
2196
2197fn latency_stats(samples: &[u64]) -> (String, String, String) {
2198    let p50 = percentile(samples, 50);
2199    let p95 = percentile(samples, 95);
2200    let p99 = percentile(samples, 99);
2201    (
2202        p50.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2203        p95.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2204        p99.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2205    )
2206}
2207
2208fn render_strategy_editor_popup(frame: &mut Frame, state: &AppState) {
2209    let area = frame.area();
2210    let popup = Rect {
2211        x: area.x + 8,
2212        y: area.y + 4,
2213        width: area.width.saturating_sub(16).max(50),
2214        height: area.height.saturating_sub(8).max(12),
2215    };
2216    frame.render_widget(Clear, popup);
2217    let block = Block::default()
2218        .title(" Strategy Config ")
2219        .borders(Borders::ALL)
2220        .border_style(Style::default().fg(Color::Yellow));
2221    let inner = block.inner(popup);
2222    frame.render_widget(block, popup);
2223    let selected_name = state
2224        .strategy_items
2225        .get(state.strategy_editor_index)
2226        .map(String::as_str)
2227        .unwrap_or("Unknown");
2228    let rows = [
2229        (
2230            "Symbol",
2231            state
2232                .symbol_items
2233                .get(state.strategy_editor_symbol_index)
2234                .cloned()
2235                .unwrap_or_else(|| state.symbol.clone()),
2236        ),
2237        ("Fast Period", state.strategy_editor_fast.to_string()),
2238        ("Slow Period", state.strategy_editor_slow.to_string()),
2239        ("Cooldown Tick", state.strategy_editor_cooldown.to_string()),
2240    ];
2241    let mut lines = vec![
2242        Line::from(vec![
2243            Span::styled("Target: ", Style::default().fg(Color::DarkGray)),
2244            Span::styled(
2245                selected_name,
2246                Style::default()
2247                    .fg(Color::White)
2248                    .add_modifier(Modifier::BOLD),
2249            ),
2250        ]),
2251        Line::from(Span::styled(
2252            "Use [J/K] field, [H/L] value, [Enter] save+apply symbol, [Esc] cancel",
2253            Style::default().fg(Color::DarkGray),
2254        )),
2255    ];
2256    for (idx, (name, value)) in rows.iter().enumerate() {
2257        let marker = if idx == state.strategy_editor_field {
2258            "▶ "
2259        } else {
2260            "  "
2261        };
2262        let style = if idx == state.strategy_editor_field {
2263            Style::default()
2264                .fg(Color::Yellow)
2265                .add_modifier(Modifier::BOLD)
2266        } else {
2267            Style::default().fg(Color::White)
2268        };
2269        lines.push(Line::from(vec![
2270            Span::styled(marker, Style::default().fg(Color::Yellow)),
2271            Span::styled(format!("{:<14}", name), style),
2272            Span::styled(value, style),
2273        ]));
2274    }
2275    frame.render_widget(Paragraph::new(lines), inner);
2276}
2277
2278fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
2279    let area = frame.area();
2280    let popup = Rect {
2281        x: area.x + 4,
2282        y: area.y + 2,
2283        width: area.width.saturating_sub(8).max(30),
2284        height: area.height.saturating_sub(4).max(10),
2285    };
2286    frame.render_widget(Clear, popup);
2287    let block = Block::default()
2288        .title(" Account Assets ")
2289        .borders(Borders::ALL)
2290        .border_style(Style::default().fg(Color::Cyan));
2291    let inner = block.inner(popup);
2292    frame.render_widget(block, popup);
2293
2294    let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
2295    assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
2296
2297    let mut lines = Vec::with_capacity(assets.len() + 2);
2298    lines.push(Line::from(vec![
2299        Span::styled(
2300            "Asset",
2301            Style::default()
2302                .fg(Color::Cyan)
2303                .add_modifier(Modifier::BOLD),
2304        ),
2305        Span::styled(
2306            "      Free",
2307            Style::default()
2308                .fg(Color::Cyan)
2309                .add_modifier(Modifier::BOLD),
2310        ),
2311    ]));
2312    for (asset, qty) in assets {
2313        lines.push(Line::from(vec![
2314            Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
2315            Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
2316        ]));
2317    }
2318    if lines.len() == 1 {
2319        lines.push(Line::from(Span::styled(
2320            "No assets",
2321            Style::default().fg(Color::DarkGray),
2322        )));
2323    }
2324
2325    frame.render_widget(Paragraph::new(lines), inner);
2326}
2327
2328fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
2329    let area = frame.area();
2330    let popup = Rect {
2331        x: area.x + 2,
2332        y: area.y + 1,
2333        width: area.width.saturating_sub(4).max(40),
2334        height: area.height.saturating_sub(2).max(12),
2335    };
2336    frame.render_widget(Clear, popup);
2337    let block = Block::default()
2338        .title(match bucket {
2339            order_store::HistoryBucket::Day => " History (Day ROI) ",
2340            order_store::HistoryBucket::Hour => " History (Hour ROI) ",
2341            order_store::HistoryBucket::Month => " History (Month ROI) ",
2342        })
2343        .borders(Borders::ALL)
2344        .border_style(Style::default().fg(Color::Cyan));
2345    let inner = block.inner(popup);
2346    frame.render_widget(block, popup);
2347
2348    let max_rows = inner.height.saturating_sub(1) as usize;
2349    let mut visible: Vec<Line> = Vec::new();
2350    for (idx, row) in rows.iter().take(max_rows).enumerate() {
2351        let color = if idx == 0 {
2352            Color::Cyan
2353        } else if row.contains('-') && row.contains('%') {
2354            Color::White
2355        } else {
2356            Color::DarkGray
2357        };
2358        visible.push(Line::from(Span::styled(
2359            row.clone(),
2360            Style::default().fg(color),
2361        )));
2362    }
2363    if visible.is_empty() {
2364        visible.push(Line::from(Span::styled(
2365            "No history rows",
2366            Style::default().fg(Color::DarkGray),
2367        )));
2368    }
2369    frame.render_widget(Paragraph::new(visible), inner);
2370}
2371
2372fn render_selector_popup(
2373    frame: &mut Frame,
2374    title: &str,
2375    items: &[String],
2376    selected: usize,
2377    stats: Option<&HashMap<String, OrderHistoryStats>>,
2378    total_stats: Option<OrderHistoryStats>,
2379    selected_symbol: Option<&str>,
2380) {
2381    let area = frame.area();
2382    let available_width = area.width.saturating_sub(2).max(1);
2383    let width = if stats.is_some() {
2384        let min_width = 44;
2385        let preferred = 84;
2386        preferred
2387            .min(available_width)
2388            .max(min_width.min(available_width))
2389    } else {
2390        let min_width = 24;
2391        let preferred = 48;
2392        preferred
2393            .min(available_width)
2394            .max(min_width.min(available_width))
2395    };
2396    let available_height = area.height.saturating_sub(2).max(1);
2397    let desired_height = if stats.is_some() {
2398        items.len() as u16 + 7
2399    } else {
2400        items.len() as u16 + 4
2401    };
2402    let height = desired_height
2403        .min(available_height)
2404        .max(6.min(available_height));
2405    let popup = Rect {
2406        x: area.x + (area.width.saturating_sub(width)) / 2,
2407        y: area.y + (area.height.saturating_sub(height)) / 2,
2408        width,
2409        height,
2410    };
2411
2412    frame.render_widget(Clear, popup);
2413    let block = Block::default()
2414        .title(title)
2415        .borders(Borders::ALL)
2416        .border_style(Style::default().fg(Color::Cyan));
2417    let inner = block.inner(popup);
2418    frame.render_widget(block, popup);
2419
2420    let mut lines: Vec<Line> = Vec::new();
2421    if stats.is_some() {
2422        if let Some(symbol) = selected_symbol {
2423            lines.push(Line::from(vec![
2424                Span::styled("  Symbol: ", Style::default().fg(Color::DarkGray)),
2425                Span::styled(
2426                    symbol,
2427                    Style::default()
2428                        .fg(Color::Green)
2429                        .add_modifier(Modifier::BOLD),
2430                ),
2431            ]));
2432        }
2433        lines.push(Line::from(vec![Span::styled(
2434            "  Strategy           W    L    T    PnL",
2435            Style::default()
2436                .fg(Color::Cyan)
2437                .add_modifier(Modifier::BOLD),
2438        )]));
2439    }
2440
2441    let mut item_lines: Vec<Line> = items
2442        .iter()
2443        .enumerate()
2444        .map(|(idx, item)| {
2445            let item_text = if let Some(stats_map) = stats {
2446                if let Some(s) = strategy_stats_for_item(stats_map, item) {
2447                    format!(
2448                        "{:<16}  W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
2449                        item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
2450                    )
2451                } else {
2452                    format!("{:<16}  W:0   L:0   T:0   PnL:0.0000", item)
2453                }
2454            } else {
2455                item.clone()
2456            };
2457            if idx == selected {
2458                Line::from(vec![
2459                    Span::styled("▶ ", Style::default().fg(Color::Yellow)),
2460                    Span::styled(
2461                        item_text,
2462                        Style::default()
2463                            .fg(Color::White)
2464                            .add_modifier(Modifier::BOLD),
2465                    ),
2466                ])
2467            } else {
2468                Line::from(vec![
2469                    Span::styled("  ", Style::default()),
2470                    Span::styled(item_text, Style::default().fg(Color::DarkGray)),
2471                ])
2472            }
2473        })
2474        .collect();
2475    lines.append(&mut item_lines);
2476    if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
2477        let mut strategy_sum = OrderHistoryStats::default();
2478        for item in items {
2479            if let Some(s) = strategy_stats_for_item(stats_map, item) {
2480                strategy_sum.trade_count += s.trade_count;
2481                strategy_sum.win_count += s.win_count;
2482                strategy_sum.lose_count += s.lose_count;
2483                strategy_sum.realized_pnl += s.realized_pnl;
2484            }
2485        }
2486        let manual = subtract_stats(t, &strategy_sum);
2487        lines.push(Line::from(vec![Span::styled(
2488            format!(
2489                "  MANUAL(rest)       W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
2490                manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
2491            ),
2492            Style::default().fg(Color::LightBlue),
2493        )]));
2494    }
2495    if let Some(t) = total_stats {
2496        lines.push(Line::from(vec![Span::styled(
2497            format!(
2498                "  TOTAL              W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
2499                t.win_count, t.lose_count, t.trade_count, t.realized_pnl
2500            ),
2501            Style::default()
2502                .fg(Color::Yellow)
2503                .add_modifier(Modifier::BOLD),
2504        )]));
2505    }
2506
2507    frame.render_widget(
2508        Paragraph::new(lines).style(Style::default().fg(Color::White)),
2509        inner,
2510    );
2511}
2512
2513fn strategy_stats_for_item<'a>(
2514    stats_map: &'a HashMap<String, OrderHistoryStats>,
2515    item: &str,
2516) -> Option<&'a OrderHistoryStats> {
2517    if let Some(s) = stats_map.get(item) {
2518        return Some(s);
2519    }
2520    let source_tag = source_tag_for_strategy_item(item);
2521    source_tag.and_then(|tag| {
2522        stats_map
2523            .get(&tag)
2524            .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
2525    })
2526}
2527
2528fn source_tag_for_strategy_item(item: &str) -> Option<String> {
2529    match item {
2530        "MA(Config)" => return Some("cfg".to_string()),
2531        "MA(Fast 5/20)" => return Some("fst".to_string()),
2532        "MA(Slow 20/60)" => return Some("slw".to_string()),
2533        _ => {}
2534    }
2535    if let Some((_, tail)) = item.rsplit_once('[') {
2536        if let Some(tag) = tail.strip_suffix(']') {
2537            let tag = tag.trim();
2538            if !tag.is_empty() {
2539                return Some(tag.to_ascii_lowercase());
2540            }
2541        }
2542    }
2543    None
2544}
2545
2546fn parse_source_tag_from_client_order_id(client_order_id: &str) -> Option<&str> {
2547    let body = client_order_id.strip_prefix("sq-")?;
2548    let (source_tag, _) = body.split_once('-')?;
2549    if source_tag.is_empty() {
2550        None
2551    } else {
2552        Some(source_tag)
2553    }
2554}
2555
2556fn format_log_record_compact(record: &LogRecord) -> String {
2557    let level = match record.level {
2558        LogLevel::Debug => "DEBUG",
2559        LogLevel::Info => "INFO",
2560        LogLevel::Warn => "WARN",
2561        LogLevel::Error => "ERR",
2562    };
2563    let domain = match record.domain {
2564        LogDomain::Ws => "ws",
2565        LogDomain::Strategy => "strategy",
2566        LogDomain::Risk => "risk",
2567        LogDomain::Order => "order",
2568        LogDomain::Portfolio => "portfolio",
2569        LogDomain::Ui => "ui",
2570        LogDomain::System => "system",
2571    };
2572    let symbol = record.symbol.as_deref().unwrap_or("-");
2573    let strategy = record.strategy_tag.as_deref().unwrap_or("-");
2574    format!(
2575        "[{}] {}.{} {} {} {}",
2576        level, domain, record.event, symbol, strategy, record.msg
2577    )
2578}
2579
2580fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
2581    OrderHistoryStats {
2582        trade_count: total.trade_count.saturating_sub(used.trade_count),
2583        win_count: total.win_count.saturating_sub(used.win_count),
2584        lose_count: total.lose_count.saturating_sub(used.lose_count),
2585        realized_pnl: total.realized_pnl - used.realized_pnl,
2586    }
2587}
2588
2589fn split_symbol_assets(symbol: &str) -> (String, String) {
2590    const QUOTE_SUFFIXES: [&str; 10] = [
2591        "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
2592    ];
2593    for q in QUOTE_SUFFIXES {
2594        if let Some(base) = symbol.strip_suffix(q) {
2595            if !base.is_empty() {
2596                return (base.to_string(), q.to_string());
2597            }
2598        }
2599    }
2600    (symbol.to_string(), String::new())
2601}
2602
2603fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
2604    if fills.is_empty() {
2605        return None;
2606    }
2607    let (base_asset, quote_asset) = split_symbol_assets(symbol);
2608    let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
2609    let mut notional_quote = 0.0;
2610    let mut fee_quote_equiv = 0.0;
2611    let mut quote_convertible = !quote_asset.is_empty();
2612
2613    for f in fills {
2614        if f.qty > 0.0 && f.price > 0.0 {
2615            notional_quote += f.qty * f.price;
2616        }
2617        if f.commission <= 0.0 {
2618            continue;
2619        }
2620        *fee_by_asset
2621            .entry(f.commission_asset.clone())
2622            .or_insert(0.0) += f.commission;
2623        if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&quote_asset) {
2624            fee_quote_equiv += f.commission;
2625        } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
2626            fee_quote_equiv += f.commission * f.price.max(0.0);
2627        } else {
2628            quote_convertible = false;
2629        }
2630    }
2631
2632    if fee_by_asset.is_empty() {
2633        return Some("0".to_string());
2634    }
2635
2636    if quote_convertible && notional_quote > f64::EPSILON {
2637        let fee_pct = fee_quote_equiv / notional_quote * 100.0;
2638        return Some(format!(
2639            "{:.3}% ({:.4} {})",
2640            fee_pct, fee_quote_equiv, quote_asset
2641        ));
2642    }
2643
2644    let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
2645    items.sort_by(|a, b| a.0.cmp(&b.0));
2646    if items.len() == 1 {
2647        let (asset, amount) = &items[0];
2648        Some(format!("{:.6} {}", amount, asset))
2649    } else {
2650        Some(format!("mixed fees ({})", items.len()))
2651    }
2652}
2653
2654#[cfg(test)]
2655mod tests {
2656    use super::format_last_applied_fee;
2657    use crate::model::order::Fill;
2658
2659    #[test]
2660    fn fee_summary_from_quote_asset_commission() {
2661        let fills = vec![Fill {
2662            price: 2000.0,
2663            qty: 0.5,
2664            commission: 1.0,
2665            commission_asset: "USDT".to_string(),
2666        }];
2667        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
2668        assert_eq!(summary, "0.100% (1.0000 USDT)");
2669    }
2670
2671    #[test]
2672    fn fee_summary_from_base_asset_commission() {
2673        let fills = vec![Fill {
2674            price: 2000.0,
2675            qty: 0.5,
2676            commission: 0.0005,
2677            commission_asset: "ETH".to_string(),
2678        }];
2679        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
2680        assert_eq!(summary, "0.100% (1.0000 USDT)");
2681    }
2682}