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