Skip to main content

sandbox_quant/ui/
mod.rs

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