Skip to main content

sandbox_quant/ui/
mod.rs

1pub mod ui_projection;
2pub mod chart;
3pub mod dashboard;
4pub mod network_metrics;
5
6use std::collections::HashMap;
7
8use ratatui::layout::{Constraint, Direction, Layout, Rect};
9use ratatui::style::{Color, Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
12use ratatui::Frame;
13
14use crate::event::{AppEvent, AssetPnlEntry, LogDomain, LogLevel, LogRecord, WsConnectionStatus};
15use crate::model::candle::{Candle, CandleBuilder};
16use crate::model::order::{Fill, OrderSide};
17use crate::model::position::Position;
18use crate::model::signal::Signal;
19use crate::order_manager::{OrderHistoryFill, OrderHistoryStats, OrderUpdate};
20use crate::order_store;
21use crate::risk_module::RateBudgetSnapshot;
22use crate::strategy_catalog::{strategy_kind_categories, strategy_kind_labels};
23use crate::ui::network_metrics::{classify_health, count_since, percentile, rate_per_sec, ratio_pct, NetworkHealth};
24
25use ui_projection::UiProjection;
26use ui_projection::AssetEntry;
27use chart::{FillMarker, PriceChart};
28use dashboard::{KeybindBar, LogPanel, OrderHistoryPanel, OrderLogPanel, PositionPanel, StatusBar, StrategyMetricsPanel};
29
30const MAX_LOG_MESSAGES: usize = 200;
31const MAX_FILL_MARKERS: usize = 200;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum GridTab {
35    Assets,
36    Strategies,
37    Risk,
38    Network,
39    SystemLog,
40}
41
42#[derive(Debug, Clone)]
43pub struct StrategyLastEvent {
44    pub side: OrderSide,
45    pub price: Option<f64>,
46    pub timestamp_ms: u64,
47    pub is_filled: bool,
48}
49
50#[derive(Debug, Clone)]
51pub struct ViewState {
52    pub is_grid_open: bool,
53    pub selected_grid_tab: GridTab,
54    pub selected_symbol_index: usize,
55    pub selected_strategy_index: usize,
56    pub is_on_panel_selected: bool,
57    pub is_symbol_selector_open: bool,
58    pub selected_symbol_selector_index: usize,
59    pub is_strategy_selector_open: bool,
60    pub selected_strategy_selector_index: usize,
61    pub is_account_popup_open: bool,
62    pub is_history_popup_open: bool,
63    pub is_focus_popup_open: bool,
64    pub is_strategy_editor_open: bool,
65}
66
67pub struct AppState {
68    pub symbol: String,
69    pub strategy_label: String,
70    pub candles: Vec<Candle>,
71    pub current_candle: Option<CandleBuilder>,
72    pub candle_interval_ms: u64,
73    pub timeframe: String,
74    pub price_history_len: usize,
75    pub position: Position,
76    pub last_signal: Option<Signal>,
77    pub last_order: Option<OrderUpdate>,
78    pub open_order_history: Vec<String>,
79    pub filled_order_history: Vec<String>,
80    pub fast_sma: Option<f64>,
81    pub slow_sma: Option<f64>,
82    pub ws_connected: bool,
83    pub paused: bool,
84    pub tick_count: u64,
85    pub log_messages: Vec<String>,
86    pub log_records: Vec<LogRecord>,
87    pub balances: HashMap<String, f64>,
88    pub initial_equity_usdt: Option<f64>,
89    pub current_equity_usdt: Option<f64>,
90    pub history_estimated_total_pnl_usdt: Option<f64>,
91    pub fill_markers: Vec<FillMarker>,
92    pub history_trade_count: u32,
93    pub history_win_count: u32,
94    pub history_lose_count: u32,
95    pub history_realized_pnl: f64,
96    pub asset_pnl_by_symbol: HashMap<String, AssetPnlEntry>,
97    pub strategy_stats: HashMap<String, OrderHistoryStats>,
98    pub history_fills: Vec<OrderHistoryFill>,
99    pub last_price_update_ms: Option<u64>,
100    pub last_price_event_ms: Option<u64>,
101    pub last_price_latency_ms: Option<u64>,
102    pub last_order_history_update_ms: Option<u64>,
103    pub last_order_history_event_ms: Option<u64>,
104    pub last_order_history_latency_ms: Option<u64>,
105    pub trade_stats_reset_warned: bool,
106    pub symbol_selector_open: bool,
107    pub symbol_selector_index: usize,
108    pub symbol_items: Vec<String>,
109    pub strategy_selector_open: bool,
110    pub strategy_selector_index: usize,
111    pub strategy_items: Vec<String>,
112    pub strategy_item_symbols: Vec<String>,
113    pub strategy_item_active: Vec<bool>,
114    pub strategy_item_created_at_ms: Vec<i64>,
115    pub strategy_item_total_running_ms: Vec<u64>,
116    pub account_popup_open: bool,
117    pub history_popup_open: bool,
118    pub focus_popup_open: bool,
119    pub strategy_editor_open: bool,
120    pub strategy_editor_kind_category_selector_open: bool,
121    pub strategy_editor_kind_selector_open: bool,
122    pub strategy_editor_index: usize,
123    pub strategy_editor_field: usize,
124    pub strategy_editor_kind_category_items: Vec<String>,
125    pub strategy_editor_kind_category_index: usize,
126    pub strategy_editor_kind_popup_items: Vec<String>,
127    pub strategy_editor_kind_popup_labels: Vec<Option<String>>,
128    pub strategy_editor_kind_items: Vec<String>,
129    pub strategy_editor_kind_selector_index: usize,
130    pub strategy_editor_kind_index: usize,
131    pub strategy_editor_symbol_index: usize,
132    pub strategy_editor_fast: usize,
133    pub strategy_editor_slow: usize,
134    pub strategy_editor_cooldown: u64,
135    pub grid_symbol_index: usize,
136    pub grid_strategy_index: usize,
137    pub grid_select_on_panel: bool,
138    pub grid_tab: GridTab,
139    pub strategy_last_event_by_tag: HashMap<String, StrategyLastEvent>,
140    pub network_tick_drop_count: u64,
141    pub network_reconnect_count: u64,
142    pub network_tick_latencies_ms: Vec<u64>,
143    pub network_fill_latencies_ms: Vec<u64>,
144    pub network_order_sync_latencies_ms: Vec<u64>,
145    pub network_tick_in_timestamps_ms: Vec<u64>,
146    pub network_tick_drop_timestamps_ms: Vec<u64>,
147    pub network_reconnect_timestamps_ms: Vec<u64>,
148    pub network_disconnect_timestamps_ms: Vec<u64>,
149    pub network_last_fill_ms: Option<u64>,
150    pub network_pending_submit_ms_by_intent: HashMap<String, u64>,
151    pub history_rows: Vec<String>,
152    pub history_bucket: order_store::HistoryBucket,
153    pub last_applied_fee: String,
154    pub grid_open: bool,
155    pub ui_projection: UiProjection,
156    pub rate_budget_global: RateBudgetSnapshot,
157    pub rate_budget_orders: RateBudgetSnapshot,
158    pub rate_budget_account: RateBudgetSnapshot,
159    pub rate_budget_market_data: RateBudgetSnapshot,
160}
161
162impl AppState {
163    pub fn new(
164        symbol: &str,
165        strategy_label: &str,
166        price_history_len: usize,
167        candle_interval_ms: u64,
168        timeframe: &str,
169    ) -> Self {
170        Self {
171            symbol: symbol.to_string(),
172            strategy_label: strategy_label.to_string(),
173            candles: Vec::with_capacity(price_history_len),
174            current_candle: None,
175            candle_interval_ms,
176            timeframe: timeframe.to_string(),
177            price_history_len,
178            position: Position::new(symbol.to_string()),
179            last_signal: None,
180            last_order: None,
181            open_order_history: Vec::new(),
182            filled_order_history: Vec::new(),
183            fast_sma: None,
184            slow_sma: None,
185            ws_connected: false,
186            paused: false,
187            tick_count: 0,
188            log_messages: Vec::new(),
189            log_records: Vec::new(),
190            balances: HashMap::new(),
191            initial_equity_usdt: None,
192            current_equity_usdt: None,
193            history_estimated_total_pnl_usdt: None,
194            fill_markers: Vec::new(),
195            history_trade_count: 0,
196            history_win_count: 0,
197            history_lose_count: 0,
198            history_realized_pnl: 0.0,
199            asset_pnl_by_symbol: HashMap::new(),
200            strategy_stats: HashMap::new(),
201            history_fills: Vec::new(),
202            last_price_update_ms: None,
203            last_price_event_ms: None,
204            last_price_latency_ms: None,
205            last_order_history_update_ms: None,
206            last_order_history_event_ms: None,
207            last_order_history_latency_ms: None,
208            trade_stats_reset_warned: false,
209            symbol_selector_open: false,
210            symbol_selector_index: 0,
211            symbol_items: Vec::new(),
212            strategy_selector_open: false,
213            strategy_selector_index: 0,
214            strategy_items: vec![
215                "MA(Config)".to_string(),
216                "MA(Fast 5/20)".to_string(),
217                "MA(Slow 20/60)".to_string(),
218                "RSA(RSI 14 30/70)".to_string(),
219            ],
220            strategy_item_symbols: vec![
221                symbol.to_ascii_uppercase(),
222                symbol.to_ascii_uppercase(),
223                symbol.to_ascii_uppercase(),
224                symbol.to_ascii_uppercase(),
225            ],
226            strategy_item_active: vec![false, false, false, false],
227            strategy_item_created_at_ms: vec![0, 0, 0, 0],
228            strategy_item_total_running_ms: vec![0, 0, 0, 0],
229            account_popup_open: false,
230            history_popup_open: false,
231            focus_popup_open: false,
232            strategy_editor_open: false,
233            strategy_editor_kind_category_selector_open: false,
234            strategy_editor_kind_selector_open: false,
235            strategy_editor_index: 0,
236            strategy_editor_field: 0,
237            strategy_editor_kind_category_items: strategy_kind_categories(),
238            strategy_editor_kind_category_index: 0,
239            strategy_editor_kind_popup_items: Vec::new(),
240            strategy_editor_kind_popup_labels: Vec::new(),
241            strategy_editor_kind_items: strategy_kind_labels(),
242            strategy_editor_kind_selector_index: 0,
243            strategy_editor_kind_index: 0,
244            strategy_editor_symbol_index: 0,
245            strategy_editor_fast: 5,
246            strategy_editor_slow: 20,
247            strategy_editor_cooldown: 1,
248            grid_symbol_index: 0,
249            grid_strategy_index: 0,
250            grid_select_on_panel: true,
251            grid_tab: GridTab::Strategies,
252            strategy_last_event_by_tag: HashMap::new(),
253            network_tick_drop_count: 0,
254            network_reconnect_count: 0,
255            network_tick_latencies_ms: Vec::new(),
256            network_fill_latencies_ms: Vec::new(),
257            network_order_sync_latencies_ms: Vec::new(),
258            network_tick_in_timestamps_ms: Vec::new(),
259            network_tick_drop_timestamps_ms: Vec::new(),
260            network_reconnect_timestamps_ms: Vec::new(),
261            network_disconnect_timestamps_ms: Vec::new(),
262            network_last_fill_ms: None,
263            network_pending_submit_ms_by_intent: HashMap::new(),
264            history_rows: Vec::new(),
265            history_bucket: order_store::HistoryBucket::Day,
266            last_applied_fee: "---".to_string(),
267            grid_open: false,
268            ui_projection: UiProjection::new(),
269            rate_budget_global: RateBudgetSnapshot {
270                used: 0,
271                limit: 0,
272                reset_in_ms: 0,
273            },
274            rate_budget_orders: RateBudgetSnapshot {
275                used: 0,
276                limit: 0,
277                reset_in_ms: 0,
278            },
279            rate_budget_account: RateBudgetSnapshot {
280                used: 0,
281                limit: 0,
282                reset_in_ms: 0,
283            },
284            rate_budget_market_data: RateBudgetSnapshot {
285                used: 0,
286                limit: 0,
287                reset_in_ms: 0,
288            },
289        }
290    }
291
292    /// Get the latest price (from current candle or last finalized candle).
293    pub fn last_price(&self) -> Option<f64> {
294        self.current_candle
295            .as_ref()
296            .map(|cb| cb.close)
297            .or_else(|| self.candles.last().map(|c| c.close))
298    }
299
300    pub fn push_log(&mut self, msg: String) {
301        self.log_messages.push(msg);
302        if self.log_messages.len() > MAX_LOG_MESSAGES {
303            self.log_messages.remove(0);
304        }
305    }
306
307    pub fn push_log_record(&mut self, record: LogRecord) {
308        self.log_records.push(record.clone());
309        if self.log_records.len() > MAX_LOG_MESSAGES {
310            self.log_records.remove(0);
311        }
312        self.push_log(format_log_record_compact(&record));
313    }
314
315    fn push_latency_sample(samples: &mut Vec<u64>, value: u64) {
316        const MAX_SAMPLES: usize = 200;
317        samples.push(value);
318        if samples.len() > MAX_SAMPLES {
319            let drop_n = samples.len() - MAX_SAMPLES;
320            samples.drain(..drop_n);
321        }
322    }
323
324    fn push_network_event_sample(samples: &mut Vec<u64>, ts_ms: u64) {
325        samples.push(ts_ms);
326        let lower = ts_ms.saturating_sub(60_000);
327        samples.retain(|&v| v >= lower);
328    }
329
330    fn prune_network_event_windows(&mut self, now_ms: u64) {
331        let lower = now_ms.saturating_sub(60_000);
332        self.network_tick_in_timestamps_ms.retain(|&v| v >= lower);
333        self.network_tick_drop_timestamps_ms.retain(|&v| v >= lower);
334        self.network_reconnect_timestamps_ms.retain(|&v| v >= lower);
335        self.network_disconnect_timestamps_ms.retain(|&v| v >= lower);
336    }
337
338    /// Transitional projection for RFC-0016 Phase 2.
339    /// Keeps runtime behavior unchanged while exposing normalized naming.
340    pub fn view_state(&self) -> ViewState {
341        ViewState {
342            is_grid_open: self.grid_open,
343            selected_grid_tab: self.grid_tab,
344            selected_symbol_index: self.grid_symbol_index,
345            selected_strategy_index: self.grid_strategy_index,
346            is_on_panel_selected: self.grid_select_on_panel,
347            is_symbol_selector_open: self.symbol_selector_open,
348            selected_symbol_selector_index: self.symbol_selector_index,
349            is_strategy_selector_open: self.strategy_selector_open,
350            selected_strategy_selector_index: self.strategy_selector_index,
351            is_account_popup_open: self.account_popup_open,
352            is_history_popup_open: self.history_popup_open,
353            is_focus_popup_open: self.focus_popup_open,
354            is_strategy_editor_open: self.strategy_editor_open,
355        }
356    }
357
358    pub fn is_grid_open(&self) -> bool {
359        self.grid_open
360    }
361    pub fn set_grid_open(&mut self, open: bool) {
362        self.grid_open = open;
363    }
364    pub fn grid_tab(&self) -> GridTab {
365        self.grid_tab
366    }
367    pub fn set_grid_tab(&mut self, tab: GridTab) {
368        self.grid_tab = tab;
369    }
370    pub fn selected_grid_symbol_index(&self) -> usize {
371        self.grid_symbol_index
372    }
373    pub fn set_selected_grid_symbol_index(&mut self, idx: usize) {
374        self.grid_symbol_index = idx;
375    }
376    pub fn selected_grid_strategy_index(&self) -> usize {
377        self.grid_strategy_index
378    }
379    pub fn set_selected_grid_strategy_index(&mut self, idx: usize) {
380        self.grid_strategy_index = idx;
381    }
382    pub fn is_on_panel_selected(&self) -> bool {
383        self.grid_select_on_panel
384    }
385    pub fn set_on_panel_selected(&mut self, selected: bool) {
386        self.grid_select_on_panel = selected;
387    }
388    pub fn is_symbol_selector_open(&self) -> bool {
389        self.symbol_selector_open
390    }
391    pub fn set_symbol_selector_open(&mut self, open: bool) {
392        self.symbol_selector_open = open;
393    }
394    pub fn symbol_selector_index(&self) -> usize {
395        self.symbol_selector_index
396    }
397    pub fn set_symbol_selector_index(&mut self, idx: usize) {
398        self.symbol_selector_index = idx;
399    }
400    pub fn is_strategy_selector_open(&self) -> bool {
401        self.strategy_selector_open
402    }
403    pub fn set_strategy_selector_open(&mut self, open: bool) {
404        self.strategy_selector_open = open;
405    }
406    pub fn strategy_selector_index(&self) -> usize {
407        self.strategy_selector_index
408    }
409    pub fn set_strategy_selector_index(&mut self, idx: usize) {
410        self.strategy_selector_index = idx;
411    }
412    pub fn is_account_popup_open(&self) -> bool {
413        self.account_popup_open
414    }
415    pub fn set_account_popup_open(&mut self, open: bool) {
416        self.account_popup_open = open;
417    }
418    pub fn is_history_popup_open(&self) -> bool {
419        self.history_popup_open
420    }
421    pub fn set_history_popup_open(&mut self, open: bool) {
422        self.history_popup_open = open;
423    }
424    pub fn is_focus_popup_open(&self) -> bool {
425        self.focus_popup_open
426    }
427    pub fn set_focus_popup_open(&mut self, open: bool) {
428        self.focus_popup_open = open;
429    }
430    pub fn is_strategy_editor_open(&self) -> bool {
431        self.strategy_editor_open
432    }
433    pub fn set_strategy_editor_open(&mut self, open: bool) {
434        self.strategy_editor_open = open;
435    }
436    pub fn focus_symbol(&self) -> Option<&str> {
437        self.ui_projection.focus.symbol.as_deref()
438    }
439    pub fn focus_strategy_id(&self) -> Option<&str> {
440        self.ui_projection.focus.strategy_id.as_deref()
441    }
442    pub fn set_focus_symbol(&mut self, symbol: Option<String>) {
443        self.ui_projection.focus.symbol = symbol;
444    }
445    pub fn set_focus_strategy_id(&mut self, strategy_id: Option<String>) {
446        self.ui_projection.focus.strategy_id = strategy_id;
447    }
448    pub fn focus_pair(&self) -> (Option<String>, Option<String>) {
449        (
450            self.ui_projection.focus.symbol.clone(),
451            self.ui_projection.focus.strategy_id.clone(),
452        )
453    }
454    pub fn assets_view(&self) -> &[AssetEntry] {
455        &self.ui_projection.assets
456    }
457
458    pub fn refresh_history_rows(&mut self) {
459        match order_store::load_realized_returns_by_bucket(self.history_bucket, 400) {
460            Ok(rows) => {
461                use std::collections::{BTreeMap, BTreeSet};
462
463                let mut date_set: BTreeSet<String> = BTreeSet::new();
464                let mut ticker_map: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
465                for row in rows {
466                    date_set.insert(row.date.clone());
467                    ticker_map
468                        .entry(row.symbol.clone())
469                        .or_default()
470                        .insert(row.date, row.realized_return_pct);
471                }
472
473                // Keep recent dates only to avoid horizontal overflow in terminal.
474                let mut dates: Vec<String> = date_set.into_iter().collect();
475                dates.sort();
476                const MAX_DATE_COLS: usize = 6;
477                if dates.len() > MAX_DATE_COLS {
478                    dates = dates[dates.len() - MAX_DATE_COLS..].to_vec();
479                }
480
481                let mut lines = Vec::new();
482                if dates.is_empty() {
483                    lines.push("Ticker            (no daily realized roi data)".to_string());
484                    self.history_rows = lines;
485                    return;
486                }
487
488                let mut header = format!("{:<14}", "Ticker");
489                for d in &dates {
490                    header.push_str(&format!(" {:>10}", d));
491                }
492                lines.push(header);
493
494                for (ticker, by_date) in ticker_map {
495                    let mut line = format!("{:<14}", ticker);
496                    for d in &dates {
497                        let cell = by_date
498                            .get(d)
499                            .map(|v| format!("{:.2}%", v))
500                            .unwrap_or_else(|| "-".to_string());
501                        line.push_str(&format!(" {:>10}", cell));
502                    }
503                    lines.push(line);
504                }
505                self.history_rows = lines;
506            }
507            Err(e) => {
508                self.history_rows = vec![
509                    "Ticker           Date         RealizedROI   RealizedPnL".to_string(),
510                    format!("(failed to load history: {})", e),
511                ];
512            }
513        }
514    }
515
516    fn refresh_equity_usdt(&mut self) {
517        let usdt = self.balances.get("USDT").copied().unwrap_or(0.0);
518        let btc = self.balances.get("BTC").copied().unwrap_or(0.0);
519        let mark_price = self
520            .last_price()
521            .or_else(|| (self.position.entry_price > 0.0).then_some(self.position.entry_price));
522        if let Some(price) = mark_price {
523            let total = usdt + btc * price;
524            self.current_equity_usdt = Some(total);
525            self.recompute_initial_equity_from_history();
526        }
527    }
528
529    fn recompute_initial_equity_from_history(&mut self) {
530        if let Some(current) = self.current_equity_usdt {
531            if let Some(total_pnl) = self.history_estimated_total_pnl_usdt {
532                self.initial_equity_usdt = Some(current - total_pnl);
533            } else if self.history_trade_count == 0 && self.initial_equity_usdt.is_none() {
534                self.initial_equity_usdt = Some(current);
535            }
536        }
537    }
538
539    fn candle_index_for_timestamp(&self, timestamp_ms: u64) -> Option<usize> {
540        if let Some((idx, _)) = self
541            .candles
542            .iter()
543            .enumerate()
544            .find(|(_, c)| timestamp_ms >= c.open_time && timestamp_ms < c.close_time)
545        {
546            return Some(idx);
547        }
548        if let Some(cb) = &self.current_candle {
549            if cb.contains(timestamp_ms) {
550                return Some(self.candles.len());
551            }
552        }
553        // Fallback: if timestamp is newer than the latest finalized candle range
554        // (e.g. coarse timeframe like 1M and no in-progress bucket), pin to nearest past candle.
555        if let Some((idx, _)) = self
556            .candles
557            .iter()
558            .enumerate()
559            .rev()
560            .find(|(_, c)| c.open_time <= timestamp_ms)
561        {
562            return Some(idx);
563        }
564        None
565    }
566
567    fn rebuild_fill_markers_from_history(&mut self, fills: &[OrderHistoryFill]) {
568        self.fill_markers.clear();
569        for fill in fills {
570            if let Some(candle_index) = self.candle_index_for_timestamp(fill.timestamp_ms) {
571                self.fill_markers.push(FillMarker {
572                    candle_index,
573                    price: fill.price,
574                    side: fill.side,
575                });
576            }
577        }
578        if self.fill_markers.len() > MAX_FILL_MARKERS {
579            let excess = self.fill_markers.len() - MAX_FILL_MARKERS;
580            self.fill_markers.drain(..excess);
581        }
582    }
583
584    fn sync_projection_portfolio_summary(&mut self) {
585        self.ui_projection.portfolio.total_equity_usdt = self.current_equity_usdt;
586        self.ui_projection.portfolio.total_realized_pnl_usdt = self.history_realized_pnl;
587        self.ui_projection.portfolio.total_unrealized_pnl_usdt = self.position.unrealized_pnl;
588        self.ui_projection.portfolio.ws_connected = self.ws_connected;
589    }
590
591    fn ensure_projection_focus_defaults(&mut self) {
592        if self.ui_projection.focus.symbol.is_none() {
593            self.ui_projection.focus.symbol = Some(self.symbol.clone());
594        }
595        if self.ui_projection.focus.strategy_id.is_none() {
596            self.ui_projection.focus.strategy_id = Some(self.strategy_label.clone());
597        }
598    }
599
600    fn rebuild_projection_preserve_focus(&mut self, prev_focus: (Option<String>, Option<String>)) {
601        let mut next = UiProjection::from_legacy(self);
602        if prev_focus.0.is_some() {
603            next.focus.symbol = prev_focus.0;
604        }
605        if prev_focus.1.is_some() {
606            next.focus.strategy_id = prev_focus.1;
607        }
608        self.ui_projection = next;
609        self.ensure_projection_focus_defaults();
610    }
611
612    pub fn apply(&mut self, event: AppEvent) {
613        let prev_focus = self.focus_pair();
614        let mut rebuild_projection = false;
615        match event {
616            AppEvent::MarketTick(tick) => {
617                rebuild_projection = true;
618                self.tick_count += 1;
619                let now_ms = chrono::Utc::now().timestamp_millis() as u64;
620                self.last_price_update_ms = Some(now_ms);
621                self.last_price_event_ms = Some(tick.timestamp_ms);
622                self.last_price_latency_ms = Some(now_ms.saturating_sub(tick.timestamp_ms));
623                Self::push_network_event_sample(&mut self.network_tick_in_timestamps_ms, now_ms);
624                if let Some(lat) = self.last_price_latency_ms {
625                    Self::push_latency_sample(&mut self.network_tick_latencies_ms, lat);
626                }
627
628                // Aggregate tick into candles
629                let should_new = match &self.current_candle {
630                    Some(cb) => !cb.contains(tick.timestamp_ms),
631                    None => true,
632                };
633                if should_new {
634                    if let Some(cb) = self.current_candle.take() {
635                        self.candles.push(cb.finish());
636                        if self.candles.len() > self.price_history_len {
637                            self.candles.remove(0);
638                            // Shift marker indices when oldest candle is trimmed.
639                            self.fill_markers.retain_mut(|m| {
640                                if m.candle_index == 0 {
641                                    false
642                                } else {
643                                    m.candle_index -= 1;
644                                    true
645                                }
646                            });
647                        }
648                    }
649                    self.current_candle = Some(CandleBuilder::new(
650                        tick.price,
651                        tick.timestamp_ms,
652                        self.candle_interval_ms,
653                    ));
654                } else if let Some(cb) = self.current_candle.as_mut() {
655                    cb.update(tick.price);
656                } else {
657                    // Defensive fallback: avoid panic if tick ordering/state gets out of sync.
658                    self.current_candle = Some(CandleBuilder::new(
659                        tick.price,
660                        tick.timestamp_ms,
661                        self.candle_interval_ms,
662                    ));
663                    self.push_log("[WARN] Recovered missing current candle state".to_string());
664                }
665
666                self.position.update_unrealized_pnl(tick.price);
667                self.refresh_equity_usdt();
668            }
669            AppEvent::StrategySignal {
670                ref signal,
671                symbol,
672                source_tag,
673                price,
674                timestamp_ms,
675            } => {
676                self.last_signal = Some(signal.clone());
677                let source_tag = source_tag.to_ascii_lowercase();
678                match signal {
679                    Signal::Buy { .. } => {
680                        let should_emit = self
681                            .strategy_last_event_by_tag
682                            .get(&source_tag)
683                            .map(|e| e.side != OrderSide::Buy || timestamp_ms.saturating_sub(e.timestamp_ms) >= 1000)
684                            .unwrap_or(true);
685                        if should_emit {
686                            let mut record = LogRecord::new(
687                                LogLevel::Info,
688                                LogDomain::Strategy,
689                                "signal.emit",
690                                format!(
691                                    "side=BUY price={}",
692                                    price
693                                        .map(|v| format!("{:.4}", v))
694                                        .unwrap_or_else(|| "-".to_string())
695                                ),
696                            );
697                            record.symbol = Some(symbol.clone());
698                            record.strategy_tag = Some(source_tag.clone());
699                            self.push_log_record(record);
700                        }
701                        self.strategy_last_event_by_tag.insert(
702                            source_tag.clone(),
703                            StrategyLastEvent {
704                                side: OrderSide::Buy,
705                                price,
706                                timestamp_ms,
707                                is_filled: false,
708                            },
709                        );
710                    }
711                    Signal::Sell { .. } => {
712                        let should_emit = self
713                            .strategy_last_event_by_tag
714                            .get(&source_tag)
715                            .map(|e| e.side != OrderSide::Sell || timestamp_ms.saturating_sub(e.timestamp_ms) >= 1000)
716                            .unwrap_or(true);
717                        if should_emit {
718                            let mut record = LogRecord::new(
719                                LogLevel::Info,
720                                LogDomain::Strategy,
721                                "signal.emit",
722                                format!(
723                                    "side=SELL price={}",
724                                    price
725                                        .map(|v| format!("{:.4}", v))
726                                        .unwrap_or_else(|| "-".to_string())
727                                ),
728                            );
729                            record.symbol = Some(symbol.clone());
730                            record.strategy_tag = Some(source_tag.clone());
731                            self.push_log_record(record);
732                        }
733                        self.strategy_last_event_by_tag.insert(
734                            source_tag.clone(),
735                            StrategyLastEvent {
736                                side: OrderSide::Sell,
737                                price,
738                                timestamp_ms,
739                                is_filled: false,
740                            },
741                        );
742                    }
743                    Signal::Hold => {}
744                }
745            }
746            AppEvent::StrategyState { fast_sma, slow_sma } => {
747                self.fast_sma = fast_sma;
748                self.slow_sma = slow_sma;
749            }
750            AppEvent::OrderUpdate(ref update) => {
751                rebuild_projection = true;
752                match update {
753                    OrderUpdate::Filled {
754                        intent_id,
755                        client_order_id,
756                        side,
757                        fills,
758                        avg_price,
759                    } => {
760                        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
761                        let source_tag = parse_source_tag_from_client_order_id(client_order_id)
762                            .map(|s| s.to_ascii_lowercase());
763                        if let Some(submit_ms) = self.network_pending_submit_ms_by_intent.remove(intent_id)
764                        {
765                            Self::push_latency_sample(
766                                &mut self.network_fill_latencies_ms,
767                                now_ms.saturating_sub(submit_ms),
768                            );
769                        } else if let Some(signal_ms) = source_tag
770                            .as_deref()
771                            .and_then(|tag| self.strategy_last_event_by_tag.get(tag))
772                            .map(|e| e.timestamp_ms)
773                        {
774                            // Fallback for immediate-fill paths where Submitted is not emitted.
775                            Self::push_latency_sample(
776                                &mut self.network_fill_latencies_ms,
777                                now_ms.saturating_sub(signal_ms),
778                            );
779                        }
780                        self.network_last_fill_ms = Some(now_ms);
781                        if let Some(source_tag) = source_tag {
782                            self.strategy_last_event_by_tag.insert(
783                                source_tag,
784                                StrategyLastEvent {
785                                    side: *side,
786                                    price: Some(*avg_price),
787                                    timestamp_ms: now_ms,
788                                    is_filled: true,
789                                },
790                            );
791                        }
792                        if let Some(summary) = format_last_applied_fee(&self.symbol, fills) {
793                            self.last_applied_fee = summary;
794                        }
795                        self.position.apply_fill(*side, fills);
796                        self.refresh_equity_usdt();
797                        let candle_index = if self.current_candle.is_some() {
798                            self.candles.len()
799                        } else {
800                            self.candles.len().saturating_sub(1)
801                        };
802                        self.fill_markers.push(FillMarker {
803                            candle_index,
804                            price: *avg_price,
805                            side: *side,
806                        });
807                        if self.fill_markers.len() > MAX_FILL_MARKERS {
808                            self.fill_markers.remove(0);
809                        }
810                        let mut record = LogRecord::new(
811                            LogLevel::Info,
812                            LogDomain::Order,
813                            "fill.received",
814                            format!(
815                                "side={} client_order_id={} intent_id={} avg_price={:.2}",
816                                side, client_order_id, intent_id, avg_price
817                            ),
818                        );
819                        record.symbol = Some(self.symbol.clone());
820                        record.strategy_tag =
821                            parse_source_tag_from_client_order_id(client_order_id).map(|s| s.to_ascii_lowercase());
822                        self.push_log_record(record);
823                    }
824                    OrderUpdate::Submitted {
825                        intent_id,
826                        client_order_id,
827                        server_order_id,
828                    } => {
829                        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
830                        self.network_pending_submit_ms_by_intent
831                            .insert(intent_id.clone(), now_ms);
832                        self.refresh_equity_usdt();
833                        let mut record = LogRecord::new(
834                            LogLevel::Info,
835                            LogDomain::Order,
836                            "submit.accepted",
837                            format!(
838                                "client_order_id={} server_order_id={} intent_id={}",
839                                client_order_id, server_order_id, intent_id
840                            ),
841                        );
842                        record.symbol = Some(self.symbol.clone());
843                        record.strategy_tag =
844                            parse_source_tag_from_client_order_id(client_order_id).map(|s| s.to_ascii_lowercase());
845                        self.push_log_record(record);
846                    }
847                    OrderUpdate::Rejected {
848                        intent_id,
849                        client_order_id,
850                        reason_code,
851                        reason,
852                    } => {
853                        let level = if reason_code == "risk.qty_too_small" {
854                            LogLevel::Warn
855                        } else {
856                            LogLevel::Error
857                        };
858                        let mut record = LogRecord::new(
859                            level,
860                            LogDomain::Order,
861                            "reject.received",
862                            format!(
863                                "client_order_id={} intent_id={} reason_code={} reason={}",
864                                client_order_id, intent_id, reason_code, reason
865                            ),
866                        );
867                        record.symbol = Some(self.symbol.clone());
868                        record.strategy_tag =
869                            parse_source_tag_from_client_order_id(client_order_id).map(|s| s.to_ascii_lowercase());
870                        self.push_log_record(record);
871                    }
872                }
873                self.last_order = Some(update.clone());
874            }
875            AppEvent::WsStatus(ref status) => match status {
876                WsConnectionStatus::Connected => {
877                    self.ws_connected = true;
878                }
879                WsConnectionStatus::Disconnected => {
880                    self.ws_connected = false;
881                    let now_ms = chrono::Utc::now().timestamp_millis() as u64;
882                    Self::push_network_event_sample(&mut self.network_disconnect_timestamps_ms, now_ms);
883                    self.push_log("[WARN] WebSocket Disconnected".to_string());
884                }
885                WsConnectionStatus::Reconnecting { attempt, delay_ms } => {
886                    self.ws_connected = false;
887                    self.network_reconnect_count += 1;
888                    let now_ms = chrono::Utc::now().timestamp_millis() as u64;
889                    Self::push_network_event_sample(&mut self.network_reconnect_timestamps_ms, now_ms);
890                    self.push_log(format!(
891                        "[WARN] Reconnecting (attempt {}, wait {}ms)",
892                        attempt, delay_ms
893                    ));
894                }
895            },
896            AppEvent::HistoricalCandles {
897                candles,
898                interval_ms,
899                interval,
900            } => {
901                rebuild_projection = true;
902                self.candles = candles;
903                if self.candles.len() > self.price_history_len {
904                    let excess = self.candles.len() - self.price_history_len;
905                    self.candles.drain(..excess);
906                }
907                self.candle_interval_ms = interval_ms;
908                self.timeframe = interval;
909                self.current_candle = None;
910                let fills = self.history_fills.clone();
911                self.rebuild_fill_markers_from_history(&fills);
912                self.push_log(format!(
913                    "Switched to {} ({} candles)",
914                    self.timeframe,
915                    self.candles.len()
916                ));
917            }
918            AppEvent::BalanceUpdate(balances) => {
919                rebuild_projection = true;
920                self.balances = balances;
921                self.refresh_equity_usdt();
922            }
923            AppEvent::OrderHistoryUpdate(snapshot) => {
924                rebuild_projection = true;
925                let mut open = Vec::new();
926                let mut filled = Vec::new();
927
928                for row in snapshot.rows {
929                    let status = row.split_whitespace().nth(1).unwrap_or_default();
930                    if status == "FILLED" {
931                        filled.push(row);
932                    } else {
933                        open.push(row);
934                    }
935                }
936
937                if open.len() > MAX_LOG_MESSAGES {
938                    let excess = open.len() - MAX_LOG_MESSAGES;
939                    open.drain(..excess);
940                }
941                if filled.len() > MAX_LOG_MESSAGES {
942                    let excess = filled.len() - MAX_LOG_MESSAGES;
943                    filled.drain(..excess);
944                }
945
946                self.open_order_history = open;
947                self.filled_order_history = filled;
948                if snapshot.trade_data_complete {
949                    let stats_looks_reset = snapshot.stats.trade_count == 0
950                        && (self.history_trade_count > 0 || !self.history_fills.is_empty());
951                    if stats_looks_reset {
952                        if !self.trade_stats_reset_warned {
953                            self.push_log(
954                                "[WARN] Ignored transient trade stats reset from order-history sync"
955                                    .to_string(),
956                            );
957                            self.trade_stats_reset_warned = true;
958                        }
959                    } else {
960                        self.trade_stats_reset_warned = false;
961                        self.history_trade_count = snapshot.stats.trade_count;
962                        self.history_win_count = snapshot.stats.win_count;
963                        self.history_lose_count = snapshot.stats.lose_count;
964                        self.history_realized_pnl = snapshot.stats.realized_pnl;
965                        // Keep position panel aligned with exchange history state
966                        // so Qty/Entry/UnrPL reflect actual holdings, not only session fills.
967                        if snapshot.open_qty > f64::EPSILON {
968                            self.position.side = Some(OrderSide::Buy);
969                            self.position.qty = snapshot.open_qty;
970                            self.position.entry_price = snapshot.open_entry_price;
971                            if let Some(px) = self.last_price() {
972                                self.position.unrealized_pnl =
973                                    (px - snapshot.open_entry_price) * snapshot.open_qty;
974                            }
975                        } else {
976                            self.position.side = None;
977                            self.position.qty = 0.0;
978                            self.position.entry_price = 0.0;
979                            self.position.unrealized_pnl = 0.0;
980                        }
981                    }
982                    if !snapshot.fills.is_empty() || self.history_fills.is_empty() {
983                        self.history_fills = snapshot.fills.clone();
984                        self.rebuild_fill_markers_from_history(&snapshot.fills);
985                    }
986                    self.history_estimated_total_pnl_usdt = snapshot.estimated_total_pnl_usdt;
987                    self.recompute_initial_equity_from_history();
988                }
989                self.last_order_history_update_ms = Some(snapshot.fetched_at_ms);
990                self.last_order_history_event_ms = snapshot.latest_event_ms;
991                self.last_order_history_latency_ms = Some(snapshot.fetch_latency_ms);
992                Self::push_latency_sample(
993                    &mut self.network_order_sync_latencies_ms,
994                    snapshot.fetch_latency_ms,
995                );
996                self.refresh_history_rows();
997            }
998            AppEvent::StrategyStatsUpdate { strategy_stats } => {
999                rebuild_projection = true;
1000                self.strategy_stats = strategy_stats;
1001            }
1002            AppEvent::AssetPnlUpdate { by_symbol } => {
1003                rebuild_projection = true;
1004                self.asset_pnl_by_symbol = by_symbol;
1005            }
1006            AppEvent::RiskRateSnapshot {
1007                global,
1008                orders,
1009                account,
1010                market_data,
1011            } => {
1012                self.rate_budget_global = global;
1013                self.rate_budget_orders = orders;
1014                self.rate_budget_account = account;
1015                self.rate_budget_market_data = market_data;
1016            }
1017            AppEvent::TickDropped => {
1018                self.network_tick_drop_count = self.network_tick_drop_count.saturating_add(1);
1019                let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1020                Self::push_network_event_sample(&mut self.network_tick_drop_timestamps_ms, now_ms);
1021            }
1022            AppEvent::LogRecord(record) => {
1023                self.push_log_record(record);
1024            }
1025            AppEvent::LogMessage(msg) => {
1026                self.push_log(msg);
1027            }
1028            AppEvent::Error(msg) => {
1029                self.push_log(format!("[ERR] {}", msg));
1030            }
1031        }
1032        self.prune_network_event_windows(chrono::Utc::now().timestamp_millis() as u64);
1033        self.sync_projection_portfolio_summary();
1034        if rebuild_projection {
1035            self.rebuild_projection_preserve_focus(prev_focus);
1036        } else {
1037            self.ensure_projection_focus_defaults();
1038        }
1039    }
1040}
1041
1042pub fn render(frame: &mut Frame, state: &AppState) {
1043    let view = state.view_state();
1044    if view.is_grid_open {
1045        render_grid_popup(frame, state);
1046        if view.is_strategy_editor_open {
1047            render_strategy_editor_popup(frame, state);
1048        }
1049        return;
1050    }
1051
1052    let outer = Layout::default()
1053        .direction(Direction::Vertical)
1054        .constraints([
1055            Constraint::Length(1), // status bar
1056            Constraint::Min(8),    // main area (chart + position)
1057            Constraint::Length(5), // order log
1058            Constraint::Length(6), // order history
1059            Constraint::Length(8), // system log
1060            Constraint::Length(1), // keybinds
1061        ])
1062        .split(frame.area());
1063
1064    // Status bar
1065    frame.render_widget(
1066        StatusBar {
1067            symbol: &state.symbol,
1068            strategy_label: &state.strategy_label,
1069            ws_connected: state.ws_connected,
1070            paused: state.paused,
1071            timeframe: &state.timeframe,
1072            last_price_update_ms: state.last_price_update_ms,
1073            last_price_latency_ms: state.last_price_latency_ms,
1074            last_order_history_update_ms: state.last_order_history_update_ms,
1075            last_order_history_latency_ms: state.last_order_history_latency_ms,
1076        },
1077        outer[0],
1078    );
1079
1080    // Main area: chart + position panel
1081    let main_area = Layout::default()
1082        .direction(Direction::Horizontal)
1083        .constraints([Constraint::Min(40), Constraint::Length(24)])
1084        .split(outer[1]);
1085    let selected_strategy_stats =
1086        strategy_stats_for_item(&state.strategy_stats, &state.strategy_label, &state.symbol)
1087        .cloned()
1088        .unwrap_or_default();
1089
1090    // Price chart (candles + in-progress candle)
1091    let current_price = state.last_price();
1092    frame.render_widget(
1093        PriceChart::new(&state.candles, &state.symbol)
1094            .current_candle(state.current_candle.as_ref())
1095            .fill_markers(&state.fill_markers)
1096            .fast_sma(state.fast_sma)
1097            .slow_sma(state.slow_sma),
1098        main_area[0],
1099    );
1100
1101    // Right panels: Position (symbol scope) + Strategy metrics (strategy scope).
1102    let right_panels = Layout::default()
1103        .direction(Direction::Vertical)
1104        .constraints([Constraint::Min(9), Constraint::Length(8)])
1105        .split(main_area[1]);
1106    frame.render_widget(
1107        PositionPanel::new(
1108            &state.position,
1109            current_price,
1110            &state.last_applied_fee,
1111        ),
1112        right_panels[0],
1113    );
1114    frame.render_widget(
1115        StrategyMetricsPanel::new(
1116            &state.strategy_label,
1117            selected_strategy_stats.trade_count,
1118            selected_strategy_stats.win_count,
1119            selected_strategy_stats.lose_count,
1120            selected_strategy_stats.realized_pnl,
1121        ),
1122        right_panels[1],
1123    );
1124
1125    // Order log
1126    frame.render_widget(
1127        OrderLogPanel::new(
1128            &state.last_signal,
1129            &state.last_order,
1130            state.fast_sma,
1131            state.slow_sma,
1132            selected_strategy_stats.trade_count,
1133            selected_strategy_stats.win_count,
1134            selected_strategy_stats.lose_count,
1135            selected_strategy_stats.realized_pnl,
1136        ),
1137        outer[2],
1138    );
1139
1140    // Order history panel
1141    frame.render_widget(
1142        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1143        outer[3],
1144    );
1145
1146    // System log panel
1147    frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
1148
1149    // Keybind bar
1150    frame.render_widget(KeybindBar, outer[5]);
1151
1152    if view.is_symbol_selector_open {
1153        render_selector_popup(
1154            frame,
1155            " Select Symbol ",
1156            &state.symbol_items,
1157            view.selected_symbol_selector_index,
1158            None,
1159            None,
1160            None,
1161        );
1162    } else if view.is_strategy_selector_open {
1163        let selected_strategy_symbol = state
1164            .strategy_item_symbols
1165            .get(view.selected_strategy_selector_index)
1166            .map(String::as_str)
1167            .unwrap_or(state.symbol.as_str());
1168        render_selector_popup(
1169            frame,
1170            " Select Strategy ",
1171            &state.strategy_items,
1172            view.selected_strategy_selector_index,
1173            Some(&state.strategy_stats),
1174            Some(OrderHistoryStats {
1175                trade_count: state.history_trade_count,
1176                win_count: state.history_win_count,
1177                lose_count: state.history_lose_count,
1178                realized_pnl: state.history_realized_pnl,
1179            }),
1180            Some(selected_strategy_symbol),
1181        );
1182    } else if view.is_account_popup_open {
1183        render_account_popup(frame, &state.balances);
1184    } else if view.is_history_popup_open {
1185        render_history_popup(frame, &state.history_rows, state.history_bucket);
1186    } else if view.is_focus_popup_open {
1187        render_focus_popup(frame, state);
1188    } else if view.is_strategy_editor_open {
1189        render_strategy_editor_popup(frame, state);
1190    }
1191}
1192
1193fn render_focus_popup(frame: &mut Frame, state: &AppState) {
1194    let area = frame.area();
1195    let popup = Rect {
1196        x: area.x + 1,
1197        y: area.y + 1,
1198        width: area.width.saturating_sub(2).max(70),
1199        height: area.height.saturating_sub(2).max(22),
1200    };
1201    frame.render_widget(Clear, popup);
1202    let block = Block::default()
1203        .title(" Focus View (Drill-down) ")
1204        .borders(Borders::ALL)
1205        .border_style(Style::default().fg(Color::Green));
1206    let inner = block.inner(popup);
1207    frame.render_widget(block, popup);
1208
1209    let rows = Layout::default()
1210        .direction(Direction::Vertical)
1211        .constraints([
1212            Constraint::Length(2),
1213            Constraint::Min(8),
1214            Constraint::Length(7),
1215        ])
1216        .split(inner);
1217
1218    let focus_symbol = state.focus_symbol().unwrap_or(&state.symbol);
1219    let focus_strategy = state.focus_strategy_id().unwrap_or(&state.strategy_label);
1220    let focus_strategy_stats = strategy_stats_for_item(
1221        &state.strategy_stats,
1222        focus_strategy,
1223        focus_symbol,
1224    )
1225        .cloned()
1226        .unwrap_or_default();
1227    frame.render_widget(
1228        Paragraph::new(vec![
1229            Line::from(vec![
1230                Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
1231                Span::styled(
1232                    focus_symbol,
1233                    Style::default()
1234                        .fg(Color::Cyan)
1235                        .add_modifier(Modifier::BOLD),
1236                ),
1237                Span::styled("  Strategy: ", Style::default().fg(Color::DarkGray)),
1238                Span::styled(
1239                    focus_strategy,
1240                    Style::default()
1241                        .fg(Color::Magenta)
1242                        .add_modifier(Modifier::BOLD),
1243                ),
1244            ]),
1245            Line::from(Span::styled(
1246                "Reuse legacy chart/position/history widgets. Press [F]/[Esc] to close.",
1247                Style::default().fg(Color::DarkGray),
1248            )),
1249        ]),
1250        rows[0],
1251    );
1252
1253    let main_cols = Layout::default()
1254        .direction(Direction::Horizontal)
1255        .constraints([Constraint::Min(48), Constraint::Length(28)])
1256        .split(rows[1]);
1257
1258    frame.render_widget(
1259        PriceChart::new(&state.candles, focus_symbol)
1260            .current_candle(state.current_candle.as_ref())
1261            .fill_markers(&state.fill_markers)
1262            .fast_sma(state.fast_sma)
1263            .slow_sma(state.slow_sma),
1264        main_cols[0],
1265    );
1266    let focus_right = Layout::default()
1267        .direction(Direction::Vertical)
1268        .constraints([Constraint::Min(8), Constraint::Length(8)])
1269        .split(main_cols[1]);
1270    frame.render_widget(
1271        PositionPanel::new(
1272            &state.position,
1273            state.last_price(),
1274            &state.last_applied_fee,
1275        ),
1276        focus_right[0],
1277    );
1278    frame.render_widget(
1279        StrategyMetricsPanel::new(
1280            focus_strategy,
1281            focus_strategy_stats.trade_count,
1282            focus_strategy_stats.win_count,
1283            focus_strategy_stats.lose_count,
1284            focus_strategy_stats.realized_pnl,
1285        ),
1286        focus_right[1],
1287    );
1288
1289    frame.render_widget(
1290        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1291        rows[2],
1292    );
1293}
1294
1295fn render_grid_popup(frame: &mut Frame, state: &AppState) {
1296    let view = state.view_state();
1297    let area = frame.area();
1298    let popup = area;
1299    frame.render_widget(Clear, popup);
1300    let block = Block::default()
1301        .title(" Portfolio Grid ")
1302        .borders(Borders::ALL)
1303        .border_style(Style::default().fg(Color::Cyan));
1304    let inner = block.inner(popup);
1305    frame.render_widget(block, popup);
1306
1307    let root = Layout::default()
1308        .direction(Direction::Vertical)
1309        .constraints([Constraint::Length(2), Constraint::Min(1)])
1310        .split(inner);
1311    let tab_area = root[0];
1312    let body_area = root[1];
1313
1314    let tab_span = |tab: GridTab, key: &str, label: &str| -> Span<'_> {
1315        let selected = view.selected_grid_tab == tab;
1316        Span::styled(
1317            format!("[{} {}]", key, label),
1318            if selected {
1319                Style::default()
1320                    .fg(Color::Yellow)
1321                    .add_modifier(Modifier::BOLD)
1322            } else {
1323                Style::default().fg(Color::DarkGray)
1324            },
1325        )
1326    };
1327    frame.render_widget(
1328        Paragraph::new(Line::from(vec![
1329            tab_span(GridTab::Assets, "1", "Assets"),
1330            Span::raw(" "),
1331            tab_span(GridTab::Strategies, "2", "Strategies"),
1332            Span::raw(" "),
1333            tab_span(GridTab::Risk, "3", "Risk"),
1334            Span::raw(" "),
1335            tab_span(GridTab::Network, "4", "Network"),
1336            Span::raw(" "),
1337            tab_span(GridTab::SystemLog, "5", "SystemLog"),
1338        ])),
1339        tab_area,
1340    );
1341
1342    let global_pressure =
1343        state.rate_budget_global.used as f64 / (state.rate_budget_global.limit.max(1) as f64);
1344    let orders_pressure =
1345        state.rate_budget_orders.used as f64 / (state.rate_budget_orders.limit.max(1) as f64);
1346    let account_pressure =
1347        state.rate_budget_account.used as f64 / (state.rate_budget_account.limit.max(1) as f64);
1348    let market_pressure = state.rate_budget_market_data.used as f64
1349        / (state.rate_budget_market_data.limit.max(1) as f64);
1350    let max_pressure = global_pressure
1351        .max(orders_pressure)
1352        .max(account_pressure)
1353        .max(market_pressure);
1354    let (risk_label, risk_color) = if max_pressure >= 0.90 {
1355        ("CRIT", Color::Red)
1356    } else if max_pressure >= 0.70 {
1357        ("WARN", Color::Yellow)
1358    } else {
1359        ("OK", Color::Green)
1360    };
1361
1362    if view.selected_grid_tab == GridTab::Assets {
1363        let spot_assets: Vec<&AssetEntry> = state
1364            .assets_view()
1365            .iter()
1366            .filter(|a| !a.is_futures)
1367            .collect();
1368        let fut_assets: Vec<&AssetEntry> = state
1369            .assets_view()
1370            .iter()
1371            .filter(|a| a.is_futures)
1372            .collect();
1373        let spot_total_rlz: f64 = spot_assets.iter().map(|a| a.realized_pnl_usdt).sum();
1374        let spot_total_unrlz: f64 = spot_assets.iter().map(|a| a.unrealized_pnl_usdt).sum();
1375        let fut_total_rlz: f64 = fut_assets.iter().map(|a| a.realized_pnl_usdt).sum();
1376        let fut_total_unrlz: f64 = fut_assets.iter().map(|a| a.unrealized_pnl_usdt).sum();
1377        let total_rlz = spot_total_rlz + fut_total_rlz;
1378        let total_unrlz = spot_total_unrlz + fut_total_unrlz;
1379        let total_pnl = total_rlz + total_unrlz;
1380        let panel_chunks = Layout::default()
1381            .direction(Direction::Vertical)
1382            .constraints([
1383                Constraint::Percentage(46),
1384                Constraint::Percentage(46),
1385                Constraint::Length(3),
1386                Constraint::Length(1),
1387            ])
1388            .split(body_area);
1389
1390        let spot_header = Row::new(vec![
1391            Cell::from("Asset"),
1392            Cell::from("Qty"),
1393            Cell::from("Price"),
1394            Cell::from("RlzPnL"),
1395            Cell::from("UnrPnL"),
1396        ])
1397        .style(Style::default().fg(Color::DarkGray));
1398        let mut spot_rows: Vec<Row> = spot_assets
1399            .iter()
1400            .map(|a| {
1401                Row::new(vec![
1402                    Cell::from(a.symbol.clone()),
1403                    Cell::from(format!("{:.5}", a.position_qty)),
1404                    Cell::from(
1405                        a.last_price
1406                            .map(|v| format!("{:.2}", v))
1407                            .unwrap_or_else(|| "---".to_string()),
1408                    ),
1409                    Cell::from(format!("{:+.4}", a.realized_pnl_usdt)),
1410                    Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
1411                ])
1412            })
1413            .collect();
1414        if spot_rows.is_empty() {
1415            spot_rows.push(
1416                Row::new(vec![
1417                    Cell::from("(no spot assets)"),
1418                    Cell::from("-"),
1419                    Cell::from("-"),
1420                    Cell::from("-"),
1421                    Cell::from("-"),
1422                ])
1423                .style(Style::default().fg(Color::DarkGray)),
1424            );
1425        }
1426        frame.render_widget(
1427            Table::new(
1428                spot_rows,
1429                [
1430                    Constraint::Length(16),
1431                    Constraint::Length(12),
1432                    Constraint::Length(10),
1433                    Constraint::Length(10),
1434                    Constraint::Length(10),
1435                ],
1436            )
1437            .header(spot_header)
1438            .column_spacing(1)
1439            .block(
1440                Block::default()
1441                    .title(format!(
1442                        " Spot Assets | Total {} | PnL {:+.4} (R {:+.4} / U {:+.4}) ",
1443                        spot_assets.len(),
1444                        spot_total_rlz + spot_total_unrlz,
1445                        spot_total_rlz,
1446                        spot_total_unrlz
1447                    ))
1448                    .borders(Borders::ALL)
1449                    .border_style(Style::default().fg(Color::DarkGray)),
1450            ),
1451            panel_chunks[0],
1452        );
1453
1454        let fut_header = Row::new(vec![
1455            Cell::from("Symbol"),
1456            Cell::from("Side"),
1457            Cell::from("PosQty"),
1458            Cell::from("Entry"),
1459            Cell::from("RlzPnL"),
1460            Cell::from("UnrPnL"),
1461        ])
1462        .style(Style::default().fg(Color::DarkGray));
1463        let mut fut_rows: Vec<Row> = fut_assets
1464            .iter()
1465            .map(|a| {
1466                Row::new(vec![
1467                    Cell::from(a.symbol.clone()),
1468                    Cell::from(a.side.clone().unwrap_or_else(|| "-".to_string())),
1469                    Cell::from(format!("{:.5}", a.position_qty)),
1470                    Cell::from(
1471                        a.entry_price
1472                            .map(|v| format!("{:.2}", v))
1473                            .unwrap_or_else(|| "---".to_string()),
1474                    ),
1475                    Cell::from(format!("{:+.4}", a.realized_pnl_usdt)),
1476                    Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
1477                ])
1478            })
1479            .collect();
1480        if fut_rows.is_empty() {
1481            fut_rows.push(
1482                Row::new(vec![
1483                    Cell::from("(no futures positions)"),
1484                    Cell::from("-"),
1485                    Cell::from("-"),
1486                    Cell::from("-"),
1487                    Cell::from("-"),
1488                    Cell::from("-"),
1489                ])
1490                .style(Style::default().fg(Color::DarkGray)),
1491            );
1492        }
1493        frame.render_widget(
1494            Table::new(
1495                fut_rows,
1496                [
1497                    Constraint::Length(18),
1498                    Constraint::Length(8),
1499                    Constraint::Length(10),
1500                    Constraint::Length(10),
1501                    Constraint::Length(10),
1502                    Constraint::Length(10),
1503                ],
1504            )
1505            .header(fut_header)
1506            .column_spacing(1)
1507            .block(
1508                Block::default()
1509                    .title(format!(
1510                        " Futures Positions | Total {} | PnL {:+.4} (R {:+.4} / U {:+.4}) ",
1511                        fut_assets.len(),
1512                        fut_total_rlz + fut_total_unrlz,
1513                        fut_total_rlz,
1514                        fut_total_unrlz
1515                    ))
1516                    .borders(Borders::ALL)
1517                    .border_style(Style::default().fg(Color::DarkGray)),
1518            ),
1519            panel_chunks[1],
1520        );
1521        let total_color = if total_pnl > 0.0 {
1522            Color::Green
1523        } else if total_pnl < 0.0 {
1524            Color::Red
1525        } else {
1526            Color::DarkGray
1527        };
1528        frame.render_widget(
1529            Paragraph::new(Line::from(vec![
1530                Span::styled(" Total PnL: ", Style::default().fg(Color::DarkGray)),
1531                Span::styled(
1532                    format!("{:+.4}", total_pnl),
1533                    Style::default().fg(total_color).add_modifier(Modifier::BOLD),
1534                ),
1535                Span::styled(
1536                    format!("   Realized: {:+.4}   Unrealized: {:+.4}", total_rlz, total_unrlz),
1537                    Style::default().fg(Color::DarkGray),
1538                ),
1539            ]))
1540            .block(
1541                Block::default()
1542                    .borders(Borders::ALL)
1543                    .border_style(Style::default().fg(Color::DarkGray)),
1544            ),
1545            panel_chunks[2],
1546        );
1547        frame.render_widget(Paragraph::new("[1/2/3/4/5] tab  [G/Esc] close"), panel_chunks[3]);
1548        return;
1549    }
1550
1551    if view.selected_grid_tab == GridTab::Risk {
1552        let chunks = Layout::default()
1553            .direction(Direction::Vertical)
1554            .constraints([
1555                Constraint::Length(2),
1556                Constraint::Length(4),
1557                Constraint::Min(3),
1558                Constraint::Length(1),
1559            ])
1560            .split(body_area);
1561        frame.render_widget(
1562            Paragraph::new(Line::from(vec![
1563                Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
1564                Span::styled(
1565                    risk_label,
1566                    Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
1567                ),
1568                Span::styled(
1569                    "  (70%=WARN, 90%=CRIT)",
1570                    Style::default().fg(Color::DarkGray),
1571                ),
1572            ])),
1573            chunks[0],
1574        );
1575        let risk_rows = vec![
1576            Row::new(vec![
1577                Cell::from("GLOBAL"),
1578                Cell::from(format!(
1579                    "{}/{}",
1580                    state.rate_budget_global.used, state.rate_budget_global.limit
1581                )),
1582                Cell::from(format!("{}ms", state.rate_budget_global.reset_in_ms)),
1583            ]),
1584            Row::new(vec![
1585                Cell::from("ORDERS"),
1586                Cell::from(format!(
1587                    "{}/{}",
1588                    state.rate_budget_orders.used, state.rate_budget_orders.limit
1589                )),
1590                Cell::from(format!("{}ms", state.rate_budget_orders.reset_in_ms)),
1591            ]),
1592            Row::new(vec![
1593                Cell::from("ACCOUNT"),
1594                Cell::from(format!(
1595                    "{}/{}",
1596                    state.rate_budget_account.used, state.rate_budget_account.limit
1597                )),
1598                Cell::from(format!("{}ms", state.rate_budget_account.reset_in_ms)),
1599            ]),
1600            Row::new(vec![
1601                Cell::from("MARKET"),
1602                Cell::from(format!(
1603                    "{}/{}",
1604                    state.rate_budget_market_data.used, state.rate_budget_market_data.limit
1605                )),
1606                Cell::from(format!("{}ms", state.rate_budget_market_data.reset_in_ms)),
1607            ]),
1608        ];
1609        frame.render_widget(
1610            Table::new(
1611                risk_rows,
1612                [
1613                    Constraint::Length(10),
1614                    Constraint::Length(16),
1615                    Constraint::Length(12),
1616                ],
1617            )
1618            .header(Row::new(vec![
1619                Cell::from("Group"),
1620                Cell::from("Used/Limit"),
1621                Cell::from("Reset In"),
1622            ]))
1623            .column_spacing(1)
1624            .block(
1625                Block::default()
1626                    .title(" Risk Budgets ")
1627                    .borders(Borders::ALL)
1628                    .border_style(Style::default().fg(Color::DarkGray)),
1629            ),
1630            chunks[1],
1631        );
1632        let recent_rejections: Vec<&String> = state
1633            .log_messages
1634            .iter()
1635            .filter(|m| m.contains("order.reject.received"))
1636            .rev()
1637            .take(20)
1638            .collect();
1639        let mut lines = vec![Line::from(Span::styled(
1640            "Recent Rejections",
1641            Style::default()
1642                .fg(Color::Cyan)
1643                .add_modifier(Modifier::BOLD),
1644        ))];
1645        for msg in recent_rejections.into_iter().rev() {
1646            lines.push(Line::from(Span::styled(
1647                msg.as_str(),
1648                Style::default().fg(Color::Red),
1649            )));
1650        }
1651        if lines.len() == 1 {
1652            lines.push(Line::from(Span::styled(
1653                "(no rejections yet)",
1654                Style::default().fg(Color::DarkGray),
1655            )));
1656        }
1657        frame.render_widget(
1658            Paragraph::new(lines).block(
1659                Block::default()
1660                    .borders(Borders::ALL)
1661                    .border_style(Style::default().fg(Color::DarkGray)),
1662            ),
1663            chunks[2],
1664        );
1665        frame.render_widget(Paragraph::new("[1/2/3/4/5] tab  [G/Esc] close"), chunks[3]);
1666        return;
1667    }
1668
1669    if view.selected_grid_tab == GridTab::Network {
1670        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1671        let tick_in_1s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 1_000);
1672        let tick_in_10s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 10_000);
1673        let tick_in_60s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 60_000);
1674        let tick_drop_1s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 1_000);
1675        let tick_drop_10s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 10_000);
1676        let tick_drop_60s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 60_000);
1677        let reconnect_60s = count_since(&state.network_reconnect_timestamps_ms, now_ms, 60_000);
1678        let disconnect_60s = count_since(&state.network_disconnect_timestamps_ms, now_ms, 60_000);
1679
1680        let tick_in_rate_1s = rate_per_sec(tick_in_1s, 1.0);
1681        let tick_drop_rate_1s = rate_per_sec(tick_drop_1s, 1.0);
1682        let tick_drop_rate_10s = rate_per_sec(tick_drop_10s, 10.0);
1683        let tick_drop_rate_60s = rate_per_sec(tick_drop_60s, 60.0);
1684        let tick_drop_ratio_10s = ratio_pct(tick_drop_10s, tick_in_10s.saturating_add(tick_drop_10s));
1685        let tick_drop_ratio_60s = ratio_pct(tick_drop_60s, tick_in_60s.saturating_add(tick_drop_60s));
1686        let reconnect_rate_60s = reconnect_60s as f64;
1687        let disconnect_rate_60s = disconnect_60s as f64;
1688        let heartbeat_gap_ms = state.last_price_update_ms.map(|ts| now_ms.saturating_sub(ts));
1689        let tick_p95_ms = percentile(&state.network_tick_latencies_ms, 95);
1690        let health = classify_health(
1691            state.ws_connected,
1692            tick_drop_ratio_10s,
1693            reconnect_rate_60s,
1694            tick_p95_ms,
1695            heartbeat_gap_ms,
1696        );
1697        let (health_label, health_color) = match health {
1698            NetworkHealth::Ok => ("OK", Color::Green),
1699            NetworkHealth::Warn => ("WARN", Color::Yellow),
1700            NetworkHealth::Crit => ("CRIT", Color::Red),
1701        };
1702
1703        let chunks = Layout::default()
1704            .direction(Direction::Vertical)
1705            .constraints([
1706                Constraint::Length(2),
1707                Constraint::Min(6),
1708                Constraint::Length(6),
1709                Constraint::Length(1),
1710            ])
1711            .split(body_area);
1712        frame.render_widget(
1713            Paragraph::new(Line::from(vec![
1714                Span::styled("Health: ", Style::default().fg(Color::DarkGray)),
1715                Span::styled(
1716                    health_label,
1717                    Style::default()
1718                        .fg(health_color)
1719                        .add_modifier(Modifier::BOLD),
1720                ),
1721                Span::styled("  WS: ", Style::default().fg(Color::DarkGray)),
1722                Span::styled(
1723                    if state.ws_connected {
1724                        "CONNECTED"
1725                    } else {
1726                        "DISCONNECTED"
1727                    },
1728                    Style::default().fg(if state.ws_connected {
1729                        Color::Green
1730                    } else {
1731                        Color::Red
1732                    }),
1733                ),
1734                Span::styled(
1735                    format!(
1736                        "  in1s={:.1}/s  drop10s={:.2}/s  ratio10s={:.2}%  reconn60s={:.0}/min",
1737                        tick_in_rate_1s, tick_drop_rate_10s, tick_drop_ratio_10s, reconnect_rate_60s
1738                    ),
1739                    Style::default().fg(Color::DarkGray),
1740                ),
1741            ])),
1742            chunks[0],
1743        );
1744
1745        let tick_stats = latency_stats(&state.network_tick_latencies_ms);
1746        let fill_stats = latency_stats(&state.network_fill_latencies_ms);
1747        let sync_stats = latency_stats(&state.network_order_sync_latencies_ms);
1748        let last_fill_age = state
1749            .network_last_fill_ms
1750            .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
1751            .unwrap_or_else(|| "-".to_string());
1752        let rows = vec![
1753            Row::new(vec![
1754                Cell::from("Tick Latency"),
1755                Cell::from(tick_stats.0),
1756                Cell::from(tick_stats.1),
1757                Cell::from(tick_stats.2),
1758                Cell::from(
1759                    state
1760                        .last_price_latency_ms
1761                        .map(|v| format!("{}ms", v))
1762                        .unwrap_or_else(|| "-".to_string()),
1763                ),
1764            ]),
1765            Row::new(vec![
1766                Cell::from("Fill Latency"),
1767                Cell::from(fill_stats.0),
1768                Cell::from(fill_stats.1),
1769                Cell::from(fill_stats.2),
1770                Cell::from(last_fill_age),
1771            ]),
1772            Row::new(vec![
1773                Cell::from("Order Sync"),
1774                Cell::from(sync_stats.0),
1775                Cell::from(sync_stats.1),
1776                Cell::from(sync_stats.2),
1777                Cell::from(
1778                    state
1779                        .last_order_history_latency_ms
1780                        .map(|v| format!("{}ms", v))
1781                        .unwrap_or_else(|| "-".to_string()),
1782                ),
1783            ]),
1784        ];
1785        frame.render_widget(
1786            Table::new(
1787                rows,
1788                [
1789                    Constraint::Length(14),
1790                    Constraint::Length(12),
1791                    Constraint::Length(12),
1792                    Constraint::Length(12),
1793                    Constraint::Length(14),
1794                ],
1795            )
1796            .header(Row::new(vec![
1797                Cell::from("Metric"),
1798                Cell::from("p50"),
1799                Cell::from("p95"),
1800                Cell::from("p99"),
1801                Cell::from("last/age"),
1802            ]))
1803            .column_spacing(1)
1804            .block(
1805                Block::default()
1806                    .title(" Network Metrics ")
1807                    .borders(Borders::ALL)
1808                    .border_style(Style::default().fg(Color::DarkGray)),
1809            ),
1810            chunks[1],
1811        );
1812
1813        let summary_rows = vec![
1814            Row::new(vec![
1815                Cell::from("tick_drop_rate_1s"),
1816                Cell::from(format!("{:.2}/s", tick_drop_rate_1s)),
1817                Cell::from("tick_drop_rate_60s"),
1818                Cell::from(format!("{:.2}/s", tick_drop_rate_60s)),
1819            ]),
1820            Row::new(vec![
1821                Cell::from("drop_ratio_60s"),
1822                Cell::from(format!("{:.2}%", tick_drop_ratio_60s)),
1823                Cell::from("disconnect_rate_60s"),
1824                Cell::from(format!("{:.0}/min", disconnect_rate_60s)),
1825            ]),
1826            Row::new(vec![
1827                Cell::from("last_tick_age"),
1828                Cell::from(
1829                    heartbeat_gap_ms
1830                        .map(format_age_ms)
1831                        .unwrap_or_else(|| "-".to_string()),
1832                ),
1833                Cell::from("last_order_update_age"),
1834                Cell::from(
1835                    state
1836                        .last_order_history_update_ms
1837                        .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
1838                        .unwrap_or_else(|| "-".to_string()),
1839                ),
1840            ]),
1841            Row::new(vec![
1842                Cell::from("tick_drop_total"),
1843                Cell::from(state.network_tick_drop_count.to_string()),
1844                Cell::from("reconnect_total"),
1845                Cell::from(state.network_reconnect_count.to_string()),
1846            ]),
1847        ];
1848        frame.render_widget(
1849            Table::new(
1850                summary_rows,
1851                [
1852                    Constraint::Length(20),
1853                    Constraint::Length(18),
1854                    Constraint::Length(20),
1855                    Constraint::Length(18),
1856                ],
1857            )
1858            .column_spacing(1)
1859            .block(
1860                Block::default()
1861                    .title(" Network Summary ")
1862                    .borders(Borders::ALL)
1863                    .border_style(Style::default().fg(Color::DarkGray)),
1864            ),
1865            chunks[2],
1866        );
1867        frame.render_widget(Paragraph::new("[1/2/3/4/5] tab  [G/Esc] close"), chunks[3]);
1868        return;
1869    }
1870
1871    if view.selected_grid_tab == GridTab::SystemLog {
1872        let chunks = Layout::default()
1873            .direction(Direction::Vertical)
1874            .constraints([Constraint::Min(6), Constraint::Length(1)])
1875            .split(body_area);
1876        let max_rows = chunks[0].height.saturating_sub(2) as usize;
1877        let mut log_rows: Vec<Row> = state
1878            .log_messages
1879            .iter()
1880            .rev()
1881            .take(max_rows.max(1))
1882            .rev()
1883            .map(|line| Row::new(vec![Cell::from(line.clone())]))
1884            .collect();
1885        if log_rows.is_empty() {
1886            log_rows.push(
1887                Row::new(vec![Cell::from("(no system logs yet)")])
1888                    .style(Style::default().fg(Color::DarkGray)),
1889            );
1890        }
1891        frame.render_widget(
1892            Table::new(log_rows, [Constraint::Min(1)])
1893                .header(Row::new(vec![Cell::from("Message")]).style(Style::default().fg(Color::DarkGray)))
1894                .column_spacing(1)
1895                .block(
1896                    Block::default()
1897                        .title(" System Log ")
1898                        .borders(Borders::ALL)
1899                        .border_style(Style::default().fg(Color::DarkGray)),
1900                ),
1901            chunks[0],
1902        );
1903        frame.render_widget(Paragraph::new("[1/2/3/4/5] tab  [G/Esc] close"), chunks[1]);
1904        return;
1905    }
1906
1907    let selected_symbol = state
1908        .symbol_items
1909        .get(view.selected_symbol_index)
1910        .map(String::as_str)
1911        .unwrap_or(state.symbol.as_str());
1912    let strategy_chunks = Layout::default()
1913        .direction(Direction::Vertical)
1914        .constraints([
1915            Constraint::Length(2),
1916            Constraint::Length(3),
1917            Constraint::Min(12),
1918            Constraint::Length(1),
1919        ])
1920        .split(body_area);
1921
1922    let mut on_indices: Vec<usize> = Vec::new();
1923    let mut off_indices: Vec<usize> = Vec::new();
1924    for idx in 0..state.strategy_items.len() {
1925        if state
1926            .strategy_item_active
1927            .get(idx)
1928            .copied()
1929            .unwrap_or(false)
1930        {
1931            on_indices.push(idx);
1932        } else {
1933            off_indices.push(idx);
1934        }
1935    }
1936    let on_weight = on_indices.len().max(1) as u32;
1937    let off_weight = off_indices.len().max(1) as u32;
1938
1939    frame.render_widget(
1940        Paragraph::new(Line::from(vec![
1941            Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
1942            Span::styled(
1943                risk_label,
1944                Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
1945            ),
1946            Span::styled("  GLOBAL ", Style::default().fg(Color::DarkGray)),
1947            Span::styled(
1948                format!(
1949                    "{}/{}",
1950                    state.rate_budget_global.used, state.rate_budget_global.limit
1951                ),
1952                Style::default().fg(if global_pressure >= 0.9 {
1953                    Color::Red
1954                } else if global_pressure >= 0.7 {
1955                    Color::Yellow
1956                } else {
1957                    Color::Cyan
1958                }),
1959            ),
1960            Span::styled("  ORD ", Style::default().fg(Color::DarkGray)),
1961            Span::styled(
1962                format!(
1963                    "{}/{}",
1964                    state.rate_budget_orders.used, state.rate_budget_orders.limit
1965                ),
1966                Style::default().fg(if orders_pressure >= 0.9 {
1967                    Color::Red
1968                } else if orders_pressure >= 0.7 {
1969                    Color::Yellow
1970                } else {
1971                    Color::Cyan
1972                }),
1973            ),
1974            Span::styled("  ACC ", Style::default().fg(Color::DarkGray)),
1975            Span::styled(
1976                format!(
1977                    "{}/{}",
1978                    state.rate_budget_account.used, state.rate_budget_account.limit
1979                ),
1980                Style::default().fg(if account_pressure >= 0.9 {
1981                    Color::Red
1982                } else if account_pressure >= 0.7 {
1983                    Color::Yellow
1984                } else {
1985                    Color::Cyan
1986                }),
1987            ),
1988            Span::styled("  MKT ", Style::default().fg(Color::DarkGray)),
1989            Span::styled(
1990                format!(
1991                    "{}/{}",
1992                    state.rate_budget_market_data.used, state.rate_budget_market_data.limit
1993                ),
1994                Style::default().fg(if market_pressure >= 0.9 {
1995                    Color::Red
1996                } else if market_pressure >= 0.7 {
1997                    Color::Yellow
1998                } else {
1999                    Color::Cyan
2000                }),
2001            ),
2002        ])),
2003        strategy_chunks[0],
2004    );
2005
2006    let strategy_area = strategy_chunks[2];
2007    let min_panel_height: u16 = 6;
2008    let total_height = strategy_area.height;
2009    let (on_height, off_height) = if total_height >= min_panel_height.saturating_mul(2) {
2010        let total_weight = on_weight + off_weight;
2011        let mut on_h =
2012            ((total_height as u32 * on_weight) / total_weight).max(min_panel_height as u32) as u16;
2013        let max_on_h = total_height.saturating_sub(min_panel_height);
2014        if on_h > max_on_h {
2015            on_h = max_on_h;
2016        }
2017        let off_h = total_height.saturating_sub(on_h);
2018        (on_h, off_h)
2019    } else {
2020        let on_h = (total_height / 2).max(1);
2021        let off_h = total_height.saturating_sub(on_h).max(1);
2022        (on_h, off_h)
2023    };
2024    let on_area = Rect {
2025        x: strategy_area.x,
2026        y: strategy_area.y,
2027        width: strategy_area.width,
2028        height: on_height,
2029    };
2030    let off_area = Rect {
2031        x: strategy_area.x,
2032        y: strategy_area.y.saturating_add(on_height),
2033        width: strategy_area.width,
2034        height: off_height,
2035    };
2036
2037    let pnl_sum_for_indices = |indices: &[usize], state: &AppState| -> f64 {
2038        indices
2039            .iter()
2040            .map(|idx| {
2041                let item = state
2042                    .strategy_items
2043                    .get(*idx)
2044                    .map(String::as_str)
2045                    .unwrap_or("-");
2046                let row_symbol = state
2047                    .strategy_item_symbols
2048                    .get(*idx)
2049                    .map(String::as_str)
2050                    .unwrap_or(state.symbol.as_str());
2051                strategy_stats_for_item(&state.strategy_stats, item, row_symbol)
2052                    .map(|s| s.realized_pnl)
2053                    .unwrap_or(0.0)
2054            })
2055            .sum()
2056    };
2057    let on_pnl_sum = pnl_sum_for_indices(&on_indices, state);
2058    let off_pnl_sum = pnl_sum_for_indices(&off_indices, state);
2059    let total_pnl_sum = on_pnl_sum + off_pnl_sum;
2060
2061    let total_row = Row::new(vec![
2062        Cell::from("ON Total"),
2063        Cell::from(on_indices.len().to_string()),
2064        Cell::from(format!("{:+.4}", on_pnl_sum)),
2065        Cell::from("OFF Total"),
2066        Cell::from(off_indices.len().to_string()),
2067        Cell::from(format!("{:+.4}", off_pnl_sum)),
2068        Cell::from("All Total"),
2069        Cell::from(format!("{:+.4}", total_pnl_sum)),
2070    ]);
2071    let total_table = Table::new(
2072        vec![total_row],
2073        [
2074            Constraint::Length(10),
2075            Constraint::Length(5),
2076            Constraint::Length(12),
2077            Constraint::Length(10),
2078            Constraint::Length(5),
2079            Constraint::Length(12),
2080            Constraint::Length(10),
2081            Constraint::Length(12),
2082        ],
2083    )
2084    .column_spacing(1)
2085    .block(
2086        Block::default()
2087            .title(" Total ")
2088            .borders(Borders::ALL)
2089            .border_style(Style::default().fg(Color::DarkGray)),
2090    );
2091    frame.render_widget(total_table, strategy_chunks[1]);
2092
2093    let render_strategy_window = |frame: &mut Frame,
2094                                  area: Rect,
2095                                  title: &str,
2096                                  indices: &[usize],
2097                                  state: &AppState,
2098                                  pnl_sum: f64,
2099                                  selected_panel: bool| {
2100        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
2101        let inner_height = area.height.saturating_sub(2);
2102        let row_capacity = inner_height.saturating_sub(1) as usize;
2103        let selected_pos = indices
2104            .iter()
2105            .position(|idx| *idx == view.selected_strategy_index);
2106        let window_start = if row_capacity == 0 {
2107            0
2108        } else if let Some(pos) = selected_pos {
2109            pos.saturating_sub(row_capacity.saturating_sub(1))
2110        } else {
2111            0
2112        };
2113        let window_end = if row_capacity == 0 {
2114            0
2115        } else {
2116            (window_start + row_capacity).min(indices.len())
2117        };
2118        let visible_indices = if indices.is_empty() || row_capacity == 0 {
2119            &indices[0..0]
2120        } else {
2121            &indices[window_start..window_end]
2122        };
2123        let header = Row::new(vec![
2124            Cell::from(" "),
2125            Cell::from("Symbol"),
2126            Cell::from("Strategy"),
2127            Cell::from("Run"),
2128            Cell::from("Last"),
2129            Cell::from("Px"),
2130            Cell::from("Age"),
2131            Cell::from("W"),
2132            Cell::from("L"),
2133            Cell::from("T"),
2134            Cell::from("PnL"),
2135        ])
2136        .style(Style::default().fg(Color::DarkGray));
2137        let mut rows: Vec<Row> = visible_indices
2138            .iter()
2139            .map(|idx| {
2140                let row_symbol = state
2141                    .strategy_item_symbols
2142                    .get(*idx)
2143                    .map(String::as_str)
2144                    .unwrap_or("-");
2145                let item = state
2146                    .strategy_items
2147                    .get(*idx)
2148                    .cloned()
2149                    .unwrap_or_else(|| "-".to_string());
2150                let running = state
2151                    .strategy_item_total_running_ms
2152                    .get(*idx)
2153                    .copied()
2154                    .map(format_running_time)
2155                    .unwrap_or_else(|| "-".to_string());
2156                let stats = strategy_stats_for_item(&state.strategy_stats, &item, row_symbol);
2157                let source_tag = source_tag_for_strategy_item(&item);
2158                let last_evt = source_tag
2159                    .as_ref()
2160                    .and_then(|tag| state.strategy_last_event_by_tag.get(tag));
2161                let (last_label, last_px, last_age, last_style) = if let Some(evt) = last_evt {
2162                    let age = now_ms.saturating_sub(evt.timestamp_ms);
2163                    let age_txt = if age < 1_000 {
2164                        format!("{}ms", age)
2165                    } else if age < 60_000 {
2166                        format!("{}s", age / 1_000)
2167                    } else {
2168                        format!("{}m", age / 60_000)
2169                    };
2170                    let side_txt = match evt.side {
2171                        OrderSide::Buy => "BUY",
2172                        OrderSide::Sell => "SELL",
2173                    };
2174                    let px_txt = evt
2175                        .price
2176                        .map(|v| format!("{:.2}", v))
2177                        .unwrap_or_else(|| "-".to_string());
2178                    let style = match evt.side {
2179                        OrderSide::Buy => Style::default()
2180                            .fg(Color::Green)
2181                            .add_modifier(Modifier::BOLD),
2182                        OrderSide::Sell => {
2183                            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
2184                        }
2185                    };
2186                    (side_txt.to_string(), px_txt, age_txt, style)
2187                } else {
2188                    (
2189                        "-".to_string(),
2190                        "-".to_string(),
2191                        "-".to_string(),
2192                        Style::default().fg(Color::DarkGray),
2193                    )
2194                };
2195                let (w, l, t, pnl) = if let Some(s) = stats {
2196                    (
2197                        s.win_count.to_string(),
2198                        s.lose_count.to_string(),
2199                        s.trade_count.to_string(),
2200                        format!("{:+.4}", s.realized_pnl),
2201                    )
2202                } else {
2203                    (
2204                        "0".to_string(),
2205                        "0".to_string(),
2206                        "0".to_string(),
2207                        "+0.0000".to_string(),
2208                    )
2209                };
2210                let marker = if *idx == view.selected_strategy_index {
2211                    "▶"
2212                } else {
2213                    " "
2214                };
2215                let mut row = Row::new(vec![
2216                    Cell::from(marker),
2217                    Cell::from(row_symbol.to_string()),
2218                    Cell::from(item),
2219                    Cell::from(running),
2220                    Cell::from(last_label).style(last_style),
2221                    Cell::from(last_px),
2222                    Cell::from(last_age),
2223                    Cell::from(w),
2224                    Cell::from(l),
2225                    Cell::from(t),
2226                    Cell::from(pnl),
2227                ]);
2228                if *idx == view.selected_strategy_index {
2229                    row = row.style(
2230                        Style::default()
2231                            .fg(Color::Yellow)
2232                            .add_modifier(Modifier::BOLD),
2233                    );
2234                }
2235                row
2236            })
2237            .collect();
2238
2239        if rows.is_empty() {
2240            rows.push(
2241                Row::new(vec![
2242                    Cell::from(" "),
2243                    Cell::from("-"),
2244                    Cell::from("(empty)"),
2245                    Cell::from("-"),
2246                    Cell::from("-"),
2247                    Cell::from("-"),
2248                    Cell::from("-"),
2249                    Cell::from("-"),
2250                    Cell::from("-"),
2251                    Cell::from("-"),
2252                    Cell::from("-"),
2253                ])
2254                .style(Style::default().fg(Color::DarkGray)),
2255            );
2256        }
2257
2258        let table = Table::new(
2259            rows,
2260            [
2261                Constraint::Length(2),
2262                Constraint::Length(12),
2263                Constraint::Min(14),
2264                Constraint::Length(9),
2265                Constraint::Length(5),
2266                Constraint::Length(9),
2267                Constraint::Length(6),
2268                Constraint::Length(3),
2269                Constraint::Length(3),
2270                Constraint::Length(4),
2271                Constraint::Length(11),
2272            ],
2273        )
2274        .header(header)
2275        .column_spacing(1)
2276        .block(
2277            Block::default()
2278                .title(format!(
2279                    "{} | Total {:+.4} | {}/{}",
2280                    title,
2281                    pnl_sum,
2282                    visible_indices.len(),
2283                    indices.len()
2284                ))
2285                .borders(Borders::ALL)
2286                .border_style(if selected_panel {
2287                    Style::default().fg(Color::Yellow)
2288                } else if risk_label == "CRIT" {
2289                    Style::default().fg(Color::Red)
2290                } else if risk_label == "WARN" {
2291                    Style::default().fg(Color::Yellow)
2292                } else {
2293                    Style::default().fg(Color::DarkGray)
2294                }),
2295        );
2296        frame.render_widget(table, area);
2297    };
2298
2299    render_strategy_window(
2300        frame,
2301        on_area,
2302        " ON Strategies ",
2303        &on_indices,
2304        state,
2305        on_pnl_sum,
2306        view.is_on_panel_selected,
2307    );
2308    render_strategy_window(
2309        frame,
2310        off_area,
2311        " OFF Strategies ",
2312        &off_indices,
2313        state,
2314        off_pnl_sum,
2315        !view.is_on_panel_selected,
2316    );
2317    frame.render_widget(
2318        Paragraph::new(Line::from(vec![
2319            Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
2320            Span::styled(
2321                selected_symbol,
2322                Style::default()
2323                    .fg(Color::Green)
2324                    .add_modifier(Modifier::BOLD),
2325            ),
2326            Span::styled(
2327                "  [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",
2328                Style::default().fg(Color::DarkGray),
2329            ),
2330        ])),
2331        strategy_chunks[3],
2332    );
2333}
2334
2335fn format_running_time(total_running_ms: u64) -> String {
2336    let total_sec = total_running_ms / 1000;
2337    let days = total_sec / 86_400;
2338    let hours = (total_sec % 86_400) / 3_600;
2339    let minutes = (total_sec % 3_600) / 60;
2340    if days > 0 {
2341        format!("{}d {:02}h", days, hours)
2342    } else {
2343        format!("{:02}h {:02}m", hours, minutes)
2344    }
2345}
2346
2347fn format_age_ms(age_ms: u64) -> String {
2348    if age_ms < 1_000 {
2349        format!("{}ms", age_ms)
2350    } else if age_ms < 60_000 {
2351        format!("{}s", age_ms / 1_000)
2352    } else {
2353        format!("{}m", age_ms / 60_000)
2354    }
2355}
2356
2357fn latency_stats(samples: &[u64]) -> (String, String, String) {
2358    let p50 = percentile(samples, 50);
2359    let p95 = percentile(samples, 95);
2360    let p99 = percentile(samples, 99);
2361    (
2362        p50.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2363        p95.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2364        p99.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2365    )
2366}
2367
2368fn render_strategy_editor_popup(frame: &mut Frame, state: &AppState) {
2369    let area = frame.area();
2370    let popup = Rect {
2371        x: area.x + 8,
2372        y: area.y + 4,
2373        width: area.width.saturating_sub(16).max(50),
2374        height: area.height.saturating_sub(8).max(12),
2375    };
2376    frame.render_widget(Clear, popup);
2377    let block = Block::default()
2378        .title(" Strategy Config ")
2379        .borders(Borders::ALL)
2380        .border_style(Style::default().fg(Color::Yellow));
2381    let inner = block.inner(popup);
2382    frame.render_widget(block, popup);
2383    let selected_name = state
2384        .strategy_items
2385        .get(state.strategy_editor_index)
2386        .map(String::as_str)
2387        .unwrap_or("Unknown");
2388    let strategy_kind = state
2389        .strategy_editor_kind_items
2390        .get(state.strategy_editor_kind_index)
2391        .map(String::as_str)
2392        .unwrap_or("MA");
2393    let is_rsa = strategy_kind.eq_ignore_ascii_case("RSA");
2394    let is_atr = strategy_kind.eq_ignore_ascii_case("ATR");
2395    let is_chb = strategy_kind.eq_ignore_ascii_case("CHB");
2396    let period_1_label = if is_rsa {
2397        "RSI Period"
2398    } else if is_atr {
2399        "ATR Period"
2400    } else if is_chb {
2401        "Entry Window"
2402    } else {
2403        "Fast Period"
2404    };
2405    let period_2_label = if is_rsa {
2406        "Upper RSI"
2407    } else if is_atr {
2408        "Threshold x100"
2409    } else if is_chb {
2410        "Exit Window"
2411    } else {
2412        "Slow Period"
2413    };
2414    let rows = [
2415        ("Strategy", strategy_kind.to_string()),
2416        (
2417            "Symbol",
2418            state
2419                .symbol_items
2420                .get(state.strategy_editor_symbol_index)
2421                .cloned()
2422                .unwrap_or_else(|| state.symbol.clone()),
2423        ),
2424        (period_1_label, state.strategy_editor_fast.to_string()),
2425        (period_2_label, state.strategy_editor_slow.to_string()),
2426        ("Cooldown Tick", state.strategy_editor_cooldown.to_string()),
2427    ];
2428    let mut lines = vec![
2429        Line::from(vec![
2430            Span::styled("Target: ", Style::default().fg(Color::DarkGray)),
2431            Span::styled(
2432                selected_name,
2433                Style::default()
2434                    .fg(Color::White)
2435                    .add_modifier(Modifier::BOLD),
2436            ),
2437        ]),
2438        Line::from(Span::styled(
2439            "Use [J/K] field, [H/L] value, [Enter] save, [Esc] cancel",
2440            Style::default().fg(Color::DarkGray),
2441        )),
2442    ];
2443    if is_rsa {
2444        let lower = 100usize.saturating_sub(state.strategy_editor_slow.clamp(51, 95));
2445        lines.push(Line::from(Span::styled(
2446            format!("RSA lower threshold auto-derived: {}", lower),
2447            Style::default().fg(Color::DarkGray),
2448        )));
2449    } else if is_atr {
2450        let threshold_x100 = state.strategy_editor_slow.clamp(110, 500);
2451        lines.push(Line::from(Span::styled(
2452            format!("ATR expansion threshold: {:.2}x", threshold_x100 as f64 / 100.0),
2453            Style::default().fg(Color::DarkGray),
2454        )));
2455    } else if is_chb {
2456        lines.push(Line::from(Span::styled(
2457            "CHB breakout: buy on entry high break, sell on exit low break",
2458            Style::default().fg(Color::DarkGray),
2459        )));
2460    }
2461    for (idx, (name, value)) in rows.iter().enumerate() {
2462        let marker = if idx == state.strategy_editor_field {
2463            "▶ "
2464        } else {
2465            "  "
2466        };
2467        let style = if idx == state.strategy_editor_field {
2468            Style::default()
2469                .fg(Color::Yellow)
2470                .add_modifier(Modifier::BOLD)
2471        } else {
2472            Style::default().fg(Color::White)
2473        };
2474        lines.push(Line::from(vec![
2475            Span::styled(marker, Style::default().fg(Color::Yellow)),
2476            Span::styled(format!("{:<14}", name), style),
2477            Span::styled(value, style),
2478        ]));
2479    }
2480    frame.render_widget(Paragraph::new(lines), inner);
2481    if state.strategy_editor_kind_category_selector_open {
2482        render_selector_popup(
2483            frame,
2484            " Select Strategy Category ",
2485            &state.strategy_editor_kind_category_items,
2486            state
2487                .strategy_editor_kind_category_index
2488                .min(state.strategy_editor_kind_category_items.len().saturating_sub(1)),
2489            None,
2490            None,
2491            None,
2492        );
2493    } else if state.strategy_editor_kind_selector_open {
2494        render_selector_popup(
2495            frame,
2496            " Select Strategy Type ",
2497            &state.strategy_editor_kind_popup_items,
2498            state
2499                .strategy_editor_kind_selector_index
2500                .min(state.strategy_editor_kind_popup_items.len().saturating_sub(1)),
2501            None,
2502            None,
2503            None,
2504        );
2505    }
2506}
2507
2508fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
2509    let area = frame.area();
2510    let popup = Rect {
2511        x: area.x + 4,
2512        y: area.y + 2,
2513        width: area.width.saturating_sub(8).max(30),
2514        height: area.height.saturating_sub(4).max(10),
2515    };
2516    frame.render_widget(Clear, popup);
2517    let block = Block::default()
2518        .title(" Account Assets ")
2519        .borders(Borders::ALL)
2520        .border_style(Style::default().fg(Color::Cyan));
2521    let inner = block.inner(popup);
2522    frame.render_widget(block, popup);
2523
2524    let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
2525    assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
2526
2527    let mut lines = Vec::with_capacity(assets.len() + 2);
2528    lines.push(Line::from(vec![
2529        Span::styled(
2530            "Asset",
2531            Style::default()
2532                .fg(Color::Cyan)
2533                .add_modifier(Modifier::BOLD),
2534        ),
2535        Span::styled(
2536            "      Free",
2537            Style::default()
2538                .fg(Color::Cyan)
2539                .add_modifier(Modifier::BOLD),
2540        ),
2541    ]));
2542    for (asset, qty) in assets {
2543        lines.push(Line::from(vec![
2544            Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
2545            Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
2546        ]));
2547    }
2548    if lines.len() == 1 {
2549        lines.push(Line::from(Span::styled(
2550            "No assets",
2551            Style::default().fg(Color::DarkGray),
2552        )));
2553    }
2554
2555    frame.render_widget(Paragraph::new(lines), inner);
2556}
2557
2558fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
2559    let area = frame.area();
2560    let popup = Rect {
2561        x: area.x + 2,
2562        y: area.y + 1,
2563        width: area.width.saturating_sub(4).max(40),
2564        height: area.height.saturating_sub(2).max(12),
2565    };
2566    frame.render_widget(Clear, popup);
2567    let block = Block::default()
2568        .title(match bucket {
2569            order_store::HistoryBucket::Day => " History (Day ROI) ",
2570            order_store::HistoryBucket::Hour => " History (Hour ROI) ",
2571            order_store::HistoryBucket::Month => " History (Month ROI) ",
2572        })
2573        .borders(Borders::ALL)
2574        .border_style(Style::default().fg(Color::Cyan));
2575    let inner = block.inner(popup);
2576    frame.render_widget(block, popup);
2577
2578    let max_rows = inner.height.saturating_sub(1) as usize;
2579    let mut visible: Vec<Line> = Vec::new();
2580    for (idx, row) in rows.iter().take(max_rows).enumerate() {
2581        let color = if idx == 0 {
2582            Color::Cyan
2583        } else if row.contains('-') && row.contains('%') {
2584            Color::White
2585        } else {
2586            Color::DarkGray
2587        };
2588        visible.push(Line::from(Span::styled(
2589            row.clone(),
2590            Style::default().fg(color),
2591        )));
2592    }
2593    if visible.is_empty() {
2594        visible.push(Line::from(Span::styled(
2595            "No history rows",
2596            Style::default().fg(Color::DarkGray),
2597        )));
2598    }
2599    frame.render_widget(Paragraph::new(visible), inner);
2600}
2601
2602fn render_selector_popup(
2603    frame: &mut Frame,
2604    title: &str,
2605    items: &[String],
2606    selected: usize,
2607    stats: Option<&HashMap<String, OrderHistoryStats>>,
2608    total_stats: Option<OrderHistoryStats>,
2609    selected_symbol: Option<&str>,
2610) {
2611    let area = frame.area();
2612    let available_width = area.width.saturating_sub(2).max(1);
2613    let width = if stats.is_some() {
2614        let min_width = 44;
2615        let preferred = 84;
2616        preferred
2617            .min(available_width)
2618            .max(min_width.min(available_width))
2619    } else {
2620        let min_width = 24;
2621        let preferred = 48;
2622        preferred
2623            .min(available_width)
2624            .max(min_width.min(available_width))
2625    };
2626    let available_height = area.height.saturating_sub(2).max(1);
2627    let desired_height = if stats.is_some() {
2628        items.len() as u16 + 7
2629    } else {
2630        items.len() as u16 + 4
2631    };
2632    let height = desired_height
2633        .min(available_height)
2634        .max(6.min(available_height));
2635    let popup = Rect {
2636        x: area.x + (area.width.saturating_sub(width)) / 2,
2637        y: area.y + (area.height.saturating_sub(height)) / 2,
2638        width,
2639        height,
2640    };
2641
2642    frame.render_widget(Clear, popup);
2643    let block = Block::default()
2644        .title(title)
2645        .borders(Borders::ALL)
2646        .border_style(Style::default().fg(Color::Cyan));
2647    let inner = block.inner(popup);
2648    frame.render_widget(block, popup);
2649
2650    let mut lines: Vec<Line> = Vec::new();
2651    if stats.is_some() {
2652        if let Some(symbol) = selected_symbol {
2653            lines.push(Line::from(vec![
2654                Span::styled("  Symbol: ", Style::default().fg(Color::DarkGray)),
2655                Span::styled(
2656                    symbol,
2657                    Style::default()
2658                        .fg(Color::Green)
2659                        .add_modifier(Modifier::BOLD),
2660                ),
2661            ]));
2662        }
2663        lines.push(Line::from(vec![Span::styled(
2664            "  Strategy           W    L    T    PnL",
2665            Style::default()
2666                .fg(Color::Cyan)
2667                .add_modifier(Modifier::BOLD),
2668        )]));
2669    }
2670
2671    let mut item_lines: Vec<Line> = items
2672        .iter()
2673        .enumerate()
2674        .map(|(idx, item)| {
2675            let item_text = if let Some(stats_map) = stats {
2676                let symbol = selected_symbol.unwrap_or("-");
2677                if let Some(s) = strategy_stats_for_item(stats_map, item, symbol) {
2678                    format!(
2679                        "{:<16}  W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
2680                        item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
2681                    )
2682                } else {
2683                    format!("{:<16}  W:0   L:0   T:0   PnL:0.0000", item)
2684                }
2685            } else {
2686                item.clone()
2687            };
2688            if idx == selected {
2689                Line::from(vec![
2690                    Span::styled("▶ ", Style::default().fg(Color::Yellow)),
2691                    Span::styled(
2692                        item_text,
2693                        Style::default()
2694                            .fg(Color::White)
2695                            .add_modifier(Modifier::BOLD),
2696                    ),
2697                ])
2698            } else {
2699                Line::from(vec![
2700                    Span::styled("  ", Style::default()),
2701                    Span::styled(item_text, Style::default().fg(Color::DarkGray)),
2702                ])
2703            }
2704        })
2705        .collect();
2706    lines.append(&mut item_lines);
2707    if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
2708        let mut strategy_sum = OrderHistoryStats::default();
2709        for item in items {
2710            let symbol = selected_symbol.unwrap_or("-");
2711            if let Some(s) = strategy_stats_for_item(stats_map, item, symbol) {
2712                strategy_sum.trade_count += s.trade_count;
2713                strategy_sum.win_count += s.win_count;
2714                strategy_sum.lose_count += s.lose_count;
2715                strategy_sum.realized_pnl += s.realized_pnl;
2716            }
2717        }
2718        let manual = subtract_stats(t, &strategy_sum);
2719        lines.push(Line::from(vec![Span::styled(
2720            format!(
2721                "  MANUAL(rest)       W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
2722                manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
2723            ),
2724            Style::default().fg(Color::LightBlue),
2725        )]));
2726    }
2727    if let Some(t) = total_stats {
2728        lines.push(Line::from(vec![Span::styled(
2729            format!(
2730                "  TOTAL              W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
2731                t.win_count, t.lose_count, t.trade_count, t.realized_pnl
2732            ),
2733            Style::default()
2734                .fg(Color::Yellow)
2735                .add_modifier(Modifier::BOLD),
2736        )]));
2737    }
2738
2739    frame.render_widget(
2740        Paragraph::new(lines).style(Style::default().fg(Color::White)),
2741        inner,
2742    );
2743}
2744
2745fn strategy_stats_for_item<'a>(
2746    stats_map: &'a HashMap<String, OrderHistoryStats>,
2747    item: &str,
2748    symbol: &str,
2749) -> Option<&'a OrderHistoryStats> {
2750    if let Some(source_tag) = source_tag_for_strategy_item(item) {
2751        let scoped = strategy_stats_scope_key(symbol, &source_tag);
2752        if let Some(s) = stats_map.get(&scoped) {
2753            return Some(s);
2754        }
2755    }
2756    if let Some(s) = stats_map.get(item) {
2757        return Some(s);
2758    }
2759    let source_tag = source_tag_for_strategy_item(item);
2760    source_tag.and_then(|tag| {
2761        stats_map
2762            .get(&tag)
2763            .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
2764    })
2765}
2766
2767fn strategy_stats_scope_key(symbol: &str, source_tag: &str) -> String {
2768    format!(
2769        "{}::{}",
2770        symbol.trim().to_ascii_uppercase(),
2771        source_tag.trim().to_ascii_lowercase()
2772    )
2773}
2774
2775fn source_tag_for_strategy_item(item: &str) -> Option<String> {
2776    match item {
2777        "MA(Config)" => return Some("cfg".to_string()),
2778        "MA(Fast 5/20)" => return Some("fst".to_string()),
2779        "MA(Slow 20/60)" => return Some("slw".to_string()),
2780        "RSA(RSI 14 30/70)" => return Some("rsa".to_string()),
2781        "DCT(Donchian 20/10)" => return Some("dct".to_string()),
2782        "MRV(SMA 20 -2.00%)" => return Some("mrv".to_string()),
2783        "BBR(BB 20 2.00x)" => return Some("bbr".to_string()),
2784        "STO(Stoch 14 20/80)" => return Some("sto".to_string()),
2785        "VLC(Compression 20 1.20%)" => return Some("vlc".to_string()),
2786        "ORB(Opening 12/8)" => return Some("orb".to_string()),
2787        "REG(Regime 10/30)" => return Some("reg".to_string()),
2788        "ENS(Vote 10/30)" => return Some("ens".to_string()),
2789        "MAC(MACD 12/26)" => return Some("mac".to_string()),
2790        "ROC(ROC 10 0.20%)" => return Some("roc".to_string()),
2791        "ARN(Aroon 14 70)" => return Some("arn".to_string()),
2792        _ => {}
2793    }
2794    if let Some((_, tail)) = item.rsplit_once('[') {
2795        if let Some(tag) = tail.strip_suffix(']') {
2796            let tag = tag.trim();
2797            if !tag.is_empty() {
2798                return Some(tag.to_ascii_lowercase());
2799            }
2800        }
2801    }
2802    None
2803}
2804
2805fn parse_source_tag_from_client_order_id(client_order_id: &str) -> Option<&str> {
2806    let body = client_order_id.strip_prefix("sq-")?;
2807    let (source_tag, _) = body.split_once('-')?;
2808    if source_tag.is_empty() {
2809        None
2810    } else {
2811        Some(source_tag)
2812    }
2813}
2814
2815fn format_log_record_compact(record: &LogRecord) -> String {
2816    let level = match record.level {
2817        LogLevel::Debug => "DEBUG",
2818        LogLevel::Info => "INFO",
2819        LogLevel::Warn => "WARN",
2820        LogLevel::Error => "ERR",
2821    };
2822    let domain = match record.domain {
2823        LogDomain::Ws => "ws",
2824        LogDomain::Strategy => "strategy",
2825        LogDomain::Risk => "risk",
2826        LogDomain::Order => "order",
2827        LogDomain::Portfolio => "portfolio",
2828        LogDomain::Ui => "ui",
2829        LogDomain::System => "system",
2830    };
2831    let symbol = record.symbol.as_deref().unwrap_or("-");
2832    let strategy = record.strategy_tag.as_deref().unwrap_or("-");
2833    format!(
2834        "[{}] {}.{} {} {} {}",
2835        level, domain, record.event, symbol, strategy, record.msg
2836    )
2837}
2838
2839fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
2840    OrderHistoryStats {
2841        trade_count: total.trade_count.saturating_sub(used.trade_count),
2842        win_count: total.win_count.saturating_sub(used.win_count),
2843        lose_count: total.lose_count.saturating_sub(used.lose_count),
2844        realized_pnl: total.realized_pnl - used.realized_pnl,
2845    }
2846}
2847
2848fn split_symbol_assets(symbol: &str) -> (String, String) {
2849    const QUOTE_SUFFIXES: [&str; 10] = [
2850        "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
2851    ];
2852    for q in QUOTE_SUFFIXES {
2853        if let Some(base) = symbol.strip_suffix(q) {
2854            if !base.is_empty() {
2855                return (base.to_string(), q.to_string());
2856            }
2857        }
2858    }
2859    (symbol.to_string(), String::new())
2860}
2861
2862fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
2863    if fills.is_empty() {
2864        return None;
2865    }
2866    let (base_asset, quote_asset) = split_symbol_assets(symbol);
2867    let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
2868    let mut notional_quote = 0.0;
2869    let mut fee_quote_equiv = 0.0;
2870    let mut quote_convertible = !quote_asset.is_empty();
2871
2872    for f in fills {
2873        if f.qty > 0.0 && f.price > 0.0 {
2874            notional_quote += f.qty * f.price;
2875        }
2876        if f.commission <= 0.0 {
2877            continue;
2878        }
2879        *fee_by_asset
2880            .entry(f.commission_asset.clone())
2881            .or_insert(0.0) += f.commission;
2882        if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&quote_asset) {
2883            fee_quote_equiv += f.commission;
2884        } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
2885            fee_quote_equiv += f.commission * f.price.max(0.0);
2886        } else {
2887            quote_convertible = false;
2888        }
2889    }
2890
2891    if fee_by_asset.is_empty() {
2892        return Some("0".to_string());
2893    }
2894
2895    if quote_convertible && notional_quote > f64::EPSILON {
2896        let fee_pct = fee_quote_equiv / notional_quote * 100.0;
2897        return Some(format!(
2898            "{:.3}% ({:.4} {})",
2899            fee_pct, fee_quote_equiv, quote_asset
2900        ));
2901    }
2902
2903    let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
2904    items.sort_by(|a, b| a.0.cmp(&b.0));
2905    if items.len() == 1 {
2906        let (asset, amount) = &items[0];
2907        Some(format!("{:.6} {}", amount, asset))
2908    } else {
2909        Some(format!("mixed fees ({})", items.len()))
2910    }
2911}
2912
2913#[cfg(test)]
2914mod tests {
2915    use super::format_last_applied_fee;
2916    use crate::model::order::Fill;
2917
2918    #[test]
2919    fn fee_summary_from_quote_asset_commission() {
2920        let fills = vec![Fill {
2921            price: 2000.0,
2922            qty: 0.5,
2923            commission: 1.0,
2924            commission_asset: "USDT".to_string(),
2925        }];
2926        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
2927        assert_eq!(summary, "0.100% (1.0000 USDT)");
2928    }
2929
2930    #[test]
2931    fn fee_summary_from_base_asset_commission() {
2932        let fills = vec![Fill {
2933            price: 2000.0,
2934            qty: 0.5,
2935            commission: 0.0005,
2936            commission_asset: "ETH".to_string(),
2937        }];
2938        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
2939        assert_eq!(summary, "0.100% (1.0000 USDT)");
2940    }
2941}