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