Skip to main content

sandbox_quant/ui/
mod.rs

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