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