Skip to main content

sandbox_quant/ui/
mod.rs

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