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                p_win,
1076                gate_mode,
1077                gate_blocked,
1078            } => {
1079                let key = strategy_stats_scope_key(&symbol, &source_tag);
1080                self.ev_snapshot_by_scope.insert(
1081                    key,
1082                    EvSnapshotEntry {
1083                        ev,
1084                        p_win,
1085                        gate_mode,
1086                        gate_blocked,
1087                        updated_at_ms: chrono::Utc::now().timestamp_millis() as u64,
1088                    },
1089                );
1090            }
1091            AppEvent::ExitPolicyUpdate {
1092                symbol,
1093                source_tag,
1094                stop_price,
1095                expected_holding_ms,
1096                protective_stop_ok,
1097            } => {
1098                let key = strategy_stats_scope_key(&symbol, &source_tag);
1099                self.exit_policy_by_scope.insert(
1100                    key,
1101                    ExitPolicyEntry {
1102                        stop_price,
1103                        expected_holding_ms,
1104                        protective_stop_ok,
1105                        updated_at_ms: chrono::Utc::now().timestamp_millis() as u64,
1106                    },
1107                );
1108            }
1109            AppEvent::AssetPnlUpdate { by_symbol } => {
1110                rebuild_projection = true;
1111                self.asset_pnl_by_symbol = by_symbol;
1112            }
1113            AppEvent::RiskRateSnapshot {
1114                global,
1115                orders,
1116                account,
1117                market_data,
1118            } => {
1119                self.rate_budget_global = global;
1120                self.rate_budget_orders = orders;
1121                self.rate_budget_account = account;
1122                self.rate_budget_market_data = market_data;
1123            }
1124            AppEvent::CloseAllRequested {
1125                job_id,
1126                total,
1127                symbols,
1128            } => {
1129                self.close_all_running = true;
1130                self.close_all_job_id = Some(job_id);
1131                self.close_all_total = total;
1132                self.close_all_completed = 0;
1133                self.close_all_failed = 0;
1134                self.close_all_current_symbol = None;
1135                self.close_all_status_expire_at_ms = None;
1136                self.close_all_row_status_by_symbol.clear();
1137                for symbol in symbols {
1138                    self.close_all_row_status_by_symbol
1139                        .insert(normalize_symbol_for_scope(&symbol), "PENDING".to_string());
1140                }
1141                self.push_log(format!("[INFO] close-all #{} started total={}", job_id, total));
1142            }
1143            AppEvent::CloseAllProgress {
1144                job_id,
1145                symbol,
1146                completed,
1147                total,
1148                failed,
1149                reason,
1150            } => {
1151                if self.close_all_job_id != Some(job_id) {
1152                    self.close_all_job_id = Some(job_id);
1153                }
1154                self.close_all_running = completed < total;
1155                self.close_all_total = total;
1156                self.close_all_completed = completed;
1157                self.close_all_failed = failed;
1158                self.close_all_current_symbol = Some(symbol.clone());
1159                let symbol_key = normalize_symbol_for_scope(&symbol);
1160                let row_status = if let Some(r) = reason.as_ref() {
1161                    if r.contains("too small")
1162                        || r.contains("No ")
1163                        || r.contains("Insufficient ")
1164                    {
1165                        "SKIP"
1166                    } else {
1167                        "FAIL"
1168                    }
1169                } else {
1170                    "DONE"
1171                };
1172                self.close_all_row_status_by_symbol
1173                    .insert(symbol_key, row_status.to_string());
1174                let ok = completed.saturating_sub(failed);
1175                self.push_log(format!(
1176                    "[INFO] close-all #{} progress {}/{} ok={} fail={} symbol={}",
1177                    job_id, completed, total, ok, failed, symbol
1178                ));
1179                if let Some(r) = reason {
1180                    self.push_log(format!(
1181                        "[WARN] close-all #{} {} ({}/{}) reason={}",
1182                        job_id, symbol, completed, total, r
1183                    ));
1184                }
1185            }
1186            AppEvent::CloseAllFinished {
1187                job_id,
1188                completed,
1189                total,
1190                failed,
1191            } => {
1192                self.close_all_running = false;
1193                self.close_all_job_id = Some(job_id);
1194                self.close_all_total = total;
1195                self.close_all_completed = completed;
1196                self.close_all_failed = failed;
1197                self.close_all_status_expire_at_ms =
1198                    Some((chrono::Utc::now().timestamp_millis() as u64).saturating_add(5_000));
1199                let ok = completed.saturating_sub(failed);
1200                self.push_log(format!(
1201                    "[INFO] close-all #{} finished ok={} fail={} total={}",
1202                    job_id, ok, failed, total
1203                ));
1204            }
1205            AppEvent::TickDropped => {
1206                self.network_tick_drop_count = self.network_tick_drop_count.saturating_add(1);
1207                let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1208                Self::push_network_event_sample(&mut self.network_tick_drop_timestamps_ms, now_ms);
1209            }
1210            AppEvent::LogRecord(record) => {
1211                self.push_log_record(record);
1212            }
1213            AppEvent::LogMessage(msg) => {
1214                self.push_log(msg);
1215            }
1216            AppEvent::Error(msg) => {
1217                self.push_log(format!("[ERR] {}", msg));
1218            }
1219        }
1220        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1221        if !self.close_all_running
1222            && self
1223                .close_all_status_expire_at_ms
1224                .map(|ts| now_ms >= ts)
1225                .unwrap_or(false)
1226        {
1227            self.close_all_job_id = None;
1228            self.close_all_total = 0;
1229            self.close_all_completed = 0;
1230            self.close_all_failed = 0;
1231            self.close_all_current_symbol = None;
1232            self.close_all_status_expire_at_ms = None;
1233            self.close_all_row_status_by_symbol.clear();
1234        }
1235        self.prune_network_event_windows(now_ms);
1236        self.sync_projection_portfolio_summary();
1237        if rebuild_projection {
1238            self.rebuild_projection_preserve_focus(prev_focus);
1239        } else {
1240            self.ensure_projection_focus_defaults();
1241        }
1242    }
1243}
1244
1245pub fn render(frame: &mut Frame, state: &AppState) {
1246    let view = state.view_state();
1247    if view.is_grid_open {
1248        render_grid_popup(frame, state);
1249        if view.is_strategy_editor_open {
1250            render_strategy_editor_popup(frame, state);
1251        }
1252        if view.is_close_all_confirm_open {
1253            render_close_all_confirm_popup(frame);
1254        }
1255        return;
1256    }
1257
1258    let area = frame.area();
1259    let status_h = 1u16;
1260    let keybind_h = 1u16;
1261    let min_main_h = 8u16;
1262    let mut order_log_h = 5u16;
1263    let mut order_history_h = 6u16;
1264    let mut system_log_h = 8u16;
1265    let mut lower_total = order_log_h + order_history_h + system_log_h;
1266    let lower_budget = area
1267        .height
1268        .saturating_sub(status_h + keybind_h + min_main_h);
1269    if lower_total > lower_budget {
1270        let mut overflow = lower_total - lower_budget;
1271        while overflow > 0 && system_log_h > 0 {
1272            system_log_h -= 1;
1273            overflow -= 1;
1274        }
1275        while overflow > 0 && order_history_h > 0 {
1276            order_history_h -= 1;
1277            overflow -= 1;
1278        }
1279        while overflow > 0 && order_log_h > 0 {
1280            order_log_h -= 1;
1281            overflow -= 1;
1282        }
1283        lower_total = order_log_h + order_history_h + system_log_h;
1284        if lower_total > lower_budget {
1285            // Extremely small terminal fallback: keep bottom panels hidden first.
1286            order_log_h = 0;
1287            order_history_h = 0;
1288            system_log_h = 0;
1289        }
1290    }
1291
1292    let outer = Layout::default()
1293        .direction(Direction::Vertical)
1294        .constraints([
1295            Constraint::Length(status_h),        // status bar
1296            Constraint::Min(min_main_h),         // main area (chart + position)
1297            Constraint::Length(order_log_h),     // order log
1298            Constraint::Length(order_history_h), // order history
1299            Constraint::Length(system_log_h),    // system log
1300            Constraint::Length(keybind_h),       // keybinds
1301        ])
1302        .split(area);
1303
1304    let close_all_status_text = state.close_all_status_text();
1305    // Status bar
1306    frame.render_widget(
1307        StatusBar {
1308            symbol: &state.symbol,
1309            strategy_label: &state.strategy_label,
1310            ws_connected: state.ws_connected,
1311            paused: state.paused,
1312            timeframe: &state.timeframe,
1313            last_price_update_ms: state.last_price_update_ms,
1314            last_price_latency_ms: state.last_price_latency_ms,
1315            last_order_history_update_ms: state.last_order_history_update_ms,
1316            last_order_history_latency_ms: state.last_order_history_latency_ms,
1317            close_all_status: close_all_status_text.as_deref(),
1318            close_all_running: state.close_all_running,
1319        },
1320        outer[0],
1321    );
1322
1323    // Main area: chart + position panel
1324    let main_area = Layout::default()
1325        .direction(Direction::Horizontal)
1326        .constraints([Constraint::Min(40), Constraint::Length(24)])
1327        .split(outer[1]);
1328    let selected_strategy_stats =
1329        strategy_stats_for_item(&state.strategy_stats, &state.strategy_label, &state.symbol)
1330        .cloned()
1331        .unwrap_or_default();
1332
1333    // Price chart (candles + in-progress candle)
1334    let current_price = state.last_price();
1335    frame.render_widget(
1336        PriceChart::new(&state.candles, &state.symbol)
1337            .current_candle(state.current_candle.as_ref())
1338            .fill_markers(&state.fill_markers)
1339            .fast_sma(state.fast_sma)
1340            .slow_sma(state.slow_sma),
1341        main_area[0],
1342    );
1343
1344    // Right panels: Position (symbol scope) + Strategy metrics (strategy scope).
1345    let right_panels = Layout::default()
1346        .direction(Direction::Vertical)
1347        .constraints([Constraint::Min(9), Constraint::Length(8)])
1348        .split(main_area[1]);
1349    frame.render_widget(
1350        PositionPanel::new(
1351            &state.position,
1352            current_price,
1353            &state.last_applied_fee,
1354            ev_snapshot_for_item(&state.ev_snapshot_by_scope, &state.strategy_label, &state.symbol),
1355            exit_policy_for_item(&state.exit_policy_by_scope, &state.strategy_label, &state.symbol),
1356        ),
1357        right_panels[0],
1358    );
1359    frame.render_widget(
1360        StrategyMetricsPanel::new(
1361            &state.strategy_label,
1362            selected_strategy_stats.trade_count,
1363            selected_strategy_stats.win_count,
1364            selected_strategy_stats.lose_count,
1365            selected_strategy_stats.realized_pnl,
1366        ),
1367        right_panels[1],
1368    );
1369
1370    // Order log
1371    frame.render_widget(
1372        OrderLogPanel::new(
1373            &state.last_signal,
1374            &state.last_order,
1375            state.fast_sma,
1376            state.slow_sma,
1377            selected_strategy_stats.trade_count,
1378            selected_strategy_stats.win_count,
1379            selected_strategy_stats.lose_count,
1380            selected_strategy_stats.realized_pnl,
1381        ),
1382        outer[2],
1383    );
1384
1385    // Order history panel
1386    frame.render_widget(
1387        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1388        outer[3],
1389    );
1390
1391    // System log panel
1392    frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
1393
1394    // Keybind bar
1395    frame.render_widget(KeybindBar, outer[5]);
1396
1397    if view.is_close_all_confirm_open {
1398        render_close_all_confirm_popup(frame);
1399    } else if view.is_symbol_selector_open {
1400        render_selector_popup(
1401            frame,
1402            " Select Symbol ",
1403            &state.symbol_items,
1404            view.selected_symbol_selector_index,
1405            None,
1406            None,
1407            None,
1408        );
1409    } else if view.is_strategy_selector_open {
1410        let selected_strategy_symbol = state
1411            .strategy_item_symbols
1412            .get(view.selected_strategy_selector_index)
1413            .map(String::as_str)
1414            .unwrap_or(state.symbol.as_str());
1415        render_selector_popup(
1416            frame,
1417            " Select Strategy ",
1418            &state.strategy_items,
1419            view.selected_strategy_selector_index,
1420            Some(&state.strategy_stats),
1421            Some(OrderHistoryStats {
1422                trade_count: state.history_trade_count,
1423                win_count: state.history_win_count,
1424                lose_count: state.history_lose_count,
1425                realized_pnl: state.history_realized_pnl,
1426            }),
1427            Some(selected_strategy_symbol),
1428        );
1429    } else if view.is_account_popup_open {
1430        render_account_popup(frame, &state.balances);
1431    } else if view.is_history_popup_open {
1432        render_history_popup(frame, &state.history_rows, state.history_bucket);
1433    } else if view.is_focus_popup_open {
1434        render_focus_popup(frame, state);
1435    } else if view.is_strategy_editor_open {
1436        render_strategy_editor_popup(frame, state);
1437    }
1438}
1439
1440fn render_focus_popup(frame: &mut Frame, state: &AppState) {
1441    let area = frame.area();
1442    let popup = Rect {
1443        x: area.x + 1,
1444        y: area.y + 1,
1445        width: area.width.saturating_sub(2).max(70),
1446        height: area.height.saturating_sub(2).max(22),
1447    };
1448    frame.render_widget(Clear, popup);
1449    let block = Block::default()
1450        .title(" Focus View (Drill-down) ")
1451        .borders(Borders::ALL)
1452        .border_style(Style::default().fg(Color::Green));
1453    let inner = block.inner(popup);
1454    frame.render_widget(block, popup);
1455
1456    let rows = Layout::default()
1457        .direction(Direction::Vertical)
1458        .constraints([
1459            Constraint::Length(2),
1460            Constraint::Min(8),
1461            Constraint::Length(7),
1462        ])
1463        .split(inner);
1464
1465    let focus_symbol = state.focus_symbol().unwrap_or(&state.symbol);
1466    let focus_strategy = state.focus_strategy_id().unwrap_or(&state.strategy_label);
1467    let focus_strategy_stats = strategy_stats_for_item(
1468        &state.strategy_stats,
1469        focus_strategy,
1470        focus_symbol,
1471    )
1472        .cloned()
1473        .unwrap_or_default();
1474    frame.render_widget(
1475        Paragraph::new(vec![
1476            Line::from(vec![
1477                Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
1478                Span::styled(
1479                    focus_symbol,
1480                    Style::default()
1481                        .fg(Color::Cyan)
1482                        .add_modifier(Modifier::BOLD),
1483                ),
1484                Span::styled("  Strategy: ", Style::default().fg(Color::DarkGray)),
1485                Span::styled(
1486                    focus_strategy,
1487                    Style::default()
1488                        .fg(Color::Magenta)
1489                        .add_modifier(Modifier::BOLD),
1490                ),
1491            ]),
1492            Line::from(Span::styled(
1493                "Reuse legacy chart/position/history widgets. Press [F]/[Esc] to close.",
1494                Style::default().fg(Color::DarkGray),
1495            )),
1496        ]),
1497        rows[0],
1498    );
1499
1500    let main_cols = Layout::default()
1501        .direction(Direction::Horizontal)
1502        .constraints([Constraint::Min(48), Constraint::Length(28)])
1503        .split(rows[1]);
1504
1505    frame.render_widget(
1506        PriceChart::new(&state.candles, focus_symbol)
1507            .current_candle(state.current_candle.as_ref())
1508            .fill_markers(&state.fill_markers)
1509            .fast_sma(state.fast_sma)
1510            .slow_sma(state.slow_sma),
1511        main_cols[0],
1512    );
1513    let focus_right = Layout::default()
1514        .direction(Direction::Vertical)
1515        .constraints([Constraint::Min(8), Constraint::Length(8)])
1516        .split(main_cols[1]);
1517    frame.render_widget(
1518        PositionPanel::new(
1519            &state.position,
1520            state.last_price(),
1521            &state.last_applied_fee,
1522            ev_snapshot_for_item(&state.ev_snapshot_by_scope, focus_strategy, focus_symbol),
1523            exit_policy_for_item(&state.exit_policy_by_scope, focus_strategy, focus_symbol),
1524        ),
1525        focus_right[0],
1526    );
1527    frame.render_widget(
1528        StrategyMetricsPanel::new(
1529            focus_strategy,
1530            focus_strategy_stats.trade_count,
1531            focus_strategy_stats.win_count,
1532            focus_strategy_stats.lose_count,
1533            focus_strategy_stats.realized_pnl,
1534        ),
1535        focus_right[1],
1536    );
1537
1538    frame.render_widget(
1539        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1540        rows[2],
1541    );
1542}
1543
1544fn render_close_all_confirm_popup(frame: &mut Frame) {
1545    let area = frame.area();
1546    let popup = Rect {
1547        x: area.x + area.width.saturating_sub(60) / 2,
1548        y: area.y + area.height.saturating_sub(7) / 2,
1549        width: 60.min(area.width.saturating_sub(2)).max(40),
1550        height: 7.min(area.height.saturating_sub(2)).max(5),
1551    };
1552    frame.render_widget(Clear, popup);
1553    let block = Block::default()
1554        .title(" Confirm Close-All ")
1555        .borders(Borders::ALL)
1556        .border_style(Style::default().fg(Color::Red));
1557    let inner = block.inner(popup);
1558    frame.render_widget(block, popup);
1559    let lines = vec![
1560        Line::from(Span::styled(
1561            "Close all open positions now?",
1562            Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
1563        )),
1564        Line::from(Span::styled(
1565            "[Y/Enter] Confirm   [N/Esc] Cancel",
1566            Style::default().fg(Color::DarkGray),
1567        )),
1568    ];
1569    frame.render_widget(Paragraph::new(lines), inner);
1570}
1571
1572
1573fn render_grid_popup(frame: &mut Frame, state: &AppState) {
1574    let view = state.view_state();
1575    let area = frame.area();
1576    let popup = area;
1577    frame.render_widget(Clear, popup);
1578    let block = Block::default()
1579        .title(" Portfolio Grid ")
1580        .borders(Borders::ALL)
1581        .border_style(Style::default().fg(Color::Cyan));
1582    let inner = block.inner(popup);
1583    frame.render_widget(block, popup);
1584
1585    let root = Layout::default()
1586        .direction(Direction::Vertical)
1587        .constraints([Constraint::Length(2), Constraint::Min(1)])
1588        .split(inner);
1589    let tab_area = root[0];
1590    let body_area = root[1];
1591
1592    let tab_span = |tab: GridTab, key: &str, label: &str| -> Span<'_> {
1593        let selected = view.selected_grid_tab == tab;
1594        Span::styled(
1595            format!("[{} {}]", key, label),
1596            if selected {
1597                Style::default()
1598                    .fg(Color::Yellow)
1599                    .add_modifier(Modifier::BOLD)
1600            } else {
1601                Style::default().fg(Color::DarkGray)
1602            },
1603        )
1604    };
1605    frame.render_widget(
1606        Paragraph::new(Line::from(vec![
1607            tab_span(GridTab::Assets, "1", "Assets"),
1608            Span::raw(" "),
1609            tab_span(GridTab::Strategies, "2", "Strategies"),
1610            Span::raw(" "),
1611            tab_span(GridTab::Positions, "3", "Positions"),
1612            Span::raw(" "),
1613            tab_span(GridTab::Risk, "4", "Risk"),
1614            Span::raw(" "),
1615            tab_span(GridTab::Network, "5", "Network"),
1616            Span::raw(" "),
1617            tab_span(GridTab::History, "6", "History"),
1618            Span::raw(" "),
1619            tab_span(GridTab::SystemLog, "7", "SystemLog"),
1620        ])),
1621        tab_area,
1622    );
1623
1624    let global_pressure =
1625        state.rate_budget_global.used as f64 / (state.rate_budget_global.limit.max(1) as f64);
1626    let orders_pressure =
1627        state.rate_budget_orders.used as f64 / (state.rate_budget_orders.limit.max(1) as f64);
1628    let account_pressure =
1629        state.rate_budget_account.used as f64 / (state.rate_budget_account.limit.max(1) as f64);
1630    let market_pressure = state.rate_budget_market_data.used as f64
1631        / (state.rate_budget_market_data.limit.max(1) as f64);
1632    let max_pressure = global_pressure
1633        .max(orders_pressure)
1634        .max(account_pressure)
1635        .max(market_pressure);
1636    let (risk_label, risk_color) = if max_pressure >= 0.90 {
1637        ("CRIT", Color::Red)
1638    } else if max_pressure >= 0.70 {
1639        ("WARN", Color::Yellow)
1640    } else {
1641        ("OK", Color::Green)
1642    };
1643
1644    if view.selected_grid_tab == GridTab::Assets {
1645        let spot_assets: Vec<&AssetEntry> = state
1646            .assets_view()
1647            .iter()
1648            .filter(|a| !a.is_futures)
1649            .collect();
1650        let fut_assets: Vec<&AssetEntry> = state
1651            .assets_view()
1652            .iter()
1653            .filter(|a| a.is_futures)
1654            .collect();
1655        let spot_total_rlz: f64 = spot_assets.iter().map(|a| a.realized_pnl_usdt).sum();
1656        let spot_total_unrlz: f64 = spot_assets.iter().map(|a| a.unrealized_pnl_usdt).sum();
1657        let fut_total_rlz: f64 = fut_assets.iter().map(|a| a.realized_pnl_usdt).sum();
1658        let fut_total_unrlz: f64 = fut_assets.iter().map(|a| a.unrealized_pnl_usdt).sum();
1659        let total_rlz = spot_total_rlz + fut_total_rlz;
1660        let total_unrlz = spot_total_unrlz + fut_total_unrlz;
1661        let total_pnl = total_rlz + total_unrlz;
1662        let panel_chunks = Layout::default()
1663            .direction(Direction::Vertical)
1664            .constraints([
1665                Constraint::Percentage(46),
1666                Constraint::Percentage(46),
1667                Constraint::Length(3),
1668                Constraint::Length(1),
1669            ])
1670            .split(body_area);
1671
1672        let spot_header = Row::new(vec![
1673            Cell::from("Asset"),
1674            Cell::from("Qty"),
1675            Cell::from("Price"),
1676            Cell::from("RlzPnL"),
1677            Cell::from("UnrPnL"),
1678        ])
1679        .style(Style::default().fg(Color::DarkGray));
1680        let mut spot_rows: Vec<Row> = spot_assets
1681            .iter()
1682            .map(|a| {
1683                Row::new(vec![
1684                    Cell::from(a.symbol.clone()),
1685                    Cell::from(format!("{:.5}", a.position_qty)),
1686                    Cell::from(
1687                        a.last_price
1688                            .map(|v| format!("{:.2}", v))
1689                            .unwrap_or_else(|| "---".to_string()),
1690                    ),
1691                    Cell::from(format!("{:+.4}", a.realized_pnl_usdt)),
1692                    Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
1693                ])
1694            })
1695            .collect();
1696        if spot_rows.is_empty() {
1697            spot_rows.push(
1698                Row::new(vec![
1699                    Cell::from("(no spot assets)"),
1700                    Cell::from("-"),
1701                    Cell::from("-"),
1702                    Cell::from("-"),
1703                    Cell::from("-"),
1704                ])
1705                .style(Style::default().fg(Color::DarkGray)),
1706            );
1707        }
1708        frame.render_widget(
1709            Table::new(
1710                spot_rows,
1711                [
1712                    Constraint::Length(16),
1713                    Constraint::Length(12),
1714                    Constraint::Length(10),
1715                    Constraint::Length(10),
1716                    Constraint::Length(10),
1717                ],
1718            )
1719            .header(spot_header)
1720            .column_spacing(1)
1721            .block(
1722                Block::default()
1723                    .title(format!(
1724                        " Spot Assets | Total {} | PnL {:+.4} (R {:+.4} / U {:+.4}) ",
1725                        spot_assets.len(),
1726                        spot_total_rlz + spot_total_unrlz,
1727                        spot_total_rlz,
1728                        spot_total_unrlz
1729                    ))
1730                    .borders(Borders::ALL)
1731                    .border_style(Style::default().fg(Color::DarkGray)),
1732            ),
1733            panel_chunks[0],
1734        );
1735
1736        let fut_header = Row::new(vec![
1737            Cell::from("Symbol"),
1738            Cell::from("Side"),
1739            Cell::from("PosQty"),
1740            Cell::from("Entry"),
1741            Cell::from("RlzPnL"),
1742            Cell::from("UnrPnL"),
1743        ])
1744        .style(Style::default().fg(Color::DarkGray));
1745        let mut fut_rows: Vec<Row> = fut_assets
1746            .iter()
1747            .map(|a| {
1748                Row::new(vec![
1749                    Cell::from(a.symbol.clone()),
1750                    Cell::from(a.side.clone().unwrap_or_else(|| "-".to_string())),
1751                    Cell::from(format!("{:.5}", a.position_qty)),
1752                    Cell::from(
1753                        a.entry_price
1754                            .map(|v| format!("{:.2}", v))
1755                            .unwrap_or_else(|| "---".to_string()),
1756                    ),
1757                    Cell::from(format!("{:+.4}", a.realized_pnl_usdt)),
1758                    Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
1759                ])
1760            })
1761            .collect();
1762        if fut_rows.is_empty() {
1763            fut_rows.push(
1764                Row::new(vec![
1765                    Cell::from("(no futures positions)"),
1766                    Cell::from("-"),
1767                    Cell::from("-"),
1768                    Cell::from("-"),
1769                    Cell::from("-"),
1770                    Cell::from("-"),
1771                ])
1772                .style(Style::default().fg(Color::DarkGray)),
1773            );
1774        }
1775        frame.render_widget(
1776            Table::new(
1777                fut_rows,
1778                [
1779                    Constraint::Length(18),
1780                    Constraint::Length(8),
1781                    Constraint::Length(10),
1782                    Constraint::Length(10),
1783                    Constraint::Length(10),
1784                    Constraint::Length(10),
1785                ],
1786            )
1787            .header(fut_header)
1788            .column_spacing(1)
1789            .block(
1790                Block::default()
1791                    .title(format!(
1792                        " Futures Positions | Total {} | PnL {:+.4} (R {:+.4} / U {:+.4}) ",
1793                        fut_assets.len(),
1794                        fut_total_rlz + fut_total_unrlz,
1795                        fut_total_rlz,
1796                        fut_total_unrlz
1797                    ))
1798                    .borders(Borders::ALL)
1799                    .border_style(Style::default().fg(Color::DarkGray)),
1800            ),
1801            panel_chunks[1],
1802        );
1803        let total_color = if total_pnl > 0.0 {
1804            Color::Green
1805        } else if total_pnl < 0.0 {
1806            Color::Red
1807        } else {
1808            Color::DarkGray
1809        };
1810        frame.render_widget(
1811            Paragraph::new(Line::from(vec![
1812                Span::styled(" Total PnL: ", Style::default().fg(Color::DarkGray)),
1813                Span::styled(
1814                    format!("{:+.4}", total_pnl),
1815                    Style::default().fg(total_color).add_modifier(Modifier::BOLD),
1816                ),
1817                Span::styled(
1818                    format!("   Realized: {:+.4}   Unrealized: {:+.4}", total_rlz, total_unrlz),
1819                    Style::default().fg(Color::DarkGray),
1820                ),
1821            ]))
1822            .block(
1823                Block::default()
1824                    .borders(Borders::ALL)
1825                    .border_style(Style::default().fg(Color::DarkGray)),
1826            ),
1827            panel_chunks[2],
1828        );
1829        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]);
1830        return;
1831    }
1832
1833    if view.selected_grid_tab == GridTab::Risk {
1834        let chunks = Layout::default()
1835            .direction(Direction::Vertical)
1836            .constraints([
1837                Constraint::Length(2),
1838                Constraint::Length(4),
1839                Constraint::Min(3),
1840                Constraint::Length(1),
1841            ])
1842            .split(body_area);
1843        frame.render_widget(
1844            Paragraph::new(Line::from(vec![
1845                Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
1846                Span::styled(
1847                    risk_label,
1848                    Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
1849                ),
1850                Span::styled(
1851                    "  (70%=WARN, 90%=CRIT)",
1852                    Style::default().fg(Color::DarkGray),
1853                ),
1854            ])),
1855            chunks[0],
1856        );
1857        let risk_rows = vec![
1858            Row::new(vec![
1859                Cell::from("GLOBAL"),
1860                Cell::from(format!(
1861                    "{}/{}",
1862                    state.rate_budget_global.used, state.rate_budget_global.limit
1863                )),
1864                Cell::from(format!("{}ms", state.rate_budget_global.reset_in_ms)),
1865            ]),
1866            Row::new(vec![
1867                Cell::from("ORDERS"),
1868                Cell::from(format!(
1869                    "{}/{}",
1870                    state.rate_budget_orders.used, state.rate_budget_orders.limit
1871                )),
1872                Cell::from(format!("{}ms", state.rate_budget_orders.reset_in_ms)),
1873            ]),
1874            Row::new(vec![
1875                Cell::from("ACCOUNT"),
1876                Cell::from(format!(
1877                    "{}/{}",
1878                    state.rate_budget_account.used, state.rate_budget_account.limit
1879                )),
1880                Cell::from(format!("{}ms", state.rate_budget_account.reset_in_ms)),
1881            ]),
1882            Row::new(vec![
1883                Cell::from("MARKET"),
1884                Cell::from(format!(
1885                    "{}/{}",
1886                    state.rate_budget_market_data.used, state.rate_budget_market_data.limit
1887                )),
1888                Cell::from(format!("{}ms", state.rate_budget_market_data.reset_in_ms)),
1889            ]),
1890        ];
1891        frame.render_widget(
1892            Table::new(
1893                risk_rows,
1894                [
1895                    Constraint::Length(10),
1896                    Constraint::Length(16),
1897                    Constraint::Length(12),
1898                ],
1899            )
1900            .header(Row::new(vec![
1901                Cell::from("Group"),
1902                Cell::from("Used/Limit"),
1903                Cell::from("Reset In"),
1904            ]))
1905            .column_spacing(1)
1906            .block(
1907                Block::default()
1908                    .title(" Risk Budgets ")
1909                    .borders(Borders::ALL)
1910                    .border_style(Style::default().fg(Color::DarkGray)),
1911            ),
1912            chunks[1],
1913        );
1914        let recent_rejections: Vec<&String> = state
1915            .log_messages
1916            .iter()
1917            .filter(|m| m.contains("order.reject.received"))
1918            .rev()
1919            .take(20)
1920            .collect();
1921        let mut lines = vec![Line::from(Span::styled(
1922            "Recent Rejections",
1923            Style::default()
1924                .fg(Color::Cyan)
1925                .add_modifier(Modifier::BOLD),
1926        ))];
1927        for msg in recent_rejections.into_iter().rev() {
1928            lines.push(Line::from(Span::styled(
1929                msg.as_str(),
1930                Style::default().fg(Color::Red),
1931            )));
1932        }
1933        if lines.len() == 1 {
1934            lines.push(Line::from(Span::styled(
1935                "(no rejections yet)",
1936                Style::default().fg(Color::DarkGray),
1937            )));
1938        }
1939        frame.render_widget(
1940            Paragraph::new(lines).block(
1941                Block::default()
1942                    .borders(Borders::ALL)
1943                    .border_style(Style::default().fg(Color::DarkGray)),
1944            ),
1945            chunks[2],
1946        );
1947        frame.render_widget(Paragraph::new("[1/2/3/4/5/6/7] tab  [U] <$1 filter  [Z] close-all  [G/Esc] close"), chunks[3]);
1948        return;
1949    }
1950
1951    if view.selected_grid_tab == GridTab::Network {
1952        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1953        let tick_in_1s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 1_000);
1954        let tick_in_10s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 10_000);
1955        let tick_in_60s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 60_000);
1956        let tick_drop_1s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 1_000);
1957        let tick_drop_10s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 10_000);
1958        let tick_drop_60s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 60_000);
1959        let reconnect_60s = count_since(&state.network_reconnect_timestamps_ms, now_ms, 60_000);
1960        let disconnect_60s = count_since(&state.network_disconnect_timestamps_ms, now_ms, 60_000);
1961
1962        let tick_in_rate_1s = rate_per_sec(tick_in_1s, 1.0);
1963        let tick_drop_rate_1s = rate_per_sec(tick_drop_1s, 1.0);
1964        let tick_drop_rate_10s = rate_per_sec(tick_drop_10s, 10.0);
1965        let tick_drop_rate_60s = rate_per_sec(tick_drop_60s, 60.0);
1966        let tick_drop_ratio_10s = ratio_pct(tick_drop_10s, tick_in_10s.saturating_add(tick_drop_10s));
1967        let tick_drop_ratio_60s = ratio_pct(tick_drop_60s, tick_in_60s.saturating_add(tick_drop_60s));
1968        let reconnect_rate_60s = reconnect_60s as f64;
1969        let disconnect_rate_60s = disconnect_60s as f64;
1970        let heartbeat_gap_ms = state.last_price_update_ms.map(|ts| now_ms.saturating_sub(ts));
1971        let tick_p95_ms = percentile(&state.network_tick_latencies_ms, 95);
1972        let health = classify_health(
1973            state.ws_connected,
1974            tick_drop_ratio_10s,
1975            reconnect_rate_60s,
1976            tick_p95_ms,
1977            heartbeat_gap_ms,
1978        );
1979        let (health_label, health_color) = match health {
1980            NetworkHealth::Ok => ("OK", Color::Green),
1981            NetworkHealth::Warn => ("WARN", Color::Yellow),
1982            NetworkHealth::Crit => ("CRIT", Color::Red),
1983        };
1984
1985        let chunks = Layout::default()
1986            .direction(Direction::Vertical)
1987            .constraints([
1988                Constraint::Length(2),
1989                Constraint::Min(6),
1990                Constraint::Length(6),
1991                Constraint::Length(1),
1992            ])
1993            .split(body_area);
1994        frame.render_widget(
1995            Paragraph::new(Line::from(vec![
1996                Span::styled("Health: ", Style::default().fg(Color::DarkGray)),
1997                Span::styled(
1998                    health_label,
1999                    Style::default()
2000                        .fg(health_color)
2001                        .add_modifier(Modifier::BOLD),
2002                ),
2003                Span::styled("  WS: ", Style::default().fg(Color::DarkGray)),
2004                Span::styled(
2005                    if state.ws_connected {
2006                        "CONNECTED"
2007                    } else {
2008                        "DISCONNECTED"
2009                    },
2010                    Style::default().fg(if state.ws_connected {
2011                        Color::Green
2012                    } else {
2013                        Color::Red
2014                    }),
2015                ),
2016                Span::styled(
2017                    format!(
2018                        "  in1s={:.1}/s  drop10s={:.2}/s  ratio10s={:.2}%  reconn60s={:.0}/min",
2019                        tick_in_rate_1s, tick_drop_rate_10s, tick_drop_ratio_10s, reconnect_rate_60s
2020                    ),
2021                    Style::default().fg(Color::DarkGray),
2022                ),
2023            ])),
2024            chunks[0],
2025        );
2026
2027        let tick_stats = latency_stats(&state.network_tick_latencies_ms);
2028        let fill_stats = latency_stats(&state.network_fill_latencies_ms);
2029        let sync_stats = latency_stats(&state.network_order_sync_latencies_ms);
2030        let last_fill_age = state
2031            .network_last_fill_ms
2032            .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
2033            .unwrap_or_else(|| "-".to_string());
2034        let rows = vec![
2035            Row::new(vec![
2036                Cell::from("Tick Latency"),
2037                Cell::from(tick_stats.0),
2038                Cell::from(tick_stats.1),
2039                Cell::from(tick_stats.2),
2040                Cell::from(
2041                    state
2042                        .last_price_latency_ms
2043                        .map(|v| format!("{}ms", v))
2044                        .unwrap_or_else(|| "-".to_string()),
2045                ),
2046            ]),
2047            Row::new(vec![
2048                Cell::from("Fill Latency"),
2049                Cell::from(fill_stats.0),
2050                Cell::from(fill_stats.1),
2051                Cell::from(fill_stats.2),
2052                Cell::from(last_fill_age),
2053            ]),
2054            Row::new(vec![
2055                Cell::from("Order Sync"),
2056                Cell::from(sync_stats.0),
2057                Cell::from(sync_stats.1),
2058                Cell::from(sync_stats.2),
2059                Cell::from(
2060                    state
2061                        .last_order_history_latency_ms
2062                        .map(|v| format!("{}ms", v))
2063                        .unwrap_or_else(|| "-".to_string()),
2064                ),
2065            ]),
2066        ];
2067        frame.render_widget(
2068            Table::new(
2069                rows,
2070                [
2071                    Constraint::Length(14),
2072                    Constraint::Length(12),
2073                    Constraint::Length(12),
2074                    Constraint::Length(12),
2075                    Constraint::Length(14),
2076                ],
2077            )
2078            .header(Row::new(vec![
2079                Cell::from("Metric"),
2080                Cell::from("p50"),
2081                Cell::from("p95"),
2082                Cell::from("p99"),
2083                Cell::from("last/age"),
2084            ]))
2085            .column_spacing(1)
2086            .block(
2087                Block::default()
2088                    .title(" Network Metrics ")
2089                    .borders(Borders::ALL)
2090                    .border_style(Style::default().fg(Color::DarkGray)),
2091            ),
2092            chunks[1],
2093        );
2094
2095        let summary_rows = vec![
2096            Row::new(vec![
2097                Cell::from("tick_drop_rate_1s"),
2098                Cell::from(format!("{:.2}/s", tick_drop_rate_1s)),
2099                Cell::from("tick_drop_rate_60s"),
2100                Cell::from(format!("{:.2}/s", tick_drop_rate_60s)),
2101            ]),
2102            Row::new(vec![
2103                Cell::from("drop_ratio_60s"),
2104                Cell::from(format!("{:.2}%", tick_drop_ratio_60s)),
2105                Cell::from("disconnect_rate_60s"),
2106                Cell::from(format!("{:.0}/min", disconnect_rate_60s)),
2107            ]),
2108            Row::new(vec![
2109                Cell::from("last_tick_age"),
2110                Cell::from(
2111                    heartbeat_gap_ms
2112                        .map(format_age_ms)
2113                        .unwrap_or_else(|| "-".to_string()),
2114                ),
2115                Cell::from("last_order_update_age"),
2116                Cell::from(
2117                    state
2118                        .last_order_history_update_ms
2119                        .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
2120                        .unwrap_or_else(|| "-".to_string()),
2121                ),
2122            ]),
2123            Row::new(vec![
2124                Cell::from("tick_drop_total"),
2125                Cell::from(state.network_tick_drop_count.to_string()),
2126                Cell::from("reconnect_total"),
2127                Cell::from(state.network_reconnect_count.to_string()),
2128            ]),
2129        ];
2130        frame.render_widget(
2131            Table::new(
2132                summary_rows,
2133                [
2134                    Constraint::Length(20),
2135                    Constraint::Length(18),
2136                    Constraint::Length(20),
2137                    Constraint::Length(18),
2138                ],
2139            )
2140            .column_spacing(1)
2141            .block(
2142                Block::default()
2143                    .title(" Network Summary ")
2144                    .borders(Borders::ALL)
2145                    .border_style(Style::default().fg(Color::DarkGray)),
2146            ),
2147            chunks[2],
2148        );
2149        frame.render_widget(Paragraph::new("[1/2/3/4/5/6/7] tab  [U] <$1 filter  [Z] close-all  [G/Esc] close"), chunks[3]);
2150        return;
2151    }
2152
2153    if view.selected_grid_tab == GridTab::History {
2154        let chunks = Layout::default()
2155            .direction(Direction::Vertical)
2156            .constraints([Constraint::Length(2), Constraint::Min(6), Constraint::Length(1)])
2157            .split(body_area);
2158        frame.render_widget(
2159            Paragraph::new(Line::from(vec![
2160                Span::styled("Bucket: ", Style::default().fg(Color::DarkGray)),
2161                Span::styled(
2162                    match state.history_bucket {
2163                        order_store::HistoryBucket::Day => "Day",
2164                        order_store::HistoryBucket::Hour => "Hour",
2165                        order_store::HistoryBucket::Month => "Month",
2166                    },
2167                    Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
2168                ),
2169                Span::styled(
2170                    "  (popup hotkeys: D/H/M)",
2171                    Style::default().fg(Color::DarkGray),
2172                ),
2173            ])),
2174            chunks[0],
2175        );
2176
2177        let visible = build_history_lines(&state.history_rows, chunks[1].height.saturating_sub(2) as usize);
2178        frame.render_widget(
2179            Paragraph::new(visible).block(
2180                Block::default()
2181                    .title(" History ")
2182                    .borders(Borders::ALL)
2183                    .border_style(Style::default().fg(Color::DarkGray)),
2184            ),
2185            chunks[1],
2186        );
2187        frame.render_widget(Paragraph::new("[1/2/3/4/5/6/7] tab  [U] <$1 filter  [Z] close-all  [G/Esc] close"), chunks[2]);
2188        return;
2189    }
2190
2191    if view.selected_grid_tab == GridTab::Positions {
2192        let chunks = Layout::default()
2193            .direction(Direction::Vertical)
2194            .constraints([Constraint::Length(2), Constraint::Min(6), Constraint::Length(1)])
2195            .split(body_area);
2196        let persisted = if cfg!(test) {
2197            Vec::new()
2198        } else {
2199            order_store::load_recent_persisted_trades_filtered(None, None, 20_000)
2200                .unwrap_or_default()
2201        };
2202        let open_orders: Vec<_> = build_open_order_positions_from_trades(&persisted)
2203            .into_iter()
2204            .filter(|row| {
2205                let px = asset_last_price_for_symbol(state, &row.symbol).unwrap_or(row.entry_price);
2206                !state.hide_small_positions || (px * row.qty_open).abs() >= 1.0
2207            })
2208            .collect();
2209
2210        frame.render_widget(
2211            Paragraph::new(Line::from(vec![
2212                Span::styled("Open Position Orders: ", Style::default().fg(Color::DarkGray)),
2213                Span::styled(
2214                    open_orders.len().to_string(),
2215                    Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
2216                ),
2217                Span::styled(
2218                    if state.hide_small_positions {
2219                        "  (order_id scope, filter: >= $1 | [U] toggle)"
2220                    } else {
2221                        "  (order_id scope, filter: OFF | [U] toggle)"
2222                    },
2223                    Style::default().fg(Color::DarkGray),
2224                ),
2225            ])),
2226            chunks[0],
2227        );
2228
2229        let header = Row::new(vec![
2230            Cell::from("Symbol"),
2231            Cell::from("Source"),
2232            Cell::from("OrderId"),
2233            Cell::from("Close"),
2234            Cell::from("Market"),
2235            Cell::from("Side"),
2236            Cell::from("Qty"),
2237            Cell::from("Entry"),
2238            Cell::from("Last"),
2239            Cell::from("Stop"),
2240            Cell::from("EV"),
2241            Cell::from("Score"),
2242            Cell::from("Gate"),
2243            Cell::from("StopType"),
2244            Cell::from("UnrPnL"),
2245        ])
2246        .style(Style::default().fg(Color::DarkGray));
2247        let mut rows: Vec<Row> = if open_orders.is_empty() {
2248            state
2249                .assets_view()
2250                .iter()
2251                .filter(|a| {
2252                    let has_pos =
2253                        a.position_qty.abs() > f64::EPSILON || a.entry_price.is_some() || a.side.is_some();
2254                    let px = a
2255                        .last_price
2256                        .or(a.entry_price)
2257                        .unwrap_or(0.0);
2258                    has_pos && (!state.hide_small_positions || (px * a.position_qty.abs()) >= 1.0)
2259                })
2260                .map(|a| {
2261                    let ev_snapshot =
2262                        latest_ev_snapshot_for_symbol_relaxed(&state.ev_snapshot_by_scope, &a.symbol);
2263                    let exit_policy =
2264                        latest_exit_policy_for_symbol_relaxed(&state.exit_policy_by_scope, &a.symbol);
2265                    Row::new(vec![
2266                        Cell::from(a.symbol.clone()),
2267                        Cell::from("SYS"),
2268                        Cell::from("-"),
2269                        Cell::from(
2270                            close_all_row_status_for_symbol(state, &a.symbol).unwrap_or_else(|| "-".to_string()),
2271                        ),
2272                        Cell::from(if a.is_futures { "FUT" } else { "SPOT" }),
2273                        Cell::from(a.side.clone().unwrap_or_else(|| "-".to_string())),
2274                        Cell::from(format!("{:.5}", a.position_qty)),
2275                        Cell::from(
2276                            a.entry_price
2277                                .map(|v| format!("{:.2}", v))
2278                                .unwrap_or_else(|| "-".to_string()),
2279                        ),
2280                        Cell::from(
2281                            a.last_price
2282                                .map(|v| format!("{:.2}", v))
2283                                .unwrap_or_else(|| "-".to_string()),
2284                        ),
2285                        Cell::from(
2286                            exit_policy
2287                                .and_then(|p| p.stop_price)
2288                                .map(|v| format!("{:.2}", v))
2289                                .unwrap_or_else(|| "-".to_string()),
2290                        ),
2291                        Cell::from(
2292                            ev_snapshot
2293                                .map(|v| format!("{:+.3}", v.ev))
2294                                .unwrap_or_else(|| "-".to_string()),
2295                        ),
2296                        Cell::from(
2297                            ev_snapshot
2298                                .map(|v| format!("{:.2}", v.p_win))
2299                                .unwrap_or_else(|| "-".to_string()),
2300                        ),
2301                        Cell::from(
2302                            ev_snapshot
2303                                .map(|v| {
2304                                    if v.gate_blocked {
2305                                        "BLOCK".to_string()
2306                                    } else {
2307                                        v.gate_mode.to_ascii_uppercase()
2308                                    }
2309                                })
2310                                .unwrap_or_else(|| "-".to_string()),
2311                        ),
2312                        Cell::from(
2313                            if exit_policy.and_then(|p| p.stop_price).is_none() {
2314                                "-".to_string()
2315                            } else if exit_policy.and_then(|p| p.protective_stop_ok) == Some(true) {
2316                                "ORDER".to_string()
2317                            } else {
2318                                "CALC".to_string()
2319                            },
2320                        ),
2321                        Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
2322                    ])
2323                })
2324                .collect()
2325        } else {
2326            open_orders
2327                .iter()
2328                .map(|row| {
2329                    let symbol_view = display_symbol_for_storage(&row.symbol);
2330                    let market_is_fut = row.symbol.ends_with("#FUT");
2331                    let asset_last = asset_last_price_for_symbol(state, &row.symbol);
2332                    let ev_snapshot =
2333                        ev_snapshot_for_symbol_and_tag(state, &row.symbol, &row.source_tag)
2334                            .or_else(|| {
2335                                latest_ev_snapshot_for_symbol_relaxed(
2336                                    &state.ev_snapshot_by_scope,
2337                                    &row.symbol,
2338                                )
2339                                .cloned()
2340                            });
2341                    let exit_policy =
2342                        exit_policy_for_symbol_and_tag(state, &row.symbol, &row.source_tag)
2343                            .or_else(|| {
2344                                latest_exit_policy_for_symbol_relaxed(
2345                                    &state.exit_policy_by_scope,
2346                                    &row.symbol,
2347                                )
2348                                .cloned()
2349                            });
2350                    let unr = asset_last
2351                        .map(|px| (px - row.entry_price) * row.qty_open)
2352                        .unwrap_or(0.0);
2353                    Row::new(vec![
2354                        Cell::from(symbol_view),
2355                        Cell::from(row.source_tag.to_ascii_uppercase()),
2356                        Cell::from(row.order_id.to_string()),
2357                        Cell::from(
2358                            close_all_row_status_for_symbol(state, &row.symbol)
2359                                .unwrap_or_else(|| "-".to_string()),
2360                        ),
2361                        Cell::from(if market_is_fut { "FUT" } else { "SPOT" }),
2362                        Cell::from("BUY"),
2363                        Cell::from(format!("{:.5}", row.qty_open)),
2364                        Cell::from(format!("{:.2}", row.entry_price)),
2365                        Cell::from(asset_last.map(|v| format!("{:.2}", v)).unwrap_or_else(|| "-".to_string())),
2366                        Cell::from(
2367                            exit_policy
2368                                .as_ref()
2369                                .and_then(|p| p.stop_price)
2370                                .map(|v| format!("{:.2}", v))
2371                                .unwrap_or_else(|| "-".to_string()),
2372                        ),
2373                        Cell::from(
2374                            ev_snapshot
2375                                .as_ref()
2376                                .map(|v| format!("{:+.3}", v.ev))
2377                                .unwrap_or_else(|| "-".to_string()),
2378                        ),
2379                        Cell::from(
2380                            ev_snapshot
2381                                .as_ref()
2382                                .map(|v| format!("{:.2}", v.p_win))
2383                                .unwrap_or_else(|| "-".to_string()),
2384                        ),
2385                        Cell::from(
2386                            ev_snapshot
2387                                .as_ref()
2388                                .map(|v| {
2389                                    if v.gate_blocked {
2390                                        "BLOCK".to_string()
2391                                    } else {
2392                                        v.gate_mode.to_ascii_uppercase()
2393                                    }
2394                                })
2395                                .unwrap_or_else(|| "-".to_string()),
2396                        ),
2397                        Cell::from(
2398                            if exit_policy.as_ref().and_then(|p| p.stop_price).is_none() {
2399                                "-".to_string()
2400                            } else if exit_policy.as_ref().and_then(|p| p.protective_stop_ok) == Some(true) {
2401                                "ORDER".to_string()
2402                            } else {
2403                                "CALC".to_string()
2404                            },
2405                        ),
2406                        Cell::from(format!("{:+.4}", unr)),
2407                    ])
2408                })
2409                .collect()
2410        };
2411        if rows.is_empty() {
2412            rows.push(
2413                Row::new(vec![
2414                    Cell::from("(no open positions)"),
2415                    Cell::from("-"),
2416                    Cell::from("-"),
2417                    Cell::from("-"),
2418                    Cell::from("-"),
2419                    Cell::from("-"),
2420                    Cell::from("-"),
2421                    Cell::from("-"),
2422                    Cell::from("-"),
2423                    Cell::from("-"),
2424                    Cell::from("-"),
2425                    Cell::from("-"),
2426                    Cell::from("-"),
2427                    Cell::from("-"),
2428                    Cell::from("-"),
2429                ])
2430                .style(Style::default().fg(Color::DarkGray)),
2431            );
2432        }
2433        frame.render_widget(
2434            Table::new(
2435                rows,
2436                [
2437                    Constraint::Length(14),
2438                    Constraint::Length(8),
2439                    Constraint::Length(12),
2440                    Constraint::Length(8),
2441                    Constraint::Length(7),
2442                    Constraint::Length(8),
2443                    Constraint::Length(11),
2444                    Constraint::Length(10),
2445                    Constraint::Length(10),
2446                    Constraint::Length(10),
2447                    Constraint::Length(8),
2448                    Constraint::Length(7),
2449                    Constraint::Length(8),
2450                    Constraint::Length(9),
2451                    Constraint::Length(11),
2452                ],
2453            )
2454            .header(header)
2455            .column_spacing(1)
2456            .block(
2457                Block::default()
2458                    .title(" Positions ")
2459                    .borders(Borders::ALL)
2460                    .border_style(Style::default().fg(Color::DarkGray)),
2461            ),
2462            chunks[1],
2463        );
2464        frame.render_widget(Paragraph::new("[1/2/3/4/5/6/7] tab  [U] <$1 filter  [Z] close-all  [G/Esc] close"), chunks[2]);
2465        return;
2466    }
2467
2468    if view.selected_grid_tab == GridTab::SystemLog {
2469        let chunks = Layout::default()
2470            .direction(Direction::Vertical)
2471            .constraints([Constraint::Min(6), Constraint::Length(1)])
2472            .split(body_area);
2473        let max_rows = chunks[0].height.saturating_sub(2) as usize;
2474        let mut log_rows: Vec<Row> = state
2475            .log_messages
2476            .iter()
2477            .rev()
2478            .take(max_rows.max(1))
2479            .rev()
2480            .map(|line| Row::new(vec![Cell::from(line.clone())]))
2481            .collect();
2482        if log_rows.is_empty() {
2483            log_rows.push(
2484                Row::new(vec![Cell::from("(no system logs yet)")])
2485                    .style(Style::default().fg(Color::DarkGray)),
2486            );
2487        }
2488        frame.render_widget(
2489            Table::new(log_rows, [Constraint::Min(1)])
2490                .header(Row::new(vec![Cell::from("Message")]).style(Style::default().fg(Color::DarkGray)))
2491                .column_spacing(1)
2492                .block(
2493                    Block::default()
2494                        .title(" System Log ")
2495                        .borders(Borders::ALL)
2496                        .border_style(Style::default().fg(Color::DarkGray)),
2497                ),
2498            chunks[0],
2499        );
2500        frame.render_widget(Paragraph::new("[1/2/3/4/5/6/7] tab  [U] <$1 filter  [Z] close-all  [G/Esc] close"), chunks[1]);
2501        return;
2502    }
2503
2504    let selected_symbol = state
2505        .symbol_items
2506        .get(view.selected_symbol_index)
2507        .map(String::as_str)
2508        .unwrap_or(state.symbol.as_str());
2509    let strategy_chunks = Layout::default()
2510        .direction(Direction::Vertical)
2511        .constraints([
2512            Constraint::Length(2),
2513            Constraint::Length(3),
2514            Constraint::Min(12),
2515            Constraint::Length(1),
2516        ])
2517        .split(body_area);
2518
2519    let mut on_indices: Vec<usize> = Vec::new();
2520    let mut off_indices: Vec<usize> = Vec::new();
2521    for idx in 0..state.strategy_items.len() {
2522        if state
2523            .strategy_item_active
2524            .get(idx)
2525            .copied()
2526            .unwrap_or(false)
2527        {
2528            on_indices.push(idx);
2529        } else {
2530            off_indices.push(idx);
2531        }
2532    }
2533    let on_weight = on_indices.len().max(1) as u32;
2534    let off_weight = off_indices.len().max(1) as u32;
2535
2536    frame.render_widget(
2537        Paragraph::new(Line::from(vec![
2538            Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
2539            Span::styled(
2540                risk_label,
2541                Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
2542            ),
2543            Span::styled("  GLOBAL ", Style::default().fg(Color::DarkGray)),
2544            Span::styled(
2545                format!(
2546                    "{}/{}",
2547                    state.rate_budget_global.used, state.rate_budget_global.limit
2548                ),
2549                Style::default().fg(if global_pressure >= 0.9 {
2550                    Color::Red
2551                } else if global_pressure >= 0.7 {
2552                    Color::Yellow
2553                } else {
2554                    Color::Cyan
2555                }),
2556            ),
2557            Span::styled("  ORD ", Style::default().fg(Color::DarkGray)),
2558            Span::styled(
2559                format!(
2560                    "{}/{}",
2561                    state.rate_budget_orders.used, state.rate_budget_orders.limit
2562                ),
2563                Style::default().fg(if orders_pressure >= 0.9 {
2564                    Color::Red
2565                } else if orders_pressure >= 0.7 {
2566                    Color::Yellow
2567                } else {
2568                    Color::Cyan
2569                }),
2570            ),
2571            Span::styled("  ACC ", Style::default().fg(Color::DarkGray)),
2572            Span::styled(
2573                format!(
2574                    "{}/{}",
2575                    state.rate_budget_account.used, state.rate_budget_account.limit
2576                ),
2577                Style::default().fg(if account_pressure >= 0.9 {
2578                    Color::Red
2579                } else if account_pressure >= 0.7 {
2580                    Color::Yellow
2581                } else {
2582                    Color::Cyan
2583                }),
2584            ),
2585            Span::styled("  MKT ", Style::default().fg(Color::DarkGray)),
2586            Span::styled(
2587                format!(
2588                    "{}/{}",
2589                    state.rate_budget_market_data.used, state.rate_budget_market_data.limit
2590                ),
2591                Style::default().fg(if market_pressure >= 0.9 {
2592                    Color::Red
2593                } else if market_pressure >= 0.7 {
2594                    Color::Yellow
2595                } else {
2596                    Color::Cyan
2597                }),
2598            ),
2599        ])),
2600        strategy_chunks[0],
2601    );
2602
2603    let strategy_area = strategy_chunks[2];
2604    let min_panel_height: u16 = 6;
2605    let total_height = strategy_area.height;
2606    let (on_height, off_height) = if total_height >= min_panel_height.saturating_mul(2) {
2607        let total_weight = on_weight + off_weight;
2608        let mut on_h =
2609            ((total_height as u32 * on_weight) / total_weight).max(min_panel_height as u32) as u16;
2610        let max_on_h = total_height.saturating_sub(min_panel_height);
2611        if on_h > max_on_h {
2612            on_h = max_on_h;
2613        }
2614        let off_h = total_height.saturating_sub(on_h);
2615        (on_h, off_h)
2616    } else {
2617        let on_h = (total_height / 2).max(1);
2618        let off_h = total_height.saturating_sub(on_h).max(1);
2619        (on_h, off_h)
2620    };
2621    let on_area = Rect {
2622        x: strategy_area.x,
2623        y: strategy_area.y,
2624        width: strategy_area.width,
2625        height: on_height,
2626    };
2627    let off_area = Rect {
2628        x: strategy_area.x,
2629        y: strategy_area.y.saturating_add(on_height),
2630        width: strategy_area.width,
2631        height: off_height,
2632    };
2633
2634    let pnl_sum_for_indices = |indices: &[usize], state: &AppState| -> f64 {
2635        indices
2636            .iter()
2637            .map(|idx| {
2638                let item = state
2639                    .strategy_items
2640                    .get(*idx)
2641                    .map(String::as_str)
2642                    .unwrap_or("-");
2643                let row_symbol = state
2644                    .strategy_item_symbols
2645                    .get(*idx)
2646                    .map(String::as_str)
2647                    .unwrap_or(state.symbol.as_str());
2648                strategy_stats_for_item(&state.strategy_stats, item, row_symbol)
2649                    .map(|s| s.realized_pnl)
2650                    .unwrap_or(0.0)
2651            })
2652            .sum()
2653    };
2654    let on_pnl_sum = pnl_sum_for_indices(&on_indices, state);
2655    let off_pnl_sum = pnl_sum_for_indices(&off_indices, state);
2656    let total_pnl_sum = on_pnl_sum + off_pnl_sum;
2657
2658    let total_row = Row::new(vec![
2659        Cell::from("ON Total"),
2660        Cell::from(on_indices.len().to_string()),
2661        Cell::from(format!("{:+.4}", on_pnl_sum)),
2662        Cell::from("OFF Total"),
2663        Cell::from(off_indices.len().to_string()),
2664        Cell::from(format!("{:+.4}", off_pnl_sum)),
2665        Cell::from("All Total"),
2666        Cell::from(format!("{:+.4}", total_pnl_sum)),
2667    ]);
2668    let total_table = Table::new(
2669        vec![total_row],
2670        [
2671            Constraint::Length(10),
2672            Constraint::Length(5),
2673            Constraint::Length(12),
2674            Constraint::Length(10),
2675            Constraint::Length(5),
2676            Constraint::Length(12),
2677            Constraint::Length(10),
2678            Constraint::Length(12),
2679        ],
2680    )
2681    .column_spacing(1)
2682    .block(
2683        Block::default()
2684            .title(" Total ")
2685            .borders(Borders::ALL)
2686            .border_style(Style::default().fg(Color::DarkGray)),
2687    );
2688    frame.render_widget(total_table, strategy_chunks[1]);
2689
2690    let render_strategy_window = |frame: &mut Frame,
2691                                  area: Rect,
2692                                  title: &str,
2693                                  indices: &[usize],
2694                                  state: &AppState,
2695                                  pnl_sum: f64,
2696                                  selected_panel: bool| {
2697        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
2698        let inner_height = area.height.saturating_sub(2);
2699        let row_capacity = inner_height.saturating_sub(1) as usize;
2700        let selected_pos = indices
2701            .iter()
2702            .position(|idx| *idx == view.selected_strategy_index);
2703        let window_start = if row_capacity == 0 {
2704            0
2705        } else if let Some(pos) = selected_pos {
2706            pos.saturating_sub(row_capacity.saturating_sub(1))
2707        } else {
2708            0
2709        };
2710        let window_end = if row_capacity == 0 {
2711            0
2712        } else {
2713            (window_start + row_capacity).min(indices.len())
2714        };
2715        let visible_indices = if indices.is_empty() || row_capacity == 0 {
2716            &indices[0..0]
2717        } else {
2718            &indices[window_start..window_end]
2719        };
2720        let header = Row::new(vec![
2721            Cell::from(" "),
2722            Cell::from("Symbol"),
2723            Cell::from("Strategy"),
2724            Cell::from("Run"),
2725            Cell::from("Last"),
2726            Cell::from("Px"),
2727            Cell::from("Age"),
2728            Cell::from("W"),
2729            Cell::from("L"),
2730            Cell::from("T"),
2731            Cell::from("PnL"),
2732        ])
2733        .style(Style::default().fg(Color::DarkGray));
2734        let mut rows: Vec<Row> = visible_indices
2735            .iter()
2736            .map(|idx| {
2737                let row_symbol = state
2738                    .strategy_item_symbols
2739                    .get(*idx)
2740                    .map(String::as_str)
2741                    .unwrap_or("-");
2742                let item = state
2743                    .strategy_items
2744                    .get(*idx)
2745                    .cloned()
2746                    .unwrap_or_else(|| "-".to_string());
2747                let running = state
2748                    .strategy_item_total_running_ms
2749                    .get(*idx)
2750                    .copied()
2751                    .map(format_running_time)
2752                    .unwrap_or_else(|| "-".to_string());
2753                let stats = strategy_stats_for_item(&state.strategy_stats, &item, row_symbol);
2754                let source_tag = source_tag_for_strategy_item(&item);
2755                let last_evt = source_tag
2756                    .as_ref()
2757                    .and_then(|tag| state.strategy_last_event_by_tag.get(tag));
2758                let (last_label, last_px, last_age, last_style) = if let Some(evt) = last_evt {
2759                    let age = now_ms.saturating_sub(evt.timestamp_ms);
2760                    let age_txt = if age < 1_000 {
2761                        format!("{}ms", age)
2762                    } else if age < 60_000 {
2763                        format!("{}s", age / 1_000)
2764                    } else {
2765                        format!("{}m", age / 60_000)
2766                    };
2767                    let side_txt = match evt.side {
2768                        OrderSide::Buy => "BUY",
2769                        OrderSide::Sell => "SELL",
2770                    };
2771                    let px_txt = evt
2772                        .price
2773                        .map(|v| format!("{:.2}", v))
2774                        .unwrap_or_else(|| "-".to_string());
2775                    let style = match evt.side {
2776                        OrderSide::Buy => Style::default()
2777                            .fg(Color::Green)
2778                            .add_modifier(Modifier::BOLD),
2779                        OrderSide::Sell => {
2780                            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
2781                        }
2782                    };
2783                    (side_txt.to_string(), px_txt, age_txt, style)
2784                } else {
2785                    (
2786                        "-".to_string(),
2787                        "-".to_string(),
2788                        "-".to_string(),
2789                        Style::default().fg(Color::DarkGray),
2790                    )
2791                };
2792                let (w, l, t, pnl) = if let Some(s) = stats {
2793                    (
2794                        s.win_count.to_string(),
2795                        s.lose_count.to_string(),
2796                        s.trade_count.to_string(),
2797                        format!("{:+.4}", s.realized_pnl),
2798                    )
2799                } else {
2800                    (
2801                        "0".to_string(),
2802                        "0".to_string(),
2803                        "0".to_string(),
2804                        "+0.0000".to_string(),
2805                    )
2806                };
2807                let marker = if *idx == view.selected_strategy_index {
2808                    "▶"
2809                } else {
2810                    " "
2811                };
2812                let mut row = Row::new(vec![
2813                    Cell::from(marker),
2814                    Cell::from(row_symbol.to_string()),
2815                    Cell::from(item),
2816                    Cell::from(running),
2817                    Cell::from(last_label).style(last_style),
2818                    Cell::from(last_px),
2819                    Cell::from(last_age),
2820                    Cell::from(w),
2821                    Cell::from(l),
2822                    Cell::from(t),
2823                    Cell::from(pnl),
2824                ]);
2825                if *idx == view.selected_strategy_index {
2826                    row = row.style(
2827                        Style::default()
2828                            .fg(Color::Yellow)
2829                            .add_modifier(Modifier::BOLD),
2830                    );
2831                }
2832                row
2833            })
2834            .collect();
2835
2836        if rows.is_empty() {
2837            rows.push(
2838                Row::new(vec![
2839                    Cell::from(" "),
2840                    Cell::from("-"),
2841                    Cell::from("(empty)"),
2842                    Cell::from("-"),
2843                    Cell::from("-"),
2844                    Cell::from("-"),
2845                    Cell::from("-"),
2846                    Cell::from("-"),
2847                    Cell::from("-"),
2848                    Cell::from("-"),
2849                    Cell::from("-"),
2850                ])
2851                .style(Style::default().fg(Color::DarkGray)),
2852            );
2853        }
2854
2855        let table = Table::new(
2856            rows,
2857            [
2858                Constraint::Length(2),
2859                Constraint::Length(12),
2860                Constraint::Min(14),
2861                Constraint::Length(9),
2862                Constraint::Length(5),
2863                Constraint::Length(9),
2864                Constraint::Length(6),
2865                Constraint::Length(3),
2866                Constraint::Length(3),
2867                Constraint::Length(4),
2868                Constraint::Length(11),
2869            ],
2870        )
2871        .header(header)
2872        .column_spacing(1)
2873        .block(
2874            Block::default()
2875                .title(format!(
2876                    "{} | Total {:+.4} | {}/{}",
2877                    title,
2878                    pnl_sum,
2879                    visible_indices.len(),
2880                    indices.len()
2881                ))
2882                .borders(Borders::ALL)
2883                .border_style(if selected_panel {
2884                    Style::default().fg(Color::Yellow)
2885                } else if risk_label == "CRIT" {
2886                    Style::default().fg(Color::Red)
2887                } else if risk_label == "WARN" {
2888                    Style::default().fg(Color::Yellow)
2889                } else {
2890                    Style::default().fg(Color::DarkGray)
2891                }),
2892        );
2893        frame.render_widget(table, area);
2894    };
2895
2896    render_strategy_window(
2897        frame,
2898        on_area,
2899        " ON Strategies ",
2900        &on_indices,
2901        state,
2902        on_pnl_sum,
2903        view.is_on_panel_selected,
2904    );
2905    render_strategy_window(
2906        frame,
2907        off_area,
2908        " OFF Strategies ",
2909        &off_indices,
2910        state,
2911        off_pnl_sum,
2912        !view.is_on_panel_selected,
2913    );
2914    frame.render_widget(
2915        Paragraph::new(Line::from(vec![
2916            Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
2917            Span::styled(
2918                selected_symbol,
2919                Style::default()
2920                    .fg(Color::Green)
2921                    .add_modifier(Modifier::BOLD),
2922            ),
2923            Span::styled(
2924                "  [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",
2925                Style::default().fg(Color::DarkGray),
2926            ),
2927        ])),
2928        strategy_chunks[3],
2929    );
2930}
2931
2932fn format_running_time(total_running_ms: u64) -> String {
2933    let total_sec = total_running_ms / 1000;
2934    let days = total_sec / 86_400;
2935    let hours = (total_sec % 86_400) / 3_600;
2936    let minutes = (total_sec % 3_600) / 60;
2937    if days > 0 {
2938        format!("{}d {:02}h", days, hours)
2939    } else {
2940        format!("{:02}h {:02}m", hours, minutes)
2941    }
2942}
2943
2944fn format_age_ms(age_ms: u64) -> String {
2945    if age_ms < 1_000 {
2946        format!("{}ms", age_ms)
2947    } else if age_ms < 60_000 {
2948        format!("{}s", age_ms / 1_000)
2949    } else {
2950        format!("{}m", age_ms / 60_000)
2951    }
2952}
2953
2954fn latency_stats(samples: &[u64]) -> (String, String, String) {
2955    let p50 = percentile(samples, 50);
2956    let p95 = percentile(samples, 95);
2957    let p99 = percentile(samples, 99);
2958    (
2959        p50.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2960        p95.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2961        p99.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2962    )
2963}
2964
2965fn render_strategy_editor_popup(frame: &mut Frame, state: &AppState) {
2966    let area = frame.area();
2967    let popup = Rect {
2968        x: area.x + 8,
2969        y: area.y + 4,
2970        width: area.width.saturating_sub(16).max(50),
2971        height: area.height.saturating_sub(8).max(12),
2972    };
2973    frame.render_widget(Clear, popup);
2974    let block = Block::default()
2975        .title(" Strategy Config ")
2976        .borders(Borders::ALL)
2977        .border_style(Style::default().fg(Color::Yellow));
2978    let inner = block.inner(popup);
2979    frame.render_widget(block, popup);
2980    let selected_name = state
2981        .strategy_items
2982        .get(state.strategy_editor_index)
2983        .map(String::as_str)
2984        .unwrap_or("Unknown");
2985    let strategy_kind = state
2986        .strategy_editor_kind_items
2987        .get(state.strategy_editor_kind_index)
2988        .map(String::as_str)
2989        .unwrap_or("MA");
2990    let is_rsa = strategy_kind.eq_ignore_ascii_case("RSA");
2991    let is_atr = strategy_kind.eq_ignore_ascii_case("ATR");
2992    let is_chb = strategy_kind.eq_ignore_ascii_case("CHB");
2993    let period_1_label = if is_rsa {
2994        "RSI Period"
2995    } else if is_atr {
2996        "ATR Period"
2997    } else if is_chb {
2998        "Entry Window"
2999    } else {
3000        "Fast Period"
3001    };
3002    let period_2_label = if is_rsa {
3003        "Upper RSI"
3004    } else if is_atr {
3005        "Threshold x100"
3006    } else if is_chb {
3007        "Exit Window"
3008    } else {
3009        "Slow Period"
3010    };
3011    let rows = [
3012        ("Strategy", strategy_kind.to_string()),
3013        (
3014            "Symbol",
3015            state
3016                .symbol_items
3017                .get(state.strategy_editor_symbol_index)
3018                .cloned()
3019                .unwrap_or_else(|| state.symbol.clone()),
3020        ),
3021        (period_1_label, state.strategy_editor_fast.to_string()),
3022        (period_2_label, state.strategy_editor_slow.to_string()),
3023        ("Cooldown Tick", state.strategy_editor_cooldown.to_string()),
3024    ];
3025    let mut lines = vec![
3026        Line::from(vec![
3027            Span::styled("Target: ", Style::default().fg(Color::DarkGray)),
3028            Span::styled(
3029                selected_name,
3030                Style::default()
3031                    .fg(Color::White)
3032                    .add_modifier(Modifier::BOLD),
3033            ),
3034        ]),
3035        Line::from(Span::styled(
3036            "Use [J/K] field, [H/L] value, [Enter] save, [Esc] cancel",
3037            Style::default().fg(Color::DarkGray),
3038        )),
3039    ];
3040    if is_rsa {
3041        let lower = 100usize.saturating_sub(state.strategy_editor_slow.clamp(51, 95));
3042        lines.push(Line::from(Span::styled(
3043            format!("RSA lower threshold auto-derived: {}", lower),
3044            Style::default().fg(Color::DarkGray),
3045        )));
3046    } else if is_atr {
3047        let threshold_x100 = state.strategy_editor_slow.clamp(110, 500);
3048        lines.push(Line::from(Span::styled(
3049            format!("ATR expansion threshold: {:.2}x", threshold_x100 as f64 / 100.0),
3050            Style::default().fg(Color::DarkGray),
3051        )));
3052    } else if is_chb {
3053        lines.push(Line::from(Span::styled(
3054            "CHB breakout: buy on entry high break, sell on exit low break",
3055            Style::default().fg(Color::DarkGray),
3056        )));
3057    }
3058    for (idx, (name, value)) in rows.iter().enumerate() {
3059        let marker = if idx == state.strategy_editor_field {
3060            "▶ "
3061        } else {
3062            "  "
3063        };
3064        let style = if idx == state.strategy_editor_field {
3065            Style::default()
3066                .fg(Color::Yellow)
3067                .add_modifier(Modifier::BOLD)
3068        } else {
3069            Style::default().fg(Color::White)
3070        };
3071        lines.push(Line::from(vec![
3072            Span::styled(marker, Style::default().fg(Color::Yellow)),
3073            Span::styled(format!("{:<14}", name), style),
3074            Span::styled(value, style),
3075        ]));
3076    }
3077    frame.render_widget(Paragraph::new(lines), inner);
3078    if state.strategy_editor_kind_category_selector_open {
3079        render_selector_popup(
3080            frame,
3081            " Select Strategy Category ",
3082            &state.strategy_editor_kind_category_items,
3083            state
3084                .strategy_editor_kind_category_index
3085                .min(state.strategy_editor_kind_category_items.len().saturating_sub(1)),
3086            None,
3087            None,
3088            None,
3089        );
3090    } else if state.strategy_editor_kind_selector_open {
3091        render_selector_popup(
3092            frame,
3093            " Select Strategy Type ",
3094            &state.strategy_editor_kind_popup_items,
3095            state
3096                .strategy_editor_kind_selector_index
3097                .min(state.strategy_editor_kind_popup_items.len().saturating_sub(1)),
3098            None,
3099            None,
3100            None,
3101        );
3102    }
3103}
3104
3105fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
3106    let area = frame.area();
3107    let popup = Rect {
3108        x: area.x + 4,
3109        y: area.y + 2,
3110        width: area.width.saturating_sub(8).max(30),
3111        height: area.height.saturating_sub(4).max(10),
3112    };
3113    frame.render_widget(Clear, popup);
3114    let block = Block::default()
3115        .title(" Account Assets ")
3116        .borders(Borders::ALL)
3117        .border_style(Style::default().fg(Color::Cyan));
3118    let inner = block.inner(popup);
3119    frame.render_widget(block, popup);
3120
3121    let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
3122    assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
3123
3124    let mut lines = Vec::with_capacity(assets.len() + 2);
3125    lines.push(Line::from(vec![
3126        Span::styled(
3127            "Asset",
3128            Style::default()
3129                .fg(Color::Cyan)
3130                .add_modifier(Modifier::BOLD),
3131        ),
3132        Span::styled(
3133            "      Free",
3134            Style::default()
3135                .fg(Color::Cyan)
3136                .add_modifier(Modifier::BOLD),
3137        ),
3138    ]));
3139    for (asset, qty) in assets {
3140        lines.push(Line::from(vec![
3141            Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
3142            Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
3143        ]));
3144    }
3145    if lines.len() == 1 {
3146        lines.push(Line::from(Span::styled(
3147            "No assets",
3148            Style::default().fg(Color::DarkGray),
3149        )));
3150    }
3151
3152    frame.render_widget(Paragraph::new(lines), inner);
3153}
3154
3155fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
3156    let area = frame.area();
3157    let popup = Rect {
3158        x: area.x + 2,
3159        y: area.y + 1,
3160        width: area.width.saturating_sub(4).max(40),
3161        height: area.height.saturating_sub(2).max(12),
3162    };
3163    frame.render_widget(Clear, popup);
3164    let block = Block::default()
3165        .title(match bucket {
3166            order_store::HistoryBucket::Day => " History (Day ROI) ",
3167            order_store::HistoryBucket::Hour => " History (Hour ROI) ",
3168            order_store::HistoryBucket::Month => " History (Month ROI) ",
3169        })
3170        .borders(Borders::ALL)
3171        .border_style(Style::default().fg(Color::Cyan));
3172    let inner = block.inner(popup);
3173    frame.render_widget(block, popup);
3174
3175    let max_rows = inner.height.saturating_sub(1) as usize;
3176    let visible = build_history_lines(rows, max_rows);
3177    frame.render_widget(Paragraph::new(visible), inner);
3178}
3179
3180fn build_history_lines(rows: &[String], max_rows: usize) -> Vec<Line<'_>> {
3181    let mut visible: Vec<Line> = Vec::new();
3182    for (idx, row) in rows.iter().take(max_rows).enumerate() {
3183        let color = if idx == 0 {
3184            Color::Cyan
3185        } else if row.contains('-') && row.contains('%') {
3186            Color::White
3187        } else {
3188            Color::DarkGray
3189        };
3190        visible.push(Line::from(Span::styled(
3191            row.as_str(),
3192            Style::default().fg(color),
3193        )));
3194    }
3195    if visible.is_empty() {
3196        visible.push(Line::from(Span::styled(
3197            "No history rows",
3198            Style::default().fg(Color::DarkGray),
3199        )));
3200    }
3201    visible
3202}
3203
3204fn render_selector_popup(
3205    frame: &mut Frame,
3206    title: &str,
3207    items: &[String],
3208    selected: usize,
3209    stats: Option<&HashMap<String, OrderHistoryStats>>,
3210    total_stats: Option<OrderHistoryStats>,
3211    selected_symbol: Option<&str>,
3212) {
3213    let area = frame.area();
3214    let available_width = area.width.saturating_sub(2).max(1);
3215    let width = if stats.is_some() {
3216        let min_width = 44;
3217        let preferred = 84;
3218        preferred
3219            .min(available_width)
3220            .max(min_width.min(available_width))
3221    } else {
3222        let min_width = 24;
3223        let preferred = 48;
3224        preferred
3225            .min(available_width)
3226            .max(min_width.min(available_width))
3227    };
3228    let available_height = area.height.saturating_sub(2).max(1);
3229    let desired_height = if stats.is_some() {
3230        items.len() as u16 + 7
3231    } else {
3232        items.len() as u16 + 4
3233    };
3234    let height = desired_height
3235        .min(available_height)
3236        .max(6.min(available_height));
3237    let popup = Rect {
3238        x: area.x + (area.width.saturating_sub(width)) / 2,
3239        y: area.y + (area.height.saturating_sub(height)) / 2,
3240        width,
3241        height,
3242    };
3243
3244    frame.render_widget(Clear, popup);
3245    let block = Block::default()
3246        .title(title)
3247        .borders(Borders::ALL)
3248        .border_style(Style::default().fg(Color::Cyan));
3249    let inner = block.inner(popup);
3250    frame.render_widget(block, popup);
3251
3252    let mut lines: Vec<Line> = Vec::new();
3253    if stats.is_some() {
3254        if let Some(symbol) = selected_symbol {
3255            lines.push(Line::from(vec![
3256                Span::styled("  Symbol: ", Style::default().fg(Color::DarkGray)),
3257                Span::styled(
3258                    symbol,
3259                    Style::default()
3260                        .fg(Color::Green)
3261                        .add_modifier(Modifier::BOLD),
3262                ),
3263            ]));
3264        }
3265        lines.push(Line::from(vec![Span::styled(
3266            "  Strategy           W    L    T    PnL",
3267            Style::default()
3268                .fg(Color::Cyan)
3269                .add_modifier(Modifier::BOLD),
3270        )]));
3271    }
3272
3273    let mut item_lines: Vec<Line> = items
3274        .iter()
3275        .enumerate()
3276        .map(|(idx, item)| {
3277            let item_text = if let Some(stats_map) = stats {
3278                let symbol = selected_symbol.unwrap_or("-");
3279                if let Some(s) = strategy_stats_for_item(stats_map, item, symbol) {
3280                    format!(
3281                        "{:<16}  W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
3282                        item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
3283                    )
3284                } else {
3285                    format!("{:<16}  W:0   L:0   T:0   PnL:0.0000", item)
3286                }
3287            } else {
3288                item.clone()
3289            };
3290            if idx == selected {
3291                Line::from(vec![
3292                    Span::styled("▶ ", Style::default().fg(Color::Yellow)),
3293                    Span::styled(
3294                        item_text,
3295                        Style::default()
3296                            .fg(Color::White)
3297                            .add_modifier(Modifier::BOLD),
3298                    ),
3299                ])
3300            } else {
3301                Line::from(vec![
3302                    Span::styled("  ", Style::default()),
3303                    Span::styled(item_text, Style::default().fg(Color::DarkGray)),
3304                ])
3305            }
3306        })
3307        .collect();
3308    lines.append(&mut item_lines);
3309    if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
3310        let mut strategy_sum = OrderHistoryStats::default();
3311        for item in items {
3312            let symbol = selected_symbol.unwrap_or("-");
3313            if let Some(s) = strategy_stats_for_item(stats_map, item, symbol) {
3314                strategy_sum.trade_count += s.trade_count;
3315                strategy_sum.win_count += s.win_count;
3316                strategy_sum.lose_count += s.lose_count;
3317                strategy_sum.realized_pnl += s.realized_pnl;
3318            }
3319        }
3320        let manual = subtract_stats(t, &strategy_sum);
3321        lines.push(Line::from(vec![Span::styled(
3322            format!(
3323                "  MANUAL(rest)       W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
3324                manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
3325            ),
3326            Style::default().fg(Color::LightBlue),
3327        )]));
3328    }
3329    if let Some(t) = total_stats {
3330        lines.push(Line::from(vec![Span::styled(
3331            format!(
3332                "  TOTAL              W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
3333                t.win_count, t.lose_count, t.trade_count, t.realized_pnl
3334            ),
3335            Style::default()
3336                .fg(Color::Yellow)
3337                .add_modifier(Modifier::BOLD),
3338        )]));
3339    }
3340
3341    frame.render_widget(
3342        Paragraph::new(lines).style(Style::default().fg(Color::White)),
3343        inner,
3344    );
3345}
3346
3347fn parse_scope_key(scope_key: &str) -> Option<(String, String)> {
3348    let (symbol, tag) = scope_key.split_once("::")?;
3349    let symbol = symbol.trim().to_ascii_uppercase();
3350    let source_tag = tag.trim().to_ascii_lowercase();
3351    if symbol.is_empty() || source_tag.is_empty() {
3352        None
3353    } else {
3354        Some((symbol, source_tag))
3355    }
3356}
3357
3358fn ev_snapshot_for_symbol_and_tag(
3359    state: &AppState,
3360    symbol: &str,
3361    source_tag: &str,
3362) -> Option<EvSnapshotEntry> {
3363    let tag = source_tag.trim().to_ascii_lowercase();
3364    let candidates = symbol_scope_candidates(symbol);
3365    state
3366        .ev_snapshot_by_scope
3367        .iter()
3368        .filter_map(|(k, v)| {
3369            let (scope_symbol, scope_tag) = parse_scope_key(k)?;
3370            let symbol_ok = candidates
3371                .iter()
3372                .any(|prefix| prefix.trim_end_matches("::").eq_ignore_ascii_case(&scope_symbol));
3373            if symbol_ok && scope_tag == tag {
3374                Some(v)
3375            } else {
3376                None
3377            }
3378        })
3379        .max_by_key(|v| v.updated_at_ms)
3380        .cloned()
3381}
3382
3383fn exit_policy_for_symbol_and_tag(
3384    state: &AppState,
3385    symbol: &str,
3386    source_tag: &str,
3387) -> Option<ExitPolicyEntry> {
3388    let tag = source_tag.trim().to_ascii_lowercase();
3389    let candidates = symbol_scope_candidates(symbol);
3390    state
3391        .exit_policy_by_scope
3392        .iter()
3393        .filter_map(|(k, v)| {
3394            let (scope_symbol, scope_tag) = parse_scope_key(k)?;
3395            let symbol_ok = candidates
3396                .iter()
3397                .any(|prefix| prefix.trim_end_matches("::").eq_ignore_ascii_case(&scope_symbol));
3398            if symbol_ok && scope_tag == tag {
3399                Some(v)
3400            } else {
3401                None
3402            }
3403        })
3404        .max_by_key(|v| v.updated_at_ms)
3405        .cloned()
3406}
3407
3408fn display_symbol_for_storage(symbol: &str) -> String {
3409    let upper = symbol.trim().to_ascii_uppercase();
3410    if let Some(base) = upper.strip_suffix("#FUT") {
3411        format!("{} (FUT)", base)
3412    } else {
3413        upper
3414    }
3415}
3416
3417fn asset_last_price_for_symbol(state: &AppState, symbol: &str) -> Option<f64> {
3418    let target = normalize_symbol_for_scope(symbol);
3419    state
3420        .assets_view()
3421        .iter()
3422        .find(|a| normalize_symbol_for_scope(&a.symbol) == target)
3423        .and_then(|a| a.last_price)
3424}
3425
3426fn close_all_row_status_for_symbol(state: &AppState, symbol: &str) -> Option<String> {
3427    let key = normalize_symbol_for_scope(symbol);
3428    if state.close_all_running {
3429        if let Some(found) = state.close_all_row_status_by_symbol.get(&key) {
3430            if found == "PENDING" {
3431                return Some("RUNNING".to_string());
3432            }
3433            return Some(found.clone());
3434        }
3435    }
3436    state.close_all_row_status_by_symbol.get(&key).cloned()
3437}
3438
3439fn strategy_stats_for_item<'a>(
3440    stats_map: &'a HashMap<String, OrderHistoryStats>,
3441    item: &str,
3442    symbol: &str,
3443) -> Option<&'a OrderHistoryStats> {
3444    if let Some(source_tag) = source_tag_for_strategy_item(item) {
3445        let scoped = strategy_stats_scope_key(symbol, &source_tag);
3446        if let Some(s) = stats_map.get(&scoped) {
3447            return Some(s);
3448        }
3449    }
3450    if let Some(s) = stats_map.get(item) {
3451        return Some(s);
3452    }
3453    let source_tag = source_tag_for_strategy_item(item);
3454    source_tag.and_then(|tag| {
3455        stats_map
3456            .get(&tag)
3457            .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
3458    })
3459}
3460
3461fn ev_snapshot_for_item<'a>(
3462    ev_map: &'a HashMap<String, EvSnapshotEntry>,
3463    item: &str,
3464    symbol: &str,
3465) -> Option<&'a EvSnapshotEntry> {
3466    if let Some(source_tag) = source_tag_for_strategy_item(item) {
3467        if let Some(found) = ev_map.get(&strategy_stats_scope_key(symbol, &source_tag)) {
3468            return Some(found);
3469        }
3470    }
3471    latest_ev_snapshot_for_symbol(ev_map, symbol)
3472}
3473
3474fn exit_policy_for_item<'a>(
3475    policy_map: &'a HashMap<String, ExitPolicyEntry>,
3476    item: &str,
3477    symbol: &str,
3478) -> Option<&'a ExitPolicyEntry> {
3479    if let Some(source_tag) = source_tag_for_strategy_item(item) {
3480        if let Some(found) = policy_map.get(&strategy_stats_scope_key(symbol, &source_tag)) {
3481            return Some(found);
3482        }
3483    }
3484    latest_exit_policy_for_symbol(policy_map, symbol)
3485}
3486
3487fn latest_ev_snapshot_for_symbol<'a>(
3488    ev_map: &'a HashMap<String, EvSnapshotEntry>,
3489    symbol: &str,
3490) -> Option<&'a EvSnapshotEntry> {
3491    let prefix = format!("{}::", symbol.trim().to_ascii_uppercase());
3492    ev_map
3493        .iter()
3494        .filter(|(k, _)| k.starts_with(&prefix))
3495        .max_by_key(|(_, v)| v.updated_at_ms)
3496        .map(|(_, v)| v)
3497}
3498
3499fn latest_exit_policy_for_symbol<'a>(
3500    policy_map: &'a HashMap<String, ExitPolicyEntry>,
3501    symbol: &str,
3502) -> Option<&'a ExitPolicyEntry> {
3503    let prefix = format!("{}::", symbol.trim().to_ascii_uppercase());
3504    policy_map
3505        .iter()
3506        .filter(|(k, _)| k.starts_with(&prefix))
3507        .max_by_key(|(_, v)| v.updated_at_ms)
3508        .map(|(_, v)| v)
3509}
3510
3511fn latest_ev_snapshot_for_symbol_relaxed<'a>(
3512    ev_map: &'a HashMap<String, EvSnapshotEntry>,
3513    symbol: &str,
3514) -> Option<&'a EvSnapshotEntry> {
3515    let candidates = symbol_scope_candidates(symbol);
3516    ev_map
3517        .iter()
3518        .filter(|(k, _)| candidates.iter().any(|prefix| k.starts_with(prefix)))
3519        .max_by_key(|(_, v)| v.updated_at_ms)
3520        .map(|(_, v)| v)
3521}
3522
3523fn latest_exit_policy_for_symbol_relaxed<'a>(
3524    policy_map: &'a HashMap<String, ExitPolicyEntry>,
3525    symbol: &str,
3526) -> Option<&'a ExitPolicyEntry> {
3527    let candidates = symbol_scope_candidates(symbol);
3528    policy_map
3529        .iter()
3530        .filter(|(k, _)| candidates.iter().any(|prefix| k.starts_with(prefix)))
3531        .max_by_key(|(_, v)| v.updated_at_ms)
3532        .map(|(_, v)| v)
3533}
3534
3535fn symbol_scope_candidates(symbol: &str) -> Vec<String> {
3536    let mut variants: Vec<String> = Vec::new();
3537    let upper = symbol.trim().to_ascii_uppercase();
3538    let base = if let Some(raw) = upper.strip_suffix(" (FUT)") {
3539        raw.trim().to_string()
3540    } else if let Some(raw) = upper.strip_suffix("#FUT") {
3541        raw.trim().to_string()
3542    } else {
3543        upper.clone()
3544    };
3545
3546    if !base.is_empty() {
3547        variants.push(base.clone());
3548        variants.push(format!("{} (FUT)", base));
3549        variants.push(format!("{}#FUT", base));
3550    }
3551    if !upper.is_empty() {
3552        variants.push(upper);
3553    }
3554    variants.sort();
3555    variants.dedup();
3556    variants
3557        .into_iter()
3558        .map(|v| format!("{}::", v))
3559        .collect()
3560}
3561
3562fn strategy_stats_scope_key(symbol: &str, source_tag: &str) -> String {
3563    format!(
3564        "{}::{}",
3565        symbol.trim().to_ascii_uppercase(),
3566        source_tag.trim().to_ascii_lowercase()
3567    )
3568}
3569
3570fn source_tag_for_strategy_item(item: &str) -> Option<String> {
3571    match item {
3572        "MA(Config)" => return Some("cfg".to_string()),
3573        "MA(Fast 5/20)" => return Some("fst".to_string()),
3574        "MA(Slow 20/60)" => return Some("slw".to_string()),
3575        "RSA(RSI 14 30/70)" => return Some("rsa".to_string()),
3576        "DCT(Donchian 20/10)" => return Some("dct".to_string()),
3577        "MRV(SMA 20 -2.00%)" => return Some("mrv".to_string()),
3578        "BBR(BB 20 2.00x)" => return Some("bbr".to_string()),
3579        "STO(Stoch 14 20/80)" => return Some("sto".to_string()),
3580        "VLC(Compression 20 1.20%)" => return Some("vlc".to_string()),
3581        "ORB(Opening 12/8)" => return Some("orb".to_string()),
3582        "REG(Regime 10/30)" => return Some("reg".to_string()),
3583        "ENS(Vote 10/30)" => return Some("ens".to_string()),
3584        "MAC(MACD 12/26)" => return Some("mac".to_string()),
3585        "ROC(ROC 10 0.20%)" => return Some("roc".to_string()),
3586        "ARN(Aroon 14 70)" => return Some("arn".to_string()),
3587        _ => {}
3588    }
3589    if let Some((_, tail)) = item.rsplit_once('[') {
3590        if let Some(tag) = tail.strip_suffix(']') {
3591            let tag = tag.trim();
3592            if !tag.is_empty() {
3593                return Some(tag.to_ascii_lowercase());
3594            }
3595        }
3596    }
3597    None
3598}
3599
3600fn parse_source_tag_from_client_order_id(client_order_id: &str) -> Option<&str> {
3601    let body = client_order_id.strip_prefix("sq-")?;
3602    let (source_tag, _) = body.split_once('-')?;
3603    if source_tag.is_empty() {
3604        None
3605    } else {
3606        Some(source_tag)
3607    }
3608}
3609
3610fn normalize_symbol_for_scope(symbol: &str) -> String {
3611    let upper = symbol.trim().to_ascii_uppercase();
3612    if let Some(raw) = upper.strip_suffix(" (FUT)") {
3613        return raw.trim().to_string();
3614    }
3615    if let Some(raw) = upper.strip_suffix("#FUT") {
3616        return raw.trim().to_string();
3617    }
3618    upper
3619}
3620
3621fn format_log_record_compact(record: &LogRecord) -> String {
3622    let level = match record.level {
3623        LogLevel::Debug => "DEBUG",
3624        LogLevel::Info => "INFO",
3625        LogLevel::Warn => "WARN",
3626        LogLevel::Error => "ERR",
3627    };
3628    let domain = match record.domain {
3629        LogDomain::Ws => "ws",
3630        LogDomain::Strategy => "strategy",
3631        LogDomain::Risk => "risk",
3632        LogDomain::Order => "order",
3633        LogDomain::Portfolio => "portfolio",
3634        LogDomain::Ui => "ui",
3635        LogDomain::System => "system",
3636    };
3637    let symbol = record.symbol.as_deref().unwrap_or("-");
3638    let strategy = record.strategy_tag.as_deref().unwrap_or("-");
3639    format!(
3640        "[{}] {}.{} {} {} {}",
3641        level, domain, record.event, symbol, strategy, record.msg
3642    )
3643}
3644
3645fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
3646    OrderHistoryStats {
3647        trade_count: total.trade_count.saturating_sub(used.trade_count),
3648        win_count: total.win_count.saturating_sub(used.win_count),
3649        lose_count: total.lose_count.saturating_sub(used.lose_count),
3650        realized_pnl: total.realized_pnl - used.realized_pnl,
3651    }
3652}
3653
3654fn split_symbol_assets(symbol: &str) -> (String, String) {
3655    const QUOTE_SUFFIXES: [&str; 10] = [
3656        "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
3657    ];
3658    for q in QUOTE_SUFFIXES {
3659        if let Some(base) = symbol.strip_suffix(q) {
3660            if !base.is_empty() {
3661                return (base.to_string(), q.to_string());
3662            }
3663        }
3664    }
3665    (symbol.to_string(), String::new())
3666}
3667
3668fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
3669    if fills.is_empty() {
3670        return None;
3671    }
3672    let (base_asset, quote_asset) = split_symbol_assets(symbol);
3673    let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
3674    let mut notional_quote = 0.0;
3675    let mut fee_quote_equiv = 0.0;
3676    let mut quote_convertible = !quote_asset.is_empty();
3677
3678    for f in fills {
3679        if f.qty > 0.0 && f.price > 0.0 {
3680            notional_quote += f.qty * f.price;
3681        }
3682        if f.commission <= 0.0 {
3683            continue;
3684        }
3685        *fee_by_asset
3686            .entry(f.commission_asset.clone())
3687            .or_insert(0.0) += f.commission;
3688        if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&quote_asset) {
3689            fee_quote_equiv += f.commission;
3690        } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
3691            fee_quote_equiv += f.commission * f.price.max(0.0);
3692        } else {
3693            quote_convertible = false;
3694        }
3695    }
3696
3697    if fee_by_asset.is_empty() {
3698        return Some("0".to_string());
3699    }
3700
3701    if quote_convertible && notional_quote > f64::EPSILON {
3702        let fee_pct = fee_quote_equiv / notional_quote * 100.0;
3703        return Some(format!(
3704            "{:.3}% ({:.4} {})",
3705            fee_pct, fee_quote_equiv, quote_asset
3706        ));
3707    }
3708
3709    let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
3710    items.sort_by(|a, b| a.0.cmp(&b.0));
3711    if items.len() == 1 {
3712        let (asset, amount) = &items[0];
3713        Some(format!("{:.6} {}", amount, asset))
3714    } else {
3715        Some(format!("mixed fees ({})", items.len()))
3716    }
3717}
3718
3719#[cfg(test)]
3720mod tests {
3721    use super::{format_last_applied_fee, symbol_scope_candidates};
3722    use crate::model::order::Fill;
3723
3724    #[test]
3725    fn fee_summary_from_quote_asset_commission() {
3726        let fills = vec![Fill {
3727            price: 2000.0,
3728            qty: 0.5,
3729            commission: 1.0,
3730            commission_asset: "USDT".to_string(),
3731        }];
3732        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
3733        assert_eq!(summary, "0.100% (1.0000 USDT)");
3734    }
3735
3736    #[test]
3737    fn fee_summary_from_base_asset_commission() {
3738        let fills = vec![Fill {
3739            price: 2000.0,
3740            qty: 0.5,
3741            commission: 0.0005,
3742            commission_asset: "ETH".to_string(),
3743        }];
3744        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
3745        assert_eq!(summary, "0.100% (1.0000 USDT)");
3746    }
3747
3748    #[test]
3749    fn symbol_scope_candidates_include_spot_and_futures_variants() {
3750        let mut from_spot = symbol_scope_candidates("btcusdt");
3751        from_spot.sort();
3752        assert!(from_spot.contains(&"BTCUSDT::".to_string()));
3753        assert!(from_spot.contains(&"BTCUSDT (FUT)::".to_string()));
3754        assert!(from_spot.contains(&"BTCUSDT#FUT::".to_string()));
3755
3756        let mut from_fut_label = symbol_scope_candidates("BTCUSDT (FUT)");
3757        from_fut_label.sort();
3758        assert!(from_fut_label.contains(&"BTCUSDT::".to_string()));
3759        assert!(from_fut_label.contains(&"BTCUSDT (FUT)::".to_string()));
3760        assert!(from_fut_label.contains(&"BTCUSDT#FUT::".to_string()));
3761
3762        let mut from_hash_fut = symbol_scope_candidates("BTCUSDT#FUT");
3763        from_hash_fut.sort();
3764        assert!(from_hash_fut.contains(&"BTCUSDT::".to_string()));
3765        assert!(from_hash_fut.contains(&"BTCUSDT (FUT)::".to_string()));
3766        assert!(from_hash_fut.contains(&"BTCUSDT#FUT::".to_string()));
3767    }
3768}