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, Clear, Paragraph};
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 account_popup_open: bool,
73    pub history_popup_open: bool,
74    pub focus_popup_open: bool,
75    pub history_rows: Vec<String>,
76    pub history_bucket: order_store::HistoryBucket,
77    pub last_applied_fee: String,
78    pub v2_grid_open: bool,
79    pub v2_state: AppStateV2,
80    pub rate_budget_global: RateBudgetSnapshot,
81    pub rate_budget_orders: RateBudgetSnapshot,
82    pub rate_budget_account: RateBudgetSnapshot,
83    pub rate_budget_market_data: RateBudgetSnapshot,
84}
85
86impl AppState {
87    pub fn new(
88        symbol: &str,
89        strategy_label: &str,
90        price_history_len: usize,
91        candle_interval_ms: u64,
92        timeframe: &str,
93    ) -> Self {
94        Self {
95            symbol: symbol.to_string(),
96            strategy_label: strategy_label.to_string(),
97            candles: Vec::with_capacity(price_history_len),
98            current_candle: None,
99            candle_interval_ms,
100            timeframe: timeframe.to_string(),
101            price_history_len,
102            position: Position::new(symbol.to_string()),
103            last_signal: None,
104            last_order: None,
105            open_order_history: Vec::new(),
106            filled_order_history: Vec::new(),
107            fast_sma: None,
108            slow_sma: None,
109            ws_connected: false,
110            paused: false,
111            tick_count: 0,
112            log_messages: Vec::new(),
113            balances: HashMap::new(),
114            initial_equity_usdt: None,
115            current_equity_usdt: None,
116            history_estimated_total_pnl_usdt: None,
117            fill_markers: Vec::new(),
118            history_trade_count: 0,
119            history_win_count: 0,
120            history_lose_count: 0,
121            history_realized_pnl: 0.0,
122            strategy_stats: HashMap::new(),
123            history_fills: Vec::new(),
124            last_price_update_ms: None,
125            last_price_event_ms: None,
126            last_price_latency_ms: None,
127            last_order_history_update_ms: None,
128            last_order_history_event_ms: None,
129            last_order_history_latency_ms: None,
130            trade_stats_reset_warned: false,
131            symbol_selector_open: false,
132            symbol_selector_index: 0,
133            symbol_items: Vec::new(),
134            strategy_selector_open: false,
135            strategy_selector_index: 0,
136            strategy_items: vec![
137                "MA(Config)".to_string(),
138                "MA(Fast 5/20)".to_string(),
139                "MA(Slow 20/60)".to_string(),
140            ],
141            account_popup_open: false,
142            history_popup_open: false,
143            focus_popup_open: false,
144            history_rows: Vec::new(),
145            history_bucket: order_store::HistoryBucket::Day,
146            last_applied_fee: "---".to_string(),
147            v2_grid_open: false,
148            v2_state: AppStateV2::new(),
149            rate_budget_global: RateBudgetSnapshot {
150                used: 0,
151                limit: 0,
152                reset_in_ms: 0,
153            },
154            rate_budget_orders: RateBudgetSnapshot {
155                used: 0,
156                limit: 0,
157                reset_in_ms: 0,
158            },
159            rate_budget_account: RateBudgetSnapshot {
160                used: 0,
161                limit: 0,
162                reset_in_ms: 0,
163            },
164            rate_budget_market_data: RateBudgetSnapshot {
165                used: 0,
166                limit: 0,
167                reset_in_ms: 0,
168            },
169        }
170    }
171
172    /// Get the latest price (from current candle or last finalized candle).
173    pub fn last_price(&self) -> Option<f64> {
174        self.current_candle
175            .as_ref()
176            .map(|cb| cb.close)
177            .or_else(|| self.candles.last().map(|c| c.close))
178    }
179
180    pub fn push_log(&mut self, msg: String) {
181        self.log_messages.push(msg);
182        if self.log_messages.len() > MAX_LOG_MESSAGES {
183            self.log_messages.remove(0);
184        }
185    }
186
187    pub fn refresh_history_rows(&mut self) {
188        match order_store::load_realized_returns_by_bucket(self.history_bucket, 400) {
189            Ok(rows) => {
190                use std::collections::{BTreeMap, BTreeSet};
191
192                let mut date_set: BTreeSet<String> = BTreeSet::new();
193                let mut ticker_map: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
194                for row in rows {
195                    date_set.insert(row.date.clone());
196                    ticker_map
197                        .entry(row.symbol.clone())
198                        .or_default()
199                        .insert(row.date, row.realized_return_pct);
200                }
201
202                // Keep recent dates only to avoid horizontal overflow in terminal.
203                let mut dates: Vec<String> = date_set.into_iter().collect();
204                dates.sort();
205                const MAX_DATE_COLS: usize = 6;
206                if dates.len() > MAX_DATE_COLS {
207                    dates = dates[dates.len() - MAX_DATE_COLS..].to_vec();
208                }
209
210                let mut lines = Vec::new();
211                if dates.is_empty() {
212                    lines.push("Ticker            (no daily realized roi data)".to_string());
213                    self.history_rows = lines;
214                    return;
215                }
216
217                let mut header = format!("{:<14}", "Ticker");
218                for d in &dates {
219                    header.push_str(&format!(" {:>10}", d));
220                }
221                lines.push(header);
222
223                for (ticker, by_date) in ticker_map {
224                    let mut line = format!("{:<14}", ticker);
225                    for d in &dates {
226                        let cell = by_date
227                            .get(d)
228                            .map(|v| format!("{:.2}%", v))
229                            .unwrap_or_else(|| "-".to_string());
230                        line.push_str(&format!(" {:>10}", cell));
231                    }
232                    lines.push(line);
233                }
234                self.history_rows = lines;
235            }
236            Err(e) => {
237                self.history_rows = vec![
238                    "Ticker           Date         RealizedROI   RealizedPnL".to_string(),
239                    format!("(failed to load history: {})", e),
240                ];
241            }
242        }
243    }
244
245    fn refresh_equity_usdt(&mut self) {
246        let usdt = self.balances.get("USDT").copied().unwrap_or(0.0);
247        let btc = self.balances.get("BTC").copied().unwrap_or(0.0);
248        let mark_price = self
249            .last_price()
250            .or_else(|| (self.position.entry_price > 0.0).then_some(self.position.entry_price));
251        if let Some(price) = mark_price {
252            let total = usdt + btc * price;
253            self.current_equity_usdt = Some(total);
254            self.recompute_initial_equity_from_history();
255        }
256    }
257
258    fn recompute_initial_equity_from_history(&mut self) {
259        if let Some(current) = self.current_equity_usdt {
260            if let Some(total_pnl) = self.history_estimated_total_pnl_usdt {
261                self.initial_equity_usdt = Some(current - total_pnl);
262            } else if self.history_trade_count == 0 && self.initial_equity_usdt.is_none() {
263                self.initial_equity_usdt = Some(current);
264            }
265        }
266    }
267
268    fn candle_index_for_timestamp(&self, timestamp_ms: u64) -> Option<usize> {
269        if let Some((idx, _)) = self
270            .candles
271            .iter()
272            .enumerate()
273            .find(|(_, c)| timestamp_ms >= c.open_time && timestamp_ms < c.close_time)
274        {
275            return Some(idx);
276        }
277        if let Some(cb) = &self.current_candle {
278            if cb.contains(timestamp_ms) {
279                return Some(self.candles.len());
280            }
281        }
282        // Fallback: if timestamp is newer than the latest finalized candle range
283        // (e.g. coarse timeframe like 1M and no in-progress bucket), pin to nearest past candle.
284        if let Some((idx, _)) = self
285            .candles
286            .iter()
287            .enumerate()
288            .rev()
289            .find(|(_, c)| c.open_time <= timestamp_ms)
290        {
291            return Some(idx);
292        }
293        None
294    }
295
296    fn rebuild_fill_markers_from_history(&mut self, fills: &[OrderHistoryFill]) {
297        self.fill_markers.clear();
298        for fill in fills {
299            if let Some(candle_index) = self.candle_index_for_timestamp(fill.timestamp_ms) {
300                self.fill_markers.push(FillMarker {
301                    candle_index,
302                    price: fill.price,
303                    side: fill.side,
304                });
305            }
306        }
307        if self.fill_markers.len() > MAX_FILL_MARKERS {
308            let excess = self.fill_markers.len() - MAX_FILL_MARKERS;
309            self.fill_markers.drain(..excess);
310        }
311    }
312
313    pub fn apply(&mut self, event: AppEvent) {
314        let prev_focus = self.v2_state.focus.clone();
315        match event {
316            AppEvent::MarketTick(tick) => {
317                self.tick_count += 1;
318                let now_ms = chrono::Utc::now().timestamp_millis() as u64;
319                self.last_price_update_ms = Some(now_ms);
320                self.last_price_event_ms = Some(tick.timestamp_ms);
321                self.last_price_latency_ms = Some(now_ms.saturating_sub(tick.timestamp_ms));
322
323                // Aggregate tick into candles
324                let should_new = match &self.current_candle {
325                    Some(cb) => !cb.contains(tick.timestamp_ms),
326                    None => true,
327                };
328                if should_new {
329                    if let Some(cb) = self.current_candle.take() {
330                        self.candles.push(cb.finish());
331                        if self.candles.len() > self.price_history_len {
332                            self.candles.remove(0);
333                            // Shift marker indices when oldest candle is trimmed.
334                            self.fill_markers.retain_mut(|m| {
335                                if m.candle_index == 0 {
336                                    false
337                                } else {
338                                    m.candle_index -= 1;
339                                    true
340                                }
341                            });
342                        }
343                    }
344                    self.current_candle = Some(CandleBuilder::new(
345                        tick.price,
346                        tick.timestamp_ms,
347                        self.candle_interval_ms,
348                    ));
349                } else if let Some(cb) = self.current_candle.as_mut() {
350                    cb.update(tick.price);
351                } else {
352                    // Defensive fallback: avoid panic if tick ordering/state gets out of sync.
353                    self.current_candle = Some(CandleBuilder::new(
354                        tick.price,
355                        tick.timestamp_ms,
356                        self.candle_interval_ms,
357                    ));
358                    self.push_log("[WARN] Recovered missing current candle state".to_string());
359                }
360
361                self.position.update_unrealized_pnl(tick.price);
362                self.refresh_equity_usdt();
363            }
364            AppEvent::StrategySignal(ref signal) => {
365                self.last_signal = Some(signal.clone());
366                match signal {
367                    Signal::Buy { .. } => {
368                        self.push_log("Signal: BUY".to_string());
369                    }
370                    Signal::Sell { .. } => {
371                        self.push_log("Signal: SELL".to_string());
372                    }
373                    Signal::Hold => {}
374                }
375            }
376            AppEvent::StrategyState { fast_sma, slow_sma } => {
377                self.fast_sma = fast_sma;
378                self.slow_sma = slow_sma;
379            }
380            AppEvent::OrderUpdate(ref update) => {
381                match update {
382                    OrderUpdate::Filled {
383                        intent_id,
384                        client_order_id,
385                        side,
386                        fills,
387                        avg_price,
388                    } => {
389                        if let Some(summary) = format_last_applied_fee(&self.symbol, fills) {
390                            self.last_applied_fee = summary;
391                        }
392                        self.position.apply_fill(*side, fills);
393                        self.refresh_equity_usdt();
394                        let candle_index = if self.current_candle.is_some() {
395                            self.candles.len()
396                        } else {
397                            self.candles.len().saturating_sub(1)
398                        };
399                        self.fill_markers.push(FillMarker {
400                            candle_index,
401                            price: *avg_price,
402                            side: *side,
403                        });
404                        if self.fill_markers.len() > MAX_FILL_MARKERS {
405                            self.fill_markers.remove(0);
406                        }
407                        self.push_log(format!(
408                            "FILLED {} {} ({}) @ {:.2}",
409                            side, client_order_id, intent_id, avg_price
410                        ));
411                    }
412                    OrderUpdate::Submitted {
413                        intent_id,
414                        client_order_id,
415                        server_order_id,
416                    } => {
417                        self.refresh_equity_usdt();
418                        self.push_log(format!(
419                            "Submitted {} (id: {}, {})",
420                            client_order_id, server_order_id, intent_id
421                        ));
422                    }
423                    OrderUpdate::Rejected {
424                        intent_id,
425                        client_order_id,
426                        reason_code,
427                        reason,
428                    } => {
429                        self.push_log(format!(
430                            "[ERR] Rejected {} ({}) [{}]: {}",
431                            client_order_id, intent_id, reason_code, reason
432                        ));
433                    }
434                }
435                self.last_order = Some(update.clone());
436            }
437            AppEvent::WsStatus(ref status) => match status {
438                WsConnectionStatus::Connected => {
439                    self.ws_connected = true;
440                    self.push_log("WebSocket Connected".to_string());
441                }
442                WsConnectionStatus::Disconnected => {
443                    self.ws_connected = false;
444                    self.push_log("[WARN] WebSocket Disconnected".to_string());
445                }
446                WsConnectionStatus::Reconnecting { attempt, delay_ms } => {
447                    self.ws_connected = false;
448                    self.push_log(format!(
449                        "[WARN] Reconnecting (attempt {}, wait {}ms)",
450                        attempt, delay_ms
451                    ));
452                }
453            },
454            AppEvent::HistoricalCandles {
455                candles,
456                interval_ms,
457                interval,
458            } => {
459                self.candles = candles;
460                if self.candles.len() > self.price_history_len {
461                    let excess = self.candles.len() - self.price_history_len;
462                    self.candles.drain(..excess);
463                }
464                self.candle_interval_ms = interval_ms;
465                self.timeframe = interval;
466                self.current_candle = None;
467                let fills = self.history_fills.clone();
468                self.rebuild_fill_markers_from_history(&fills);
469                self.push_log(format!(
470                    "Switched to {} ({} candles)",
471                    self.timeframe,
472                    self.candles.len()
473                ));
474            }
475            AppEvent::BalanceUpdate(balances) => {
476                self.balances = balances;
477                self.refresh_equity_usdt();
478            }
479            AppEvent::OrderHistoryUpdate(snapshot) => {
480                let mut open = Vec::new();
481                let mut filled = Vec::new();
482
483                for row in snapshot.rows {
484                    let status = row.split_whitespace().nth(1).unwrap_or_default();
485                    if status == "FILLED" {
486                        filled.push(row);
487                    } else {
488                        open.push(row);
489                    }
490                }
491
492                if open.len() > MAX_LOG_MESSAGES {
493                    let excess = open.len() - MAX_LOG_MESSAGES;
494                    open.drain(..excess);
495                }
496                if filled.len() > MAX_LOG_MESSAGES {
497                    let excess = filled.len() - MAX_LOG_MESSAGES;
498                    filled.drain(..excess);
499                }
500
501                self.open_order_history = open;
502                self.filled_order_history = filled;
503                if snapshot.trade_data_complete {
504                    let stats_looks_reset = snapshot.stats.trade_count == 0
505                        && (self.history_trade_count > 0 || !self.history_fills.is_empty());
506                    if stats_looks_reset {
507                        if !self.trade_stats_reset_warned {
508                            self.push_log(
509                                "[WARN] Ignored transient trade stats reset from order-history sync"
510                                    .to_string(),
511                            );
512                            self.trade_stats_reset_warned = true;
513                        }
514                    } else {
515                        self.trade_stats_reset_warned = false;
516                        self.history_trade_count = snapshot.stats.trade_count;
517                        self.history_win_count = snapshot.stats.win_count;
518                        self.history_lose_count = snapshot.stats.lose_count;
519                        self.history_realized_pnl = snapshot.stats.realized_pnl;
520                        self.strategy_stats = snapshot.strategy_stats;
521                        // Keep position panel aligned with exchange history state
522                        // so Qty/Entry/UnrPL reflect actual holdings, not only session fills.
523                        if snapshot.open_qty > f64::EPSILON {
524                            self.position.side = Some(OrderSide::Buy);
525                            self.position.qty = snapshot.open_qty;
526                            self.position.entry_price = snapshot.open_entry_price;
527                            if let Some(px) = self.last_price() {
528                                self.position.unrealized_pnl =
529                                    (px - snapshot.open_entry_price) * snapshot.open_qty;
530                            }
531                        } else {
532                            self.position.side = None;
533                            self.position.qty = 0.0;
534                            self.position.entry_price = 0.0;
535                            self.position.unrealized_pnl = 0.0;
536                        }
537                    }
538                    if !snapshot.fills.is_empty() || self.history_fills.is_empty() {
539                        self.history_fills = snapshot.fills.clone();
540                        self.rebuild_fill_markers_from_history(&snapshot.fills);
541                    }
542                    self.history_estimated_total_pnl_usdt = snapshot.estimated_total_pnl_usdt;
543                    self.recompute_initial_equity_from_history();
544                }
545                self.last_order_history_update_ms = Some(snapshot.fetched_at_ms);
546                self.last_order_history_event_ms = snapshot.latest_event_ms;
547                self.last_order_history_latency_ms = Some(snapshot.fetch_latency_ms);
548                self.refresh_history_rows();
549            }
550            AppEvent::RiskRateSnapshot {
551                global,
552                orders,
553                account,
554                market_data,
555            } => {
556                self.rate_budget_global = global;
557                self.rate_budget_orders = orders;
558                self.rate_budget_account = account;
559                self.rate_budget_market_data = market_data;
560            }
561            AppEvent::LogMessage(msg) => {
562                self.push_log(msg);
563            }
564            AppEvent::Error(msg) => {
565                self.push_log(format!("[ERR] {}", msg));
566            }
567        }
568        let mut next = AppStateV2::from_legacy(self);
569        if prev_focus.symbol.is_some() {
570            next.focus.symbol = prev_focus.symbol;
571        }
572        if prev_focus.strategy_id.is_some() {
573            next.focus.strategy_id = prev_focus.strategy_id;
574        }
575        self.v2_state = next;
576    }
577}
578
579pub fn render(frame: &mut Frame, state: &AppState) {
580    let outer = Layout::default()
581        .direction(Direction::Vertical)
582        .constraints([
583            Constraint::Length(1), // status bar
584            Constraint::Min(8),    // main area (chart + position)
585            Constraint::Length(5), // order log
586            Constraint::Length(6), // order history
587            Constraint::Length(8), // system log
588            Constraint::Length(1), // keybinds
589        ])
590        .split(frame.area());
591
592    // Status bar
593    frame.render_widget(
594        StatusBar {
595            symbol: &state.symbol,
596            strategy_label: &state.strategy_label,
597            ws_connected: state.ws_connected,
598            paused: state.paused,
599            timeframe: &state.timeframe,
600            last_price_update_ms: state.last_price_update_ms,
601            last_price_latency_ms: state.last_price_latency_ms,
602            last_order_history_update_ms: state.last_order_history_update_ms,
603            last_order_history_latency_ms: state.last_order_history_latency_ms,
604        },
605        outer[0],
606    );
607
608    // Main area: chart + position panel
609    let main_area = Layout::default()
610        .direction(Direction::Horizontal)
611        .constraints([Constraint::Min(40), Constraint::Length(24)])
612        .split(outer[1]);
613
614    // Price chart (candles + in-progress candle)
615    let current_price = state.last_price();
616    frame.render_widget(
617        PriceChart::new(&state.candles, &state.symbol)
618            .current_candle(state.current_candle.as_ref())
619            .fill_markers(&state.fill_markers)
620            .fast_sma(state.fast_sma)
621            .slow_sma(state.slow_sma),
622        main_area[0],
623    );
624
625    // Position panel (with current price and balances)
626    frame.render_widget(
627        PositionPanel::new(
628            &state.position,
629            current_price,
630            &state.balances,
631            state.initial_equity_usdt,
632            state.current_equity_usdt,
633            state.history_trade_count,
634            state.history_realized_pnl,
635            &state.last_applied_fee,
636        ),
637        main_area[1],
638    );
639
640    // Order log
641    frame.render_widget(
642        OrderLogPanel::new(
643            &state.last_signal,
644            &state.last_order,
645            state.fast_sma,
646            state.slow_sma,
647            state.history_trade_count,
648            state.history_win_count,
649            state.history_lose_count,
650            state.history_realized_pnl,
651        ),
652        outer[2],
653    );
654
655    // Order history panel
656    frame.render_widget(
657        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
658        outer[3],
659    );
660
661    // System log panel
662    frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
663
664    // Keybind bar
665    frame.render_widget(KeybindBar, outer[5]);
666
667    if state.symbol_selector_open {
668        render_selector_popup(
669            frame,
670            " Select Symbol ",
671            &state.symbol_items,
672            state.symbol_selector_index,
673            None,
674            None,
675        );
676    } else if state.strategy_selector_open {
677        render_selector_popup(
678            frame,
679            " Select Strategy ",
680            &state.strategy_items,
681            state.strategy_selector_index,
682            Some(&state.strategy_stats),
683            Some(OrderHistoryStats {
684                trade_count: state.history_trade_count,
685                win_count: state.history_win_count,
686                lose_count: state.history_lose_count,
687                realized_pnl: state.history_realized_pnl,
688            }),
689        );
690    } else if state.account_popup_open {
691        render_account_popup(frame, &state.balances);
692    } else if state.history_popup_open {
693        render_history_popup(frame, &state.history_rows, state.history_bucket);
694    } else if state.focus_popup_open {
695        render_focus_popup(frame, state);
696    } else if state.v2_grid_open {
697        render_v2_grid_popup(frame, state);
698    }
699}
700
701fn render_focus_popup(frame: &mut Frame, state: &AppState) {
702    let area = frame.area();
703    let popup = Rect {
704        x: area.x + 1,
705        y: area.y + 1,
706        width: area.width.saturating_sub(2).max(70),
707        height: area.height.saturating_sub(2).max(22),
708    };
709    frame.render_widget(Clear, popup);
710    let block = Block::default()
711        .title(" Focus View (V2 Drill-down) ")
712        .borders(Borders::ALL)
713        .border_style(Style::default().fg(Color::Green));
714    let inner = block.inner(popup);
715    frame.render_widget(block, popup);
716
717    let rows = Layout::default()
718        .direction(Direction::Vertical)
719        .constraints([
720            Constraint::Length(2),
721            Constraint::Min(8),
722            Constraint::Length(7),
723        ])
724        .split(inner);
725
726    let focus_symbol = state
727        .v2_state
728        .focus
729        .symbol
730        .as_deref()
731        .unwrap_or(&state.symbol);
732    let focus_strategy = state
733        .v2_state
734        .focus
735        .strategy_id
736        .as_deref()
737        .unwrap_or(&state.strategy_label);
738    frame.render_widget(
739        Paragraph::new(vec![
740            Line::from(vec![
741                Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
742                Span::styled(
743                    focus_symbol,
744                    Style::default()
745                        .fg(Color::Cyan)
746                        .add_modifier(Modifier::BOLD),
747                ),
748                Span::styled("  Strategy: ", Style::default().fg(Color::DarkGray)),
749                Span::styled(
750                    focus_strategy,
751                    Style::default()
752                        .fg(Color::Magenta)
753                        .add_modifier(Modifier::BOLD),
754                ),
755            ]),
756            Line::from(Span::styled(
757                "Reuse legacy chart/position/history widgets. Press [F]/[Esc] to close.",
758                Style::default().fg(Color::DarkGray),
759            )),
760        ]),
761        rows[0],
762    );
763
764    let main_cols = Layout::default()
765        .direction(Direction::Horizontal)
766        .constraints([Constraint::Min(48), Constraint::Length(28)])
767        .split(rows[1]);
768
769    frame.render_widget(
770        PriceChart::new(&state.candles, focus_symbol)
771            .current_candle(state.current_candle.as_ref())
772            .fill_markers(&state.fill_markers)
773            .fast_sma(state.fast_sma)
774            .slow_sma(state.slow_sma),
775        main_cols[0],
776    );
777    frame.render_widget(
778        PositionPanel::new(
779            &state.position,
780            state.last_price(),
781            &state.balances,
782            state.initial_equity_usdt,
783            state.current_equity_usdt,
784            state.history_trade_count,
785            state.history_realized_pnl,
786            &state.last_applied_fee,
787        ),
788        main_cols[1],
789    );
790
791    frame.render_widget(
792        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
793        rows[2],
794    );
795}
796
797fn render_v2_grid_popup(frame: &mut Frame, state: &AppState) {
798    let area = frame.area();
799    let popup = Rect {
800        x: area.x + 1,
801        y: area.y + 1,
802        width: area.width.saturating_sub(2).max(60),
803        height: area.height.saturating_sub(2).max(20),
804    };
805    frame.render_widget(Clear, popup);
806    let block = Block::default()
807        .title(" Portfolio Grid (V2) ")
808        .borders(Borders::ALL)
809        .border_style(Style::default().fg(Color::Cyan));
810    let inner = block.inner(popup);
811    frame.render_widget(block, popup);
812
813    let chunks = Layout::default()
814        .direction(Direction::Vertical)
815        .constraints([
816            Constraint::Length(5),
817            Constraint::Length(6),
818            Constraint::Length(5),
819            Constraint::Min(4),
820        ])
821        .split(inner);
822
823    let mut asset_lines = vec![Line::from(Span::styled(
824        "Asset Table",
825        Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
826    ))];
827    for a in &state.v2_state.assets {
828        asset_lines.push(Line::from(format!(
829            "{}  px={} qty={:.5}  rlz={:+.4}  unrlz={:+.4}",
830            a.symbol,
831            a.last_price
832                .map(|v| format!("{:.2}", v))
833                .unwrap_or_else(|| "---".to_string()),
834            a.position_qty,
835            a.realized_pnl_usdt,
836            a.unrealized_pnl_usdt
837        )));
838    }
839    frame.render_widget(Paragraph::new(asset_lines), chunks[0]);
840
841    let mut strategy_lines = vec![Line::from(Span::styled(
842        "Strategy Table",
843        Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
844    ))];
845    for s in &state.v2_state.strategies {
846        strategy_lines.push(Line::from(format!(
847            "{}  W:{} L:{} T:{}  PnL:{:+.4}",
848            s.strategy_id, s.win_count, s.lose_count, s.trade_count, s.realized_pnl_usdt
849        )));
850    }
851    frame.render_widget(Paragraph::new(strategy_lines), chunks[1]);
852
853    let heat = format!(
854        "Risk/Rate Heatmap  global {}/{} | orders {}/{} | account {}/{} | mkt {}/{}",
855        state.rate_budget_global.used,
856        state.rate_budget_global.limit,
857        state.rate_budget_orders.used,
858        state.rate_budget_orders.limit,
859        state.rate_budget_account.used,
860        state.rate_budget_account.limit,
861        state.rate_budget_market_data.used,
862        state.rate_budget_market_data.limit
863    );
864    frame.render_widget(
865        Paragraph::new(vec![
866            Line::from(Span::styled(
867                "Risk/Rate Heatmap",
868                Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
869            )),
870            Line::from(heat),
871        ]),
872        chunks[2],
873    );
874
875    let mut rejection_lines = vec![Line::from(Span::styled(
876        "Rejection Stream",
877        Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
878    ))];
879    let recent_rejections: Vec<&String> = state
880        .log_messages
881        .iter()
882        .filter(|m| m.contains("[ERR] Rejected"))
883        .rev()
884        .take(20)
885        .collect();
886    for msg in recent_rejections.into_iter().rev() {
887        rejection_lines.push(Line::from(Span::styled(
888            msg.as_str(),
889            Style::default().fg(Color::Red),
890        )));
891    }
892    if rejection_lines.len() == 1 {
893        rejection_lines.push(Line::from(Span::styled(
894            "(no rejections yet)",
895            Style::default().fg(Color::DarkGray),
896        )));
897    }
898    frame.render_widget(Paragraph::new(rejection_lines), chunks[3]);
899}
900
901fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
902    let area = frame.area();
903    let popup = Rect {
904        x: area.x + 4,
905        y: area.y + 2,
906        width: area.width.saturating_sub(8).max(30),
907        height: area.height.saturating_sub(4).max(10),
908    };
909    frame.render_widget(Clear, popup);
910    let block = Block::default()
911        .title(" Account Assets ")
912        .borders(Borders::ALL)
913        .border_style(Style::default().fg(Color::Cyan));
914    let inner = block.inner(popup);
915    frame.render_widget(block, popup);
916
917    let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
918    assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
919
920    let mut lines = Vec::with_capacity(assets.len() + 2);
921    lines.push(Line::from(vec![
922        Span::styled(
923            "Asset",
924            Style::default()
925                .fg(Color::Cyan)
926                .add_modifier(Modifier::BOLD),
927        ),
928        Span::styled(
929            "      Free",
930            Style::default()
931                .fg(Color::Cyan)
932                .add_modifier(Modifier::BOLD),
933        ),
934    ]));
935    for (asset, qty) in assets {
936        lines.push(Line::from(vec![
937            Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
938            Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
939        ]));
940    }
941    if lines.len() == 1 {
942        lines.push(Line::from(Span::styled(
943            "No assets",
944            Style::default().fg(Color::DarkGray),
945        )));
946    }
947
948    frame.render_widget(Paragraph::new(lines), inner);
949}
950
951fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
952    let area = frame.area();
953    let popup = Rect {
954        x: area.x + 2,
955        y: area.y + 1,
956        width: area.width.saturating_sub(4).max(40),
957        height: area.height.saturating_sub(2).max(12),
958    };
959    frame.render_widget(Clear, popup);
960    let block = Block::default()
961        .title(match bucket {
962            order_store::HistoryBucket::Day => " History (Day ROI) ",
963            order_store::HistoryBucket::Hour => " History (Hour ROI) ",
964            order_store::HistoryBucket::Month => " History (Month ROI) ",
965        })
966        .borders(Borders::ALL)
967        .border_style(Style::default().fg(Color::Cyan));
968    let inner = block.inner(popup);
969    frame.render_widget(block, popup);
970
971    let max_rows = inner.height.saturating_sub(1) as usize;
972    let mut visible: Vec<Line> = Vec::new();
973    for (idx, row) in rows.iter().take(max_rows).enumerate() {
974        let color = if idx == 0 {
975            Color::Cyan
976        } else if row.contains('-') && row.contains('%') {
977            Color::White
978        } else {
979            Color::DarkGray
980        };
981        visible.push(Line::from(Span::styled(
982            row.clone(),
983            Style::default().fg(color),
984        )));
985    }
986    if visible.is_empty() {
987        visible.push(Line::from(Span::styled(
988            "No history rows",
989            Style::default().fg(Color::DarkGray),
990        )));
991    }
992    frame.render_widget(Paragraph::new(visible), inner);
993}
994
995fn render_selector_popup(
996    frame: &mut Frame,
997    title: &str,
998    items: &[String],
999    selected: usize,
1000    stats: Option<&HashMap<String, OrderHistoryStats>>,
1001    total_stats: Option<OrderHistoryStats>,
1002) {
1003    let area = frame.area();
1004    let available_width = area.width.saturating_sub(2).max(1);
1005    let width = if stats.is_some() {
1006        let min_width = 44;
1007        let preferred = 84;
1008        preferred
1009            .min(available_width)
1010            .max(min_width.min(available_width))
1011    } else {
1012        let min_width = 24;
1013        let preferred = 48;
1014        preferred
1015            .min(available_width)
1016            .max(min_width.min(available_width))
1017    };
1018    let available_height = area.height.saturating_sub(2).max(1);
1019    let desired_height = if stats.is_some() {
1020        items.len() as u16 + 7
1021    } else {
1022        items.len() as u16 + 4
1023    };
1024    let height = desired_height
1025        .min(available_height)
1026        .max(6.min(available_height));
1027    let popup = Rect {
1028        x: area.x + (area.width.saturating_sub(width)) / 2,
1029        y: area.y + (area.height.saturating_sub(height)) / 2,
1030        width,
1031        height,
1032    };
1033
1034    frame.render_widget(Clear, popup);
1035    let block = Block::default()
1036        .title(title)
1037        .borders(Borders::ALL)
1038        .border_style(Style::default().fg(Color::Cyan));
1039    let inner = block.inner(popup);
1040    frame.render_widget(block, popup);
1041
1042    let mut lines: Vec<Line> = Vec::new();
1043    if stats.is_some() {
1044        lines.push(Line::from(vec![Span::styled(
1045            "  Strategy           W    L    T    PnL",
1046            Style::default()
1047                .fg(Color::Cyan)
1048                .add_modifier(Modifier::BOLD),
1049        )]));
1050    }
1051
1052    let mut item_lines: Vec<Line> = items
1053        .iter()
1054        .enumerate()
1055        .map(|(idx, item)| {
1056            let item_text = if let Some(stats_map) = stats {
1057                if let Some(s) = strategy_stats_for_item(stats_map, item) {
1058                    format!(
1059                        "{:<16}  W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
1060                        item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
1061                    )
1062                } else {
1063                    format!("{:<16}  W:0   L:0   T:0   PnL:0.0000", item)
1064                }
1065            } else {
1066                item.clone()
1067            };
1068            if idx == selected {
1069                Line::from(vec![
1070                    Span::styled("▶ ", Style::default().fg(Color::Yellow)),
1071                    Span::styled(
1072                        item_text,
1073                        Style::default()
1074                            .fg(Color::White)
1075                            .add_modifier(Modifier::BOLD),
1076                    ),
1077                ])
1078            } else {
1079                Line::from(vec![
1080                    Span::styled("  ", Style::default()),
1081                    Span::styled(item_text, Style::default().fg(Color::DarkGray)),
1082                ])
1083            }
1084        })
1085        .collect();
1086    lines.append(&mut item_lines);
1087    if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
1088        let mut strategy_sum = OrderHistoryStats::default();
1089        for item in items {
1090            if let Some(s) = strategy_stats_for_item(stats_map, item) {
1091                strategy_sum.trade_count += s.trade_count;
1092                strategy_sum.win_count += s.win_count;
1093                strategy_sum.lose_count += s.lose_count;
1094                strategy_sum.realized_pnl += s.realized_pnl;
1095            }
1096        }
1097        let manual = subtract_stats(t, &strategy_sum);
1098        lines.push(Line::from(vec![Span::styled(
1099            format!(
1100                "  MANUAL(rest)       W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
1101                manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
1102            ),
1103            Style::default().fg(Color::LightBlue),
1104        )]));
1105    }
1106    if let Some(t) = total_stats {
1107        lines.push(Line::from(vec![Span::styled(
1108            format!(
1109                "  TOTAL              W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
1110                t.win_count, t.lose_count, t.trade_count, t.realized_pnl
1111            ),
1112            Style::default()
1113                .fg(Color::Yellow)
1114                .add_modifier(Modifier::BOLD),
1115        )]));
1116    }
1117
1118    frame.render_widget(
1119        Paragraph::new(lines).style(Style::default().fg(Color::White)),
1120        inner,
1121    );
1122}
1123
1124fn strategy_stats_for_item<'a>(
1125    stats_map: &'a HashMap<String, OrderHistoryStats>,
1126    item: &str,
1127) -> Option<&'a OrderHistoryStats> {
1128    if let Some(s) = stats_map.get(item) {
1129        return Some(s);
1130    }
1131    let source_tag = match item {
1132        "MA(Config)" => Some("cfg"),
1133        "MA(Fast 5/20)" => Some("fst"),
1134        "MA(Slow 20/60)" => Some("slw"),
1135        _ => None,
1136    };
1137    source_tag.and_then(|tag| {
1138        stats_map
1139            .get(tag)
1140            .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
1141    })
1142}
1143
1144fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
1145    OrderHistoryStats {
1146        trade_count: total.trade_count.saturating_sub(used.trade_count),
1147        win_count: total.win_count.saturating_sub(used.win_count),
1148        lose_count: total.lose_count.saturating_sub(used.lose_count),
1149        realized_pnl: total.realized_pnl - used.realized_pnl,
1150    }
1151}
1152
1153fn split_symbol_assets(symbol: &str) -> (String, String) {
1154    const QUOTE_SUFFIXES: [&str; 10] = [
1155        "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
1156    ];
1157    for q in QUOTE_SUFFIXES {
1158        if let Some(base) = symbol.strip_suffix(q) {
1159            if !base.is_empty() {
1160                return (base.to_string(), q.to_string());
1161            }
1162        }
1163    }
1164    (symbol.to_string(), String::new())
1165}
1166
1167fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
1168    if fills.is_empty() {
1169        return None;
1170    }
1171    let (base_asset, quote_asset) = split_symbol_assets(symbol);
1172    let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
1173    let mut notional_quote = 0.0;
1174    let mut fee_quote_equiv = 0.0;
1175    let mut quote_convertible = !quote_asset.is_empty();
1176
1177    for f in fills {
1178        if f.qty > 0.0 && f.price > 0.0 {
1179            notional_quote += f.qty * f.price;
1180        }
1181        if f.commission <= 0.0 {
1182            continue;
1183        }
1184        *fee_by_asset.entry(f.commission_asset.clone()).or_insert(0.0) += f.commission;
1185        if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&quote_asset) {
1186            fee_quote_equiv += f.commission;
1187        } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
1188            fee_quote_equiv += f.commission * f.price.max(0.0);
1189        } else {
1190            quote_convertible = false;
1191        }
1192    }
1193
1194    if fee_by_asset.is_empty() {
1195        return Some("0".to_string());
1196    }
1197
1198    if quote_convertible && notional_quote > f64::EPSILON {
1199        let fee_pct = fee_quote_equiv / notional_quote * 100.0;
1200        return Some(format!(
1201            "{:.3}% ({:.4} {})",
1202            fee_pct, fee_quote_equiv, quote_asset
1203        ));
1204    }
1205
1206    let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
1207    items.sort_by(|a, b| a.0.cmp(&b.0));
1208    if items.len() == 1 {
1209        let (asset, amount) = &items[0];
1210        Some(format!("{:.6} {}", amount, asset))
1211    } else {
1212        Some(format!("mixed fees ({})", items.len()))
1213    }
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218    use super::format_last_applied_fee;
1219    use crate::model::order::Fill;
1220
1221    #[test]
1222    fn fee_summary_from_quote_asset_commission() {
1223        let fills = vec![Fill {
1224            price: 2000.0,
1225            qty: 0.5,
1226            commission: 1.0,
1227            commission_asset: "USDT".to_string(),
1228        }];
1229        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
1230        assert_eq!(summary, "0.100% (1.0000 USDT)");
1231    }
1232
1233    #[test]
1234    fn fee_summary_from_base_asset_commission() {
1235        let fills = vec![Fill {
1236            price: 2000.0,
1237            qty: 0.5,
1238            commission: 0.0005,
1239            commission_asset: "ETH".to_string(),
1240        }];
1241        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
1242        assert_eq!(summary, "0.100% (1.0000 USDT)");
1243    }
1244}