Skip to main content

sandbox_quant/ui/
mod.rs

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