Skip to main content

sandbox_quant/ui/
mod.rs

1pub mod chart;
2pub mod dashboard;
3pub mod app_state_v2;
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 app_state_v2::AppStateV2;
23use chart::{FillMarker, PriceChart};
24use dashboard::{KeybindBar, LogPanel, OrderHistoryPanel, OrderLogPanel, PositionPanel, StatusBar};
25
26const MAX_LOG_MESSAGES: usize = 200;
27const MAX_FILL_MARKERS: usize = 200;
28
29pub struct AppState {
30    pub symbol: String,
31    pub strategy_label: String,
32    pub candles: Vec<Candle>,
33    pub current_candle: Option<CandleBuilder>,
34    pub candle_interval_ms: u64,
35    pub timeframe: String,
36    pub price_history_len: usize,
37    pub position: Position,
38    pub last_signal: Option<Signal>,
39    pub last_order: Option<OrderUpdate>,
40    pub open_order_history: Vec<String>,
41    pub filled_order_history: Vec<String>,
42    pub fast_sma: Option<f64>,
43    pub slow_sma: Option<f64>,
44    pub ws_connected: bool,
45    pub paused: bool,
46    pub tick_count: u64,
47    pub log_messages: Vec<String>,
48    pub balances: HashMap<String, f64>,
49    pub initial_equity_usdt: Option<f64>,
50    pub current_equity_usdt: Option<f64>,
51    pub history_estimated_total_pnl_usdt: Option<f64>,
52    pub fill_markers: Vec<FillMarker>,
53    pub history_trade_count: u32,
54    pub history_win_count: u32,
55    pub history_lose_count: u32,
56    pub history_realized_pnl: f64,
57    pub strategy_stats: HashMap<String, OrderHistoryStats>,
58    pub history_fills: Vec<OrderHistoryFill>,
59    pub last_price_update_ms: Option<u64>,
60    pub last_price_event_ms: Option<u64>,
61    pub last_price_latency_ms: Option<u64>,
62    pub last_order_history_update_ms: Option<u64>,
63    pub last_order_history_event_ms: Option<u64>,
64    pub last_order_history_latency_ms: Option<u64>,
65    pub trade_stats_reset_warned: bool,
66    pub symbol_selector_open: bool,
67    pub symbol_selector_index: usize,
68    pub symbol_items: Vec<String>,
69    pub strategy_selector_open: bool,
70    pub strategy_selector_index: usize,
71    pub strategy_items: Vec<String>,
72    pub strategy_item_symbols: Vec<String>,
73    pub strategy_item_active: Vec<bool>,
74    pub strategy_item_created_at_ms: Vec<i64>,
75    pub strategy_item_total_running_ms: Vec<u64>,
76    pub account_popup_open: bool,
77    pub history_popup_open: bool,
78    pub focus_popup_open: bool,
79    pub strategy_editor_open: bool,
80    pub strategy_editor_index: usize,
81    pub strategy_editor_field: usize,
82    pub strategy_editor_symbol_index: usize,
83    pub strategy_editor_fast: usize,
84    pub strategy_editor_slow: usize,
85    pub strategy_editor_cooldown: u64,
86    pub v2_grid_symbol_index: usize,
87    pub v2_grid_strategy_index: usize,
88    pub v2_grid_select_on_panel: bool,
89    pub history_rows: Vec<String>,
90    pub history_bucket: order_store::HistoryBucket,
91    pub last_applied_fee: String,
92    pub v2_grid_open: bool,
93    pub v2_state: AppStateV2,
94    pub rate_budget_global: RateBudgetSnapshot,
95    pub rate_budget_orders: RateBudgetSnapshot,
96    pub rate_budget_account: RateBudgetSnapshot,
97    pub rate_budget_market_data: RateBudgetSnapshot,
98}
99
100impl AppState {
101    pub fn new(
102        symbol: &str,
103        strategy_label: &str,
104        price_history_len: usize,
105        candle_interval_ms: u64,
106        timeframe: &str,
107    ) -> Self {
108        Self {
109            symbol: symbol.to_string(),
110            strategy_label: strategy_label.to_string(),
111            candles: Vec::with_capacity(price_history_len),
112            current_candle: None,
113            candle_interval_ms,
114            timeframe: timeframe.to_string(),
115            price_history_len,
116            position: Position::new(symbol.to_string()),
117            last_signal: None,
118            last_order: None,
119            open_order_history: Vec::new(),
120            filled_order_history: Vec::new(),
121            fast_sma: None,
122            slow_sma: None,
123            ws_connected: false,
124            paused: false,
125            tick_count: 0,
126            log_messages: Vec::new(),
127            balances: HashMap::new(),
128            initial_equity_usdt: None,
129            current_equity_usdt: None,
130            history_estimated_total_pnl_usdt: None,
131            fill_markers: Vec::new(),
132            history_trade_count: 0,
133            history_win_count: 0,
134            history_lose_count: 0,
135            history_realized_pnl: 0.0,
136            strategy_stats: HashMap::new(),
137            history_fills: Vec::new(),
138            last_price_update_ms: None,
139            last_price_event_ms: None,
140            last_price_latency_ms: None,
141            last_order_history_update_ms: None,
142            last_order_history_event_ms: None,
143            last_order_history_latency_ms: None,
144            trade_stats_reset_warned: false,
145            symbol_selector_open: false,
146            symbol_selector_index: 0,
147            symbol_items: Vec::new(),
148            strategy_selector_open: false,
149            strategy_selector_index: 0,
150            strategy_items: vec![
151                "MA(Config)".to_string(),
152                "MA(Fast 5/20)".to_string(),
153                "MA(Slow 20/60)".to_string(),
154            ],
155            strategy_item_symbols: vec![
156                symbol.to_ascii_uppercase(),
157                symbol.to_ascii_uppercase(),
158                symbol.to_ascii_uppercase(),
159            ],
160            strategy_item_active: vec![false, false, false],
161            strategy_item_created_at_ms: vec![0, 0, 0],
162            strategy_item_total_running_ms: vec![0, 0, 0],
163            account_popup_open: false,
164            history_popup_open: false,
165            focus_popup_open: false,
166            strategy_editor_open: false,
167            strategy_editor_index: 0,
168            strategy_editor_field: 0,
169            strategy_editor_symbol_index: 0,
170            strategy_editor_fast: 5,
171            strategy_editor_slow: 20,
172            strategy_editor_cooldown: 1,
173            v2_grid_symbol_index: 0,
174            v2_grid_strategy_index: 0,
175            v2_grid_select_on_panel: true,
176            history_rows: Vec::new(),
177            history_bucket: order_store::HistoryBucket::Day,
178            last_applied_fee: "---".to_string(),
179            v2_grid_open: false,
180            v2_state: AppStateV2::new(),
181            rate_budget_global: RateBudgetSnapshot {
182                used: 0,
183                limit: 0,
184                reset_in_ms: 0,
185            },
186            rate_budget_orders: RateBudgetSnapshot {
187                used: 0,
188                limit: 0,
189                reset_in_ms: 0,
190            },
191            rate_budget_account: RateBudgetSnapshot {
192                used: 0,
193                limit: 0,
194                reset_in_ms: 0,
195            },
196            rate_budget_market_data: RateBudgetSnapshot {
197                used: 0,
198                limit: 0,
199                reset_in_ms: 0,
200            },
201        }
202    }
203
204    /// Get the latest price (from current candle or last finalized candle).
205    pub fn last_price(&self) -> Option<f64> {
206        self.current_candle
207            .as_ref()
208            .map(|cb| cb.close)
209            .or_else(|| self.candles.last().map(|c| c.close))
210    }
211
212    pub fn push_log(&mut self, msg: String) {
213        self.log_messages.push(msg);
214        if self.log_messages.len() > MAX_LOG_MESSAGES {
215            self.log_messages.remove(0);
216        }
217    }
218
219    pub fn refresh_history_rows(&mut self) {
220        match order_store::load_realized_returns_by_bucket(self.history_bucket, 400) {
221            Ok(rows) => {
222                use std::collections::{BTreeMap, BTreeSet};
223
224                let mut date_set: BTreeSet<String> = BTreeSet::new();
225                let mut ticker_map: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
226                for row in rows {
227                    date_set.insert(row.date.clone());
228                    ticker_map
229                        .entry(row.symbol.clone())
230                        .or_default()
231                        .insert(row.date, row.realized_return_pct);
232                }
233
234                // Keep recent dates only to avoid horizontal overflow in terminal.
235                let mut dates: Vec<String> = date_set.into_iter().collect();
236                dates.sort();
237                const MAX_DATE_COLS: usize = 6;
238                if dates.len() > MAX_DATE_COLS {
239                    dates = dates[dates.len() - MAX_DATE_COLS..].to_vec();
240                }
241
242                let mut lines = Vec::new();
243                if dates.is_empty() {
244                    lines.push("Ticker            (no daily realized roi data)".to_string());
245                    self.history_rows = lines;
246                    return;
247                }
248
249                let mut header = format!("{:<14}", "Ticker");
250                for d in &dates {
251                    header.push_str(&format!(" {:>10}", d));
252                }
253                lines.push(header);
254
255                for (ticker, by_date) in ticker_map {
256                    let mut line = format!("{:<14}", ticker);
257                    for d in &dates {
258                        let cell = by_date
259                            .get(d)
260                            .map(|v| format!("{:.2}%", v))
261                            .unwrap_or_else(|| "-".to_string());
262                        line.push_str(&format!(" {:>10}", cell));
263                    }
264                    lines.push(line);
265                }
266                self.history_rows = lines;
267            }
268            Err(e) => {
269                self.history_rows = vec![
270                    "Ticker           Date         RealizedROI   RealizedPnL".to_string(),
271                    format!("(failed to load history: {})", e),
272                ];
273            }
274        }
275    }
276
277    fn refresh_equity_usdt(&mut self) {
278        let usdt = self.balances.get("USDT").copied().unwrap_or(0.0);
279        let btc = self.balances.get("BTC").copied().unwrap_or(0.0);
280        let mark_price = self
281            .last_price()
282            .or_else(|| (self.position.entry_price > 0.0).then_some(self.position.entry_price));
283        if let Some(price) = mark_price {
284            let total = usdt + btc * price;
285            self.current_equity_usdt = Some(total);
286            self.recompute_initial_equity_from_history();
287        }
288    }
289
290    fn recompute_initial_equity_from_history(&mut self) {
291        if let Some(current) = self.current_equity_usdt {
292            if let Some(total_pnl) = self.history_estimated_total_pnl_usdt {
293                self.initial_equity_usdt = Some(current - total_pnl);
294            } else if self.history_trade_count == 0 && self.initial_equity_usdt.is_none() {
295                self.initial_equity_usdt = Some(current);
296            }
297        }
298    }
299
300    fn candle_index_for_timestamp(&self, timestamp_ms: u64) -> Option<usize> {
301        if let Some((idx, _)) = self
302            .candles
303            .iter()
304            .enumerate()
305            .find(|(_, c)| timestamp_ms >= c.open_time && timestamp_ms < c.close_time)
306        {
307            return Some(idx);
308        }
309        if let Some(cb) = &self.current_candle {
310            if cb.contains(timestamp_ms) {
311                return Some(self.candles.len());
312            }
313        }
314        // Fallback: if timestamp is newer than the latest finalized candle range
315        // (e.g. coarse timeframe like 1M and no in-progress bucket), pin to nearest past candle.
316        if let Some((idx, _)) = self
317            .candles
318            .iter()
319            .enumerate()
320            .rev()
321            .find(|(_, c)| c.open_time <= timestamp_ms)
322        {
323            return Some(idx);
324        }
325        None
326    }
327
328    fn rebuild_fill_markers_from_history(&mut self, fills: &[OrderHistoryFill]) {
329        self.fill_markers.clear();
330        for fill in fills {
331            if let Some(candle_index) = self.candle_index_for_timestamp(fill.timestamp_ms) {
332                self.fill_markers.push(FillMarker {
333                    candle_index,
334                    price: fill.price,
335                    side: fill.side,
336                });
337            }
338        }
339        if self.fill_markers.len() > MAX_FILL_MARKERS {
340            let excess = self.fill_markers.len() - MAX_FILL_MARKERS;
341            self.fill_markers.drain(..excess);
342        }
343    }
344
345    pub fn apply(&mut self, event: AppEvent) {
346        let prev_focus = self.v2_state.focus.clone();
347        match event {
348            AppEvent::MarketTick(tick) => {
349                self.tick_count += 1;
350                let now_ms = chrono::Utc::now().timestamp_millis() as u64;
351                self.last_price_update_ms = Some(now_ms);
352                self.last_price_event_ms = Some(tick.timestamp_ms);
353                self.last_price_latency_ms = Some(now_ms.saturating_sub(tick.timestamp_ms));
354
355                // Aggregate tick into candles
356                let should_new = match &self.current_candle {
357                    Some(cb) => !cb.contains(tick.timestamp_ms),
358                    None => true,
359                };
360                if should_new {
361                    if let Some(cb) = self.current_candle.take() {
362                        self.candles.push(cb.finish());
363                        if self.candles.len() > self.price_history_len {
364                            self.candles.remove(0);
365                            // Shift marker indices when oldest candle is trimmed.
366                            self.fill_markers.retain_mut(|m| {
367                                if m.candle_index == 0 {
368                                    false
369                                } else {
370                                    m.candle_index -= 1;
371                                    true
372                                }
373                            });
374                        }
375                    }
376                    self.current_candle = Some(CandleBuilder::new(
377                        tick.price,
378                        tick.timestamp_ms,
379                        self.candle_interval_ms,
380                    ));
381                } else if let Some(cb) = self.current_candle.as_mut() {
382                    cb.update(tick.price);
383                } else {
384                    // Defensive fallback: avoid panic if tick ordering/state gets out of sync.
385                    self.current_candle = Some(CandleBuilder::new(
386                        tick.price,
387                        tick.timestamp_ms,
388                        self.candle_interval_ms,
389                    ));
390                    self.push_log("[WARN] Recovered missing current candle state".to_string());
391                }
392
393                self.position.update_unrealized_pnl(tick.price);
394                self.refresh_equity_usdt();
395            }
396            AppEvent::StrategySignal(ref signal) => {
397                self.last_signal = Some(signal.clone());
398                match signal {
399                    Signal::Buy { .. } => {
400                        self.push_log("Signal: BUY".to_string());
401                    }
402                    Signal::Sell { .. } => {
403                        self.push_log("Signal: SELL".to_string());
404                    }
405                    Signal::Hold => {}
406                }
407            }
408            AppEvent::StrategyState { fast_sma, slow_sma } => {
409                self.fast_sma = fast_sma;
410                self.slow_sma = slow_sma;
411            }
412            AppEvent::OrderUpdate(ref update) => {
413                match update {
414                    OrderUpdate::Filled {
415                        intent_id,
416                        client_order_id,
417                        side,
418                        fills,
419                        avg_price,
420                    } => {
421                        if let Some(summary) = format_last_applied_fee(&self.symbol, fills) {
422                            self.last_applied_fee = summary;
423                        }
424                        self.position.apply_fill(*side, fills);
425                        self.refresh_equity_usdt();
426                        let candle_index = if self.current_candle.is_some() {
427                            self.candles.len()
428                        } else {
429                            self.candles.len().saturating_sub(1)
430                        };
431                        self.fill_markers.push(FillMarker {
432                            candle_index,
433                            price: *avg_price,
434                            side: *side,
435                        });
436                        if self.fill_markers.len() > MAX_FILL_MARKERS {
437                            self.fill_markers.remove(0);
438                        }
439                        self.push_log(format!(
440                            "FILLED {} {} ({}) @ {:.2}",
441                            side, client_order_id, intent_id, avg_price
442                        ));
443                    }
444                    OrderUpdate::Submitted {
445                        intent_id,
446                        client_order_id,
447                        server_order_id,
448                    } => {
449                        self.refresh_equity_usdt();
450                        self.push_log(format!(
451                            "Submitted {} (id: {}, {})",
452                            client_order_id, server_order_id, intent_id
453                        ));
454                    }
455                    OrderUpdate::Rejected {
456                        intent_id,
457                        client_order_id,
458                        reason_code,
459                        reason,
460                    } => {
461                        self.push_log(format!(
462                            "[ERR] Rejected {} ({}) [{}]: {}",
463                            client_order_id, intent_id, reason_code, reason
464                        ));
465                    }
466                }
467                self.last_order = Some(update.clone());
468            }
469            AppEvent::WsStatus(ref status) => match status {
470                WsConnectionStatus::Connected => {
471                    self.ws_connected = true;
472                    self.push_log("WebSocket Connected".to_string());
473                }
474                WsConnectionStatus::Disconnected => {
475                    self.ws_connected = false;
476                    self.push_log("[WARN] WebSocket Disconnected".to_string());
477                }
478                WsConnectionStatus::Reconnecting { attempt, delay_ms } => {
479                    self.ws_connected = false;
480                    self.push_log(format!(
481                        "[WARN] Reconnecting (attempt {}, wait {}ms)",
482                        attempt, delay_ms
483                    ));
484                }
485            },
486            AppEvent::HistoricalCandles {
487                candles,
488                interval_ms,
489                interval,
490            } => {
491                self.candles = candles;
492                if self.candles.len() > self.price_history_len {
493                    let excess = self.candles.len() - self.price_history_len;
494                    self.candles.drain(..excess);
495                }
496                self.candle_interval_ms = interval_ms;
497                self.timeframe = interval;
498                self.current_candle = None;
499                let fills = self.history_fills.clone();
500                self.rebuild_fill_markers_from_history(&fills);
501                self.push_log(format!(
502                    "Switched to {} ({} candles)",
503                    self.timeframe,
504                    self.candles.len()
505                ));
506            }
507            AppEvent::BalanceUpdate(balances) => {
508                self.balances = balances;
509                self.refresh_equity_usdt();
510            }
511            AppEvent::OrderHistoryUpdate(snapshot) => {
512                let mut open = Vec::new();
513                let mut filled = Vec::new();
514
515                for row in snapshot.rows {
516                    let status = row.split_whitespace().nth(1).unwrap_or_default();
517                    if status == "FILLED" {
518                        filled.push(row);
519                    } else {
520                        open.push(row);
521                    }
522                }
523
524                if open.len() > MAX_LOG_MESSAGES {
525                    let excess = open.len() - MAX_LOG_MESSAGES;
526                    open.drain(..excess);
527                }
528                if filled.len() > MAX_LOG_MESSAGES {
529                    let excess = filled.len() - MAX_LOG_MESSAGES;
530                    filled.drain(..excess);
531                }
532
533                self.open_order_history = open;
534                self.filled_order_history = filled;
535                if snapshot.trade_data_complete {
536                    let stats_looks_reset = snapshot.stats.trade_count == 0
537                        && (self.history_trade_count > 0 || !self.history_fills.is_empty());
538                    if stats_looks_reset {
539                        if !self.trade_stats_reset_warned {
540                            self.push_log(
541                                "[WARN] Ignored transient trade stats reset from order-history sync"
542                                    .to_string(),
543                            );
544                            self.trade_stats_reset_warned = true;
545                        }
546                    } else {
547                        self.trade_stats_reset_warned = false;
548                        self.history_trade_count = snapshot.stats.trade_count;
549                        self.history_win_count = snapshot.stats.win_count;
550                        self.history_lose_count = snapshot.stats.lose_count;
551                        self.history_realized_pnl = snapshot.stats.realized_pnl;
552                        self.strategy_stats = snapshot.strategy_stats;
553                        // Keep position panel aligned with exchange history state
554                        // so Qty/Entry/UnrPL reflect actual holdings, not only session fills.
555                        if snapshot.open_qty > f64::EPSILON {
556                            self.position.side = Some(OrderSide::Buy);
557                            self.position.qty = snapshot.open_qty;
558                            self.position.entry_price = snapshot.open_entry_price;
559                            if let Some(px) = self.last_price() {
560                                self.position.unrealized_pnl =
561                                    (px - snapshot.open_entry_price) * snapshot.open_qty;
562                            }
563                        } else {
564                            self.position.side = None;
565                            self.position.qty = 0.0;
566                            self.position.entry_price = 0.0;
567                            self.position.unrealized_pnl = 0.0;
568                        }
569                    }
570                    if !snapshot.fills.is_empty() || self.history_fills.is_empty() {
571                        self.history_fills = snapshot.fills.clone();
572                        self.rebuild_fill_markers_from_history(&snapshot.fills);
573                    }
574                    self.history_estimated_total_pnl_usdt = snapshot.estimated_total_pnl_usdt;
575                    self.recompute_initial_equity_from_history();
576                }
577                self.last_order_history_update_ms = Some(snapshot.fetched_at_ms);
578                self.last_order_history_event_ms = snapshot.latest_event_ms;
579                self.last_order_history_latency_ms = Some(snapshot.fetch_latency_ms);
580                self.refresh_history_rows();
581            }
582            AppEvent::RiskRateSnapshot {
583                global,
584                orders,
585                account,
586                market_data,
587            } => {
588                self.rate_budget_global = global;
589                self.rate_budget_orders = orders;
590                self.rate_budget_account = account;
591                self.rate_budget_market_data = market_data;
592            }
593            AppEvent::LogMessage(msg) => {
594                self.push_log(msg);
595            }
596            AppEvent::Error(msg) => {
597                self.push_log(format!("[ERR] {}", msg));
598            }
599        }
600        let mut next = AppStateV2::from_legacy(self);
601        if prev_focus.symbol.is_some() {
602            next.focus.symbol = prev_focus.symbol;
603        }
604        if prev_focus.strategy_id.is_some() {
605            next.focus.strategy_id = prev_focus.strategy_id;
606        }
607        self.v2_state = next;
608    }
609}
610
611pub fn render(frame: &mut Frame, state: &AppState) {
612    let outer = Layout::default()
613        .direction(Direction::Vertical)
614        .constraints([
615            Constraint::Length(1), // status bar
616            Constraint::Min(8),    // main area (chart + position)
617            Constraint::Length(5), // order log
618            Constraint::Length(6), // order history
619            Constraint::Length(8), // system log
620            Constraint::Length(1), // keybinds
621        ])
622        .split(frame.area());
623
624    // Status bar
625    frame.render_widget(
626        StatusBar {
627            symbol: &state.symbol,
628            strategy_label: &state.strategy_label,
629            ws_connected: state.ws_connected,
630            paused: state.paused,
631            timeframe: &state.timeframe,
632            last_price_update_ms: state.last_price_update_ms,
633            last_price_latency_ms: state.last_price_latency_ms,
634            last_order_history_update_ms: state.last_order_history_update_ms,
635            last_order_history_latency_ms: state.last_order_history_latency_ms,
636        },
637        outer[0],
638    );
639
640    // Main area: chart + position panel
641    let main_area = Layout::default()
642        .direction(Direction::Horizontal)
643        .constraints([Constraint::Min(40), Constraint::Length(24)])
644        .split(outer[1]);
645
646    // Price chart (candles + in-progress candle)
647    let current_price = state.last_price();
648    frame.render_widget(
649        PriceChart::new(&state.candles, &state.symbol)
650            .current_candle(state.current_candle.as_ref())
651            .fill_markers(&state.fill_markers)
652            .fast_sma(state.fast_sma)
653            .slow_sma(state.slow_sma),
654        main_area[0],
655    );
656
657    // Position panel (with current price and balances)
658    frame.render_widget(
659        PositionPanel::new(
660            &state.position,
661            current_price,
662            &state.balances,
663            state.initial_equity_usdt,
664            state.current_equity_usdt,
665            state.history_trade_count,
666            state.history_realized_pnl,
667            &state.last_applied_fee,
668        ),
669        main_area[1],
670    );
671
672    // Order log
673    frame.render_widget(
674        OrderLogPanel::new(
675            &state.last_signal,
676            &state.last_order,
677            state.fast_sma,
678            state.slow_sma,
679            state.history_trade_count,
680            state.history_win_count,
681            state.history_lose_count,
682            state.history_realized_pnl,
683        ),
684        outer[2],
685    );
686
687    // Order history panel
688    frame.render_widget(
689        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
690        outer[3],
691    );
692
693    // System log panel
694    frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
695
696    // Keybind bar
697    frame.render_widget(KeybindBar, outer[5]);
698
699    if state.symbol_selector_open {
700        render_selector_popup(
701            frame,
702            " Select Symbol ",
703            &state.symbol_items,
704            state.symbol_selector_index,
705            None,
706            None,
707            None,
708        );
709    } else if state.strategy_selector_open {
710        let selected_strategy_symbol = state
711            .strategy_item_symbols
712            .get(state.strategy_selector_index)
713            .map(String::as_str)
714            .unwrap_or(state.symbol.as_str());
715        render_selector_popup(
716            frame,
717            " Select Strategy ",
718            &state.strategy_items,
719            state.strategy_selector_index,
720            Some(&state.strategy_stats),
721            Some(OrderHistoryStats {
722                trade_count: state.history_trade_count,
723                win_count: state.history_win_count,
724                lose_count: state.history_lose_count,
725                realized_pnl: state.history_realized_pnl,
726            }),
727            Some(selected_strategy_symbol),
728        );
729    } else if state.account_popup_open {
730        render_account_popup(frame, &state.balances);
731    } else if state.history_popup_open {
732        render_history_popup(frame, &state.history_rows, state.history_bucket);
733    } else if state.focus_popup_open {
734        render_focus_popup(frame, state);
735    } else if state.strategy_editor_open {
736        render_strategy_editor_popup(frame, state);
737    } else if state.v2_grid_open {
738        render_v2_grid_popup(frame, state);
739    }
740}
741
742fn render_focus_popup(frame: &mut Frame, state: &AppState) {
743    let area = frame.area();
744    let popup = Rect {
745        x: area.x + 1,
746        y: area.y + 1,
747        width: area.width.saturating_sub(2).max(70),
748        height: area.height.saturating_sub(2).max(22),
749    };
750    frame.render_widget(Clear, popup);
751    let block = Block::default()
752        .title(" Focus View (V2 Drill-down) ")
753        .borders(Borders::ALL)
754        .border_style(Style::default().fg(Color::Green));
755    let inner = block.inner(popup);
756    frame.render_widget(block, popup);
757
758    let rows = Layout::default()
759        .direction(Direction::Vertical)
760        .constraints([
761            Constraint::Length(2),
762            Constraint::Min(8),
763            Constraint::Length(7),
764        ])
765        .split(inner);
766
767    let focus_symbol = state
768        .v2_state
769        .focus
770        .symbol
771        .as_deref()
772        .unwrap_or(&state.symbol);
773    let focus_strategy = state
774        .v2_state
775        .focus
776        .strategy_id
777        .as_deref()
778        .unwrap_or(&state.strategy_label);
779    frame.render_widget(
780        Paragraph::new(vec![
781            Line::from(vec![
782                Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
783                Span::styled(
784                    focus_symbol,
785                    Style::default()
786                        .fg(Color::Cyan)
787                        .add_modifier(Modifier::BOLD),
788                ),
789                Span::styled("  Strategy: ", Style::default().fg(Color::DarkGray)),
790                Span::styled(
791                    focus_strategy,
792                    Style::default()
793                        .fg(Color::Magenta)
794                        .add_modifier(Modifier::BOLD),
795                ),
796            ]),
797            Line::from(Span::styled(
798                "Reuse legacy chart/position/history widgets. Press [F]/[Esc] to close.",
799                Style::default().fg(Color::DarkGray),
800            )),
801        ]),
802        rows[0],
803    );
804
805    let main_cols = Layout::default()
806        .direction(Direction::Horizontal)
807        .constraints([Constraint::Min(48), Constraint::Length(28)])
808        .split(rows[1]);
809
810    frame.render_widget(
811        PriceChart::new(&state.candles, focus_symbol)
812            .current_candle(state.current_candle.as_ref())
813            .fill_markers(&state.fill_markers)
814            .fast_sma(state.fast_sma)
815            .slow_sma(state.slow_sma),
816        main_cols[0],
817    );
818    frame.render_widget(
819        PositionPanel::new(
820            &state.position,
821            state.last_price(),
822            &state.balances,
823            state.initial_equity_usdt,
824            state.current_equity_usdt,
825            state.history_trade_count,
826            state.history_realized_pnl,
827            &state.last_applied_fee,
828        ),
829        main_cols[1],
830    );
831
832    frame.render_widget(
833        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
834        rows[2],
835    );
836}
837
838fn render_v2_grid_popup(frame: &mut Frame, state: &AppState) {
839    let area = frame.area();
840    let popup = Rect {
841        x: area.x + 1,
842        y: area.y + 1,
843        width: area.width.saturating_sub(2).max(60),
844        height: area.height.saturating_sub(2).max(20),
845    };
846    frame.render_widget(Clear, popup);
847    let block = Block::default()
848        .title(" Portfolio Grid (V2) ")
849        .borders(Borders::ALL)
850        .border_style(Style::default().fg(Color::Cyan));
851    let inner = block.inner(popup);
852    frame.render_widget(block, popup);
853
854    let heat_height: u16 = 2;
855    let rejection_min_height: u16 = 1;
856    let strategy_min_height: u16 = 16;
857    let asset_min_height: u16 = 3;
858    let desired_asset_height = (state.v2_state.assets.len() as u16).saturating_add(1);
859    let max_asset_height = inner
860        .height
861        .saturating_sub(strategy_min_height + heat_height + rejection_min_height);
862    let asset_height = desired_asset_height
863        .max(asset_min_height)
864        .min(max_asset_height.max(asset_min_height));
865
866    let chunks = Layout::default()
867        .direction(Direction::Vertical)
868        .constraints([
869            Constraint::Length(asset_height),
870            Constraint::Min(strategy_min_height),
871            Constraint::Length(heat_height),
872            Constraint::Min(rejection_min_height),
873        ])
874        .split(inner);
875
876    let asset_header = Row::new(vec![
877        Cell::from("Symbol"),
878        Cell::from("Qty"),
879        Cell::from("Price"),
880        Cell::from("RlzPnL"),
881        Cell::from("UnrPnL"),
882    ])
883    .style(Style::default().fg(Color::DarkGray));
884    let mut asset_rows: Vec<Row> = state
885        .v2_state
886        .assets
887        .iter()
888        .map(|a| {
889            let price = a
890                .last_price
891                .map(|v| format!("{:.2}", v))
892                .unwrap_or_else(|| "---".to_string());
893            let rlz = format!("{:+.4}", a.realized_pnl_usdt);
894            let unrlz = format!("{:+.4}", a.unrealized_pnl_usdt);
895            Row::new(vec![
896                Cell::from(a.symbol.clone()),
897                Cell::from(format!("{:.5}", a.position_qty)),
898                Cell::from(price),
899                Cell::from(rlz),
900                Cell::from(unrlz),
901            ])
902        })
903        .collect();
904    if asset_rows.is_empty() {
905        asset_rows.push(
906            Row::new(vec![
907                Cell::from("(no assets)"),
908                Cell::from("-"),
909                Cell::from("-"),
910                Cell::from("-"),
911                Cell::from("-"),
912            ])
913            .style(Style::default().fg(Color::DarkGray)),
914        );
915    }
916    let asset_table = Table::new(
917        asset_rows,
918        [
919            Constraint::Length(16),
920            Constraint::Length(12),
921            Constraint::Length(10),
922            Constraint::Length(10),
923            Constraint::Length(10),
924        ],
925    )
926    .header(asset_header)
927    .column_spacing(1)
928    .block(
929        Block::default()
930            .title(format!(" Asset Table | Total {} ", state.v2_state.assets.len()))
931            .borders(Borders::ALL)
932            .border_style(Style::default().fg(Color::DarkGray)),
933    );
934    frame.render_widget(asset_table, chunks[0]);
935
936    let selected_symbol = state
937        .symbol_items
938        .get(state.v2_grid_symbol_index)
939        .map(String::as_str)
940        .unwrap_or(state.symbol.as_str());
941    let strategy_chunks = Layout::default()
942        .direction(Direction::Vertical)
943        .constraints([Constraint::Length(3), Constraint::Min(10), Constraint::Length(1)])
944        .split(chunks[1]);
945
946    let mut on_indices: Vec<usize> = Vec::new();
947    let mut off_indices: Vec<usize> = Vec::new();
948    for idx in 0..state.strategy_items.len() {
949        if state.strategy_item_active.get(idx).copied().unwrap_or(false) {
950            on_indices.push(idx);
951        } else {
952            off_indices.push(idx);
953        }
954    }
955    let on_weight = on_indices.len().max(1) as u32;
956    let off_weight = off_indices.len().max(1) as u32;
957    let strategy_area = strategy_chunks[1];
958    let min_panel_height: u16 = 6;
959    let total_height = strategy_area.height;
960    let (on_height, off_height) = if total_height >= min_panel_height.saturating_mul(2) {
961        let total_weight = on_weight + off_weight;
962        let mut on_h =
963            ((total_height as u32 * on_weight) / total_weight).max(min_panel_height as u32) as u16;
964        let max_on_h = total_height.saturating_sub(min_panel_height);
965        if on_h > max_on_h {
966            on_h = max_on_h;
967        }
968        let off_h = total_height.saturating_sub(on_h);
969        (on_h, off_h)
970    } else {
971        let on_h = (total_height / 2).max(1);
972        let off_h = total_height.saturating_sub(on_h).max(1);
973        (on_h, off_h)
974    };
975    let on_area = Rect {
976        x: strategy_area.x,
977        y: strategy_area.y,
978        width: strategy_area.width,
979        height: on_height,
980    };
981    let off_area = Rect {
982        x: strategy_area.x,
983        y: strategy_area.y.saturating_add(on_height),
984        width: strategy_area.width,
985        height: off_height,
986    };
987
988    let pnl_sum_for_indices = |indices: &[usize], state: &AppState| -> f64 {
989        indices
990            .iter()
991            .map(|idx| {
992                state
993                    .strategy_items
994                    .get(*idx)
995                    .and_then(|item| strategy_stats_for_item(&state.strategy_stats, item))
996                    .map(|s| s.realized_pnl)
997                    .unwrap_or(0.0)
998            })
999            .sum()
1000    };
1001    let on_pnl_sum = pnl_sum_for_indices(&on_indices, state);
1002    let off_pnl_sum = pnl_sum_for_indices(&off_indices, state);
1003    let total_pnl_sum = on_pnl_sum + off_pnl_sum;
1004
1005    let total_row = Row::new(vec![
1006        Cell::from("ON Total"),
1007        Cell::from(on_indices.len().to_string()),
1008        Cell::from(format!("{:+.4}", on_pnl_sum)),
1009        Cell::from("OFF Total"),
1010        Cell::from(off_indices.len().to_string()),
1011        Cell::from(format!("{:+.4}", off_pnl_sum)),
1012        Cell::from("All Total"),
1013        Cell::from(format!("{:+.4}", total_pnl_sum)),
1014    ]);
1015    let total_table = Table::new(
1016        vec![total_row],
1017        [
1018            Constraint::Length(10),
1019            Constraint::Length(5),
1020            Constraint::Length(12),
1021            Constraint::Length(10),
1022            Constraint::Length(5),
1023            Constraint::Length(12),
1024            Constraint::Length(10),
1025            Constraint::Length(12),
1026        ],
1027    )
1028    .column_spacing(1)
1029    .block(
1030        Block::default()
1031            .title(" Total ")
1032            .borders(Borders::ALL)
1033            .border_style(Style::default().fg(Color::DarkGray)),
1034    );
1035    frame.render_widget(total_table, strategy_chunks[0]);
1036
1037    let render_strategy_window =
1038        |frame: &mut Frame,
1039         area: Rect,
1040         title: &str,
1041         indices: &[usize],
1042         state: &AppState,
1043         pnl_sum: f64,
1044         selected_panel: bool| {
1045            let inner_height = area.height.saturating_sub(2);
1046            let row_capacity = inner_height.saturating_sub(1) as usize;
1047            let selected_pos = indices
1048                .iter()
1049                .position(|idx| *idx == state.v2_grid_strategy_index);
1050            let window_start = if row_capacity == 0 {
1051                0
1052            } else if let Some(pos) = selected_pos {
1053                pos.saturating_sub(row_capacity.saturating_sub(1))
1054            } else {
1055                0
1056            };
1057            let window_end = if row_capacity == 0 {
1058                0
1059            } else {
1060                (window_start + row_capacity).min(indices.len())
1061            };
1062            let visible_indices = if indices.is_empty() || row_capacity == 0 {
1063                &indices[0..0]
1064            } else {
1065                &indices[window_start..window_end]
1066            };
1067            let header = Row::new(vec![
1068                Cell::from(" "),
1069                Cell::from("Symbol"),
1070                Cell::from("Strategy"),
1071                Cell::from("Run"),
1072                Cell::from("W"),
1073                Cell::from("L"),
1074                Cell::from("T"),
1075                Cell::from("PnL"),
1076            ])
1077            .style(Style::default().fg(Color::DarkGray));
1078            let mut rows: Vec<Row> = visible_indices
1079                .iter()
1080                .map(|idx| {
1081                    let row_symbol = state
1082                        .strategy_item_symbols
1083                        .get(*idx)
1084                        .map(String::as_str)
1085                        .unwrap_or("-");
1086                    let item = state
1087                        .strategy_items
1088                        .get(*idx)
1089                        .cloned()
1090                        .unwrap_or_else(|| "-".to_string());
1091                    let running = state
1092                        .strategy_item_total_running_ms
1093                        .get(*idx)
1094                        .copied()
1095                        .map(format_running_time)
1096                        .unwrap_or_else(|| "-".to_string());
1097                    let stats = strategy_stats_for_item(&state.strategy_stats, &item);
1098                    let (w, l, t, pnl) = if let Some(s) = stats {
1099                        (
1100                            s.win_count.to_string(),
1101                            s.lose_count.to_string(),
1102                            s.trade_count.to_string(),
1103                            format!("{:+.4}", s.realized_pnl),
1104                        )
1105                    } else {
1106                        ("0".to_string(), "0".to_string(), "0".to_string(), "+0.0000".to_string())
1107                    };
1108                    let marker = if *idx == state.v2_grid_strategy_index {
1109                        "▶"
1110                    } else {
1111                        " "
1112                    };
1113                    let mut row = Row::new(vec![
1114                        Cell::from(marker),
1115                        Cell::from(row_symbol.to_string()),
1116                        Cell::from(item),
1117                        Cell::from(running),
1118                        Cell::from(w),
1119                        Cell::from(l),
1120                        Cell::from(t),
1121                        Cell::from(pnl),
1122                    ]);
1123                    if *idx == state.v2_grid_strategy_index {
1124                        row = row.style(
1125                            Style::default()
1126                                .fg(Color::Yellow)
1127                                .add_modifier(Modifier::BOLD),
1128                        );
1129                    }
1130                    row
1131                })
1132                .collect();
1133
1134            if rows.is_empty() {
1135                rows.push(
1136                    Row::new(vec![
1137                        Cell::from(" "),
1138                        Cell::from("-"),
1139                        Cell::from("(empty)"),
1140                        Cell::from("-"),
1141                        Cell::from("-"),
1142                        Cell::from("-"),
1143                        Cell::from("-"),
1144                        Cell::from("-"),
1145                    ])
1146                    .style(Style::default().fg(Color::DarkGray)),
1147                );
1148            }
1149
1150            let table = Table::new(
1151                rows,
1152                [
1153                    Constraint::Length(2),
1154                    Constraint::Length(12),
1155                    Constraint::Min(16),
1156                    Constraint::Length(9),
1157                    Constraint::Length(3),
1158                    Constraint::Length(3),
1159                    Constraint::Length(4),
1160                    Constraint::Length(11),
1161                ],
1162            )
1163            .header(header)
1164            .column_spacing(1)
1165            .block(
1166                Block::default()
1167                    .title(format!(
1168                        "{} | Total {:+.4} | {}/{}",
1169                        title,
1170                        pnl_sum,
1171                        visible_indices.len(),
1172                        indices.len()
1173                    ))
1174                    .borders(Borders::ALL)
1175                    .border_style(if selected_panel {
1176                        Style::default().fg(Color::Yellow)
1177                    } else {
1178                        Style::default().fg(Color::DarkGray)
1179                    }),
1180            );
1181            frame.render_widget(table, area);
1182        };
1183
1184    render_strategy_window(
1185        frame,
1186        on_area,
1187        " ON Strategies ",
1188        &on_indices,
1189        state,
1190        on_pnl_sum,
1191        state.v2_grid_select_on_panel,
1192    );
1193    render_strategy_window(
1194        frame,
1195        off_area,
1196        " OFF Strategies ",
1197        &off_indices,
1198        state,
1199        off_pnl_sum,
1200        !state.v2_grid_select_on_panel,
1201    );
1202    frame.render_widget(
1203        Paragraph::new(Line::from(vec![
1204            Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
1205            Span::styled(
1206                selected_symbol,
1207                Style::default()
1208                    .fg(Color::Green)
1209                    .add_modifier(Modifier::BOLD),
1210            ),
1211            Span::styled(
1212                "  [Tab]panel [N]new [C]cfg [O]on/off [X]del [J/K]strategy [H/L]symbol [Enter/F]run [G/Esc]close",
1213                Style::default().fg(Color::DarkGray),
1214            ),
1215        ])),
1216        strategy_chunks[2],
1217    );
1218
1219    let heat = format!(
1220        "Risk/Rate Heatmap  global {}/{} | orders {}/{} | account {}/{} | mkt {}/{}",
1221        state.rate_budget_global.used,
1222        state.rate_budget_global.limit,
1223        state.rate_budget_orders.used,
1224        state.rate_budget_orders.limit,
1225        state.rate_budget_account.used,
1226        state.rate_budget_account.limit,
1227        state.rate_budget_market_data.used,
1228        state.rate_budget_market_data.limit
1229    );
1230    frame.render_widget(
1231        Paragraph::new(vec![
1232            Line::from(Span::styled(
1233                "Risk/Rate Heatmap",
1234                Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
1235            )),
1236            Line::from(heat),
1237        ]),
1238        chunks[2],
1239    );
1240
1241    let mut rejection_lines = vec![Line::from(Span::styled(
1242        "Rejection Stream",
1243        Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
1244    ))];
1245    let recent_rejections: Vec<&String> = state
1246        .log_messages
1247        .iter()
1248        .filter(|m| m.contains("[ERR] Rejected"))
1249        .rev()
1250        .take(20)
1251        .collect();
1252    for msg in recent_rejections.into_iter().rev() {
1253        rejection_lines.push(Line::from(Span::styled(
1254            msg.as_str(),
1255            Style::default().fg(Color::Red),
1256        )));
1257    }
1258    if rejection_lines.len() == 1 {
1259        rejection_lines.push(Line::from(Span::styled(
1260            "(no rejections yet)",
1261            Style::default().fg(Color::DarkGray),
1262        )));
1263    }
1264    frame.render_widget(Paragraph::new(rejection_lines), chunks[3]);
1265}
1266
1267fn format_running_time(total_running_ms: u64) -> String {
1268    let total_sec = total_running_ms / 1000;
1269    let days = total_sec / 86_400;
1270    let hours = (total_sec % 86_400) / 3_600;
1271    let minutes = (total_sec % 3_600) / 60;
1272    if days > 0 {
1273        format!("{}d {:02}h", days, hours)
1274    } else {
1275        format!("{:02}h {:02}m", hours, minutes)
1276    }
1277}
1278
1279fn render_strategy_editor_popup(frame: &mut Frame, state: &AppState) {
1280    let area = frame.area();
1281    let popup = Rect {
1282        x: area.x + 8,
1283        y: area.y + 4,
1284        width: area.width.saturating_sub(16).max(50),
1285        height: area.height.saturating_sub(8).max(12),
1286    };
1287    frame.render_widget(Clear, popup);
1288    let block = Block::default()
1289        .title(" Strategy Config ")
1290        .borders(Borders::ALL)
1291        .border_style(Style::default().fg(Color::Yellow));
1292    let inner = block.inner(popup);
1293    frame.render_widget(block, popup);
1294    let selected_name = state
1295        .strategy_items
1296        .get(state.strategy_editor_index)
1297        .map(String::as_str)
1298        .unwrap_or("Unknown");
1299    let rows = [
1300        (
1301            "Symbol",
1302            state
1303                .symbol_items
1304                .get(state.strategy_editor_symbol_index)
1305                .cloned()
1306                .unwrap_or_else(|| state.symbol.clone()),
1307        ),
1308        ("Fast Period", state.strategy_editor_fast.to_string()),
1309        ("Slow Period", state.strategy_editor_slow.to_string()),
1310        ("Cooldown Tick", state.strategy_editor_cooldown.to_string()),
1311    ];
1312    let mut lines = vec![
1313        Line::from(vec![
1314            Span::styled("Target: ", Style::default().fg(Color::DarkGray)),
1315            Span::styled(
1316                selected_name,
1317                Style::default()
1318                    .fg(Color::White)
1319                    .add_modifier(Modifier::BOLD),
1320            ),
1321        ]),
1322        Line::from(Span::styled(
1323            "Use [J/K] field, [H/L] value, [Enter] save+apply symbol, [Esc] cancel",
1324            Style::default().fg(Color::DarkGray),
1325        )),
1326    ];
1327    for (idx, (name, value)) in rows.iter().enumerate() {
1328        let marker = if idx == state.strategy_editor_field {
1329            "▶ "
1330        } else {
1331            "  "
1332        };
1333        let style = if idx == state.strategy_editor_field {
1334            Style::default()
1335                .fg(Color::Yellow)
1336                .add_modifier(Modifier::BOLD)
1337        } else {
1338            Style::default().fg(Color::White)
1339        };
1340        lines.push(Line::from(vec![
1341            Span::styled(marker, Style::default().fg(Color::Yellow)),
1342            Span::styled(format!("{:<14}", name), style),
1343            Span::styled(value, style),
1344        ]));
1345    }
1346    frame.render_widget(Paragraph::new(lines), inner);
1347}
1348
1349fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
1350    let area = frame.area();
1351    let popup = Rect {
1352        x: area.x + 4,
1353        y: area.y + 2,
1354        width: area.width.saturating_sub(8).max(30),
1355        height: area.height.saturating_sub(4).max(10),
1356    };
1357    frame.render_widget(Clear, popup);
1358    let block = Block::default()
1359        .title(" Account Assets ")
1360        .borders(Borders::ALL)
1361        .border_style(Style::default().fg(Color::Cyan));
1362    let inner = block.inner(popup);
1363    frame.render_widget(block, popup);
1364
1365    let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
1366    assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
1367
1368    let mut lines = Vec::with_capacity(assets.len() + 2);
1369    lines.push(Line::from(vec![
1370        Span::styled(
1371            "Asset",
1372            Style::default()
1373                .fg(Color::Cyan)
1374                .add_modifier(Modifier::BOLD),
1375        ),
1376        Span::styled(
1377            "      Free",
1378            Style::default()
1379                .fg(Color::Cyan)
1380                .add_modifier(Modifier::BOLD),
1381        ),
1382    ]));
1383    for (asset, qty) in assets {
1384        lines.push(Line::from(vec![
1385            Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
1386            Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
1387        ]));
1388    }
1389    if lines.len() == 1 {
1390        lines.push(Line::from(Span::styled(
1391            "No assets",
1392            Style::default().fg(Color::DarkGray),
1393        )));
1394    }
1395
1396    frame.render_widget(Paragraph::new(lines), inner);
1397}
1398
1399fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
1400    let area = frame.area();
1401    let popup = Rect {
1402        x: area.x + 2,
1403        y: area.y + 1,
1404        width: area.width.saturating_sub(4).max(40),
1405        height: area.height.saturating_sub(2).max(12),
1406    };
1407    frame.render_widget(Clear, popup);
1408    let block = Block::default()
1409        .title(match bucket {
1410            order_store::HistoryBucket::Day => " History (Day ROI) ",
1411            order_store::HistoryBucket::Hour => " History (Hour ROI) ",
1412            order_store::HistoryBucket::Month => " History (Month ROI) ",
1413        })
1414        .borders(Borders::ALL)
1415        .border_style(Style::default().fg(Color::Cyan));
1416    let inner = block.inner(popup);
1417    frame.render_widget(block, popup);
1418
1419    let max_rows = inner.height.saturating_sub(1) as usize;
1420    let mut visible: Vec<Line> = Vec::new();
1421    for (idx, row) in rows.iter().take(max_rows).enumerate() {
1422        let color = if idx == 0 {
1423            Color::Cyan
1424        } else if row.contains('-') && row.contains('%') {
1425            Color::White
1426        } else {
1427            Color::DarkGray
1428        };
1429        visible.push(Line::from(Span::styled(
1430            row.clone(),
1431            Style::default().fg(color),
1432        )));
1433    }
1434    if visible.is_empty() {
1435        visible.push(Line::from(Span::styled(
1436            "No history rows",
1437            Style::default().fg(Color::DarkGray),
1438        )));
1439    }
1440    frame.render_widget(Paragraph::new(visible), inner);
1441}
1442
1443fn render_selector_popup(
1444    frame: &mut Frame,
1445    title: &str,
1446    items: &[String],
1447    selected: usize,
1448    stats: Option<&HashMap<String, OrderHistoryStats>>,
1449    total_stats: Option<OrderHistoryStats>,
1450    selected_symbol: Option<&str>,
1451) {
1452    let area = frame.area();
1453    let available_width = area.width.saturating_sub(2).max(1);
1454    let width = if stats.is_some() {
1455        let min_width = 44;
1456        let preferred = 84;
1457        preferred
1458            .min(available_width)
1459            .max(min_width.min(available_width))
1460    } else {
1461        let min_width = 24;
1462        let preferred = 48;
1463        preferred
1464            .min(available_width)
1465            .max(min_width.min(available_width))
1466    };
1467    let available_height = area.height.saturating_sub(2).max(1);
1468    let desired_height = if stats.is_some() {
1469        items.len() as u16 + 7
1470    } else {
1471        items.len() as u16 + 4
1472    };
1473    let height = desired_height
1474        .min(available_height)
1475        .max(6.min(available_height));
1476    let popup = Rect {
1477        x: area.x + (area.width.saturating_sub(width)) / 2,
1478        y: area.y + (area.height.saturating_sub(height)) / 2,
1479        width,
1480        height,
1481    };
1482
1483    frame.render_widget(Clear, popup);
1484    let block = Block::default()
1485        .title(title)
1486        .borders(Borders::ALL)
1487        .border_style(Style::default().fg(Color::Cyan));
1488    let inner = block.inner(popup);
1489    frame.render_widget(block, popup);
1490
1491    let mut lines: Vec<Line> = Vec::new();
1492    if stats.is_some() {
1493        if let Some(symbol) = selected_symbol {
1494            lines.push(Line::from(vec![
1495                Span::styled("  Symbol: ", Style::default().fg(Color::DarkGray)),
1496                Span::styled(
1497                    symbol,
1498                    Style::default()
1499                        .fg(Color::Green)
1500                        .add_modifier(Modifier::BOLD),
1501                ),
1502            ]));
1503        }
1504        lines.push(Line::from(vec![Span::styled(
1505            "  Strategy           W    L    T    PnL",
1506            Style::default()
1507                .fg(Color::Cyan)
1508                .add_modifier(Modifier::BOLD),
1509        )]));
1510    }
1511
1512    let mut item_lines: Vec<Line> = items
1513        .iter()
1514        .enumerate()
1515        .map(|(idx, item)| {
1516            let item_text = if let Some(stats_map) = stats {
1517                if let Some(s) = strategy_stats_for_item(stats_map, item) {
1518                    format!(
1519                        "{:<16}  W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
1520                        item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
1521                    )
1522                } else {
1523                    format!("{:<16}  W:0   L:0   T:0   PnL:0.0000", item)
1524                }
1525            } else {
1526                item.clone()
1527            };
1528            if idx == selected {
1529                Line::from(vec![
1530                    Span::styled("▶ ", Style::default().fg(Color::Yellow)),
1531                    Span::styled(
1532                        item_text,
1533                        Style::default()
1534                            .fg(Color::White)
1535                            .add_modifier(Modifier::BOLD),
1536                    ),
1537                ])
1538            } else {
1539                Line::from(vec![
1540                    Span::styled("  ", Style::default()),
1541                    Span::styled(item_text, Style::default().fg(Color::DarkGray)),
1542                ])
1543            }
1544        })
1545        .collect();
1546    lines.append(&mut item_lines);
1547    if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
1548        let mut strategy_sum = OrderHistoryStats::default();
1549        for item in items {
1550            if let Some(s) = strategy_stats_for_item(stats_map, item) {
1551                strategy_sum.trade_count += s.trade_count;
1552                strategy_sum.win_count += s.win_count;
1553                strategy_sum.lose_count += s.lose_count;
1554                strategy_sum.realized_pnl += s.realized_pnl;
1555            }
1556        }
1557        let manual = subtract_stats(t, &strategy_sum);
1558        lines.push(Line::from(vec![Span::styled(
1559            format!(
1560                "  MANUAL(rest)       W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
1561                manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
1562            ),
1563            Style::default().fg(Color::LightBlue),
1564        )]));
1565    }
1566    if let Some(t) = total_stats {
1567        lines.push(Line::from(vec![Span::styled(
1568            format!(
1569                "  TOTAL              W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
1570                t.win_count, t.lose_count, t.trade_count, t.realized_pnl
1571            ),
1572            Style::default()
1573                .fg(Color::Yellow)
1574                .add_modifier(Modifier::BOLD),
1575        )]));
1576    }
1577
1578    frame.render_widget(
1579        Paragraph::new(lines).style(Style::default().fg(Color::White)),
1580        inner,
1581    );
1582}
1583
1584fn strategy_stats_for_item<'a>(
1585    stats_map: &'a HashMap<String, OrderHistoryStats>,
1586    item: &str,
1587) -> Option<&'a OrderHistoryStats> {
1588    if let Some(s) = stats_map.get(item) {
1589        return Some(s);
1590    }
1591    let source_tag = source_tag_for_strategy_item(item);
1592    source_tag.and_then(|tag| {
1593        stats_map
1594            .get(&tag)
1595            .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
1596    })
1597}
1598
1599fn source_tag_for_strategy_item(item: &str) -> Option<String> {
1600    match item {
1601        "MA(Config)" => return Some("cfg".to_string()),
1602        "MA(Fast 5/20)" => return Some("fst".to_string()),
1603        "MA(Slow 20/60)" => return Some("slw".to_string()),
1604        _ => {}
1605    }
1606    if let Some((_, tail)) = item.rsplit_once('[') {
1607        if let Some(tag) = tail.strip_suffix(']') {
1608            let tag = tag.trim();
1609            if !tag.is_empty() {
1610                return Some(tag.to_ascii_lowercase());
1611            }
1612        }
1613    }
1614    None
1615}
1616
1617fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
1618    OrderHistoryStats {
1619        trade_count: total.trade_count.saturating_sub(used.trade_count),
1620        win_count: total.win_count.saturating_sub(used.win_count),
1621        lose_count: total.lose_count.saturating_sub(used.lose_count),
1622        realized_pnl: total.realized_pnl - used.realized_pnl,
1623    }
1624}
1625
1626fn split_symbol_assets(symbol: &str) -> (String, String) {
1627    const QUOTE_SUFFIXES: [&str; 10] = [
1628        "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
1629    ];
1630    for q in QUOTE_SUFFIXES {
1631        if let Some(base) = symbol.strip_suffix(q) {
1632            if !base.is_empty() {
1633                return (base.to_string(), q.to_string());
1634            }
1635        }
1636    }
1637    (symbol.to_string(), String::new())
1638}
1639
1640fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
1641    if fills.is_empty() {
1642        return None;
1643    }
1644    let (base_asset, quote_asset) = split_symbol_assets(symbol);
1645    let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
1646    let mut notional_quote = 0.0;
1647    let mut fee_quote_equiv = 0.0;
1648    let mut quote_convertible = !quote_asset.is_empty();
1649
1650    for f in fills {
1651        if f.qty > 0.0 && f.price > 0.0 {
1652            notional_quote += f.qty * f.price;
1653        }
1654        if f.commission <= 0.0 {
1655            continue;
1656        }
1657        *fee_by_asset.entry(f.commission_asset.clone()).or_insert(0.0) += f.commission;
1658        if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&quote_asset) {
1659            fee_quote_equiv += f.commission;
1660        } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
1661            fee_quote_equiv += f.commission * f.price.max(0.0);
1662        } else {
1663            quote_convertible = false;
1664        }
1665    }
1666
1667    if fee_by_asset.is_empty() {
1668        return Some("0".to_string());
1669    }
1670
1671    if quote_convertible && notional_quote > f64::EPSILON {
1672        let fee_pct = fee_quote_equiv / notional_quote * 100.0;
1673        return Some(format!(
1674            "{:.3}% ({:.4} {})",
1675            fee_pct, fee_quote_equiv, quote_asset
1676        ));
1677    }
1678
1679    let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
1680    items.sort_by(|a, b| a.0.cmp(&b.0));
1681    if items.len() == 1 {
1682        let (asset, amount) = &items[0];
1683        Some(format!("{:.6} {}", amount, asset))
1684    } else {
1685        Some(format!("mixed fees ({})", items.len()))
1686    }
1687}
1688
1689#[cfg(test)]
1690mod tests {
1691    use super::format_last_applied_fee;
1692    use crate::model::order::Fill;
1693
1694    #[test]
1695    fn fee_summary_from_quote_asset_commission() {
1696        let fills = vec![Fill {
1697            price: 2000.0,
1698            qty: 0.5,
1699            commission: 1.0,
1700            commission_asset: "USDT".to_string(),
1701        }];
1702        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
1703        assert_eq!(summary, "0.100% (1.0000 USDT)");
1704    }
1705
1706    #[test]
1707    fn fee_summary_from_base_asset_commission() {
1708        let fills = vec![Fill {
1709            price: 2000.0,
1710            qty: 0.5,
1711            commission: 0.0005,
1712            commission_asset: "ETH".to_string(),
1713        }];
1714        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
1715        assert_eq!(summary, "0.100% (1.0000 USDT)");
1716    }
1717}