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