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