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