Skip to main content

sandbox_quant/ui/
mod.rs

1pub mod chart;
2pub mod dashboard;
3pub mod network_metrics;
4pub mod position_ledger;
5pub mod ui_projection;
6
7use std::collections::HashMap;
8
9use ratatui::layout::{Constraint, Direction, Layout, Rect};
10use ratatui::style::{Color, Modifier, Style};
11use ratatui::text::{Line, Span};
12use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
13use ratatui::Frame;
14
15use crate::event::{
16    AppEvent, ExitPolicyEntry, LogDomain, LogLevel, LogRecord, PortfolioStateSnapshot,
17    PredictorMetricEntry, WsConnectionStatus,
18};
19use crate::model::candle::{Candle, CandleBuilder};
20use crate::model::order::{Fill, OrderSide};
21use crate::model::position::Position;
22use crate::model::signal::Signal;
23use crate::order_manager::{OrderHistoryFill, OrderHistoryStats, OrderUpdate};
24use crate::order_store;
25use crate::risk_module::RateBudgetSnapshot;
26use crate::strategy_catalog::{strategy_kind_categories, strategy_kind_labels};
27use crate::ui::network_metrics::{
28    classify_health, count_since, percentile, rate_per_sec, ratio_pct, NetworkHealth,
29};
30use crate::ui::position_ledger::build_open_order_positions_from_trades;
31
32use chart::{FillMarker, PriceChart};
33use dashboard::{
34    KeybindBar, LogPanel, OrderHistoryPanel, OrderLogPanel, StatusBar,
35};
36use ui_projection::AssetEntry;
37use ui_projection::UiProjection;
38
39const MAX_LOG_MESSAGES: usize = 200;
40const MAX_FILL_MARKERS: usize = 200;
41
42fn predictor_horizon_priority(h: &str) -> u8 {
43    match h.trim().to_ascii_lowercase().as_str() {
44        "5m" => 2,
45        "3m" => 1,
46        _ => 0,
47    }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum GridTab {
52    Assets,
53    Strategies,
54    Risk,
55    Network,
56    History,
57    Positions,
58    Predictors,
59    SystemLog,
60}
61
62#[derive(Debug, Clone)]
63pub struct StrategyLastEvent {
64    pub side: OrderSide,
65    pub price: Option<f64>,
66    pub timestamp_ms: u64,
67    pub is_filled: bool,
68}
69
70#[derive(Debug, Clone)]
71pub struct ViewState {
72    pub is_grid_open: bool,
73    pub selected_grid_tab: GridTab,
74    pub selected_symbol_index: usize,
75    pub selected_strategy_index: usize,
76    pub is_on_panel_selected: bool,
77    pub is_symbol_selector_open: bool,
78    pub selected_symbol_selector_index: usize,
79    pub is_strategy_selector_open: bool,
80    pub selected_strategy_selector_index: usize,
81    pub is_account_popup_open: bool,
82    pub is_history_popup_open: bool,
83    pub is_focus_popup_open: bool,
84    pub is_close_all_confirm_open: bool,
85    pub is_strategy_editor_open: bool,
86}
87
88pub struct AppState {
89    pub symbol: String,
90    pub strategy_label: String,
91    pub candles: Vec<Candle>,
92    pub current_candle: Option<CandleBuilder>,
93    pub candle_interval_ms: u64,
94    pub timeframe: String,
95    pub price_history_len: usize,
96    pub position: Position,
97    pub last_signal: Option<Signal>,
98    pub last_order: Option<OrderUpdate>,
99    pub open_order_history: Vec<String>,
100    pub filled_order_history: Vec<String>,
101    pub fast_sma: Option<f64>,
102    pub slow_sma: Option<f64>,
103    pub ws_connected: bool,
104    pub paused: bool,
105    pub tick_count: u64,
106    pub log_messages: Vec<String>,
107    pub log_records: Vec<LogRecord>,
108    pub balances: HashMap<String, f64>,
109    pub initial_equity_usdt: Option<f64>,
110    pub current_equity_usdt: Option<f64>,
111    pub history_estimated_total_pnl_usdt: Option<f64>,
112    pub fill_markers: Vec<FillMarker>,
113    pub history_trade_count: u32,
114    pub history_win_count: u32,
115    pub history_lose_count: u32,
116    pub history_realized_pnl: f64,
117    pub portfolio_state: PortfolioStateSnapshot,
118    pub strategy_stats: HashMap<String, OrderHistoryStats>,
119    pub exit_policy_by_scope: HashMap<String, ExitPolicyEntry>,
120    pub predictor_metrics_by_scope: HashMap<String, PredictorMetricEntry>,
121    pub history_fills: Vec<OrderHistoryFill>,
122    pub last_price_update_ms: Option<u64>,
123    pub last_price_event_ms: Option<u64>,
124    pub last_price_latency_ms: Option<u64>,
125    pub last_order_history_update_ms: Option<u64>,
126    pub last_order_history_event_ms: Option<u64>,
127    pub last_order_history_latency_ms: Option<u64>,
128    pub trade_stats_reset_warned: bool,
129    pub symbol_selector_open: bool,
130    pub symbol_selector_index: usize,
131    pub symbol_items: Vec<String>,
132    pub strategy_selector_open: bool,
133    pub strategy_selector_index: usize,
134    pub strategy_items: Vec<String>,
135    pub strategy_item_symbols: Vec<String>,
136    pub strategy_item_active: Vec<bool>,
137    pub strategy_item_created_at_ms: Vec<i64>,
138    pub strategy_item_total_running_ms: Vec<u64>,
139    pub account_popup_open: bool,
140    pub history_popup_open: bool,
141    pub focus_popup_open: bool,
142    pub close_all_confirm_open: bool,
143    pub strategy_editor_open: bool,
144    pub strategy_editor_kind_category_selector_open: bool,
145    pub strategy_editor_kind_selector_open: bool,
146    pub strategy_editor_index: usize,
147    pub strategy_editor_field: usize,
148    pub strategy_editor_kind_category_items: Vec<String>,
149    pub strategy_editor_kind_category_index: usize,
150    pub strategy_editor_kind_popup_items: Vec<String>,
151    pub strategy_editor_kind_popup_labels: Vec<Option<String>>,
152    pub strategy_editor_kind_items: Vec<String>,
153    pub strategy_editor_kind_selector_index: usize,
154    pub strategy_editor_kind_index: usize,
155    pub strategy_editor_symbol_index: usize,
156    pub strategy_editor_fast: usize,
157    pub strategy_editor_slow: usize,
158    pub strategy_editor_cooldown: u64,
159    pub grid_symbol_index: usize,
160    pub grid_strategy_index: usize,
161    pub grid_select_on_panel: bool,
162    pub grid_tab: GridTab,
163    pub strategy_last_event_by_tag: HashMap<String, StrategyLastEvent>,
164    pub network_tick_drop_count: u64,
165    pub network_reconnect_count: u64,
166    pub network_tick_latencies_ms: Vec<u64>,
167    pub network_fill_latencies_ms: Vec<u64>,
168    pub network_order_sync_latencies_ms: Vec<u64>,
169    pub network_tick_in_timestamps_ms: Vec<u64>,
170    pub network_tick_drop_timestamps_ms: Vec<u64>,
171    pub network_reconnect_timestamps_ms: Vec<u64>,
172    pub network_disconnect_timestamps_ms: Vec<u64>,
173    pub network_last_fill_ms: Option<u64>,
174    pub network_pending_submit_ms_by_intent: HashMap<String, u64>,
175    pub history_rows: Vec<String>,
176    pub history_bucket: order_store::HistoryBucket,
177    pub last_applied_fee: String,
178    pub grid_open: bool,
179    pub ui_projection: UiProjection,
180    pub rate_budget_global: RateBudgetSnapshot,
181    pub rate_budget_orders: RateBudgetSnapshot,
182    pub rate_budget_account: RateBudgetSnapshot,
183    pub rate_budget_market_data: RateBudgetSnapshot,
184    pub close_all_running: bool,
185    pub close_all_job_id: Option<u64>,
186    pub close_all_total: usize,
187    pub close_all_completed: usize,
188    pub close_all_failed: usize,
189    pub close_all_current_symbol: Option<String>,
190    pub close_all_status_expire_at_ms: Option<u64>,
191    pub close_all_row_status_by_symbol: HashMap<String, String>,
192    pub hide_small_positions: bool,
193    pub hide_empty_predictor_rows: bool,
194    pub predictor_scroll_offset: usize,
195}
196
197impl AppState {
198    pub fn new(
199        symbol: &str,
200        strategy_label: &str,
201        price_history_len: usize,
202        candle_interval_ms: u64,
203        timeframe: &str,
204    ) -> Self {
205        Self {
206            symbol: symbol.to_string(),
207            strategy_label: strategy_label.to_string(),
208            candles: Vec::with_capacity(price_history_len),
209            current_candle: None,
210            candle_interval_ms,
211            timeframe: timeframe.to_string(),
212            price_history_len,
213            position: Position::new(symbol.to_string()),
214            last_signal: None,
215            last_order: None,
216            open_order_history: Vec::new(),
217            filled_order_history: Vec::new(),
218            fast_sma: None,
219            slow_sma: None,
220            ws_connected: false,
221            paused: false,
222            tick_count: 0,
223            log_messages: Vec::new(),
224            log_records: Vec::new(),
225            balances: HashMap::new(),
226            initial_equity_usdt: None,
227            current_equity_usdt: None,
228            history_estimated_total_pnl_usdt: None,
229            fill_markers: Vec::new(),
230            history_trade_count: 0,
231            history_win_count: 0,
232            history_lose_count: 0,
233            history_realized_pnl: 0.0,
234            portfolio_state: PortfolioStateSnapshot::default(),
235            strategy_stats: HashMap::new(),
236            exit_policy_by_scope: HashMap::new(),
237            predictor_metrics_by_scope: HashMap::new(),
238            history_fills: Vec::new(),
239            last_price_update_ms: None,
240            last_price_event_ms: None,
241            last_price_latency_ms: None,
242            last_order_history_update_ms: None,
243            last_order_history_event_ms: None,
244            last_order_history_latency_ms: None,
245            trade_stats_reset_warned: false,
246            symbol_selector_open: false,
247            symbol_selector_index: 0,
248            symbol_items: Vec::new(),
249            strategy_selector_open: false,
250            strategy_selector_index: 0,
251            strategy_items: vec![
252                "MA(Config)".to_string(),
253                "MA(Fast 5/20)".to_string(),
254                "MA(Slow 20/60)".to_string(),
255                "RSA(RSI 14 30/70)".to_string(),
256            ],
257            strategy_item_symbols: vec![
258                symbol.to_ascii_uppercase(),
259                symbol.to_ascii_uppercase(),
260                symbol.to_ascii_uppercase(),
261                symbol.to_ascii_uppercase(),
262            ],
263            strategy_item_active: vec![false, false, false, false],
264            strategy_item_created_at_ms: vec![0, 0, 0, 0],
265            strategy_item_total_running_ms: vec![0, 0, 0, 0],
266            account_popup_open: false,
267            history_popup_open: false,
268            focus_popup_open: false,
269            close_all_confirm_open: false,
270            strategy_editor_open: false,
271            strategy_editor_kind_category_selector_open: false,
272            strategy_editor_kind_selector_open: false,
273            strategy_editor_index: 0,
274            strategy_editor_field: 0,
275            strategy_editor_kind_category_items: strategy_kind_categories(),
276            strategy_editor_kind_category_index: 0,
277            strategy_editor_kind_popup_items: Vec::new(),
278            strategy_editor_kind_popup_labels: Vec::new(),
279            strategy_editor_kind_items: strategy_kind_labels(),
280            strategy_editor_kind_selector_index: 0,
281            strategy_editor_kind_index: 0,
282            strategy_editor_symbol_index: 0,
283            strategy_editor_fast: 5,
284            strategy_editor_slow: 20,
285            strategy_editor_cooldown: 1,
286            grid_symbol_index: 0,
287            grid_strategy_index: 0,
288            grid_select_on_panel: true,
289            grid_tab: GridTab::Strategies,
290            strategy_last_event_by_tag: HashMap::new(),
291            network_tick_drop_count: 0,
292            network_reconnect_count: 0,
293            network_tick_latencies_ms: Vec::new(),
294            network_fill_latencies_ms: Vec::new(),
295            network_order_sync_latencies_ms: Vec::new(),
296            network_tick_in_timestamps_ms: Vec::new(),
297            network_tick_drop_timestamps_ms: Vec::new(),
298            network_reconnect_timestamps_ms: Vec::new(),
299            network_disconnect_timestamps_ms: Vec::new(),
300            network_last_fill_ms: None,
301            network_pending_submit_ms_by_intent: HashMap::new(),
302            history_rows: Vec::new(),
303            history_bucket: order_store::HistoryBucket::Day,
304            last_applied_fee: "---".to_string(),
305            grid_open: false,
306            ui_projection: UiProjection::new(),
307            rate_budget_global: RateBudgetSnapshot {
308                used: 0,
309                limit: 0,
310                reset_in_ms: 0,
311            },
312            rate_budget_orders: RateBudgetSnapshot {
313                used: 0,
314                limit: 0,
315                reset_in_ms: 0,
316            },
317            rate_budget_account: RateBudgetSnapshot {
318                used: 0,
319                limit: 0,
320                reset_in_ms: 0,
321            },
322            rate_budget_market_data: RateBudgetSnapshot {
323                used: 0,
324                limit: 0,
325                reset_in_ms: 0,
326            },
327            close_all_running: false,
328            close_all_job_id: None,
329            close_all_total: 0,
330            close_all_completed: 0,
331            close_all_failed: 0,
332            close_all_current_symbol: None,
333            close_all_status_expire_at_ms: None,
334            close_all_row_status_by_symbol: HashMap::new(),
335            hide_small_positions: true,
336            hide_empty_predictor_rows: true,
337            predictor_scroll_offset: 0,
338        }
339    }
340
341    /// Get the latest price (from current candle or last finalized candle).
342    pub fn last_price(&self) -> Option<f64> {
343        self.current_candle
344            .as_ref()
345            .map(|cb| cb.close)
346            .or_else(|| self.candles.last().map(|c| c.close))
347    }
348
349    pub fn push_log(&mut self, msg: String) {
350        self.log_messages.push(msg);
351        if self.log_messages.len() > MAX_LOG_MESSAGES {
352            self.log_messages.remove(0);
353        }
354    }
355
356    pub fn push_log_record(&mut self, record: LogRecord) {
357        self.log_records.push(record.clone());
358        if self.log_records.len() > MAX_LOG_MESSAGES {
359            self.log_records.remove(0);
360        }
361        self.push_log(format_log_record_compact(&record));
362    }
363
364    fn push_latency_sample(samples: &mut Vec<u64>, value: u64) {
365        const MAX_SAMPLES: usize = 200;
366        samples.push(value);
367        if samples.len() > MAX_SAMPLES {
368            let drop_n = samples.len() - MAX_SAMPLES;
369            samples.drain(..drop_n);
370        }
371    }
372
373    fn push_network_event_sample(samples: &mut Vec<u64>, ts_ms: u64) {
374        samples.push(ts_ms);
375        let lower = ts_ms.saturating_sub(60_000);
376        samples.retain(|&v| v >= lower);
377    }
378
379    fn prune_network_event_windows(&mut self, now_ms: u64) {
380        let lower = now_ms.saturating_sub(60_000);
381        self.network_tick_in_timestamps_ms.retain(|&v| v >= lower);
382        self.network_tick_drop_timestamps_ms.retain(|&v| v >= lower);
383        self.network_reconnect_timestamps_ms.retain(|&v| v >= lower);
384        self.network_disconnect_timestamps_ms
385            .retain(|&v| v >= lower);
386    }
387
388    /// Transitional projection for RFC-0016 Phase 2.
389    /// Keeps runtime behavior unchanged while exposing normalized naming.
390    pub fn view_state(&self) -> ViewState {
391        ViewState {
392            is_grid_open: self.grid_open,
393            selected_grid_tab: self.grid_tab,
394            selected_symbol_index: self.grid_symbol_index,
395            selected_strategy_index: self.grid_strategy_index,
396            is_on_panel_selected: self.grid_select_on_panel,
397            is_symbol_selector_open: self.symbol_selector_open,
398            selected_symbol_selector_index: self.symbol_selector_index,
399            is_strategy_selector_open: self.strategy_selector_open,
400            selected_strategy_selector_index: self.strategy_selector_index,
401            is_account_popup_open: self.account_popup_open,
402            is_history_popup_open: self.history_popup_open,
403            is_focus_popup_open: self.focus_popup_open,
404            is_close_all_confirm_open: self.close_all_confirm_open,
405            is_strategy_editor_open: self.strategy_editor_open,
406        }
407    }
408
409    pub fn is_grid_open(&self) -> bool {
410        self.grid_open
411    }
412    pub fn set_grid_open(&mut self, open: bool) {
413        self.grid_open = open;
414    }
415    pub fn grid_tab(&self) -> GridTab {
416        self.grid_tab
417    }
418    pub fn set_grid_tab(&mut self, tab: GridTab) {
419        self.grid_tab = tab;
420        if tab != GridTab::Predictors {
421            self.predictor_scroll_offset = 0;
422        }
423    }
424    pub fn selected_grid_symbol_index(&self) -> usize {
425        self.grid_symbol_index
426    }
427    pub fn set_selected_grid_symbol_index(&mut self, idx: usize) {
428        self.grid_symbol_index = idx;
429    }
430    pub fn selected_grid_strategy_index(&self) -> usize {
431        self.grid_strategy_index
432    }
433    pub fn set_selected_grid_strategy_index(&mut self, idx: usize) {
434        self.grid_strategy_index = idx;
435    }
436    pub fn is_on_panel_selected(&self) -> bool {
437        self.grid_select_on_panel
438    }
439    pub fn set_on_panel_selected(&mut self, selected: bool) {
440        self.grid_select_on_panel = selected;
441    }
442    pub fn predictor_scroll_offset(&self) -> usize {
443        self.predictor_scroll_offset
444    }
445    pub fn set_predictor_scroll_offset(&mut self, offset: usize) {
446        self.predictor_scroll_offset = offset;
447    }
448    pub fn is_symbol_selector_open(&self) -> bool {
449        self.symbol_selector_open
450    }
451    pub fn set_symbol_selector_open(&mut self, open: bool) {
452        self.symbol_selector_open = open;
453    }
454    pub fn symbol_selector_index(&self) -> usize {
455        self.symbol_selector_index
456    }
457    pub fn set_symbol_selector_index(&mut self, idx: usize) {
458        self.symbol_selector_index = idx;
459    }
460    pub fn is_strategy_selector_open(&self) -> bool {
461        self.strategy_selector_open
462    }
463    pub fn set_strategy_selector_open(&mut self, open: bool) {
464        self.strategy_selector_open = open;
465    }
466    pub fn strategy_selector_index(&self) -> usize {
467        self.strategy_selector_index
468    }
469    pub fn set_strategy_selector_index(&mut self, idx: usize) {
470        self.strategy_selector_index = idx;
471    }
472    pub fn is_account_popup_open(&self) -> bool {
473        self.account_popup_open
474    }
475    pub fn set_account_popup_open(&mut self, open: bool) {
476        self.account_popup_open = open;
477    }
478    pub fn is_history_popup_open(&self) -> bool {
479        self.history_popup_open
480    }
481    pub fn set_history_popup_open(&mut self, open: bool) {
482        self.history_popup_open = open;
483    }
484    pub fn is_focus_popup_open(&self) -> bool {
485        self.focus_popup_open
486    }
487    pub fn set_focus_popup_open(&mut self, open: bool) {
488        self.focus_popup_open = open;
489    }
490    pub fn is_close_all_confirm_open(&self) -> bool {
491        self.close_all_confirm_open
492    }
493    pub fn set_close_all_confirm_open(&mut self, open: bool) {
494        self.close_all_confirm_open = open;
495    }
496    pub fn is_close_all_running(&self) -> bool {
497        self.close_all_running
498    }
499    pub fn close_all_status_text(&self) -> Option<String> {
500        let Some(job_id) = self.close_all_job_id else {
501            return None;
502        };
503        if self.close_all_total == 0 {
504            return Some(format!("close-all #{} RUNNING 0/0", job_id));
505        }
506        let ok = self
507            .close_all_completed
508            .saturating_sub(self.close_all_failed);
509        let current = self
510            .close_all_current_symbol
511            .as_ref()
512            .map(|s| format!(" current={}", s))
513            .unwrap_or_default();
514        let status = if self.close_all_running {
515            "RUNNING"
516        } else if self.close_all_failed == 0 {
517            "DONE"
518        } else if ok == 0 {
519            "FAILED"
520        } else {
521            "PARTIAL"
522        };
523        Some(format!(
524            "close-all #{} {} {}/{} ok:{} fail:{}{}",
525            job_id,
526            status,
527            self.close_all_completed,
528            self.close_all_total,
529            ok,
530            self.close_all_failed,
531            current
532        ))
533    }
534    pub fn is_strategy_editor_open(&self) -> bool {
535        self.strategy_editor_open
536    }
537    pub fn set_strategy_editor_open(&mut self, open: bool) {
538        self.strategy_editor_open = open;
539    }
540    pub fn focus_symbol(&self) -> Option<&str> {
541        self.ui_projection.focus.symbol.as_deref()
542    }
543    pub fn focus_strategy_id(&self) -> Option<&str> {
544        self.ui_projection.focus.strategy_id.as_deref()
545    }
546    pub fn set_focus_symbol(&mut self, symbol: Option<String>) {
547        self.ui_projection.focus.symbol = symbol;
548    }
549    pub fn set_focus_strategy_id(&mut self, strategy_id: Option<String>) {
550        self.ui_projection.focus.strategy_id = strategy_id;
551    }
552    pub fn focus_pair(&self) -> (Option<String>, Option<String>) {
553        (
554            self.ui_projection.focus.symbol.clone(),
555            self.ui_projection.focus.strategy_id.clone(),
556        )
557    }
558    pub fn assets_view(&self) -> &[AssetEntry] {
559        &self.ui_projection.assets
560    }
561
562    pub fn refresh_history_rows(&mut self) {
563        match order_store::load_realized_returns_by_bucket(self.history_bucket, 400) {
564            Ok(rows) => {
565                use std::collections::{BTreeMap, BTreeSet};
566
567                let mut date_set: BTreeSet<String> = BTreeSet::new();
568                let mut ticker_map: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
569                for row in rows {
570                    date_set.insert(row.date.clone());
571                    ticker_map
572                        .entry(row.symbol.clone())
573                        .or_default()
574                        .insert(row.date, row.realized_return_pct);
575                }
576
577                // Keep recent dates only to avoid horizontal overflow in terminal.
578                let mut dates: Vec<String> = date_set.into_iter().collect();
579                dates.sort();
580                const MAX_DATE_COLS: usize = 6;
581                if dates.len() > MAX_DATE_COLS {
582                    dates = dates[dates.len() - MAX_DATE_COLS..].to_vec();
583                }
584
585                let mut lines = Vec::new();
586                if dates.is_empty() {
587                    lines.push("Ticker            (no daily realized roi data)".to_string());
588                    self.history_rows = lines;
589                    return;
590                }
591
592                let mut header = format!("{:<14}", "Ticker");
593                for d in &dates {
594                    header.push_str(&format!(" {:>10}", d));
595                }
596                lines.push(header);
597
598                for (ticker, by_date) in ticker_map {
599                    let mut line = format!("{:<14}", ticker);
600                    for d in &dates {
601                        let cell = by_date
602                            .get(d)
603                            .map(|v| format!("{:.2}%", v))
604                            .unwrap_or_else(|| "-".to_string());
605                        line.push_str(&format!(" {:>10}", cell));
606                    }
607                    lines.push(line);
608                }
609                self.history_rows = lines;
610            }
611            Err(e) => {
612                self.history_rows = vec![
613                    "Ticker           Date         RealizedROI   RealizedPnL".to_string(),
614                    format!("(failed to load history: {})", e),
615                ];
616            }
617        }
618    }
619
620    fn refresh_equity_usdt(&mut self) {
621        let usdt = self.balances.get("USDT").copied().unwrap_or(0.0);
622        let btc = self.balances.get("BTC").copied().unwrap_or(0.0);
623        let mark_price = self
624            .last_price()
625            .or_else(|| (self.position.entry_price > 0.0).then_some(self.position.entry_price));
626        if let Some(price) = mark_price {
627            let total = usdt + btc * price;
628            self.current_equity_usdt = Some(total);
629            self.recompute_initial_equity_from_history();
630        }
631    }
632
633    fn recompute_initial_equity_from_history(&mut self) {
634        if let Some(current) = self.current_equity_usdt {
635            if let Some(total_pnl) = self.history_estimated_total_pnl_usdt {
636                self.initial_equity_usdt = Some(current - total_pnl);
637            } else if self.history_trade_count == 0 && self.initial_equity_usdt.is_none() {
638                self.initial_equity_usdt = Some(current);
639            }
640        }
641    }
642
643    fn candle_index_for_timestamp(&self, timestamp_ms: u64) -> Option<usize> {
644        if let Some((idx, _)) = self
645            .candles
646            .iter()
647            .enumerate()
648            .find(|(_, c)| timestamp_ms >= c.open_time && timestamp_ms < c.close_time)
649        {
650            return Some(idx);
651        }
652        if let Some(cb) = &self.current_candle {
653            if cb.contains(timestamp_ms) {
654                return Some(self.candles.len());
655            }
656        }
657        // Fallback: if timestamp is newer than the latest finalized candle range
658        // (e.g. coarse timeframe like 1M and no in-progress bucket), pin to nearest past candle.
659        if let Some((idx, _)) = self
660            .candles
661            .iter()
662            .enumerate()
663            .rev()
664            .find(|(_, c)| c.open_time <= timestamp_ms)
665        {
666            return Some(idx);
667        }
668        None
669    }
670
671    fn rebuild_fill_markers_from_history(&mut self, fills: &[OrderHistoryFill]) {
672        self.fill_markers.clear();
673        for fill in fills {
674            if let Some(candle_index) = self.candle_index_for_timestamp(fill.timestamp_ms) {
675                self.fill_markers.push(FillMarker {
676                    candle_index,
677                    price: fill.price,
678                    side: fill.side,
679                });
680            }
681        }
682        if self.fill_markers.len() > MAX_FILL_MARKERS {
683            let excess = self.fill_markers.len() - MAX_FILL_MARKERS;
684            self.fill_markers.drain(..excess);
685        }
686    }
687
688    fn sync_projection_portfolio_summary(&mut self) {
689        self.ui_projection.portfolio.total_equity_usdt = self.current_equity_usdt;
690        self.ui_projection.portfolio.total_realized_pnl_usdt =
691            self.portfolio_state.total_realized_pnl_usdt;
692        self.ui_projection.portfolio.total_unrealized_pnl_usdt =
693            self.portfolio_state.total_unrealized_pnl_usdt;
694        self.ui_projection.portfolio.ws_connected = self.ws_connected;
695    }
696
697    fn ensure_projection_focus_defaults(&mut self) {
698        if self.ui_projection.focus.symbol.is_none() {
699            self.ui_projection.focus.symbol = Some(self.symbol.clone());
700        }
701        if self.ui_projection.focus.strategy_id.is_none() {
702            self.ui_projection.focus.strategy_id = Some(self.strategy_label.clone());
703        }
704    }
705
706    fn rebuild_projection_preserve_focus(&mut self, prev_focus: (Option<String>, Option<String>)) {
707        let mut next = UiProjection::from_legacy(self);
708        if prev_focus.0.is_some() {
709            next.focus.symbol = prev_focus.0;
710        }
711        if prev_focus.1.is_some() {
712            next.focus.strategy_id = prev_focus.1;
713        }
714        self.ui_projection = next;
715        self.ensure_projection_focus_defaults();
716    }
717
718    pub fn apply(&mut self, event: AppEvent) {
719        let prev_focus = self.focus_pair();
720        let mut rebuild_projection = false;
721        match event {
722            AppEvent::MarketTick(tick) => {
723                rebuild_projection = true;
724                self.tick_count += 1;
725                let now_ms = chrono::Utc::now().timestamp_millis() as u64;
726                self.last_price_update_ms = Some(now_ms);
727                self.last_price_event_ms = Some(tick.timestamp_ms);
728                self.last_price_latency_ms = Some(now_ms.saturating_sub(tick.timestamp_ms));
729                Self::push_network_event_sample(&mut self.network_tick_in_timestamps_ms, now_ms);
730                if let Some(lat) = self.last_price_latency_ms {
731                    Self::push_latency_sample(&mut self.network_tick_latencies_ms, lat);
732                }
733
734                // Aggregate tick into candles
735                let should_new = match &self.current_candle {
736                    Some(cb) => !cb.contains(tick.timestamp_ms),
737                    None => true,
738                };
739                if should_new {
740                    if let Some(cb) = self.current_candle.take() {
741                        self.candles.push(cb.finish());
742                        if self.candles.len() > self.price_history_len {
743                            self.candles.remove(0);
744                            // Shift marker indices when oldest candle is trimmed.
745                            self.fill_markers.retain_mut(|m| {
746                                if m.candle_index == 0 {
747                                    false
748                                } else {
749                                    m.candle_index -= 1;
750                                    true
751                                }
752                            });
753                        }
754                    }
755                    self.current_candle = Some(CandleBuilder::new(
756                        tick.price,
757                        tick.timestamp_ms,
758                        self.candle_interval_ms,
759                    ));
760                } else if let Some(cb) = self.current_candle.as_mut() {
761                    cb.update(tick.price);
762                } else {
763                    // Defensive fallback: avoid panic if tick ordering/state gets out of sync.
764                    self.current_candle = Some(CandleBuilder::new(
765                        tick.price,
766                        tick.timestamp_ms,
767                        self.candle_interval_ms,
768                    ));
769                    self.push_log("[WARN] Recovered missing current candle state".to_string());
770                }
771
772                self.position.update_unrealized_pnl(tick.price);
773                self.refresh_equity_usdt();
774            }
775            AppEvent::StrategySignal {
776                ref signal,
777                symbol,
778                source_tag,
779                price,
780                timestamp_ms,
781            } => {
782                self.last_signal = Some(signal.clone());
783                let source_tag = source_tag.to_ascii_lowercase();
784                match signal {
785                    Signal::Buy { .. } => {
786                        let should_emit = self
787                            .strategy_last_event_by_tag
788                            .get(&source_tag)
789                            .map(|e| {
790                                e.side != OrderSide::Buy
791                                    || timestamp_ms.saturating_sub(e.timestamp_ms) >= 1000
792                            })
793                            .unwrap_or(true);
794                        if should_emit {
795                            let mut record = LogRecord::new(
796                                LogLevel::Info,
797                                LogDomain::Strategy,
798                                "signal.emit",
799                                format!(
800                                    "side=BUY price={}",
801                                    price
802                                        .map(|v| format!("{:.4}", v))
803                                        .unwrap_or_else(|| "-".to_string())
804                                ),
805                            );
806                            record.symbol = Some(symbol.clone());
807                            record.strategy_tag = Some(source_tag.clone());
808                            self.push_log_record(record);
809                        }
810                        self.strategy_last_event_by_tag.insert(
811                            source_tag.clone(),
812                            StrategyLastEvent {
813                                side: OrderSide::Buy,
814                                price,
815                                timestamp_ms,
816                                is_filled: false,
817                            },
818                        );
819                    }
820                    Signal::Sell { .. } => {
821                        let should_emit = self
822                            .strategy_last_event_by_tag
823                            .get(&source_tag)
824                            .map(|e| {
825                                e.side != OrderSide::Sell
826                                    || timestamp_ms.saturating_sub(e.timestamp_ms) >= 1000
827                            })
828                            .unwrap_or(true);
829                        if should_emit {
830                            let mut record = LogRecord::new(
831                                LogLevel::Info,
832                                LogDomain::Strategy,
833                                "signal.emit",
834                                format!(
835                                    "side=SELL price={}",
836                                    price
837                                        .map(|v| format!("{:.4}", v))
838                                        .unwrap_or_else(|| "-".to_string())
839                                ),
840                            );
841                            record.symbol = Some(symbol.clone());
842                            record.strategy_tag = Some(source_tag.clone());
843                            self.push_log_record(record);
844                        }
845                        self.strategy_last_event_by_tag.insert(
846                            source_tag.clone(),
847                            StrategyLastEvent {
848                                side: OrderSide::Sell,
849                                price,
850                                timestamp_ms,
851                                is_filled: false,
852                            },
853                        );
854                    }
855                    Signal::Hold => {}
856                }
857            }
858            AppEvent::StrategyState { fast_sma, slow_sma } => {
859                self.fast_sma = fast_sma;
860                self.slow_sma = slow_sma;
861            }
862            AppEvent::OrderUpdate(ref update) => {
863                rebuild_projection = true;
864                match update {
865                    OrderUpdate::Filled {
866                        intent_id,
867                        client_order_id,
868                        side,
869                        fills,
870                        avg_price,
871                    } => {
872                        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
873                        let source_tag = parse_source_tag_from_client_order_id(client_order_id)
874                            .map(|s| s.to_ascii_lowercase());
875                        if let Some(submit_ms) =
876                            self.network_pending_submit_ms_by_intent.remove(intent_id)
877                        {
878                            Self::push_latency_sample(
879                                &mut self.network_fill_latencies_ms,
880                                now_ms.saturating_sub(submit_ms),
881                            );
882                        } else if let Some(signal_ms) = source_tag
883                            .as_deref()
884                            .and_then(|tag| self.strategy_last_event_by_tag.get(tag))
885                            .map(|e| e.timestamp_ms)
886                        {
887                            // Fallback for immediate-fill paths where Submitted is not emitted.
888                            Self::push_latency_sample(
889                                &mut self.network_fill_latencies_ms,
890                                now_ms.saturating_sub(signal_ms),
891                            );
892                        }
893                        self.network_last_fill_ms = Some(now_ms);
894                        if let Some(source_tag) = source_tag {
895                            self.strategy_last_event_by_tag.insert(
896                                source_tag,
897                                StrategyLastEvent {
898                                    side: *side,
899                                    price: Some(*avg_price),
900                                    timestamp_ms: now_ms,
901                                    is_filled: true,
902                                },
903                            );
904                        }
905                        if let Some(summary) = format_last_applied_fee(&self.symbol, fills) {
906                            self.last_applied_fee = summary;
907                        }
908                        self.position.apply_fill(*side, fills);
909                        self.refresh_equity_usdt();
910                        let candle_index = if self.current_candle.is_some() {
911                            self.candles.len()
912                        } else {
913                            self.candles.len().saturating_sub(1)
914                        };
915                        self.fill_markers.push(FillMarker {
916                            candle_index,
917                            price: *avg_price,
918                            side: *side,
919                        });
920                        if self.fill_markers.len() > MAX_FILL_MARKERS {
921                            self.fill_markers.remove(0);
922                        }
923                        let mut record = LogRecord::new(
924                            LogLevel::Info,
925                            LogDomain::Order,
926                            "fill.received",
927                            format!(
928                                "side={} client_order_id={} intent_id={} avg_price={:.2}",
929                                side, client_order_id, intent_id, avg_price
930                            ),
931                        );
932                        record.symbol = Some(self.symbol.clone());
933                        record.strategy_tag =
934                            parse_source_tag_from_client_order_id(client_order_id)
935                                .map(|s| s.to_ascii_lowercase());
936                        self.push_log_record(record);
937                    }
938                    OrderUpdate::Submitted {
939                        intent_id,
940                        client_order_id,
941                        server_order_id,
942                    } => {
943                        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
944                        self.network_pending_submit_ms_by_intent
945                            .insert(intent_id.clone(), now_ms);
946                        self.refresh_equity_usdt();
947                        let mut record = LogRecord::new(
948                            LogLevel::Info,
949                            LogDomain::Order,
950                            "submit.accepted",
951                            format!(
952                                "client_order_id={} server_order_id={} intent_id={}",
953                                client_order_id, server_order_id, intent_id
954                            ),
955                        );
956                        record.symbol = Some(self.symbol.clone());
957                        record.strategy_tag =
958                            parse_source_tag_from_client_order_id(client_order_id)
959                                .map(|s| s.to_ascii_lowercase());
960                        self.push_log_record(record);
961                    }
962                    OrderUpdate::Rejected {
963                        intent_id,
964                        client_order_id,
965                        reason_code,
966                        reason,
967                    } => {
968                        let level = if reason_code == "risk.qty_too_small" {
969                            LogLevel::Warn
970                        } else {
971                            LogLevel::Error
972                        };
973                        let mut record = LogRecord::new(
974                            level,
975                            LogDomain::Order,
976                            "reject.received",
977                            format!(
978                                "client_order_id={} intent_id={} reason_code={} reason={}",
979                                client_order_id, intent_id, reason_code, reason
980                            ),
981                        );
982                        record.symbol = Some(self.symbol.clone());
983                        record.strategy_tag =
984                            parse_source_tag_from_client_order_id(client_order_id)
985                                .map(|s| s.to_ascii_lowercase());
986                        self.push_log_record(record);
987                    }
988                }
989                self.last_order = Some(update.clone());
990            }
991            AppEvent::WsStatus(ref status) => match status {
992                WsConnectionStatus::Connected => {
993                    self.ws_connected = true;
994                }
995                WsConnectionStatus::Disconnected => {
996                    self.ws_connected = false;
997                    let now_ms = chrono::Utc::now().timestamp_millis() as u64;
998                    Self::push_network_event_sample(
999                        &mut self.network_disconnect_timestamps_ms,
1000                        now_ms,
1001                    );
1002                    self.push_log("[WARN] WebSocket Disconnected".to_string());
1003                }
1004                WsConnectionStatus::Reconnecting { attempt, delay_ms } => {
1005                    self.ws_connected = false;
1006                    self.network_reconnect_count += 1;
1007                    let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1008                    Self::push_network_event_sample(
1009                        &mut self.network_reconnect_timestamps_ms,
1010                        now_ms,
1011                    );
1012                    self.push_log(format!(
1013                        "[WARN] Reconnecting (attempt {}, wait {}ms)",
1014                        attempt, delay_ms
1015                    ));
1016                }
1017            },
1018            AppEvent::HistoricalCandles {
1019                candles,
1020                interval_ms,
1021                interval,
1022            } => {
1023                rebuild_projection = true;
1024                self.candles = candles;
1025                if self.candles.len() > self.price_history_len {
1026                    let excess = self.candles.len() - self.price_history_len;
1027                    self.candles.drain(..excess);
1028                }
1029                self.candle_interval_ms = interval_ms;
1030                self.timeframe = interval;
1031                self.current_candle = None;
1032                let fills = self.history_fills.clone();
1033                self.rebuild_fill_markers_from_history(&fills);
1034                self.push_log(format!(
1035                    "Switched to {} ({} candles)",
1036                    self.timeframe,
1037                    self.candles.len()
1038                ));
1039            }
1040            AppEvent::BalanceUpdate(balances) => {
1041                rebuild_projection = true;
1042                self.balances = balances;
1043                self.refresh_equity_usdt();
1044            }
1045            AppEvent::OrderHistoryUpdate(snapshot) => {
1046                rebuild_projection = true;
1047                let mut open = Vec::new();
1048                let mut filled = Vec::new();
1049
1050                for row in snapshot.rows {
1051                    let status = row.split_whitespace().nth(1).unwrap_or_default();
1052                    if status == "FILLED" {
1053                        filled.push(row);
1054                    } else {
1055                        open.push(row);
1056                    }
1057                }
1058
1059                if open.len() > MAX_LOG_MESSAGES {
1060                    let excess = open.len() - MAX_LOG_MESSAGES;
1061                    open.drain(..excess);
1062                }
1063                if filled.len() > MAX_LOG_MESSAGES {
1064                    let excess = filled.len() - MAX_LOG_MESSAGES;
1065                    filled.drain(..excess);
1066                }
1067
1068                self.open_order_history = open;
1069                self.filled_order_history = filled;
1070                if snapshot.trade_data_complete {
1071                    let stats_looks_reset = snapshot.stats.trade_count == 0
1072                        && (self.history_trade_count > 0 || !self.history_fills.is_empty());
1073                    if stats_looks_reset {
1074                        if !self.trade_stats_reset_warned {
1075                            self.push_log(
1076                                "[WARN] Ignored transient trade stats reset from order-history sync"
1077                                    .to_string(),
1078                            );
1079                            self.trade_stats_reset_warned = true;
1080                        }
1081                    } else {
1082                        self.trade_stats_reset_warned = false;
1083                        self.history_trade_count = snapshot.stats.trade_count;
1084                        self.history_win_count = snapshot.stats.win_count;
1085                        self.history_lose_count = snapshot.stats.lose_count;
1086                        self.history_realized_pnl = snapshot.stats.realized_pnl;
1087                        // Keep position panel aligned with exchange history state
1088                        // so Qty/Entry/UnrPL reflect actual holdings, not only session fills.
1089                        if snapshot.open_qty > f64::EPSILON {
1090                            self.position.side = Some(OrderSide::Buy);
1091                            self.position.qty = snapshot.open_qty;
1092                            self.position.entry_price = snapshot.open_entry_price;
1093                            if let Some(px) = self.last_price() {
1094                                self.position.unrealized_pnl =
1095                                    (px - snapshot.open_entry_price) * snapshot.open_qty;
1096                            }
1097                        } else {
1098                            self.position.side = None;
1099                            self.position.qty = 0.0;
1100                            self.position.entry_price = 0.0;
1101                            self.position.unrealized_pnl = 0.0;
1102                        }
1103                    }
1104                    if !snapshot.fills.is_empty() || self.history_fills.is_empty() {
1105                        self.history_fills = snapshot.fills.clone();
1106                        self.rebuild_fill_markers_from_history(&snapshot.fills);
1107                    }
1108                    self.history_estimated_total_pnl_usdt = snapshot.estimated_total_pnl_usdt;
1109                    self.recompute_initial_equity_from_history();
1110                }
1111                self.last_order_history_update_ms = Some(snapshot.fetched_at_ms);
1112                self.last_order_history_event_ms = snapshot.latest_event_ms;
1113                self.last_order_history_latency_ms = Some(snapshot.fetch_latency_ms);
1114                Self::push_latency_sample(
1115                    &mut self.network_order_sync_latencies_ms,
1116                    snapshot.fetch_latency_ms,
1117                );
1118                self.refresh_history_rows();
1119            }
1120            AppEvent::StrategyStatsUpdate { strategy_stats } => {
1121                rebuild_projection = true;
1122                self.strategy_stats = strategy_stats;
1123            }
1124            AppEvent::PredictorMetricsUpdate {
1125                symbol,
1126                market,
1127                predictor,
1128                horizon,
1129                r2,
1130                hit_rate,
1131                mae,
1132                sample_count,
1133                updated_at_ms,
1134            } => {
1135                let key = predictor_metrics_scope_key(&symbol, &market, &predictor, &horizon);
1136                self.predictor_metrics_by_scope.insert(
1137                    key,
1138                    PredictorMetricEntry {
1139                        symbol,
1140                        market,
1141                        predictor,
1142                        horizon,
1143                        r2,
1144                        hit_rate,
1145                        mae,
1146                        sample_count,
1147                        updated_at_ms,
1148                    },
1149                );
1150            }
1151            AppEvent::ExitPolicyUpdate {
1152                symbol,
1153                source_tag,
1154                stop_price,
1155                expected_holding_ms,
1156                protective_stop_ok,
1157            } => {
1158                let key = strategy_stats_scope_key(&symbol, &source_tag);
1159                self.exit_policy_by_scope.insert(
1160                    key,
1161                    ExitPolicyEntry {
1162                        stop_price,
1163                        expected_holding_ms,
1164                        protective_stop_ok,
1165                        updated_at_ms: chrono::Utc::now().timestamp_millis() as u64,
1166                    },
1167                );
1168            }
1169            AppEvent::PortfolioStateUpdate { snapshot } => {
1170                rebuild_projection = true;
1171                self.portfolio_state = snapshot;
1172            }
1173            AppEvent::RiskRateSnapshot {
1174                global,
1175                orders,
1176                account,
1177                market_data,
1178            } => {
1179                self.rate_budget_global = global;
1180                self.rate_budget_orders = orders;
1181                self.rate_budget_account = account;
1182                self.rate_budget_market_data = market_data;
1183            }
1184            AppEvent::CloseAllRequested {
1185                job_id,
1186                total,
1187                symbols,
1188            } => {
1189                self.close_all_running = true;
1190                self.close_all_job_id = Some(job_id);
1191                self.close_all_total = total;
1192                self.close_all_completed = 0;
1193                self.close_all_failed = 0;
1194                self.close_all_current_symbol = None;
1195                self.close_all_status_expire_at_ms = None;
1196                self.close_all_row_status_by_symbol.clear();
1197                for symbol in symbols {
1198                    self.close_all_row_status_by_symbol
1199                        .insert(normalize_symbol_for_scope(&symbol), "PENDING".to_string());
1200                }
1201                self.push_log(format!(
1202                    "[INFO] close-all #{} started total={}",
1203                    job_id, total
1204                ));
1205            }
1206            AppEvent::CloseAllProgress {
1207                job_id,
1208                symbol,
1209                completed,
1210                total,
1211                failed,
1212                reason,
1213            } => {
1214                if self.close_all_job_id != Some(job_id) {
1215                    self.close_all_job_id = Some(job_id);
1216                }
1217                self.close_all_running = completed < total;
1218                self.close_all_total = total;
1219                self.close_all_completed = completed;
1220                self.close_all_failed = failed;
1221                self.close_all_current_symbol = Some(symbol.clone());
1222                let symbol_key = normalize_symbol_for_scope(&symbol);
1223                let row_status = if let Some(r) = reason.as_ref() {
1224                    if r.contains("too small") || r.contains("No ") || r.contains("Insufficient ") {
1225                        "SKIP"
1226                    } else {
1227                        "FAIL"
1228                    }
1229                } else {
1230                    "DONE"
1231                };
1232                self.close_all_row_status_by_symbol
1233                    .insert(symbol_key, row_status.to_string());
1234                let ok = completed.saturating_sub(failed);
1235                self.push_log(format!(
1236                    "[INFO] close-all #{} progress {}/{} ok={} fail={} symbol={}",
1237                    job_id, completed, total, ok, failed, symbol
1238                ));
1239                if let Some(r) = reason {
1240                    self.push_log(format!(
1241                        "[WARN] close-all #{} {} ({}/{}) reason={}",
1242                        job_id, symbol, completed, total, r
1243                    ));
1244                }
1245            }
1246            AppEvent::CloseAllFinished {
1247                job_id,
1248                completed,
1249                total,
1250                failed,
1251            } => {
1252                self.close_all_running = false;
1253                self.close_all_job_id = Some(job_id);
1254                self.close_all_total = total;
1255                self.close_all_completed = completed;
1256                self.close_all_failed = failed;
1257                self.close_all_status_expire_at_ms =
1258                    Some((chrono::Utc::now().timestamp_millis() as u64).saturating_add(5_000));
1259                let ok = completed.saturating_sub(failed);
1260                self.push_log(format!(
1261                    "[INFO] close-all #{} finished ok={} fail={} total={}",
1262                    job_id, ok, failed, total
1263                ));
1264            }
1265            AppEvent::TickDropped => {
1266                self.network_tick_drop_count = self.network_tick_drop_count.saturating_add(1);
1267                let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1268                Self::push_network_event_sample(&mut self.network_tick_drop_timestamps_ms, now_ms);
1269            }
1270            AppEvent::LogRecord(record) => {
1271                self.push_log_record(record);
1272            }
1273            AppEvent::LogMessage(msg) => {
1274                self.push_log(msg);
1275            }
1276            AppEvent::Error(msg) => {
1277                self.push_log(format!("[ERR] {}", msg));
1278            }
1279        }
1280        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1281        if !self.close_all_running
1282            && self
1283                .close_all_status_expire_at_ms
1284                .map(|ts| now_ms >= ts)
1285                .unwrap_or(false)
1286        {
1287            self.close_all_job_id = None;
1288            self.close_all_total = 0;
1289            self.close_all_completed = 0;
1290            self.close_all_failed = 0;
1291            self.close_all_current_symbol = None;
1292            self.close_all_status_expire_at_ms = None;
1293            self.close_all_row_status_by_symbol.clear();
1294        }
1295        self.prune_network_event_windows(now_ms);
1296        self.sync_projection_portfolio_summary();
1297        if rebuild_projection {
1298            self.rebuild_projection_preserve_focus(prev_focus);
1299        } else {
1300            self.ensure_projection_focus_defaults();
1301        }
1302    }
1303}
1304
1305pub fn render(frame: &mut Frame, state: &AppState) {
1306    let view = state.view_state();
1307    if view.is_grid_open {
1308        render_grid_popup(frame, state);
1309        if view.is_strategy_editor_open {
1310            render_strategy_editor_popup(frame, state);
1311        }
1312        if view.is_close_all_confirm_open {
1313            render_close_all_confirm_popup(frame);
1314        }
1315        return;
1316    }
1317
1318    let area = frame.area();
1319    let status_h = 1u16;
1320    let keybind_h = 1u16;
1321    let min_main_h = 8u16;
1322    let mut order_log_h = 5u16;
1323    let mut order_history_h = 6u16;
1324    let mut system_log_h = 8u16;
1325    let mut lower_total = order_log_h + order_history_h + system_log_h;
1326    let lower_budget = area
1327        .height
1328        .saturating_sub(status_h + keybind_h + min_main_h);
1329    if lower_total > lower_budget {
1330        let mut overflow = lower_total - lower_budget;
1331        while overflow > 0 && system_log_h > 0 {
1332            system_log_h -= 1;
1333            overflow -= 1;
1334        }
1335        while overflow > 0 && order_history_h > 0 {
1336            order_history_h -= 1;
1337            overflow -= 1;
1338        }
1339        while overflow > 0 && order_log_h > 0 {
1340            order_log_h -= 1;
1341            overflow -= 1;
1342        }
1343        lower_total = order_log_h + order_history_h + system_log_h;
1344        if lower_total > lower_budget {
1345            // Extremely small terminal fallback: keep bottom panels hidden first.
1346            order_log_h = 0;
1347            order_history_h = 0;
1348            system_log_h = 0;
1349        }
1350    }
1351
1352    let outer = Layout::default()
1353        .direction(Direction::Vertical)
1354        .constraints([
1355            Constraint::Length(status_h),        // status bar
1356            Constraint::Min(min_main_h),         // main area (chart + position)
1357            Constraint::Length(order_log_h),     // order log
1358            Constraint::Length(order_history_h), // order history
1359            Constraint::Length(system_log_h),    // system log
1360            Constraint::Length(keybind_h),       // keybinds
1361        ])
1362        .split(area);
1363
1364    let close_all_status_text = state.close_all_status_text();
1365    // Status bar
1366    frame.render_widget(
1367        StatusBar {
1368            symbol: &state.symbol,
1369            strategy_label: &state.strategy_label,
1370            ws_connected: state.ws_connected,
1371            paused: state.paused,
1372            timeframe: &state.timeframe,
1373            last_price_update_ms: state.last_price_update_ms,
1374            last_price_latency_ms: state.last_price_latency_ms,
1375            last_order_history_update_ms: state.last_order_history_update_ms,
1376            last_order_history_latency_ms: state.last_order_history_latency_ms,
1377            close_all_status: close_all_status_text.as_deref(),
1378            close_all_running: state.close_all_running,
1379        },
1380        outer[0],
1381    );
1382
1383    // Main area: chart only (position/strategy side panels removed).
1384    let main_area = Layout::default()
1385        .direction(Direction::Horizontal)
1386        .constraints([Constraint::Min(40)])
1387        .split(outer[1]);
1388    let selected_strategy_stats =
1389        strategy_stats_for_item(&state.strategy_stats, &state.strategy_label, &state.symbol)
1390            .cloned()
1391            .unwrap_or_default();
1392
1393    // Price chart (candles + in-progress candle)
1394    frame.render_widget(
1395        PriceChart::new(&state.candles, &state.symbol)
1396            .current_candle(state.current_candle.as_ref())
1397            .fill_markers(&state.fill_markers)
1398            .fast_sma(state.fast_sma)
1399            .slow_sma(state.slow_sma),
1400        main_area[0],
1401    );
1402
1403    // Order log
1404    frame.render_widget(
1405        OrderLogPanel::new(
1406            &state.last_signal,
1407            &state.last_order,
1408            state.fast_sma,
1409            state.slow_sma,
1410            selected_strategy_stats.trade_count,
1411            selected_strategy_stats.win_count,
1412            selected_strategy_stats.lose_count,
1413            selected_strategy_stats.realized_pnl,
1414        ),
1415        outer[2],
1416    );
1417
1418    // Order history panel
1419    frame.render_widget(
1420        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1421        outer[3],
1422    );
1423
1424    // System log panel
1425    frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
1426
1427    // Keybind bar
1428    frame.render_widget(KeybindBar, outer[5]);
1429
1430    if view.is_close_all_confirm_open {
1431        render_close_all_confirm_popup(frame);
1432    } else if view.is_symbol_selector_open {
1433        render_selector_popup(
1434            frame,
1435            " Select Symbol ",
1436            &state.symbol_items,
1437            view.selected_symbol_selector_index,
1438            None,
1439            None,
1440            None,
1441        );
1442    } else if view.is_strategy_selector_open {
1443        let selected_strategy_symbol = state
1444            .strategy_item_symbols
1445            .get(view.selected_strategy_selector_index)
1446            .map(String::as_str)
1447            .unwrap_or(state.symbol.as_str());
1448        render_selector_popup(
1449            frame,
1450            " Select Strategy ",
1451            &state.strategy_items,
1452            view.selected_strategy_selector_index,
1453            Some(&state.strategy_stats),
1454            Some(OrderHistoryStats {
1455                trade_count: state.history_trade_count,
1456                win_count: state.history_win_count,
1457                lose_count: state.history_lose_count,
1458                realized_pnl: state.history_realized_pnl,
1459            }),
1460            Some(selected_strategy_symbol),
1461        );
1462    } else if view.is_account_popup_open {
1463        render_account_popup(frame, &state.balances);
1464    } else if view.is_history_popup_open {
1465        render_history_popup(frame, &state.history_rows, state.history_bucket);
1466    } else if view.is_focus_popup_open {
1467        render_focus_popup(frame, state);
1468    } else if view.is_strategy_editor_open {
1469        render_strategy_editor_popup(frame, state);
1470    }
1471}
1472
1473fn render_focus_popup(frame: &mut Frame, state: &AppState) {
1474    let area = frame.area();
1475    let popup = Rect {
1476        x: area.x + 1,
1477        y: area.y + 1,
1478        width: area.width.saturating_sub(2).max(70),
1479        height: area.height.saturating_sub(2).max(22),
1480    };
1481    frame.render_widget(Clear, popup);
1482    let block = Block::default()
1483        .title(" Focus View (Drill-down) ")
1484        .borders(Borders::ALL)
1485        .border_style(Style::default().fg(Color::Green));
1486    let inner = block.inner(popup);
1487    frame.render_widget(block, popup);
1488
1489    let rows = Layout::default()
1490        .direction(Direction::Vertical)
1491        .constraints([
1492            Constraint::Length(2),
1493            Constraint::Min(8),
1494            Constraint::Length(7),
1495        ])
1496        .split(inner);
1497
1498    let focus_symbol = state.focus_symbol().unwrap_or(&state.symbol);
1499    let focus_strategy = state.focus_strategy_id().unwrap_or(&state.strategy_label);
1500    frame.render_widget(
1501        Paragraph::new(vec![
1502            Line::from(vec![
1503                Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
1504                Span::styled(
1505                    focus_symbol,
1506                    Style::default()
1507                        .fg(Color::Cyan)
1508                        .add_modifier(Modifier::BOLD),
1509                ),
1510                Span::styled("  Strategy: ", Style::default().fg(Color::DarkGray)),
1511                Span::styled(
1512                    focus_strategy,
1513                    Style::default()
1514                        .fg(Color::Magenta)
1515                        .add_modifier(Modifier::BOLD),
1516                ),
1517            ]),
1518            Line::from(Span::styled(
1519                "Reuse legacy chart/position/history widgets. Press [F]/[Esc] to close.",
1520                Style::default().fg(Color::DarkGray),
1521            )),
1522        ]),
1523        rows[0],
1524    );
1525
1526    let main_cols = Layout::default()
1527        .direction(Direction::Horizontal)
1528        .constraints([Constraint::Min(48)])
1529        .split(rows[1]);
1530
1531    frame.render_widget(
1532        PriceChart::new(&state.candles, focus_symbol)
1533            .current_candle(state.current_candle.as_ref())
1534            .fill_markers(&state.fill_markers)
1535            .fast_sma(state.fast_sma)
1536            .slow_sma(state.slow_sma),
1537        main_cols[0],
1538    );
1539    frame.render_widget(
1540        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1541        rows[2],
1542    );
1543}
1544
1545fn render_close_all_confirm_popup(frame: &mut Frame) {
1546    let area = frame.area();
1547    let popup = Rect {
1548        x: area.x + area.width.saturating_sub(60) / 2,
1549        y: area.y + area.height.saturating_sub(7) / 2,
1550        width: 60.min(area.width.saturating_sub(2)).max(40),
1551        height: 7.min(area.height.saturating_sub(2)).max(5),
1552    };
1553    frame.render_widget(Clear, popup);
1554    let block = Block::default()
1555        .title(" Confirm Close-All ")
1556        .borders(Borders::ALL)
1557        .border_style(Style::default().fg(Color::Red));
1558    let inner = block.inner(popup);
1559    frame.render_widget(block, popup);
1560    let lines = vec![
1561        Line::from(Span::styled(
1562            "Close all open positions now?",
1563            Style::default()
1564                .fg(Color::White)
1565                .add_modifier(Modifier::BOLD),
1566        )),
1567        Line::from(Span::styled(
1568            "[Y/Enter] Confirm   [N/Esc] Cancel",
1569            Style::default().fg(Color::DarkGray),
1570        )),
1571    ];
1572    frame.render_widget(Paragraph::new(lines), inner);
1573}
1574
1575fn render_grid_popup(frame: &mut Frame, state: &AppState) {
1576    let view = state.view_state();
1577    let selected_grid_tab = match view.selected_grid_tab {
1578        GridTab::Assets | GridTab::Strategies | GridTab::Positions => GridTab::Risk,
1579        other => other,
1580    };
1581    let area = frame.area();
1582    let popup = area;
1583    frame.render_widget(Clear, popup);
1584    let block = Block::default()
1585        .title(" Portfolio Grid ")
1586        .borders(Borders::ALL)
1587        .border_style(Style::default().fg(Color::Cyan));
1588    let inner = block.inner(popup);
1589    frame.render_widget(block, popup);
1590
1591    let root = Layout::default()
1592        .direction(Direction::Vertical)
1593        .constraints([Constraint::Length(2), Constraint::Min(1)])
1594        .split(inner);
1595    let tab_area = root[0];
1596    let body_area = root[1];
1597
1598    let tab_span = |tab: GridTab, key: &str, label: &str| -> Span<'_> {
1599        let selected = selected_grid_tab == tab;
1600        Span::styled(
1601            format!("[{} {}]", key, label),
1602            if selected {
1603                Style::default()
1604                    .fg(Color::Yellow)
1605                    .add_modifier(Modifier::BOLD)
1606            } else {
1607                Style::default().fg(Color::DarkGray)
1608            },
1609        )
1610    };
1611    frame.render_widget(
1612        Paragraph::new(Line::from(vec![
1613            tab_span(GridTab::Risk, "1", "Portfolio"),
1614            Span::raw(" "),
1615            tab_span(GridTab::Network, "2", "Network"),
1616            Span::raw(" "),
1617            tab_span(GridTab::History, "3", "History"),
1618            Span::raw(" "),
1619            tab_span(GridTab::Predictors, "4", "Predictors"),
1620            Span::raw(" "),
1621            tab_span(GridTab::SystemLog, "5", "SystemLog"),
1622        ])),
1623        tab_area,
1624    );
1625
1626    let global_pressure =
1627        state.rate_budget_global.used as f64 / (state.rate_budget_global.limit.max(1) as f64);
1628    let orders_pressure =
1629        state.rate_budget_orders.used as f64 / (state.rate_budget_orders.limit.max(1) as f64);
1630    let account_pressure =
1631        state.rate_budget_account.used as f64 / (state.rate_budget_account.limit.max(1) as f64);
1632    let market_pressure = state.rate_budget_market_data.used as f64
1633        / (state.rate_budget_market_data.limit.max(1) as f64);
1634    let max_pressure = global_pressure
1635        .max(orders_pressure)
1636        .max(account_pressure)
1637        .max(market_pressure);
1638    let (risk_label, risk_color) = if max_pressure >= 0.90 {
1639        ("CRIT", Color::Red)
1640    } else if max_pressure >= 0.70 {
1641        ("WARN", Color::Yellow)
1642    } else {
1643        ("OK", Color::Green)
1644    };
1645
1646    if selected_grid_tab == GridTab::Assets {
1647        let spot_assets: Vec<&AssetEntry> = state
1648            .assets_view()
1649            .iter()
1650            .filter(|a| !a.is_futures)
1651            .collect();
1652        let fut_assets: Vec<&AssetEntry> = state
1653            .assets_view()
1654            .iter()
1655            .filter(|a| a.is_futures)
1656            .collect();
1657        let spot_total_rlz: f64 = spot_assets.iter().map(|a| a.realized_pnl_usdt).sum();
1658        let spot_total_unrlz: f64 = spot_assets.iter().map(|a| a.unrealized_pnl_usdt).sum();
1659        let fut_total_rlz: f64 = fut_assets.iter().map(|a| a.realized_pnl_usdt).sum();
1660        let fut_total_unrlz: f64 = fut_assets.iter().map(|a| a.unrealized_pnl_usdt).sum();
1661        let total_rlz = spot_total_rlz + fut_total_rlz;
1662        let total_unrlz = spot_total_unrlz + fut_total_unrlz;
1663        let total_pnl = total_rlz + total_unrlz;
1664        let panel_chunks = Layout::default()
1665            .direction(Direction::Vertical)
1666            .constraints([
1667                Constraint::Percentage(46),
1668                Constraint::Percentage(46),
1669                Constraint::Length(3),
1670                Constraint::Length(1),
1671            ])
1672            .split(body_area);
1673
1674        let spot_header = Row::new(vec![
1675            Cell::from("Asset"),
1676            Cell::from("Qty"),
1677            Cell::from("Price"),
1678            Cell::from("RlzPnL"),
1679            Cell::from("UnrPnL"),
1680        ])
1681        .style(Style::default().fg(Color::DarkGray));
1682        let mut spot_rows: Vec<Row> = spot_assets
1683            .iter()
1684            .map(|a| {
1685                Row::new(vec![
1686                    Cell::from(a.symbol.clone()),
1687                    Cell::from(format!("{:.5}", a.position_qty)),
1688                    Cell::from(
1689                        a.last_price
1690                            .map(|v| format!("{:.2}", v))
1691                            .unwrap_or_else(|| "---".to_string()),
1692                    ),
1693                    Cell::from(format!("{:+.4}", a.realized_pnl_usdt)),
1694                    Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
1695                ])
1696            })
1697            .collect();
1698        if spot_rows.is_empty() {
1699            spot_rows.push(
1700                Row::new(vec![
1701                    Cell::from("(no spot assets)"),
1702                    Cell::from("-"),
1703                    Cell::from("-"),
1704                    Cell::from("-"),
1705                    Cell::from("-"),
1706                ])
1707                .style(Style::default().fg(Color::DarkGray)),
1708            );
1709        }
1710        frame.render_widget(
1711            Table::new(
1712                spot_rows,
1713                [
1714                    Constraint::Length(16),
1715                    Constraint::Length(12),
1716                    Constraint::Length(10),
1717                    Constraint::Length(10),
1718                    Constraint::Length(10),
1719                ],
1720            )
1721            .header(spot_header)
1722            .column_spacing(1)
1723            .block(
1724                Block::default()
1725                    .title(format!(
1726                        " Spot Assets | Total {} | PnL {:+.4} (R {:+.4} / U {:+.4}) ",
1727                        spot_assets.len(),
1728                        spot_total_rlz + spot_total_unrlz,
1729                        spot_total_rlz,
1730                        spot_total_unrlz
1731                    ))
1732                    .borders(Borders::ALL)
1733                    .border_style(Style::default().fg(Color::DarkGray)),
1734            ),
1735            panel_chunks[0],
1736        );
1737
1738        let fut_header = Row::new(vec![
1739            Cell::from("Symbol"),
1740            Cell::from("Side"),
1741            Cell::from("PosQty"),
1742            Cell::from("Entry"),
1743            Cell::from("RlzPnL"),
1744            Cell::from("UnrPnL"),
1745        ])
1746        .style(Style::default().fg(Color::DarkGray));
1747        let mut fut_rows: Vec<Row> = fut_assets
1748            .iter()
1749            .map(|a| {
1750                Row::new(vec![
1751                    Cell::from(a.symbol.clone()),
1752                    Cell::from(a.side.clone().unwrap_or_else(|| "-".to_string())),
1753                    Cell::from(format!("{:.5}", a.position_qty)),
1754                    Cell::from(
1755                        a.entry_price
1756                            .map(|v| format!("{:.2}", v))
1757                            .unwrap_or_else(|| "---".to_string()),
1758                    ),
1759                    Cell::from(format!("{:+.4}", a.realized_pnl_usdt)),
1760                    Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
1761                ])
1762            })
1763            .collect();
1764        if fut_rows.is_empty() {
1765            fut_rows.push(
1766                Row::new(vec![
1767                    Cell::from("(no futures positions)"),
1768                    Cell::from("-"),
1769                    Cell::from("-"),
1770                    Cell::from("-"),
1771                    Cell::from("-"),
1772                    Cell::from("-"),
1773                ])
1774                .style(Style::default().fg(Color::DarkGray)),
1775            );
1776        }
1777        frame.render_widget(
1778            Table::new(
1779                fut_rows,
1780                [
1781                    Constraint::Length(18),
1782                    Constraint::Length(8),
1783                    Constraint::Length(10),
1784                    Constraint::Length(10),
1785                    Constraint::Length(10),
1786                    Constraint::Length(10),
1787                ],
1788            )
1789            .header(fut_header)
1790            .column_spacing(1)
1791            .block(
1792                Block::default()
1793                    .title(format!(
1794                        " Futures Positions | Total {} | PnL {:+.4} (R {:+.4} / U {:+.4}) ",
1795                        fut_assets.len(),
1796                        fut_total_rlz + fut_total_unrlz,
1797                        fut_total_rlz,
1798                        fut_total_unrlz
1799                    ))
1800                    .borders(Borders::ALL)
1801                    .border_style(Style::default().fg(Color::DarkGray)),
1802            ),
1803            panel_chunks[1],
1804        );
1805        let total_color = if total_pnl > 0.0 {
1806            Color::Green
1807        } else if total_pnl < 0.0 {
1808            Color::Red
1809        } else {
1810            Color::DarkGray
1811        };
1812        frame.render_widget(
1813            Paragraph::new(Line::from(vec![
1814                Span::styled(" Total PnL: ", Style::default().fg(Color::DarkGray)),
1815                Span::styled(
1816                    format!("{:+.4}", total_pnl),
1817                    Style::default()
1818                        .fg(total_color)
1819                        .add_modifier(Modifier::BOLD),
1820                ),
1821                Span::styled(
1822                    format!(
1823                        "   Realized: {:+.4}   Unrealized: {:+.4}",
1824                        total_rlz, total_unrlz
1825                    ),
1826                    Style::default().fg(Color::DarkGray),
1827                ),
1828            ]))
1829            .block(
1830                Block::default()
1831                    .borders(Borders::ALL)
1832                    .border_style(Style::default().fg(Color::DarkGray)),
1833            ),
1834            panel_chunks[2],
1835        );
1836        frame.render_widget(
1837            Paragraph::new("[1/2/3/4/5] tab  [U] <$1 filter  [Z] close-all  [G/Esc] close"),
1838            panel_chunks[3],
1839        );
1840        return;
1841    }
1842
1843    if selected_grid_tab == GridTab::Risk {
1844        let chunks = Layout::default()
1845            .direction(Direction::Vertical)
1846            .constraints([
1847                Constraint::Length(2),
1848                Constraint::Length(7),
1849                Constraint::Length(4),
1850                Constraint::Min(4),
1851                Constraint::Length(1),
1852            ])
1853            .split(body_area);
1854        frame.render_widget(
1855            Paragraph::new(Line::from(vec![
1856                Span::styled("Portfolio: ", Style::default().fg(Color::DarkGray)),
1857                Span::styled(
1858                    risk_label,
1859                    Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
1860                ),
1861                Span::styled(
1862                    "  (70%=WARN, 90%=CRIT)",
1863                    Style::default().fg(Color::DarkGray),
1864                ),
1865            ])),
1866            chunks[0],
1867        );
1868        let risk_rows = vec![
1869            Row::new(vec![
1870                Cell::from("GLOBAL"),
1871                Cell::from(format!(
1872                    "{}/{}",
1873                    state.rate_budget_global.used, state.rate_budget_global.limit
1874                )),
1875                Cell::from(format!("{}ms", state.rate_budget_global.reset_in_ms)),
1876            ]),
1877            Row::new(vec![
1878                Cell::from("ORDERS"),
1879                Cell::from(format!(
1880                    "{}/{}",
1881                    state.rate_budget_orders.used, state.rate_budget_orders.limit
1882                )),
1883                Cell::from(format!("{}ms", state.rate_budget_orders.reset_in_ms)),
1884            ]),
1885            Row::new(vec![
1886                Cell::from("ACCOUNT"),
1887                Cell::from(format!(
1888                    "{}/{}",
1889                    state.rate_budget_account.used, state.rate_budget_account.limit
1890                )),
1891                Cell::from(format!("{}ms", state.rate_budget_account.reset_in_ms)),
1892            ]),
1893            Row::new(vec![
1894                Cell::from("MARKET"),
1895                Cell::from(format!(
1896                    "{}/{}",
1897                    state.rate_budget_market_data.used, state.rate_budget_market_data.limit
1898                )),
1899                Cell::from(format!("{}ms", state.rate_budget_market_data.reset_in_ms)),
1900            ]),
1901        ];
1902        frame.render_widget(
1903            Table::new(
1904                risk_rows,
1905                [
1906                    Constraint::Length(10),
1907                    Constraint::Length(16),
1908                    Constraint::Length(12),
1909                ],
1910            )
1911            .header(Row::new(vec![
1912                Cell::from("Group"),
1913                Cell::from("Used/Limit"),
1914                Cell::from("Reset In"),
1915            ]))
1916            .column_spacing(1)
1917            .block(
1918                Block::default()
1919                    .title(" Portfolio Budgets ")
1920                    .borders(Borders::ALL)
1921                    .border_style(Style::default().fg(Color::DarkGray)),
1922            ),
1923            chunks[1],
1924        );
1925        let reserved_color = if state.portfolio_state.reserved_cash_usdt >= 0.0 {
1926            Color::Cyan
1927        } else {
1928            Color::Red
1929        };
1930        let net_exp_color = if state.portfolio_state.net_exposure_usdt >= 0.0 {
1931            Color::Green
1932        } else {
1933            Color::Yellow
1934        };
1935        let portfolio_lines = vec![
1936            Line::from(vec![
1937                Span::styled("OpenOrders ", Style::default().fg(Color::DarkGray)),
1938                Span::styled(
1939                    format!("{}", state.portfolio_state.open_orders_count),
1940                    Style::default()
1941                        .fg(Color::Cyan)
1942                        .add_modifier(Modifier::BOLD),
1943                ),
1944                Span::raw("  "),
1945                Span::styled("Reserved ", Style::default().fg(Color::DarkGray)),
1946                Span::styled(
1947                    format!("{:.2} USDT", state.portfolio_state.reserved_cash_usdt),
1948                    Style::default().fg(reserved_color),
1949                ),
1950                Span::raw("  "),
1951                Span::styled("Gross ", Style::default().fg(Color::DarkGray)),
1952                Span::styled(
1953                    format!("{:.2}", state.portfolio_state.gross_exposure_usdt),
1954                    Style::default().fg(Color::Cyan),
1955                ),
1956                Span::raw("  "),
1957                Span::styled("Net ", Style::default().fg(Color::DarkGray)),
1958                Span::styled(
1959                    format!("{:+.2}", state.portfolio_state.net_exposure_usdt),
1960                    Style::default().fg(net_exp_color),
1961                ),
1962            ]),
1963            Line::from(vec![
1964                Span::styled("Total Rlz ", Style::default().fg(Color::DarkGray)),
1965                Span::styled(
1966                    format!("{:+.4}", state.portfolio_state.total_realized_pnl_usdt),
1967                    Style::default().fg(if state.portfolio_state.total_realized_pnl_usdt >= 0.0 {
1968                        Color::Green
1969                    } else {
1970                        Color::Red
1971                    }),
1972                ),
1973                Span::raw("  "),
1974                Span::styled("Total Unr ", Style::default().fg(Color::DarkGray)),
1975                Span::styled(
1976                    format!("{:+.4}", state.portfolio_state.total_unrealized_pnl_usdt),
1977                    Style::default().fg(
1978                        if state.portfolio_state.total_unrealized_pnl_usdt >= 0.0 {
1979                            Color::Green
1980                        } else {
1981                            Color::Red
1982                        },
1983                    ),
1984                ),
1985            ]),
1986        ];
1987        frame.render_widget(
1988            Paragraph::new(portfolio_lines).block(
1989                Block::default()
1990                    .title(" Portfolio Summary ")
1991                    .borders(Borders::ALL)
1992                    .border_style(Style::default().fg(Color::DarkGray)),
1993            ),
1994            chunks[2],
1995        );
1996
1997        let mut symbols: Vec<_> = state.portfolio_state.by_symbol.keys().cloned().collect();
1998        symbols.sort();
1999        let mut position_rows = Vec::new();
2000        for symbol in symbols {
2001            if let Some(entry) = state.portfolio_state.by_symbol.get(&symbol) {
2002                position_rows.push(Row::new(vec![
2003                    Cell::from(symbol),
2004                    Cell::from(entry.side.map(|s| s.to_string()).unwrap_or_else(|| "-".to_string())),
2005                    Cell::from(format!("{:.5}", entry.position_qty)),
2006                    Cell::from(format!("{:.2}", entry.entry_price)),
2007                    Cell::from(format!("{:+.4}", entry.unrealized_pnl_usdt)),
2008                ]));
2009            }
2010        }
2011        if position_rows.is_empty() {
2012            position_rows.push(
2013                Row::new(vec![
2014                    Cell::from("(no positions)"),
2015                    Cell::from("-"),
2016                    Cell::from("-"),
2017                    Cell::from("-"),
2018                    Cell::from("-"),
2019                ])
2020                .style(Style::default().fg(Color::DarkGray)),
2021            );
2022        }
2023        frame.render_widget(
2024            Table::new(
2025                position_rows,
2026                [
2027                    Constraint::Length(16),
2028                    Constraint::Length(8),
2029                    Constraint::Length(10),
2030                    Constraint::Length(10),
2031                    Constraint::Length(10),
2032                ],
2033            )
2034            .header(Row::new(vec![
2035                Cell::from("Symbol"),
2036                Cell::from("Side"),
2037                Cell::from("Qty"),
2038                Cell::from("Entry"),
2039                Cell::from("UnrPnL"),
2040            ]))
2041            .column_spacing(1)
2042            .block(
2043                Block::default()
2044                    .title(" Portfolio Positions ")
2045                    .borders(Borders::ALL)
2046                    .border_style(Style::default().fg(Color::DarkGray)),
2047            ),
2048            chunks[3],
2049        );
2050        frame.render_widget(
2051            Paragraph::new("[1/2/3/4/5] tab  [U] <$1 filter  [Z] close-all  [G/Esc] close"),
2052            chunks[4],
2053        );
2054        return;
2055    }
2056
2057    if selected_grid_tab == GridTab::Network {
2058        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
2059        let tick_in_1s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 1_000);
2060        let tick_in_10s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 10_000);
2061        let tick_in_60s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 60_000);
2062        let tick_drop_1s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 1_000);
2063        let tick_drop_10s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 10_000);
2064        let tick_drop_60s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 60_000);
2065        let reconnect_60s = count_since(&state.network_reconnect_timestamps_ms, now_ms, 60_000);
2066        let disconnect_60s = count_since(&state.network_disconnect_timestamps_ms, now_ms, 60_000);
2067
2068        let tick_in_rate_1s = rate_per_sec(tick_in_1s, 1.0);
2069        let tick_drop_rate_1s = rate_per_sec(tick_drop_1s, 1.0);
2070        let tick_drop_rate_10s = rate_per_sec(tick_drop_10s, 10.0);
2071        let tick_drop_rate_60s = rate_per_sec(tick_drop_60s, 60.0);
2072        let tick_drop_ratio_10s =
2073            ratio_pct(tick_drop_10s, tick_in_10s.saturating_add(tick_drop_10s));
2074        let tick_drop_ratio_60s =
2075            ratio_pct(tick_drop_60s, tick_in_60s.saturating_add(tick_drop_60s));
2076        let reconnect_rate_60s = reconnect_60s as f64;
2077        let disconnect_rate_60s = disconnect_60s as f64;
2078        let heartbeat_gap_ms = state
2079            .last_price_update_ms
2080            .map(|ts| now_ms.saturating_sub(ts));
2081        let tick_p95_ms = percentile(&state.network_tick_latencies_ms, 95);
2082        let health = classify_health(
2083            state.ws_connected,
2084            tick_drop_ratio_10s,
2085            reconnect_rate_60s,
2086            tick_p95_ms,
2087            heartbeat_gap_ms,
2088        );
2089        let (health_label, health_color) = match health {
2090            NetworkHealth::Ok => ("OK", Color::Green),
2091            NetworkHealth::Warn => ("WARN", Color::Yellow),
2092            NetworkHealth::Crit => ("CRIT", Color::Red),
2093        };
2094
2095        let chunks = Layout::default()
2096            .direction(Direction::Vertical)
2097            .constraints([
2098                Constraint::Length(2),
2099                Constraint::Min(6),
2100                Constraint::Length(6),
2101                Constraint::Length(1),
2102            ])
2103            .split(body_area);
2104        frame.render_widget(
2105            Paragraph::new(Line::from(vec![
2106                Span::styled("Health: ", Style::default().fg(Color::DarkGray)),
2107                Span::styled(
2108                    health_label,
2109                    Style::default()
2110                        .fg(health_color)
2111                        .add_modifier(Modifier::BOLD),
2112                ),
2113                Span::styled("  WS: ", Style::default().fg(Color::DarkGray)),
2114                Span::styled(
2115                    if state.ws_connected {
2116                        "CONNECTED"
2117                    } else {
2118                        "DISCONNECTED"
2119                    },
2120                    Style::default().fg(if state.ws_connected {
2121                        Color::Green
2122                    } else {
2123                        Color::Red
2124                    }),
2125                ),
2126                Span::styled(
2127                    format!(
2128                        "  in1s={:.1}/s  drop10s={:.2}/s  ratio10s={:.2}%  reconn60s={:.0}/min",
2129                        tick_in_rate_1s,
2130                        tick_drop_rate_10s,
2131                        tick_drop_ratio_10s,
2132                        reconnect_rate_60s
2133                    ),
2134                    Style::default().fg(Color::DarkGray),
2135                ),
2136            ])),
2137            chunks[0],
2138        );
2139
2140        let tick_stats = latency_stats(&state.network_tick_latencies_ms);
2141        let fill_stats = latency_stats(&state.network_fill_latencies_ms);
2142        let sync_stats = latency_stats(&state.network_order_sync_latencies_ms);
2143        let last_fill_age = state
2144            .network_last_fill_ms
2145            .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
2146            .unwrap_or_else(|| "-".to_string());
2147        let rows = vec![
2148            Row::new(vec![
2149                Cell::from("Tick Latency"),
2150                Cell::from(tick_stats.0),
2151                Cell::from(tick_stats.1),
2152                Cell::from(tick_stats.2),
2153                Cell::from(
2154                    state
2155                        .last_price_latency_ms
2156                        .map(|v| format!("{}ms", v))
2157                        .unwrap_or_else(|| "-".to_string()),
2158                ),
2159            ]),
2160            Row::new(vec![
2161                Cell::from("Fill Latency"),
2162                Cell::from(fill_stats.0),
2163                Cell::from(fill_stats.1),
2164                Cell::from(fill_stats.2),
2165                Cell::from(last_fill_age),
2166            ]),
2167            Row::new(vec![
2168                Cell::from("Order Sync"),
2169                Cell::from(sync_stats.0),
2170                Cell::from(sync_stats.1),
2171                Cell::from(sync_stats.2),
2172                Cell::from(
2173                    state
2174                        .last_order_history_latency_ms
2175                        .map(|v| format!("{}ms", v))
2176                        .unwrap_or_else(|| "-".to_string()),
2177                ),
2178            ]),
2179        ];
2180        frame.render_widget(
2181            Table::new(
2182                rows,
2183                [
2184                    Constraint::Length(14),
2185                    Constraint::Length(12),
2186                    Constraint::Length(12),
2187                    Constraint::Length(12),
2188                    Constraint::Length(14),
2189                ],
2190            )
2191            .header(Row::new(vec![
2192                Cell::from("Metric"),
2193                Cell::from("p50"),
2194                Cell::from("p95"),
2195                Cell::from("p99"),
2196                Cell::from("last/age"),
2197            ]))
2198            .column_spacing(1)
2199            .block(
2200                Block::default()
2201                    .title(" Network Metrics ")
2202                    .borders(Borders::ALL)
2203                    .border_style(Style::default().fg(Color::DarkGray)),
2204            ),
2205            chunks[1],
2206        );
2207
2208        let summary_rows = vec![
2209            Row::new(vec![
2210                Cell::from("tick_drop_rate_1s"),
2211                Cell::from(format!("{:.2}/s", tick_drop_rate_1s)),
2212                Cell::from("tick_drop_rate_60s"),
2213                Cell::from(format!("{:.2}/s", tick_drop_rate_60s)),
2214            ]),
2215            Row::new(vec![
2216                Cell::from("drop_ratio_60s"),
2217                Cell::from(format!("{:.2}%", tick_drop_ratio_60s)),
2218                Cell::from("disconnect_rate_60s"),
2219                Cell::from(format!("{:.0}/min", disconnect_rate_60s)),
2220            ]),
2221            Row::new(vec![
2222                Cell::from("last_tick_age"),
2223                Cell::from(
2224                    heartbeat_gap_ms
2225                        .map(format_age_ms)
2226                        .unwrap_or_else(|| "-".to_string()),
2227                ),
2228                Cell::from("last_order_update_age"),
2229                Cell::from(
2230                    state
2231                        .last_order_history_update_ms
2232                        .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
2233                        .unwrap_or_else(|| "-".to_string()),
2234                ),
2235            ]),
2236            Row::new(vec![
2237                Cell::from("tick_drop_total"),
2238                Cell::from(state.network_tick_drop_count.to_string()),
2239                Cell::from("reconnect_total"),
2240                Cell::from(state.network_reconnect_count.to_string()),
2241            ]),
2242        ];
2243        frame.render_widget(
2244            Table::new(
2245                summary_rows,
2246                [
2247                    Constraint::Length(20),
2248                    Constraint::Length(18),
2249                    Constraint::Length(20),
2250                    Constraint::Length(18),
2251                ],
2252            )
2253            .column_spacing(1)
2254            .block(
2255                Block::default()
2256                    .title(" Network Summary ")
2257                    .borders(Borders::ALL)
2258                    .border_style(Style::default().fg(Color::DarkGray)),
2259            ),
2260            chunks[2],
2261        );
2262        frame.render_widget(
2263            Paragraph::new("[1/2/3/4/5] tab  [U] <$1 filter  [Z] close-all  [G/Esc] close"),
2264            chunks[3],
2265        );
2266        return;
2267    }
2268
2269    if selected_grid_tab == GridTab::History {
2270        let chunks = Layout::default()
2271            .direction(Direction::Vertical)
2272            .constraints([
2273                Constraint::Length(2),
2274                Constraint::Min(6),
2275                Constraint::Length(1),
2276            ])
2277            .split(body_area);
2278        frame.render_widget(
2279            Paragraph::new(Line::from(vec![
2280                Span::styled("Bucket: ", Style::default().fg(Color::DarkGray)),
2281                Span::styled(
2282                    match state.history_bucket {
2283                        order_store::HistoryBucket::Day => "Day",
2284                        order_store::HistoryBucket::Hour => "Hour",
2285                        order_store::HistoryBucket::Month => "Month",
2286                    },
2287                    Style::default()
2288                        .fg(Color::Cyan)
2289                        .add_modifier(Modifier::BOLD),
2290                ),
2291                Span::styled(
2292                    "  (popup hotkeys: D/H/M)",
2293                    Style::default().fg(Color::DarkGray),
2294                ),
2295            ])),
2296            chunks[0],
2297        );
2298
2299        let visible = build_history_lines(
2300            &state.history_rows,
2301            chunks[1].height.saturating_sub(2) as usize,
2302        );
2303        frame.render_widget(
2304            Paragraph::new(visible).block(
2305                Block::default()
2306                    .title(" History ")
2307                    .borders(Borders::ALL)
2308                    .border_style(Style::default().fg(Color::DarkGray)),
2309            ),
2310            chunks[1],
2311        );
2312        frame.render_widget(
2313            Paragraph::new("[1/2/3/4/5] tab  [U] <$1 filter  [Z] close-all  [G/Esc] close"),
2314            chunks[2],
2315        );
2316        return;
2317    }
2318
2319    if view.selected_grid_tab == GridTab::Positions {
2320        let chunks = Layout::default()
2321            .direction(Direction::Vertical)
2322            .constraints([
2323                Constraint::Length(2),
2324                Constraint::Min(6),
2325                Constraint::Length(1),
2326            ])
2327            .split(body_area);
2328        let persisted = if cfg!(test) {
2329            Vec::new()
2330        } else {
2331            order_store::load_recent_persisted_trades_filtered(None, None, 20_000)
2332                .unwrap_or_default()
2333        };
2334        let open_orders: Vec<_> = build_open_order_positions_from_trades(&persisted)
2335            .into_iter()
2336            .filter(|row| {
2337                let px = asset_last_price_for_symbol(state, &row.symbol).unwrap_or(row.entry_price);
2338                !state.hide_small_positions || (px * row.qty_open).abs() >= 1.0
2339            })
2340            .collect();
2341
2342        frame.render_widget(
2343            Paragraph::new(Line::from(vec![
2344                Span::styled(
2345                    "Open Position Orders: ",
2346                    Style::default().fg(Color::DarkGray),
2347                ),
2348                Span::styled(
2349                    open_orders.len().to_string(),
2350                    Style::default()
2351                        .fg(Color::Cyan)
2352                        .add_modifier(Modifier::BOLD),
2353                ),
2354                Span::styled(
2355                    if state.hide_small_positions {
2356                        "  (order_id scope, filter: >= $1 | [U] toggle)"
2357                    } else {
2358                        "  (order_id scope, filter: OFF | [U] toggle)"
2359                    },
2360                    Style::default().fg(Color::DarkGray),
2361                ),
2362            ])),
2363            chunks[0],
2364        );
2365
2366        let header = Row::new(vec![
2367            Cell::from("Symbol"),
2368            Cell::from("Source"),
2369            Cell::from("OrderId"),
2370            Cell::from("Close"),
2371            Cell::from("Market"),
2372            Cell::from("Side"),
2373            Cell::from("Qty"),
2374            Cell::from("Entry"),
2375            Cell::from("Last"),
2376            Cell::from("Stop"),
2377            Cell::from("StopType"),
2378            Cell::from("UnrPnL"),
2379        ])
2380        .style(Style::default().fg(Color::DarkGray));
2381        let mut rows: Vec<Row> = if open_orders.is_empty() {
2382            state
2383                .assets_view()
2384                .iter()
2385                .filter(|a| {
2386                    let has_pos = a.position_qty.abs() > f64::EPSILON
2387                        || a.entry_price.is_some()
2388                        || a.side.is_some();
2389                    let px = a.last_price.or(a.entry_price).unwrap_or(0.0);
2390                    has_pos && (!state.hide_small_positions || (px * a.position_qty.abs()) >= 1.0)
2391                })
2392                .map(|a| {
2393                    let exit_policy = latest_exit_policy_for_symbol_relaxed(
2394                        &state.exit_policy_by_scope,
2395                        &a.symbol,
2396                    );
2397                    Row::new(vec![
2398                        Cell::from(a.symbol.clone()),
2399                        Cell::from("SYS"),
2400                        Cell::from("-"),
2401                        Cell::from(
2402                            close_all_row_status_for_symbol(state, &a.symbol)
2403                                .unwrap_or_else(|| "-".to_string()),
2404                        ),
2405                        Cell::from(if a.is_futures { "FUT" } else { "SPOT" }),
2406                        Cell::from(a.side.clone().unwrap_or_else(|| "-".to_string())),
2407                        Cell::from(format!("{:.5}", a.position_qty)),
2408                        Cell::from(
2409                            a.entry_price
2410                                .map(|v| format!("{:.2}", v))
2411                                .unwrap_or_else(|| "-".to_string()),
2412                        ),
2413                        Cell::from(
2414                            a.last_price
2415                                .map(|v| format!("{:.2}", v))
2416                                .unwrap_or_else(|| "-".to_string()),
2417                        ),
2418                        Cell::from(
2419                            exit_policy
2420                                .and_then(|p| p.stop_price)
2421                                .map(|v| format!("{:.2}", v))
2422                                .unwrap_or_else(|| "-".to_string()),
2423                        ),
2424                        Cell::from(if exit_policy.and_then(|p| p.stop_price).is_none() {
2425                            "-".to_string()
2426                        } else if exit_policy.and_then(|p| p.protective_stop_ok) == Some(true) {
2427                            "ORDER".to_string()
2428                        } else {
2429                            "CALC".to_string()
2430                        }),
2431                        Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
2432                    ])
2433                })
2434                .collect()
2435        } else {
2436            let mut rows: Vec<Row> = open_orders
2437                .iter()
2438                .map(|row| {
2439                    let symbol_view = display_symbol_for_storage(&row.symbol);
2440                    let market_is_fut = row.symbol.ends_with("#FUT");
2441                    let symbol_key = normalize_symbol_for_scope(&row.symbol);
2442                    let asset_entry = state.assets_view().iter().find(|a| {
2443                        normalize_symbol_for_scope(&a.symbol) == symbol_key
2444                            && a.is_futures == market_is_fut
2445                    });
2446                    let asset_last = asset_last_price_for_symbol(state, &row.symbol);
2447                    let exit_policy =
2448                        exit_policy_for_symbol_and_tag(state, &row.symbol, &row.source_tag)
2449                            .or_else(|| {
2450                                latest_exit_policy_for_symbol_relaxed(
2451                                    &state.exit_policy_by_scope,
2452                                    &row.symbol,
2453                                )
2454                                .cloned()
2455                            });
2456                    let unr = asset_last
2457                        .map(|px| (px - row.entry_price) * row.qty_open)
2458                        .or_else(|| asset_entry.map(|a| a.unrealized_pnl_usdt))
2459                        .unwrap_or(0.0);
2460                    let side_text = asset_entry
2461                        .and_then(|a| a.side.clone())
2462                        .unwrap_or_else(|| "BUY".to_string());
2463                    Row::new(vec![
2464                        Cell::from(symbol_view),
2465                        Cell::from(row.source_tag.to_ascii_uppercase()),
2466                        Cell::from(row.order_id.to_string()),
2467                        Cell::from(
2468                            close_all_row_status_for_symbol(state, &row.symbol)
2469                                .unwrap_or_else(|| "-".to_string()),
2470                        ),
2471                        Cell::from(if market_is_fut { "FUT" } else { "SPOT" }),
2472                        Cell::from(side_text),
2473                        Cell::from(format!("{:.5}", row.qty_open)),
2474                        Cell::from(format!("{:.2}", row.entry_price)),
2475                        Cell::from(
2476                            asset_last
2477                                .map(|v| format!("{:.2}", v))
2478                                .unwrap_or_else(|| "-".to_string()),
2479                        ),
2480                        Cell::from(
2481                            exit_policy
2482                                .as_ref()
2483                                .and_then(|p| p.stop_price)
2484                                .map(|v| format!("{:.2}", v))
2485                                .unwrap_or_else(|| "-".to_string()),
2486                        ),
2487                        Cell::from(
2488                            if exit_policy.as_ref().and_then(|p| p.stop_price).is_none() {
2489                                "-".to_string()
2490                            } else if exit_policy.as_ref().and_then(|p| p.protective_stop_ok)
2491                                == Some(true)
2492                            {
2493                                "ORDER".to_string()
2494                            } else {
2495                                "CALC".to_string()
2496                            },
2497                        ),
2498                        Cell::from(format!("{:+.4}", unr)),
2499                    ])
2500                })
2501                .collect();
2502
2503            let represented_symbols: std::collections::HashSet<String> = open_orders
2504                .iter()
2505                .map(|r| {
2506                    let market = if r.symbol.ends_with("#FUT") {
2507                        "FUT"
2508                    } else {
2509                        "SPOT"
2510                    };
2511                    format!("{}::{}", normalize_symbol_for_scope(&r.symbol), market)
2512                })
2513                .collect();
2514            for a in state.assets_view().iter().filter(|a| {
2515                let has_pos = a.position_qty.abs() > f64::EPSILON || a.entry_price.is_some();
2516                let px = a.last_price.or(a.entry_price).unwrap_or(0.0);
2517                let symbol_key = format!(
2518                    "{}::{}",
2519                    normalize_symbol_for_scope(&a.symbol),
2520                    if a.is_futures { "FUT" } else { "SPOT" }
2521                );
2522                has_pos
2523                    && !represented_symbols.contains(&symbol_key)
2524                    && (!state.hide_small_positions || (px * a.position_qty.abs()) >= 1.0)
2525            }) {
2526                let exit_policy =
2527                    latest_exit_policy_for_symbol_relaxed(&state.exit_policy_by_scope, &a.symbol);
2528                rows.push(Row::new(vec![
2529                    Cell::from(a.symbol.clone()),
2530                    Cell::from("SYS"),
2531                    Cell::from("-"),
2532                    Cell::from(
2533                        close_all_row_status_for_symbol(state, &a.symbol)
2534                            .unwrap_or_else(|| "-".to_string()),
2535                    ),
2536                    Cell::from(if a.is_futures { "FUT" } else { "SPOT" }),
2537                    Cell::from(a.side.clone().unwrap_or_else(|| "-".to_string())),
2538                    Cell::from(format!("{:.5}", a.position_qty)),
2539                    Cell::from(
2540                        a.entry_price
2541                            .map(|v| format!("{:.2}", v))
2542                            .unwrap_or_else(|| "-".to_string()),
2543                    ),
2544                    Cell::from(
2545                        a.last_price
2546                            .map(|v| format!("{:.2}", v))
2547                            .unwrap_or_else(|| "-".to_string()),
2548                    ),
2549                    Cell::from(
2550                        exit_policy
2551                            .and_then(|p| p.stop_price)
2552                            .map(|v| format!("{:.2}", v))
2553                            .unwrap_or_else(|| "-".to_string()),
2554                    ),
2555                    Cell::from(if exit_policy.and_then(|p| p.stop_price).is_none() {
2556                        "-".to_string()
2557                    } else if exit_policy.and_then(|p| p.protective_stop_ok) == Some(true) {
2558                        "ORDER".to_string()
2559                    } else {
2560                        "CALC".to_string()
2561                    }),
2562                    Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
2563                ]));
2564            }
2565            rows
2566        };
2567        if rows.is_empty() {
2568            rows.push(
2569                Row::new(vec![
2570                    Cell::from("(no open positions)"),
2571                    Cell::from("-"),
2572                    Cell::from("-"),
2573                    Cell::from("-"),
2574                    Cell::from("-"),
2575                    Cell::from("-"),
2576                    Cell::from("-"),
2577                    Cell::from("-"),
2578                    Cell::from("-"),
2579                    Cell::from("-"),
2580                    Cell::from("-"),
2581                    Cell::from("-"),
2582                ])
2583                .style(Style::default().fg(Color::DarkGray)),
2584            );
2585        }
2586        frame.render_widget(
2587            Table::new(
2588                rows,
2589                [
2590                    Constraint::Length(14),
2591                    Constraint::Length(8),
2592                    Constraint::Length(12),
2593                    Constraint::Length(8),
2594                    Constraint::Length(7),
2595                    Constraint::Length(8),
2596                    Constraint::Length(11),
2597                    Constraint::Length(10),
2598                    Constraint::Length(10),
2599                    Constraint::Length(10),
2600                    Constraint::Length(8),
2601                    Constraint::Length(11),
2602                ],
2603            )
2604            .header(header)
2605            .column_spacing(1)
2606            .block(
2607                Block::default()
2608                    .title(" Positions ")
2609                    .borders(Borders::ALL)
2610                    .border_style(Style::default().fg(Color::DarkGray)),
2611            ),
2612            chunks[1],
2613        );
2614        frame.render_widget(
2615            Paragraph::new("[1/2/3/4/5] tab  [U] <$1 filter  [Z] close-all  [G/Esc] close"),
2616            chunks[2],
2617        );
2618        return;
2619    }
2620
2621    if selected_grid_tab == GridTab::Predictors {
2622        let chunks = Layout::default()
2623            .direction(Direction::Vertical)
2624            .constraints([
2625                Constraint::Length(2),
2626                Constraint::Min(6),
2627                Constraint::Length(1),
2628            ])
2629            .split(body_area);
2630        let mut entries: Vec<&PredictorMetricEntry> = state
2631            .predictor_metrics_by_scope
2632            .values()
2633            .filter(|e| !state.hide_empty_predictor_rows || e.sample_count > 0)
2634            .collect();
2635        entries.sort_by(|a, b| {
2636            predictor_horizon_priority(&b.horizon)
2637                .cmp(&predictor_horizon_priority(&a.horizon))
2638                .then_with(|| match (a.r2, b.r2) {
2639                    (Some(ra), Some(rb)) => rb
2640                        .partial_cmp(&ra)
2641                        .unwrap_or(std::cmp::Ordering::Equal)
2642                        .then_with(|| b.sample_count.cmp(&a.sample_count))
2643                        .then_with(|| b.updated_at_ms.cmp(&a.updated_at_ms)),
2644                    (Some(_), None) => std::cmp::Ordering::Less,
2645                    (None, Some(_)) => std::cmp::Ordering::Greater,
2646                    (None, None) => b
2647                        .sample_count
2648                        .cmp(&a.sample_count)
2649                        .then_with(|| b.updated_at_ms.cmp(&a.updated_at_ms)),
2650                })
2651        });
2652        let visible_rows = chunks[1].height.saturating_sub(3).max(1) as usize;
2653        let max_start = entries.len().saturating_sub(visible_rows);
2654        let start = state.predictor_scroll_offset.min(max_start);
2655        let end = (start + visible_rows).min(entries.len());
2656        let visible_entries = &entries[start..end];
2657        frame.render_widget(
2658            Paragraph::new(Line::from(vec![
2659                Span::styled("Predictor Rows: ", Style::default().fg(Color::DarkGray)),
2660                Span::styled(
2661                    entries.len().to_string(),
2662                    Style::default()
2663                        .fg(Color::Cyan)
2664                        .add_modifier(Modifier::BOLD),
2665                ),
2666                Span::styled(
2667                    if state.hide_empty_predictor_rows {
2668                        "  (N>0 only | [U] toggle)"
2669                    } else {
2670                        "  (all rows | [U] toggle)"
2671                    },
2672                    Style::default().fg(Color::DarkGray),
2673                ),
2674                Span::styled(
2675                    format!("  view {}-{}", start.saturating_add(1), end.max(start)),
2676                    Style::default().fg(Color::DarkGray),
2677                ),
2678            ])),
2679            chunks[0],
2680        );
2681
2682        let header = Row::new(vec![
2683            Cell::from("Symbol"),
2684            Cell::from("Market"),
2685            Cell::from("Predictor"),
2686            Cell::from("Horizon"),
2687            Cell::from("R2"),
2688            Cell::from("MAE"),
2689            Cell::from("N"),
2690        ])
2691        .style(Style::default().fg(Color::DarkGray));
2692        let mut rows: Vec<Row> = visible_entries
2693            .iter()
2694            .map(|e| {
2695                Row::new(vec![
2696                    Cell::from(display_symbol_for_storage(&e.symbol)),
2697                    Cell::from(e.market.to_ascii_uppercase()),
2698                    Cell::from(e.predictor.clone()),
2699                    Cell::from(e.horizon.clone()),
2700                    Cell::from(e.r2.map(|v| format!("{:+.3}", v)).unwrap_or_else(|| {
2701                        if e.sample_count > 0 {
2702                            "WARMUP".to_string()
2703                        } else {
2704                            "-".to_string()
2705                        }
2706                    })),
2707                    Cell::from(
2708                        e.mae
2709                            .map(|v| {
2710                                if v.abs() < 1e-5 {
2711                                    format!("{:.2e}", v)
2712                                } else {
2713                                    format!("{:.5}", v)
2714                                }
2715                            })
2716                            .unwrap_or_else(|| "-".to_string()),
2717                    ),
2718                    Cell::from(e.sample_count.to_string()),
2719                ])
2720            })
2721            .collect();
2722        if rows.is_empty() {
2723            rows.push(
2724                Row::new(vec![
2725                    Cell::from("(no predictor metrics)"),
2726                    Cell::from("-"),
2727                    Cell::from("-"),
2728                    Cell::from("-"),
2729                    Cell::from("-"),
2730                    Cell::from("-"),
2731                    Cell::from("-"),
2732                ])
2733                .style(Style::default().fg(Color::DarkGray)),
2734            );
2735        }
2736        frame.render_widget(
2737            Table::new(
2738                rows,
2739                [
2740                    Constraint::Length(14),
2741                    Constraint::Length(7),
2742                    Constraint::Length(14),
2743                    Constraint::Length(8),
2744                    Constraint::Length(8),
2745                    Constraint::Length(10),
2746                    Constraint::Length(6),
2747                ],
2748            )
2749            .header(header)
2750            .column_spacing(1)
2751            .block(
2752                Block::default()
2753                    .title(" Predictors ")
2754                    .borders(Borders::ALL)
2755                    .border_style(Style::default().fg(Color::DarkGray)),
2756            ),
2757            chunks[1],
2758        );
2759        frame.render_widget(
2760            Paragraph::new(
2761                "[1/2/3/4/5] tab  [J/K] scroll  [U] <$1 filter  [Z] close-all  [G/Esc] close",
2762            ),
2763            chunks[2],
2764        );
2765        return;
2766    }
2767
2768    if selected_grid_tab == GridTab::SystemLog {
2769        let chunks = Layout::default()
2770            .direction(Direction::Vertical)
2771            .constraints([Constraint::Min(6), Constraint::Length(1)])
2772            .split(body_area);
2773        let max_rows = chunks[0].height.saturating_sub(2) as usize;
2774        let mut log_rows: Vec<Row> = state
2775            .log_messages
2776            .iter()
2777            .rev()
2778            .take(max_rows.max(1))
2779            .rev()
2780            .map(|line| Row::new(vec![Cell::from(line.clone())]))
2781            .collect();
2782        if log_rows.is_empty() {
2783            log_rows.push(
2784                Row::new(vec![Cell::from("(no system logs yet)")])
2785                    .style(Style::default().fg(Color::DarkGray)),
2786            );
2787        }
2788        frame.render_widget(
2789            Table::new(log_rows, [Constraint::Min(1)])
2790                .header(
2791                    Row::new(vec![Cell::from("Message")])
2792                        .style(Style::default().fg(Color::DarkGray)),
2793                )
2794                .column_spacing(1)
2795                .block(
2796                    Block::default()
2797                        .title(" System Log ")
2798                        .borders(Borders::ALL)
2799                        .border_style(Style::default().fg(Color::DarkGray)),
2800                ),
2801            chunks[0],
2802        );
2803        frame.render_widget(
2804            Paragraph::new("[1/2/3/4/5] tab  [U] <$1 filter  [Z] close-all  [G/Esc] close"),
2805            chunks[1],
2806        );
2807        return;
2808    }
2809
2810    let selected_symbol = state
2811        .symbol_items
2812        .get(view.selected_symbol_index)
2813        .map(String::as_str)
2814        .unwrap_or(state.symbol.as_str());
2815    let strategy_chunks = Layout::default()
2816        .direction(Direction::Vertical)
2817        .constraints([
2818            Constraint::Length(2),
2819            Constraint::Length(3),
2820            Constraint::Min(12),
2821            Constraint::Length(1),
2822        ])
2823        .split(body_area);
2824
2825    let mut on_indices: Vec<usize> = Vec::new();
2826    let mut off_indices: Vec<usize> = Vec::new();
2827    for idx in 0..state.strategy_items.len() {
2828        if state
2829            .strategy_item_active
2830            .get(idx)
2831            .copied()
2832            .unwrap_or(false)
2833        {
2834            on_indices.push(idx);
2835        } else {
2836            off_indices.push(idx);
2837        }
2838    }
2839    let on_weight = on_indices.len().max(1) as u32;
2840    let off_weight = off_indices.len().max(1) as u32;
2841
2842    frame.render_widget(
2843        Paragraph::new(Line::from(vec![
2844            Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
2845            Span::styled(
2846                risk_label,
2847                Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
2848            ),
2849            Span::styled("  GLOBAL ", Style::default().fg(Color::DarkGray)),
2850            Span::styled(
2851                format!(
2852                    "{}/{}",
2853                    state.rate_budget_global.used, state.rate_budget_global.limit
2854                ),
2855                Style::default().fg(if global_pressure >= 0.9 {
2856                    Color::Red
2857                } else if global_pressure >= 0.7 {
2858                    Color::Yellow
2859                } else {
2860                    Color::Cyan
2861                }),
2862            ),
2863            Span::styled("  ORD ", Style::default().fg(Color::DarkGray)),
2864            Span::styled(
2865                format!(
2866                    "{}/{}",
2867                    state.rate_budget_orders.used, state.rate_budget_orders.limit
2868                ),
2869                Style::default().fg(if orders_pressure >= 0.9 {
2870                    Color::Red
2871                } else if orders_pressure >= 0.7 {
2872                    Color::Yellow
2873                } else {
2874                    Color::Cyan
2875                }),
2876            ),
2877            Span::styled("  ACC ", Style::default().fg(Color::DarkGray)),
2878            Span::styled(
2879                format!(
2880                    "{}/{}",
2881                    state.rate_budget_account.used, state.rate_budget_account.limit
2882                ),
2883                Style::default().fg(if account_pressure >= 0.9 {
2884                    Color::Red
2885                } else if account_pressure >= 0.7 {
2886                    Color::Yellow
2887                } else {
2888                    Color::Cyan
2889                }),
2890            ),
2891            Span::styled("  MKT ", Style::default().fg(Color::DarkGray)),
2892            Span::styled(
2893                format!(
2894                    "{}/{}",
2895                    state.rate_budget_market_data.used, state.rate_budget_market_data.limit
2896                ),
2897                Style::default().fg(if market_pressure >= 0.9 {
2898                    Color::Red
2899                } else if market_pressure >= 0.7 {
2900                    Color::Yellow
2901                } else {
2902                    Color::Cyan
2903                }),
2904            ),
2905            Span::styled("  EXP ", Style::default().fg(Color::DarkGray)),
2906            Span::styled(
2907                format!("{:+.0}", state.portfolio_state.net_exposure_usdt),
2908                Style::default().fg(if state.portfolio_state.net_exposure_usdt >= 0.0 {
2909                    Color::Green
2910                } else {
2911                    Color::Yellow
2912                }),
2913            ),
2914            Span::styled("  RSV ", Style::default().fg(Color::DarkGray)),
2915            Span::styled(
2916                format!("{:.0}", state.portfolio_state.reserved_cash_usdt),
2917                Style::default().fg(Color::Cyan),
2918            ),
2919        ])),
2920        strategy_chunks[0],
2921    );
2922
2923    let strategy_area = strategy_chunks[2];
2924    let min_panel_height: u16 = 6;
2925    let total_height = strategy_area.height;
2926    let (on_height, off_height) = if total_height >= min_panel_height.saturating_mul(2) {
2927        let total_weight = on_weight + off_weight;
2928        let mut on_h =
2929            ((total_height as u32 * on_weight) / total_weight).max(min_panel_height as u32) as u16;
2930        let max_on_h = total_height.saturating_sub(min_panel_height);
2931        if on_h > max_on_h {
2932            on_h = max_on_h;
2933        }
2934        let off_h = total_height.saturating_sub(on_h);
2935        (on_h, off_h)
2936    } else {
2937        let on_h = (total_height / 2).max(1);
2938        let off_h = total_height.saturating_sub(on_h).max(1);
2939        (on_h, off_h)
2940    };
2941    let on_area = Rect {
2942        x: strategy_area.x,
2943        y: strategy_area.y,
2944        width: strategy_area.width,
2945        height: on_height,
2946    };
2947    let off_area = Rect {
2948        x: strategy_area.x,
2949        y: strategy_area.y.saturating_add(on_height),
2950        width: strategy_area.width,
2951        height: off_height,
2952    };
2953
2954    let pnl_sum_for_indices = |indices: &[usize], state: &AppState| -> f64 {
2955        indices
2956            .iter()
2957            .map(|idx| {
2958                let item = state
2959                    .strategy_items
2960                    .get(*idx)
2961                    .map(String::as_str)
2962                    .unwrap_or("-");
2963                let row_symbol = state
2964                    .strategy_item_symbols
2965                    .get(*idx)
2966                    .map(String::as_str)
2967                    .unwrap_or(state.symbol.as_str());
2968                strategy_stats_for_item(&state.strategy_stats, item, row_symbol)
2969                    .map(|s| s.realized_pnl)
2970                    .unwrap_or(0.0)
2971            })
2972            .sum()
2973    };
2974    let on_pnl_sum = pnl_sum_for_indices(&on_indices, state);
2975    let off_pnl_sum = pnl_sum_for_indices(&off_indices, state);
2976    let total_pnl_sum = on_pnl_sum + off_pnl_sum;
2977
2978    let total_row = Row::new(vec![
2979        Cell::from("ON Total"),
2980        Cell::from(on_indices.len().to_string()),
2981        Cell::from(format!("{:+.4}", on_pnl_sum)),
2982        Cell::from("OFF Total"),
2983        Cell::from(off_indices.len().to_string()),
2984        Cell::from(format!("{:+.4}", off_pnl_sum)),
2985        Cell::from("All Total"),
2986        Cell::from(format!("{:+.4}", total_pnl_sum)),
2987    ]);
2988    let total_table = Table::new(
2989        vec![total_row],
2990        [
2991            Constraint::Length(10),
2992            Constraint::Length(5),
2993            Constraint::Length(12),
2994            Constraint::Length(10),
2995            Constraint::Length(5),
2996            Constraint::Length(12),
2997            Constraint::Length(10),
2998            Constraint::Length(12),
2999        ],
3000    )
3001    .column_spacing(1)
3002    .block(
3003        Block::default()
3004            .title(" Total ")
3005            .borders(Borders::ALL)
3006            .border_style(Style::default().fg(Color::DarkGray)),
3007    );
3008    frame.render_widget(total_table, strategy_chunks[1]);
3009
3010    let render_strategy_window = |frame: &mut Frame,
3011                                  area: Rect,
3012                                  title: &str,
3013                                  indices: &[usize],
3014                                  state: &AppState,
3015                                  pnl_sum: f64,
3016                                  selected_panel: bool| {
3017        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
3018        let inner_height = area.height.saturating_sub(2);
3019        let row_capacity = inner_height.saturating_sub(1) as usize;
3020        let selected_pos = indices
3021            .iter()
3022            .position(|idx| *idx == view.selected_strategy_index);
3023        let window_start = if row_capacity == 0 {
3024            0
3025        } else if let Some(pos) = selected_pos {
3026            pos.saturating_sub(row_capacity.saturating_sub(1))
3027        } else {
3028            0
3029        };
3030        let window_end = if row_capacity == 0 {
3031            0
3032        } else {
3033            (window_start + row_capacity).min(indices.len())
3034        };
3035        let visible_indices = if indices.is_empty() || row_capacity == 0 {
3036            &indices[0..0]
3037        } else {
3038            &indices[window_start..window_end]
3039        };
3040        let header = Row::new(vec![
3041            Cell::from(" "),
3042            Cell::from("Symbol"),
3043            Cell::from("Strategy"),
3044            Cell::from("Run"),
3045            Cell::from("Last"),
3046            Cell::from("Px"),
3047            Cell::from("Age"),
3048            Cell::from("W"),
3049            Cell::from("L"),
3050            Cell::from("T"),
3051            Cell::from("PnL"),
3052        ])
3053        .style(Style::default().fg(Color::DarkGray));
3054        let mut rows: Vec<Row> = visible_indices
3055            .iter()
3056            .map(|idx| {
3057                let row_symbol = state
3058                    .strategy_item_symbols
3059                    .get(*idx)
3060                    .map(String::as_str)
3061                    .unwrap_or("-");
3062                let item = state
3063                    .strategy_items
3064                    .get(*idx)
3065                    .cloned()
3066                    .unwrap_or_else(|| "-".to_string());
3067                let running = state
3068                    .strategy_item_total_running_ms
3069                    .get(*idx)
3070                    .copied()
3071                    .map(format_running_time)
3072                    .unwrap_or_else(|| "-".to_string());
3073                let stats = strategy_stats_for_item(&state.strategy_stats, &item, row_symbol);
3074                let source_tag = source_tag_for_strategy_item(&item);
3075                let last_evt = source_tag
3076                    .as_ref()
3077                    .and_then(|tag| state.strategy_last_event_by_tag.get(tag));
3078                let (last_label, last_px, last_age, last_style) = if let Some(evt) = last_evt {
3079                    let age = now_ms.saturating_sub(evt.timestamp_ms);
3080                    let age_txt = if age < 1_000 {
3081                        format!("{}ms", age)
3082                    } else if age < 60_000 {
3083                        format!("{}s", age / 1_000)
3084                    } else {
3085                        format!("{}m", age / 60_000)
3086                    };
3087                    let side_txt = match evt.side {
3088                        OrderSide::Buy => "BUY",
3089                        OrderSide::Sell => "SELL",
3090                    };
3091                    let px_txt = evt
3092                        .price
3093                        .map(|v| format!("{:.2}", v))
3094                        .unwrap_or_else(|| "-".to_string());
3095                    let style = match evt.side {
3096                        OrderSide::Buy => Style::default()
3097                            .fg(Color::Green)
3098                            .add_modifier(Modifier::BOLD),
3099                        OrderSide::Sell => {
3100                            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
3101                        }
3102                    };
3103                    (side_txt.to_string(), px_txt, age_txt, style)
3104                } else {
3105                    (
3106                        "-".to_string(),
3107                        "-".to_string(),
3108                        "-".to_string(),
3109                        Style::default().fg(Color::DarkGray),
3110                    )
3111                };
3112                let (w, l, t, pnl) = if let Some(s) = stats {
3113                    (
3114                        s.win_count.to_string(),
3115                        s.lose_count.to_string(),
3116                        s.trade_count.to_string(),
3117                        format!("{:+.4}", s.realized_pnl),
3118                    )
3119                } else {
3120                    (
3121                        "0".to_string(),
3122                        "0".to_string(),
3123                        "0".to_string(),
3124                        "+0.0000".to_string(),
3125                    )
3126                };
3127                let marker = if *idx == view.selected_strategy_index {
3128                    "▶"
3129                } else {
3130                    " "
3131                };
3132                let mut row = Row::new(vec![
3133                    Cell::from(marker),
3134                    Cell::from(row_symbol.to_string()),
3135                    Cell::from(item),
3136                    Cell::from(running),
3137                    Cell::from(last_label).style(last_style),
3138                    Cell::from(last_px),
3139                    Cell::from(last_age),
3140                    Cell::from(w),
3141                    Cell::from(l),
3142                    Cell::from(t),
3143                    Cell::from(pnl),
3144                ]);
3145                if *idx == view.selected_strategy_index {
3146                    row = row.style(
3147                        Style::default()
3148                            .fg(Color::Yellow)
3149                            .add_modifier(Modifier::BOLD),
3150                    );
3151                }
3152                row
3153            })
3154            .collect();
3155
3156        if rows.is_empty() {
3157            rows.push(
3158                Row::new(vec![
3159                    Cell::from(" "),
3160                    Cell::from("-"),
3161                    Cell::from("(empty)"),
3162                    Cell::from("-"),
3163                    Cell::from("-"),
3164                    Cell::from("-"),
3165                    Cell::from("-"),
3166                    Cell::from("-"),
3167                    Cell::from("-"),
3168                    Cell::from("-"),
3169                    Cell::from("-"),
3170                ])
3171                .style(Style::default().fg(Color::DarkGray)),
3172            );
3173        }
3174
3175        let table = Table::new(
3176            rows,
3177            [
3178                Constraint::Length(2),
3179                Constraint::Length(12),
3180                Constraint::Min(14),
3181                Constraint::Length(9),
3182                Constraint::Length(5),
3183                Constraint::Length(9),
3184                Constraint::Length(6),
3185                Constraint::Length(3),
3186                Constraint::Length(3),
3187                Constraint::Length(4),
3188                Constraint::Length(11),
3189            ],
3190        )
3191        .header(header)
3192        .column_spacing(1)
3193        .block(
3194            Block::default()
3195                .title(format!(
3196                    "{} | Total {:+.4} | {}/{}",
3197                    title,
3198                    pnl_sum,
3199                    visible_indices.len(),
3200                    indices.len()
3201                ))
3202                .borders(Borders::ALL)
3203                .border_style(if selected_panel {
3204                    Style::default().fg(Color::Yellow)
3205                } else if risk_label == "CRIT" {
3206                    Style::default().fg(Color::Red)
3207                } else if risk_label == "WARN" {
3208                    Style::default().fg(Color::Yellow)
3209                } else {
3210                    Style::default().fg(Color::DarkGray)
3211                }),
3212        );
3213        frame.render_widget(table, area);
3214    };
3215
3216    render_strategy_window(
3217        frame,
3218        on_area,
3219        " ON Strategies ",
3220        &on_indices,
3221        state,
3222        on_pnl_sum,
3223        view.is_on_panel_selected,
3224    );
3225    render_strategy_window(
3226        frame,
3227        off_area,
3228        " OFF Strategies ",
3229        &off_indices,
3230        state,
3231        off_pnl_sum,
3232        !view.is_on_panel_selected,
3233    );
3234    frame.render_widget(
3235        Paragraph::new(Line::from(vec![
3236            Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
3237            Span::styled(
3238                selected_symbol,
3239                Style::default()
3240                    .fg(Color::Green)
3241                    .add_modifier(Modifier::BOLD),
3242            ),
3243            Span::styled(
3244                "  [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",
3245                Style::default().fg(Color::DarkGray),
3246            ),
3247        ])),
3248        strategy_chunks[3],
3249    );
3250}
3251
3252fn format_running_time(total_running_ms: u64) -> String {
3253    let total_sec = total_running_ms / 1000;
3254    let days = total_sec / 86_400;
3255    let hours = (total_sec % 86_400) / 3_600;
3256    let minutes = (total_sec % 3_600) / 60;
3257    if days > 0 {
3258        format!("{}d {:02}h", days, hours)
3259    } else {
3260        format!("{:02}h {:02}m", hours, minutes)
3261    }
3262}
3263
3264fn format_age_ms(age_ms: u64) -> String {
3265    if age_ms < 1_000 {
3266        format!("{}ms", age_ms)
3267    } else if age_ms < 60_000 {
3268        format!("{}s", age_ms / 1_000)
3269    } else {
3270        format!("{}m", age_ms / 60_000)
3271    }
3272}
3273
3274fn latency_stats(samples: &[u64]) -> (String, String, String) {
3275    let p50 = percentile(samples, 50);
3276    let p95 = percentile(samples, 95);
3277    let p99 = percentile(samples, 99);
3278    (
3279        p50.map(|v| format!("{}ms", v))
3280            .unwrap_or_else(|| "-".to_string()),
3281        p95.map(|v| format!("{}ms", v))
3282            .unwrap_or_else(|| "-".to_string()),
3283        p99.map(|v| format!("{}ms", v))
3284            .unwrap_or_else(|| "-".to_string()),
3285    )
3286}
3287
3288fn render_strategy_editor_popup(frame: &mut Frame, state: &AppState) {
3289    let area = frame.area();
3290    let popup = Rect {
3291        x: area.x + 8,
3292        y: area.y + 4,
3293        width: area.width.saturating_sub(16).max(50),
3294        height: area.height.saturating_sub(8).max(12),
3295    };
3296    frame.render_widget(Clear, popup);
3297    let block = Block::default()
3298        .title(" Strategy Config ")
3299        .borders(Borders::ALL)
3300        .border_style(Style::default().fg(Color::Yellow));
3301    let inner = block.inner(popup);
3302    frame.render_widget(block, popup);
3303    let selected_name = state
3304        .strategy_items
3305        .get(state.strategy_editor_index)
3306        .map(String::as_str)
3307        .unwrap_or("Unknown");
3308    let strategy_kind = state
3309        .strategy_editor_kind_items
3310        .get(state.strategy_editor_kind_index)
3311        .map(String::as_str)
3312        .unwrap_or("MA");
3313    let is_rsa = strategy_kind.eq_ignore_ascii_case("RSA");
3314    let is_atr = strategy_kind.eq_ignore_ascii_case("ATR");
3315    let is_chb = strategy_kind.eq_ignore_ascii_case("CHB");
3316    let period_1_label = if is_rsa {
3317        "RSI Period"
3318    } else if is_atr {
3319        "ATR Period"
3320    } else if is_chb {
3321        "Entry Window"
3322    } else {
3323        "Fast Period"
3324    };
3325    let period_2_label = if is_rsa {
3326        "Upper RSI"
3327    } else if is_atr {
3328        "Threshold x100"
3329    } else if is_chb {
3330        "Exit Window"
3331    } else {
3332        "Slow Period"
3333    };
3334    let rows = [
3335        ("Strategy", strategy_kind.to_string()),
3336        (
3337            "Symbol",
3338            state
3339                .symbol_items
3340                .get(state.strategy_editor_symbol_index)
3341                .cloned()
3342                .unwrap_or_else(|| state.symbol.clone()),
3343        ),
3344        (period_1_label, state.strategy_editor_fast.to_string()),
3345        (period_2_label, state.strategy_editor_slow.to_string()),
3346        ("Cooldown Tick", state.strategy_editor_cooldown.to_string()),
3347    ];
3348    let mut lines = vec![
3349        Line::from(vec![
3350            Span::styled("Target: ", Style::default().fg(Color::DarkGray)),
3351            Span::styled(
3352                selected_name,
3353                Style::default()
3354                    .fg(Color::White)
3355                    .add_modifier(Modifier::BOLD),
3356            ),
3357        ]),
3358        Line::from(Span::styled(
3359            "Use [J/K] field, [H/L] value, [Enter] save, [Esc] cancel",
3360            Style::default().fg(Color::DarkGray),
3361        )),
3362    ];
3363    if is_rsa {
3364        let lower = 100usize.saturating_sub(state.strategy_editor_slow.clamp(51, 95));
3365        lines.push(Line::from(Span::styled(
3366            format!("RSA lower threshold auto-derived: {}", lower),
3367            Style::default().fg(Color::DarkGray),
3368        )));
3369    } else if is_atr {
3370        let threshold_x100 = state.strategy_editor_slow.clamp(110, 500);
3371        lines.push(Line::from(Span::styled(
3372            format!(
3373                "ATR expansion threshold: {:.2}x",
3374                threshold_x100 as f64 / 100.0
3375            ),
3376            Style::default().fg(Color::DarkGray),
3377        )));
3378    } else if is_chb {
3379        lines.push(Line::from(Span::styled(
3380            "CHB breakout: buy on entry high break, sell on exit low break",
3381            Style::default().fg(Color::DarkGray),
3382        )));
3383    }
3384    for (idx, (name, value)) in rows.iter().enumerate() {
3385        let marker = if idx == state.strategy_editor_field {
3386            "▶ "
3387        } else {
3388            "  "
3389        };
3390        let style = if idx == state.strategy_editor_field {
3391            Style::default()
3392                .fg(Color::Yellow)
3393                .add_modifier(Modifier::BOLD)
3394        } else {
3395            Style::default().fg(Color::White)
3396        };
3397        lines.push(Line::from(vec![
3398            Span::styled(marker, Style::default().fg(Color::Yellow)),
3399            Span::styled(format!("{:<14}", name), style),
3400            Span::styled(value, style),
3401        ]));
3402    }
3403    frame.render_widget(Paragraph::new(lines), inner);
3404    if state.strategy_editor_kind_category_selector_open {
3405        render_selector_popup(
3406            frame,
3407            " Select Strategy Category ",
3408            &state.strategy_editor_kind_category_items,
3409            state.strategy_editor_kind_category_index.min(
3410                state
3411                    .strategy_editor_kind_category_items
3412                    .len()
3413                    .saturating_sub(1),
3414            ),
3415            None,
3416            None,
3417            None,
3418        );
3419    } else if state.strategy_editor_kind_selector_open {
3420        render_selector_popup(
3421            frame,
3422            " Select Strategy Type ",
3423            &state.strategy_editor_kind_popup_items,
3424            state.strategy_editor_kind_selector_index.min(
3425                state
3426                    .strategy_editor_kind_popup_items
3427                    .len()
3428                    .saturating_sub(1),
3429            ),
3430            None,
3431            None,
3432            None,
3433        );
3434    }
3435}
3436
3437fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
3438    let area = frame.area();
3439    let popup = Rect {
3440        x: area.x + 4,
3441        y: area.y + 2,
3442        width: area.width.saturating_sub(8).max(30),
3443        height: area.height.saturating_sub(4).max(10),
3444    };
3445    frame.render_widget(Clear, popup);
3446    let block = Block::default()
3447        .title(" Account Assets ")
3448        .borders(Borders::ALL)
3449        .border_style(Style::default().fg(Color::Cyan));
3450    let inner = block.inner(popup);
3451    frame.render_widget(block, popup);
3452
3453    let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
3454    assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
3455
3456    let mut lines = Vec::with_capacity(assets.len() + 2);
3457    lines.push(Line::from(vec![
3458        Span::styled(
3459            "Asset",
3460            Style::default()
3461                .fg(Color::Cyan)
3462                .add_modifier(Modifier::BOLD),
3463        ),
3464        Span::styled(
3465            "      Free",
3466            Style::default()
3467                .fg(Color::Cyan)
3468                .add_modifier(Modifier::BOLD),
3469        ),
3470    ]));
3471    for (asset, qty) in assets {
3472        lines.push(Line::from(vec![
3473            Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
3474            Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
3475        ]));
3476    }
3477    if lines.len() == 1 {
3478        lines.push(Line::from(Span::styled(
3479            "No assets",
3480            Style::default().fg(Color::DarkGray),
3481        )));
3482    }
3483
3484    frame.render_widget(Paragraph::new(lines), inner);
3485}
3486
3487fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
3488    let area = frame.area();
3489    let popup = Rect {
3490        x: area.x + 2,
3491        y: area.y + 1,
3492        width: area.width.saturating_sub(4).max(40),
3493        height: area.height.saturating_sub(2).max(12),
3494    };
3495    frame.render_widget(Clear, popup);
3496    let block = Block::default()
3497        .title(match bucket {
3498            order_store::HistoryBucket::Day => " History (Day ROI) ",
3499            order_store::HistoryBucket::Hour => " History (Hour ROI) ",
3500            order_store::HistoryBucket::Month => " History (Month ROI) ",
3501        })
3502        .borders(Borders::ALL)
3503        .border_style(Style::default().fg(Color::Cyan));
3504    let inner = block.inner(popup);
3505    frame.render_widget(block, popup);
3506
3507    let max_rows = inner.height.saturating_sub(1) as usize;
3508    let visible = build_history_lines(rows, max_rows);
3509    frame.render_widget(Paragraph::new(visible), inner);
3510}
3511
3512fn build_history_lines(rows: &[String], max_rows: usize) -> Vec<Line<'_>> {
3513    let mut visible: Vec<Line> = Vec::new();
3514    for (idx, row) in rows.iter().take(max_rows).enumerate() {
3515        let color = if idx == 0 {
3516            Color::Cyan
3517        } else if row.contains('-') && row.contains('%') {
3518            Color::White
3519        } else {
3520            Color::DarkGray
3521        };
3522        visible.push(Line::from(Span::styled(
3523            row.as_str(),
3524            Style::default().fg(color),
3525        )));
3526    }
3527    if visible.is_empty() {
3528        visible.push(Line::from(Span::styled(
3529            "No history rows",
3530            Style::default().fg(Color::DarkGray),
3531        )));
3532    }
3533    visible
3534}
3535
3536fn render_selector_popup(
3537    frame: &mut Frame,
3538    title: &str,
3539    items: &[String],
3540    selected: usize,
3541    stats: Option<&HashMap<String, OrderHistoryStats>>,
3542    total_stats: Option<OrderHistoryStats>,
3543    selected_symbol: Option<&str>,
3544) {
3545    let area = frame.area();
3546    let available_width = area.width.saturating_sub(2).max(1);
3547    let width = if stats.is_some() {
3548        let min_width = 44;
3549        let preferred = 84;
3550        preferred
3551            .min(available_width)
3552            .max(min_width.min(available_width))
3553    } else {
3554        let min_width = 24;
3555        let preferred = 48;
3556        preferred
3557            .min(available_width)
3558            .max(min_width.min(available_width))
3559    };
3560    let available_height = area.height.saturating_sub(2).max(1);
3561    let desired_height = if stats.is_some() {
3562        items.len() as u16 + 7
3563    } else {
3564        items.len() as u16 + 4
3565    };
3566    let height = desired_height
3567        .min(available_height)
3568        .max(6.min(available_height));
3569    let popup = Rect {
3570        x: area.x + (area.width.saturating_sub(width)) / 2,
3571        y: area.y + (area.height.saturating_sub(height)) / 2,
3572        width,
3573        height,
3574    };
3575
3576    frame.render_widget(Clear, popup);
3577    let block = Block::default()
3578        .title(title)
3579        .borders(Borders::ALL)
3580        .border_style(Style::default().fg(Color::Cyan));
3581    let inner = block.inner(popup);
3582    frame.render_widget(block, popup);
3583
3584    let mut lines: Vec<Line> = Vec::new();
3585    if stats.is_some() {
3586        if let Some(symbol) = selected_symbol {
3587            lines.push(Line::from(vec![
3588                Span::styled("  Symbol: ", Style::default().fg(Color::DarkGray)),
3589                Span::styled(
3590                    symbol,
3591                    Style::default()
3592                        .fg(Color::Green)
3593                        .add_modifier(Modifier::BOLD),
3594                ),
3595            ]));
3596        }
3597        lines.push(Line::from(vec![Span::styled(
3598            "  Strategy           W    L    T    PnL",
3599            Style::default()
3600                .fg(Color::Cyan)
3601                .add_modifier(Modifier::BOLD),
3602        )]));
3603    }
3604
3605    let mut item_lines: Vec<Line> = items
3606        .iter()
3607        .enumerate()
3608        .map(|(idx, item)| {
3609            let item_text = if let Some(stats_map) = stats {
3610                let symbol = selected_symbol.unwrap_or("-");
3611                if let Some(s) = strategy_stats_for_item(stats_map, item, symbol) {
3612                    format!(
3613                        "{:<16}  W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
3614                        item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
3615                    )
3616                } else {
3617                    format!("{:<16}  W:0   L:0   T:0   PnL:0.0000", item)
3618                }
3619            } else {
3620                item.clone()
3621            };
3622            if idx == selected {
3623                Line::from(vec![
3624                    Span::styled("▶ ", Style::default().fg(Color::Yellow)),
3625                    Span::styled(
3626                        item_text,
3627                        Style::default()
3628                            .fg(Color::White)
3629                            .add_modifier(Modifier::BOLD),
3630                    ),
3631                ])
3632            } else {
3633                Line::from(vec![
3634                    Span::styled("  ", Style::default()),
3635                    Span::styled(item_text, Style::default().fg(Color::DarkGray)),
3636                ])
3637            }
3638        })
3639        .collect();
3640    lines.append(&mut item_lines);
3641    if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
3642        let mut strategy_sum = OrderHistoryStats::default();
3643        for item in items {
3644            let symbol = selected_symbol.unwrap_or("-");
3645            if let Some(s) = strategy_stats_for_item(stats_map, item, symbol) {
3646                strategy_sum.trade_count += s.trade_count;
3647                strategy_sum.win_count += s.win_count;
3648                strategy_sum.lose_count += s.lose_count;
3649                strategy_sum.realized_pnl += s.realized_pnl;
3650            }
3651        }
3652        let manual = subtract_stats(t, &strategy_sum);
3653        lines.push(Line::from(vec![Span::styled(
3654            format!(
3655                "  MANUAL(rest)       W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
3656                manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
3657            ),
3658            Style::default().fg(Color::LightBlue),
3659        )]));
3660    }
3661    if let Some(t) = total_stats {
3662        lines.push(Line::from(vec![Span::styled(
3663            format!(
3664                "  TOTAL              W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
3665                t.win_count, t.lose_count, t.trade_count, t.realized_pnl
3666            ),
3667            Style::default()
3668                .fg(Color::Yellow)
3669                .add_modifier(Modifier::BOLD),
3670        )]));
3671    }
3672
3673    frame.render_widget(
3674        Paragraph::new(lines).style(Style::default().fg(Color::White)),
3675        inner,
3676    );
3677}
3678
3679fn parse_scope_key(scope_key: &str) -> Option<(String, String)> {
3680    let (symbol, tag) = scope_key.split_once("::")?;
3681    let symbol = symbol.trim().to_ascii_uppercase();
3682    let source_tag = tag.trim().to_ascii_lowercase();
3683    if symbol.is_empty() || source_tag.is_empty() {
3684        None
3685    } else {
3686        Some((symbol, source_tag))
3687    }
3688}
3689
3690fn exit_policy_for_symbol_and_tag(
3691    state: &AppState,
3692    symbol: &str,
3693    source_tag: &str,
3694) -> Option<ExitPolicyEntry> {
3695    let tag = source_tag.trim().to_ascii_lowercase();
3696    let candidates = symbol_scope_candidates(symbol);
3697    state
3698        .exit_policy_by_scope
3699        .iter()
3700        .filter_map(|(k, v)| {
3701            let (scope_symbol, scope_tag) = parse_scope_key(k)?;
3702            let symbol_ok = candidates.iter().any(|prefix| {
3703                prefix
3704                    .trim_end_matches("::")
3705                    .eq_ignore_ascii_case(&scope_symbol)
3706            });
3707            if symbol_ok && scope_tag == tag {
3708                Some(v)
3709            } else {
3710                None
3711            }
3712        })
3713        .max_by_key(|v| v.updated_at_ms)
3714        .cloned()
3715}
3716
3717fn display_symbol_for_storage(symbol: &str) -> String {
3718    let upper = symbol.trim().to_ascii_uppercase();
3719    if let Some(base) = upper.strip_suffix("#FUT") {
3720        format!("{} (FUT)", base)
3721    } else {
3722        upper
3723    }
3724}
3725
3726fn asset_last_price_for_symbol(state: &AppState, symbol: &str) -> Option<f64> {
3727    let target = normalize_symbol_for_scope(symbol);
3728    state
3729        .assets_view()
3730        .iter()
3731        .find(|a| normalize_symbol_for_scope(&a.symbol) == target)
3732        .and_then(|a| a.last_price)
3733}
3734
3735fn close_all_row_status_for_symbol(state: &AppState, symbol: &str) -> Option<String> {
3736    let key = normalize_symbol_for_scope(symbol);
3737    if state.close_all_running {
3738        if let Some(found) = state.close_all_row_status_by_symbol.get(&key) {
3739            if found == "PENDING" {
3740                return Some("RUNNING".to_string());
3741            }
3742            return Some(found.clone());
3743        }
3744    }
3745    state.close_all_row_status_by_symbol.get(&key).cloned()
3746}
3747
3748fn strategy_stats_for_item<'a>(
3749    stats_map: &'a HashMap<String, OrderHistoryStats>,
3750    item: &str,
3751    symbol: &str,
3752) -> Option<&'a OrderHistoryStats> {
3753    if let Some(source_tag) = source_tag_for_strategy_item(item) {
3754        let scoped = strategy_stats_scope_key(symbol, &source_tag);
3755        if let Some(s) = stats_map.get(&scoped) {
3756            return Some(s);
3757        }
3758    }
3759    if let Some(s) = stats_map.get(item) {
3760        return Some(s);
3761    }
3762    let source_tag = source_tag_for_strategy_item(item);
3763    source_tag.and_then(|tag| {
3764        stats_map
3765            .get(&tag)
3766            .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
3767    })
3768}
3769
3770fn exit_policy_for_item<'a>(
3771    policy_map: &'a HashMap<String, ExitPolicyEntry>,
3772    item: &str,
3773    symbol: &str,
3774) -> Option<&'a ExitPolicyEntry> {
3775    if let Some(source_tag) = source_tag_for_strategy_item(item) {
3776        if let Some(found) = policy_map.get(&strategy_stats_scope_key(symbol, &source_tag)) {
3777            return Some(found);
3778        }
3779    }
3780    latest_exit_policy_for_symbol(policy_map, symbol)
3781}
3782
3783fn latest_exit_policy_for_symbol<'a>(
3784    policy_map: &'a HashMap<String, ExitPolicyEntry>,
3785    symbol: &str,
3786) -> Option<&'a ExitPolicyEntry> {
3787    let prefix = format!("{}::", symbol.trim().to_ascii_uppercase());
3788    policy_map
3789        .iter()
3790        .filter(|(k, _)| k.starts_with(&prefix))
3791        .max_by_key(|(_, v)| v.updated_at_ms)
3792        .map(|(_, v)| v)
3793}
3794
3795fn latest_exit_policy_for_symbol_relaxed<'a>(
3796    policy_map: &'a HashMap<String, ExitPolicyEntry>,
3797    symbol: &str,
3798) -> Option<&'a ExitPolicyEntry> {
3799    let candidates = symbol_scope_candidates(symbol);
3800    policy_map
3801        .iter()
3802        .filter(|(k, _)| candidates.iter().any(|prefix| k.starts_with(prefix)))
3803        .max_by_key(|(_, v)| v.updated_at_ms)
3804        .map(|(_, v)| v)
3805}
3806
3807fn symbol_scope_candidates(symbol: &str) -> Vec<String> {
3808    let mut variants: Vec<String> = Vec::new();
3809    let upper = symbol.trim().to_ascii_uppercase();
3810    let base = if let Some(raw) = upper.strip_suffix(" (FUT)") {
3811        raw.trim().to_string()
3812    } else if let Some(raw) = upper.strip_suffix("#FUT") {
3813        raw.trim().to_string()
3814    } else {
3815        upper.clone()
3816    };
3817
3818    if !base.is_empty() {
3819        variants.push(base.clone());
3820        variants.push(format!("{} (FUT)", base));
3821        variants.push(format!("{}#FUT", base));
3822    }
3823    if !upper.is_empty() {
3824        variants.push(upper);
3825    }
3826    variants.sort();
3827    variants.dedup();
3828    variants.into_iter().map(|v| format!("{}::", v)).collect()
3829}
3830
3831fn strategy_stats_scope_key(symbol: &str, source_tag: &str) -> String {
3832    format!(
3833        "{}::{}",
3834        symbol.trim().to_ascii_uppercase(),
3835        source_tag.trim().to_ascii_lowercase()
3836    )
3837}
3838
3839fn predictor_metrics_scope_key(
3840    symbol: &str,
3841    market: &str,
3842    predictor: &str,
3843    horizon: &str,
3844) -> String {
3845    format!(
3846        "{}::{}::{}::{}",
3847        symbol.trim().to_ascii_uppercase(),
3848        market.trim().to_ascii_lowercase(),
3849        predictor.trim().to_ascii_lowercase(),
3850        horizon.trim().to_ascii_lowercase()
3851    )
3852}
3853
3854fn source_tag_for_strategy_item(item: &str) -> Option<String> {
3855    match item {
3856        "MA(Config)" => return Some("cfg".to_string()),
3857        "MA(Fast 5/20)" => return Some("fst".to_string()),
3858        "MA(Slow 20/60)" => return Some("slw".to_string()),
3859        "RSA(RSI 14 30/70)" => return Some("rsa".to_string()),
3860        "DCT(Donchian 20/10)" => return Some("dct".to_string()),
3861        "MRV(SMA 20 -2.00%)" => return Some("mrv".to_string()),
3862        "BBR(BB 20 2.00x)" => return Some("bbr".to_string()),
3863        "STO(Stoch 14 20/80)" => return Some("sto".to_string()),
3864        "VLC(Compression 20 1.20%)" => return Some("vlc".to_string()),
3865        "ORB(Opening 12/8)" => return Some("orb".to_string()),
3866        "REG(Regime 10/30)" => return Some("reg".to_string()),
3867        "ENS(Vote 10/30)" => return Some("ens".to_string()),
3868        "MAC(MACD 12/26)" => return Some("mac".to_string()),
3869        "ROC(ROC 10 0.20%)" => return Some("roc".to_string()),
3870        "ARN(Aroon 14 70)" => return Some("arn".to_string()),
3871        _ => {}
3872    }
3873    if let Some((_, tail)) = item.rsplit_once('[') {
3874        if let Some(tag) = tail.strip_suffix(']') {
3875            let tag = tag.trim();
3876            if !tag.is_empty() {
3877                return Some(tag.to_ascii_lowercase());
3878            }
3879        }
3880    }
3881    None
3882}
3883
3884fn parse_source_tag_from_client_order_id(client_order_id: &str) -> Option<&str> {
3885    let body = client_order_id.strip_prefix("sq-")?;
3886    let (source_tag, _) = body.split_once('-')?;
3887    if source_tag.is_empty() {
3888        None
3889    } else {
3890        Some(source_tag)
3891    }
3892}
3893
3894fn normalize_symbol_for_scope(symbol: &str) -> String {
3895    let upper = symbol.trim().to_ascii_uppercase();
3896    if let Some(raw) = upper.strip_suffix(" (FUT)") {
3897        return raw.trim().to_string();
3898    }
3899    if let Some(raw) = upper.strip_suffix("#FUT") {
3900        return raw.trim().to_string();
3901    }
3902    upper
3903}
3904
3905fn format_log_record_compact(record: &LogRecord) -> String {
3906    let level = match record.level {
3907        LogLevel::Debug => "DEBUG",
3908        LogLevel::Info => "INFO",
3909        LogLevel::Warn => "WARN",
3910        LogLevel::Error => "ERR",
3911    };
3912    let domain = match record.domain {
3913        LogDomain::Ws => "ws",
3914        LogDomain::Strategy => "strategy",
3915        LogDomain::Risk => "risk",
3916        LogDomain::Order => "order",
3917        LogDomain::Portfolio => "portfolio",
3918        LogDomain::Ui => "ui",
3919        LogDomain::System => "system",
3920    };
3921    let symbol = record.symbol.as_deref().unwrap_or("-");
3922    let strategy = record.strategy_tag.as_deref().unwrap_or("-");
3923    format!(
3924        "[{}] {}.{} {} {} {}",
3925        level, domain, record.event, symbol, strategy, record.msg
3926    )
3927}
3928
3929fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
3930    OrderHistoryStats {
3931        trade_count: total.trade_count.saturating_sub(used.trade_count),
3932        win_count: total.win_count.saturating_sub(used.win_count),
3933        lose_count: total.lose_count.saturating_sub(used.lose_count),
3934        realized_pnl: total.realized_pnl - used.realized_pnl,
3935    }
3936}
3937
3938fn split_symbol_assets(symbol: &str) -> (String, String) {
3939    const QUOTE_SUFFIXES: [&str; 10] = [
3940        "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
3941    ];
3942    for q in QUOTE_SUFFIXES {
3943        if let Some(base) = symbol.strip_suffix(q) {
3944            if !base.is_empty() {
3945                return (base.to_string(), q.to_string());
3946            }
3947        }
3948    }
3949    (symbol.to_string(), String::new())
3950}
3951
3952fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
3953    if fills.is_empty() {
3954        return None;
3955    }
3956    let (base_asset, quote_asset) = split_symbol_assets(symbol);
3957    let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
3958    let mut notional_quote = 0.0;
3959    let mut fee_quote_equiv = 0.0;
3960    let mut quote_convertible = !quote_asset.is_empty();
3961
3962    for f in fills {
3963        if f.qty > 0.0 && f.price > 0.0 {
3964            notional_quote += f.qty * f.price;
3965        }
3966        if f.commission <= 0.0 {
3967            continue;
3968        }
3969        *fee_by_asset
3970            .entry(f.commission_asset.clone())
3971            .or_insert(0.0) += f.commission;
3972        if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&quote_asset) {
3973            fee_quote_equiv += f.commission;
3974        } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
3975            fee_quote_equiv += f.commission * f.price.max(0.0);
3976        } else {
3977            quote_convertible = false;
3978        }
3979    }
3980
3981    if fee_by_asset.is_empty() {
3982        return Some("0".to_string());
3983    }
3984
3985    if quote_convertible && notional_quote > f64::EPSILON {
3986        let fee_pct = fee_quote_equiv / notional_quote * 100.0;
3987        return Some(format!(
3988            "{:.3}% ({:.4} {})",
3989            fee_pct, fee_quote_equiv, quote_asset
3990        ));
3991    }
3992
3993    let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
3994    items.sort_by(|a, b| a.0.cmp(&b.0));
3995    if items.len() == 1 {
3996        let (asset, amount) = &items[0];
3997        Some(format!("{:.6} {}", amount, asset))
3998    } else {
3999        Some(format!("mixed fees ({})", items.len()))
4000    }
4001}
4002
4003#[cfg(test)]
4004mod tests {
4005    use super::{format_last_applied_fee, symbol_scope_candidates};
4006    use crate::model::order::Fill;
4007
4008    #[test]
4009    fn fee_summary_from_quote_asset_commission() {
4010        let fills = vec![Fill {
4011            price: 2000.0,
4012            qty: 0.5,
4013            commission: 1.0,
4014            commission_asset: "USDT".to_string(),
4015        }];
4016        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
4017        assert_eq!(summary, "0.100% (1.0000 USDT)");
4018    }
4019
4020    #[test]
4021    fn fee_summary_from_base_asset_commission() {
4022        let fills = vec![Fill {
4023            price: 2000.0,
4024            qty: 0.5,
4025            commission: 0.0005,
4026            commission_asset: "ETH".to_string(),
4027        }];
4028        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
4029        assert_eq!(summary, "0.100% (1.0000 USDT)");
4030    }
4031
4032    #[test]
4033    fn symbol_scope_candidates_include_spot_and_futures_variants() {
4034        let mut from_spot = symbol_scope_candidates("btcusdt");
4035        from_spot.sort();
4036        assert!(from_spot.contains(&"BTCUSDT::".to_string()));
4037        assert!(from_spot.contains(&"BTCUSDT (FUT)::".to_string()));
4038        assert!(from_spot.contains(&"BTCUSDT#FUT::".to_string()));
4039
4040        let mut from_fut_label = symbol_scope_candidates("BTCUSDT (FUT)");
4041        from_fut_label.sort();
4042        assert!(from_fut_label.contains(&"BTCUSDT::".to_string()));
4043        assert!(from_fut_label.contains(&"BTCUSDT (FUT)::".to_string()));
4044        assert!(from_fut_label.contains(&"BTCUSDT#FUT::".to_string()));
4045
4046        let mut from_hash_fut = symbol_scope_candidates("BTCUSDT#FUT");
4047        from_hash_fut.sort();
4048        assert!(from_hash_fut.contains(&"BTCUSDT::".to_string()));
4049        assert!(from_hash_fut.contains(&"BTCUSDT (FUT)::".to_string()));
4050        assert!(from_hash_fut.contains(&"BTCUSDT#FUT::".to_string()));
4051    }
4052}