Skip to main content

sandbox_quant/ui/
mod.rs

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