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