Skip to main content

sandbox_quant/ui/
mod.rs

1pub mod chart;
2pub mod dashboard;
3
4use std::collections::HashMap;
5
6use ratatui::layout::{Constraint, Direction, Layout, Rect};
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, Clear, Paragraph};
10use ratatui::Frame;
11
12use crate::event::{AppEvent, WsConnectionStatus};
13use crate::model::candle::{Candle, CandleBuilder};
14use crate::model::order::{Fill, OrderSide};
15use crate::model::position::Position;
16use crate::model::signal::Signal;
17use crate::order_manager::{OrderHistoryFill, OrderHistoryStats, OrderUpdate};
18use crate::order_store;
19
20use chart::{FillMarker, PriceChart};
21use dashboard::{KeybindBar, LogPanel, OrderHistoryPanel, OrderLogPanel, PositionPanel, StatusBar};
22
23const MAX_LOG_MESSAGES: usize = 200;
24const MAX_FILL_MARKERS: usize = 200;
25
26pub struct AppState {
27    pub symbol: String,
28    pub strategy_label: String,
29    pub candles: Vec<Candle>,
30    pub current_candle: Option<CandleBuilder>,
31    pub candle_interval_ms: u64,
32    pub timeframe: String,
33    pub price_history_len: usize,
34    pub position: Position,
35    pub last_signal: Option<Signal>,
36    pub last_order: Option<OrderUpdate>,
37    pub open_order_history: Vec<String>,
38    pub filled_order_history: Vec<String>,
39    pub fast_sma: Option<f64>,
40    pub slow_sma: Option<f64>,
41    pub ws_connected: bool,
42    pub paused: bool,
43    pub tick_count: u64,
44    pub log_messages: Vec<String>,
45    pub balances: HashMap<String, f64>,
46    pub initial_equity_usdt: Option<f64>,
47    pub current_equity_usdt: Option<f64>,
48    pub history_estimated_total_pnl_usdt: Option<f64>,
49    pub fill_markers: Vec<FillMarker>,
50    pub history_trade_count: u32,
51    pub history_win_count: u32,
52    pub history_lose_count: u32,
53    pub history_realized_pnl: f64,
54    pub strategy_stats: HashMap<String, OrderHistoryStats>,
55    pub history_fills: Vec<OrderHistoryFill>,
56    pub last_price_update_ms: Option<u64>,
57    pub last_price_event_ms: Option<u64>,
58    pub last_price_latency_ms: Option<u64>,
59    pub last_order_history_update_ms: Option<u64>,
60    pub last_order_history_event_ms: Option<u64>,
61    pub last_order_history_latency_ms: Option<u64>,
62    pub trade_stats_reset_warned: bool,
63    pub symbol_selector_open: bool,
64    pub symbol_selector_index: usize,
65    pub symbol_items: Vec<String>,
66    pub strategy_selector_open: bool,
67    pub strategy_selector_index: usize,
68    pub strategy_items: Vec<String>,
69    pub account_popup_open: bool,
70    pub history_popup_open: bool,
71    pub history_rows: Vec<String>,
72    pub history_bucket: order_store::HistoryBucket,
73    pub last_applied_fee: String,
74}
75
76impl AppState {
77    pub fn new(
78        symbol: &str,
79        strategy_label: &str,
80        price_history_len: usize,
81        candle_interval_ms: u64,
82        timeframe: &str,
83    ) -> Self {
84        Self {
85            symbol: symbol.to_string(),
86            strategy_label: strategy_label.to_string(),
87            candles: Vec::with_capacity(price_history_len),
88            current_candle: None,
89            candle_interval_ms,
90            timeframe: timeframe.to_string(),
91            price_history_len,
92            position: Position::new(symbol.to_string()),
93            last_signal: None,
94            last_order: None,
95            open_order_history: Vec::new(),
96            filled_order_history: Vec::new(),
97            fast_sma: None,
98            slow_sma: None,
99            ws_connected: false,
100            paused: false,
101            tick_count: 0,
102            log_messages: Vec::new(),
103            balances: HashMap::new(),
104            initial_equity_usdt: None,
105            current_equity_usdt: None,
106            history_estimated_total_pnl_usdt: None,
107            fill_markers: Vec::new(),
108            history_trade_count: 0,
109            history_win_count: 0,
110            history_lose_count: 0,
111            history_realized_pnl: 0.0,
112            strategy_stats: HashMap::new(),
113            history_fills: Vec::new(),
114            last_price_update_ms: None,
115            last_price_event_ms: None,
116            last_price_latency_ms: None,
117            last_order_history_update_ms: None,
118            last_order_history_event_ms: None,
119            last_order_history_latency_ms: None,
120            trade_stats_reset_warned: false,
121            symbol_selector_open: false,
122            symbol_selector_index: 0,
123            symbol_items: Vec::new(),
124            strategy_selector_open: false,
125            strategy_selector_index: 0,
126            strategy_items: vec![
127                "MA(Config)".to_string(),
128                "MA(Fast 5/20)".to_string(),
129                "MA(Slow 20/60)".to_string(),
130            ],
131            account_popup_open: false,
132            history_popup_open: false,
133            history_rows: Vec::new(),
134            history_bucket: order_store::HistoryBucket::Day,
135            last_applied_fee: "---".to_string(),
136        }
137    }
138
139    /// Get the latest price (from current candle or last finalized candle).
140    pub fn last_price(&self) -> Option<f64> {
141        self.current_candle
142            .as_ref()
143            .map(|cb| cb.close)
144            .or_else(|| self.candles.last().map(|c| c.close))
145    }
146
147    pub fn push_log(&mut self, msg: String) {
148        self.log_messages.push(msg);
149        if self.log_messages.len() > MAX_LOG_MESSAGES {
150            self.log_messages.remove(0);
151        }
152    }
153
154    pub fn refresh_history_rows(&mut self) {
155        match order_store::load_realized_returns_by_bucket(self.history_bucket, 400) {
156            Ok(rows) => {
157                use std::collections::{BTreeMap, BTreeSet};
158
159                let mut date_set: BTreeSet<String> = BTreeSet::new();
160                let mut ticker_map: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
161                for row in rows {
162                    date_set.insert(row.date.clone());
163                    ticker_map
164                        .entry(row.symbol.clone())
165                        .or_default()
166                        .insert(row.date, row.realized_return_pct);
167                }
168
169                // Keep recent dates only to avoid horizontal overflow in terminal.
170                let mut dates: Vec<String> = date_set.into_iter().collect();
171                dates.sort();
172                const MAX_DATE_COLS: usize = 6;
173                if dates.len() > MAX_DATE_COLS {
174                    dates = dates[dates.len() - MAX_DATE_COLS..].to_vec();
175                }
176
177                let mut lines = Vec::new();
178                if dates.is_empty() {
179                    lines.push("Ticker            (no daily realized roi data)".to_string());
180                    self.history_rows = lines;
181                    return;
182                }
183
184                let mut header = format!("{:<14}", "Ticker");
185                for d in &dates {
186                    header.push_str(&format!(" {:>10}", d));
187                }
188                lines.push(header);
189
190                for (ticker, by_date) in ticker_map {
191                    let mut line = format!("{:<14}", ticker);
192                    for d in &dates {
193                        let cell = by_date
194                            .get(d)
195                            .map(|v| format!("{:.2}%", v))
196                            .unwrap_or_else(|| "-".to_string());
197                        line.push_str(&format!(" {:>10}", cell));
198                    }
199                    lines.push(line);
200                }
201                self.history_rows = lines;
202            }
203            Err(e) => {
204                self.history_rows = vec![
205                    "Ticker           Date         RealizedROI   RealizedPnL".to_string(),
206                    format!("(failed to load history: {})", e),
207                ];
208            }
209        }
210    }
211
212    fn refresh_equity_usdt(&mut self) {
213        let usdt = self.balances.get("USDT").copied().unwrap_or(0.0);
214        let btc = self.balances.get("BTC").copied().unwrap_or(0.0);
215        let mark_price = self
216            .last_price()
217            .or_else(|| (self.position.entry_price > 0.0).then_some(self.position.entry_price));
218        if let Some(price) = mark_price {
219            let total = usdt + btc * price;
220            self.current_equity_usdt = Some(total);
221            self.recompute_initial_equity_from_history();
222        }
223    }
224
225    fn recompute_initial_equity_from_history(&mut self) {
226        if let Some(current) = self.current_equity_usdt {
227            if let Some(total_pnl) = self.history_estimated_total_pnl_usdt {
228                self.initial_equity_usdt = Some(current - total_pnl);
229            } else if self.history_trade_count == 0 && self.initial_equity_usdt.is_none() {
230                self.initial_equity_usdt = Some(current);
231            }
232        }
233    }
234
235    fn candle_index_for_timestamp(&self, timestamp_ms: u64) -> Option<usize> {
236        if let Some((idx, _)) = self
237            .candles
238            .iter()
239            .enumerate()
240            .find(|(_, c)| timestamp_ms >= c.open_time && timestamp_ms < c.close_time)
241        {
242            return Some(idx);
243        }
244        if let Some(cb) = &self.current_candle {
245            if cb.contains(timestamp_ms) {
246                return Some(self.candles.len());
247            }
248        }
249        // Fallback: if timestamp is newer than the latest finalized candle range
250        // (e.g. coarse timeframe like 1M and no in-progress bucket), pin to nearest past candle.
251        if let Some((idx, _)) = self
252            .candles
253            .iter()
254            .enumerate()
255            .rev()
256            .find(|(_, c)| c.open_time <= timestamp_ms)
257        {
258            return Some(idx);
259        }
260        None
261    }
262
263    fn rebuild_fill_markers_from_history(&mut self, fills: &[OrderHistoryFill]) {
264        self.fill_markers.clear();
265        for fill in fills {
266            if let Some(candle_index) = self.candle_index_for_timestamp(fill.timestamp_ms) {
267                self.fill_markers.push(FillMarker {
268                    candle_index,
269                    price: fill.price,
270                    side: fill.side,
271                });
272            }
273        }
274        if self.fill_markers.len() > MAX_FILL_MARKERS {
275            let excess = self.fill_markers.len() - MAX_FILL_MARKERS;
276            self.fill_markers.drain(..excess);
277        }
278    }
279
280    pub fn apply(&mut self, event: AppEvent) {
281        match event {
282            AppEvent::MarketTick(tick) => {
283                self.tick_count += 1;
284                let now_ms = chrono::Utc::now().timestamp_millis() as u64;
285                self.last_price_update_ms = Some(now_ms);
286                self.last_price_event_ms = Some(tick.timestamp_ms);
287                self.last_price_latency_ms = Some(now_ms.saturating_sub(tick.timestamp_ms));
288
289                // Aggregate tick into candles
290                let should_new = match &self.current_candle {
291                    Some(cb) => !cb.contains(tick.timestamp_ms),
292                    None => true,
293                };
294                if should_new {
295                    if let Some(cb) = self.current_candle.take() {
296                        self.candles.push(cb.finish());
297                        if self.candles.len() > self.price_history_len {
298                            self.candles.remove(0);
299                            // Shift marker indices when oldest candle is trimmed.
300                            self.fill_markers.retain_mut(|m| {
301                                if m.candle_index == 0 {
302                                    false
303                                } else {
304                                    m.candle_index -= 1;
305                                    true
306                                }
307                            });
308                        }
309                    }
310                    self.current_candle = Some(CandleBuilder::new(
311                        tick.price,
312                        tick.timestamp_ms,
313                        self.candle_interval_ms,
314                    ));
315                } else if let Some(cb) = self.current_candle.as_mut() {
316                    cb.update(tick.price);
317                } else {
318                    // Defensive fallback: avoid panic if tick ordering/state gets out of sync.
319                    self.current_candle = Some(CandleBuilder::new(
320                        tick.price,
321                        tick.timestamp_ms,
322                        self.candle_interval_ms,
323                    ));
324                    self.push_log("[WARN] Recovered missing current candle state".to_string());
325                }
326
327                self.position.update_unrealized_pnl(tick.price);
328                self.refresh_equity_usdt();
329            }
330            AppEvent::StrategySignal(ref signal) => {
331                self.last_signal = Some(signal.clone());
332                match signal {
333                    Signal::Buy { .. } => {
334                        self.push_log("Signal: BUY".to_string());
335                    }
336                    Signal::Sell { .. } => {
337                        self.push_log("Signal: SELL".to_string());
338                    }
339                    Signal::Hold => {}
340                }
341            }
342            AppEvent::StrategyState { fast_sma, slow_sma } => {
343                self.fast_sma = fast_sma;
344                self.slow_sma = slow_sma;
345            }
346            AppEvent::OrderUpdate(ref update) => {
347                match update {
348                    OrderUpdate::Filled {
349                        intent_id,
350                        client_order_id,
351                        side,
352                        fills,
353                        avg_price,
354                    } => {
355                        if let Some(summary) = format_last_applied_fee(&self.symbol, fills) {
356                            self.last_applied_fee = summary;
357                        }
358                        self.position.apply_fill(*side, fills);
359                        self.refresh_equity_usdt();
360                        let candle_index = if self.current_candle.is_some() {
361                            self.candles.len()
362                        } else {
363                            self.candles.len().saturating_sub(1)
364                        };
365                        self.fill_markers.push(FillMarker {
366                            candle_index,
367                            price: *avg_price,
368                            side: *side,
369                        });
370                        if self.fill_markers.len() > MAX_FILL_MARKERS {
371                            self.fill_markers.remove(0);
372                        }
373                        self.push_log(format!(
374                            "FILLED {} {} ({}) @ {:.2}",
375                            side, client_order_id, intent_id, avg_price
376                        ));
377                    }
378                    OrderUpdate::Submitted {
379                        intent_id,
380                        client_order_id,
381                        server_order_id,
382                    } => {
383                        self.refresh_equity_usdt();
384                        self.push_log(format!(
385                            "Submitted {} (id: {}, {})",
386                            client_order_id, server_order_id, intent_id
387                        ));
388                    }
389                    OrderUpdate::Rejected {
390                        intent_id,
391                        client_order_id,
392                        reason_code,
393                        reason,
394                    } => {
395                        self.push_log(format!(
396                            "[ERR] Rejected {} ({}) [{}]: {}",
397                            client_order_id, intent_id, reason_code, reason
398                        ));
399                    }
400                }
401                self.last_order = Some(update.clone());
402            }
403            AppEvent::WsStatus(ref status) => match status {
404                WsConnectionStatus::Connected => {
405                    self.ws_connected = true;
406                    self.push_log("WebSocket Connected".to_string());
407                }
408                WsConnectionStatus::Disconnected => {
409                    self.ws_connected = false;
410                    self.push_log("[WARN] WebSocket Disconnected".to_string());
411                }
412                WsConnectionStatus::Reconnecting { attempt, delay_ms } => {
413                    self.ws_connected = false;
414                    self.push_log(format!(
415                        "[WARN] Reconnecting (attempt {}, wait {}ms)",
416                        attempt, delay_ms
417                    ));
418                }
419            },
420            AppEvent::HistoricalCandles {
421                candles,
422                interval_ms,
423                interval,
424            } => {
425                self.candles = candles;
426                if self.candles.len() > self.price_history_len {
427                    let excess = self.candles.len() - self.price_history_len;
428                    self.candles.drain(..excess);
429                }
430                self.candle_interval_ms = interval_ms;
431                self.timeframe = interval;
432                self.current_candle = None;
433                let fills = self.history_fills.clone();
434                self.rebuild_fill_markers_from_history(&fills);
435                self.push_log(format!(
436                    "Switched to {} ({} candles)",
437                    self.timeframe,
438                    self.candles.len()
439                ));
440            }
441            AppEvent::BalanceUpdate(balances) => {
442                self.balances = balances;
443                self.refresh_equity_usdt();
444            }
445            AppEvent::OrderHistoryUpdate(snapshot) => {
446                let mut open = Vec::new();
447                let mut filled = Vec::new();
448
449                for row in snapshot.rows {
450                    let status = row.split_whitespace().nth(1).unwrap_or_default();
451                    if status == "FILLED" {
452                        filled.push(row);
453                    } else {
454                        open.push(row);
455                    }
456                }
457
458                if open.len() > MAX_LOG_MESSAGES {
459                    let excess = open.len() - MAX_LOG_MESSAGES;
460                    open.drain(..excess);
461                }
462                if filled.len() > MAX_LOG_MESSAGES {
463                    let excess = filled.len() - MAX_LOG_MESSAGES;
464                    filled.drain(..excess);
465                }
466
467                self.open_order_history = open;
468                self.filled_order_history = filled;
469                if snapshot.trade_data_complete {
470                    let stats_looks_reset = snapshot.stats.trade_count == 0
471                        && (self.history_trade_count > 0 || !self.history_fills.is_empty());
472                    if stats_looks_reset {
473                        if !self.trade_stats_reset_warned {
474                            self.push_log(
475                                "[WARN] Ignored transient trade stats reset from order-history sync"
476                                    .to_string(),
477                            );
478                            self.trade_stats_reset_warned = true;
479                        }
480                    } else {
481                        self.trade_stats_reset_warned = false;
482                        self.history_trade_count = snapshot.stats.trade_count;
483                        self.history_win_count = snapshot.stats.win_count;
484                        self.history_lose_count = snapshot.stats.lose_count;
485                        self.history_realized_pnl = snapshot.stats.realized_pnl;
486                        self.strategy_stats = snapshot.strategy_stats;
487                        // Keep position panel aligned with exchange history state
488                        // so Qty/Entry/UnrPL reflect actual holdings, not only session fills.
489                        if snapshot.open_qty > f64::EPSILON {
490                            self.position.side = Some(OrderSide::Buy);
491                            self.position.qty = snapshot.open_qty;
492                            self.position.entry_price = snapshot.open_entry_price;
493                            if let Some(px) = self.last_price() {
494                                self.position.unrealized_pnl =
495                                    (px - snapshot.open_entry_price) * snapshot.open_qty;
496                            }
497                        } else {
498                            self.position.side = None;
499                            self.position.qty = 0.0;
500                            self.position.entry_price = 0.0;
501                            self.position.unrealized_pnl = 0.0;
502                        }
503                    }
504                    if !snapshot.fills.is_empty() || self.history_fills.is_empty() {
505                        self.history_fills = snapshot.fills.clone();
506                        self.rebuild_fill_markers_from_history(&snapshot.fills);
507                    }
508                    self.history_estimated_total_pnl_usdt = snapshot.estimated_total_pnl_usdt;
509                    self.recompute_initial_equity_from_history();
510                }
511                self.last_order_history_update_ms = Some(snapshot.fetched_at_ms);
512                self.last_order_history_event_ms = snapshot.latest_event_ms;
513                self.last_order_history_latency_ms = Some(snapshot.fetch_latency_ms);
514                self.refresh_history_rows();
515            }
516            AppEvent::LogMessage(msg) => {
517                self.push_log(msg);
518            }
519            AppEvent::Error(msg) => {
520                self.push_log(format!("[ERR] {}", msg));
521            }
522        }
523    }
524}
525
526pub fn render(frame: &mut Frame, state: &AppState) {
527    let outer = Layout::default()
528        .direction(Direction::Vertical)
529        .constraints([
530            Constraint::Length(1), // status bar
531            Constraint::Min(8),    // main area (chart + position)
532            Constraint::Length(5), // order log
533            Constraint::Length(6), // order history
534            Constraint::Length(8), // system log
535            Constraint::Length(1), // keybinds
536        ])
537        .split(frame.area());
538
539    // Status bar
540    frame.render_widget(
541        StatusBar {
542            symbol: &state.symbol,
543            strategy_label: &state.strategy_label,
544            ws_connected: state.ws_connected,
545            paused: state.paused,
546            timeframe: &state.timeframe,
547            last_price_update_ms: state.last_price_update_ms,
548            last_price_latency_ms: state.last_price_latency_ms,
549            last_order_history_update_ms: state.last_order_history_update_ms,
550            last_order_history_latency_ms: state.last_order_history_latency_ms,
551        },
552        outer[0],
553    );
554
555    // Main area: chart + position panel
556    let main_area = Layout::default()
557        .direction(Direction::Horizontal)
558        .constraints([Constraint::Min(40), Constraint::Length(24)])
559        .split(outer[1]);
560
561    // Price chart (candles + in-progress candle)
562    let current_price = state.last_price();
563    frame.render_widget(
564        PriceChart::new(&state.candles, &state.symbol)
565            .current_candle(state.current_candle.as_ref())
566            .fill_markers(&state.fill_markers)
567            .fast_sma(state.fast_sma)
568            .slow_sma(state.slow_sma),
569        main_area[0],
570    );
571
572    // Position panel (with current price and balances)
573    frame.render_widget(
574        PositionPanel::new(
575            &state.position,
576            current_price,
577            &state.balances,
578            state.initial_equity_usdt,
579            state.current_equity_usdt,
580            state.history_trade_count,
581            state.history_realized_pnl,
582            &state.last_applied_fee,
583        ),
584        main_area[1],
585    );
586
587    // Order log
588    frame.render_widget(
589        OrderLogPanel::new(
590            &state.last_signal,
591            &state.last_order,
592            state.fast_sma,
593            state.slow_sma,
594            state.history_trade_count,
595            state.history_win_count,
596            state.history_lose_count,
597            state.history_realized_pnl,
598        ),
599        outer[2],
600    );
601
602    // Order history panel
603    frame.render_widget(
604        OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
605        outer[3],
606    );
607
608    // System log panel
609    frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
610
611    // Keybind bar
612    frame.render_widget(KeybindBar, outer[5]);
613
614    if state.symbol_selector_open {
615        render_selector_popup(
616            frame,
617            " Select Symbol ",
618            &state.symbol_items,
619            state.symbol_selector_index,
620            None,
621            None,
622        );
623    } else if state.strategy_selector_open {
624        render_selector_popup(
625            frame,
626            " Select Strategy ",
627            &state.strategy_items,
628            state.strategy_selector_index,
629            Some(&state.strategy_stats),
630            Some(OrderHistoryStats {
631                trade_count: state.history_trade_count,
632                win_count: state.history_win_count,
633                lose_count: state.history_lose_count,
634                realized_pnl: state.history_realized_pnl,
635            }),
636        );
637    } else if state.account_popup_open {
638        render_account_popup(frame, &state.balances);
639    } else if state.history_popup_open {
640        render_history_popup(frame, &state.history_rows, state.history_bucket);
641    }
642}
643
644fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
645    let area = frame.area();
646    let popup = Rect {
647        x: area.x + 4,
648        y: area.y + 2,
649        width: area.width.saturating_sub(8).max(30),
650        height: area.height.saturating_sub(4).max(10),
651    };
652    frame.render_widget(Clear, popup);
653    let block = Block::default()
654        .title(" Account Assets ")
655        .borders(Borders::ALL)
656        .border_style(Style::default().fg(Color::Cyan));
657    let inner = block.inner(popup);
658    frame.render_widget(block, popup);
659
660    let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
661    assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
662
663    let mut lines = Vec::with_capacity(assets.len() + 2);
664    lines.push(Line::from(vec![
665        Span::styled(
666            "Asset",
667            Style::default()
668                .fg(Color::Cyan)
669                .add_modifier(Modifier::BOLD),
670        ),
671        Span::styled(
672            "      Free",
673            Style::default()
674                .fg(Color::Cyan)
675                .add_modifier(Modifier::BOLD),
676        ),
677    ]));
678    for (asset, qty) in assets {
679        lines.push(Line::from(vec![
680            Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
681            Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
682        ]));
683    }
684    if lines.len() == 1 {
685        lines.push(Line::from(Span::styled(
686            "No assets",
687            Style::default().fg(Color::DarkGray),
688        )));
689    }
690
691    frame.render_widget(Paragraph::new(lines), inner);
692}
693
694fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
695    let area = frame.area();
696    let popup = Rect {
697        x: area.x + 2,
698        y: area.y + 1,
699        width: area.width.saturating_sub(4).max(40),
700        height: area.height.saturating_sub(2).max(12),
701    };
702    frame.render_widget(Clear, popup);
703    let block = Block::default()
704        .title(match bucket {
705            order_store::HistoryBucket::Day => " History (Day ROI) ",
706            order_store::HistoryBucket::Hour => " History (Hour ROI) ",
707            order_store::HistoryBucket::Month => " History (Month ROI) ",
708        })
709        .borders(Borders::ALL)
710        .border_style(Style::default().fg(Color::Cyan));
711    let inner = block.inner(popup);
712    frame.render_widget(block, popup);
713
714    let max_rows = inner.height.saturating_sub(1) as usize;
715    let mut visible: Vec<Line> = Vec::new();
716    for (idx, row) in rows.iter().take(max_rows).enumerate() {
717        let color = if idx == 0 {
718            Color::Cyan
719        } else if row.contains('-') && row.contains('%') {
720            Color::White
721        } else {
722            Color::DarkGray
723        };
724        visible.push(Line::from(Span::styled(
725            row.clone(),
726            Style::default().fg(color),
727        )));
728    }
729    if visible.is_empty() {
730        visible.push(Line::from(Span::styled(
731            "No history rows",
732            Style::default().fg(Color::DarkGray),
733        )));
734    }
735    frame.render_widget(Paragraph::new(visible), inner);
736}
737
738fn render_selector_popup(
739    frame: &mut Frame,
740    title: &str,
741    items: &[String],
742    selected: usize,
743    stats: Option<&HashMap<String, OrderHistoryStats>>,
744    total_stats: Option<OrderHistoryStats>,
745) {
746    let area = frame.area();
747    let available_width = area.width.saturating_sub(2).max(1);
748    let width = if stats.is_some() {
749        let min_width = 44;
750        let preferred = 84;
751        preferred
752            .min(available_width)
753            .max(min_width.min(available_width))
754    } else {
755        let min_width = 24;
756        let preferred = 48;
757        preferred
758            .min(available_width)
759            .max(min_width.min(available_width))
760    };
761    let available_height = area.height.saturating_sub(2).max(1);
762    let desired_height = if stats.is_some() {
763        items.len() as u16 + 7
764    } else {
765        items.len() as u16 + 4
766    };
767    let height = desired_height
768        .min(available_height)
769        .max(6.min(available_height));
770    let popup = Rect {
771        x: area.x + (area.width.saturating_sub(width)) / 2,
772        y: area.y + (area.height.saturating_sub(height)) / 2,
773        width,
774        height,
775    };
776
777    frame.render_widget(Clear, popup);
778    let block = Block::default()
779        .title(title)
780        .borders(Borders::ALL)
781        .border_style(Style::default().fg(Color::Cyan));
782    let inner = block.inner(popup);
783    frame.render_widget(block, popup);
784
785    let mut lines: Vec<Line> = Vec::new();
786    if stats.is_some() {
787        lines.push(Line::from(vec![Span::styled(
788            "  Strategy           W    L    T    PnL",
789            Style::default()
790                .fg(Color::Cyan)
791                .add_modifier(Modifier::BOLD),
792        )]));
793    }
794
795    let mut item_lines: Vec<Line> = items
796        .iter()
797        .enumerate()
798        .map(|(idx, item)| {
799            let item_text = if let Some(stats_map) = stats {
800                if let Some(s) = strategy_stats_for_item(stats_map, item) {
801                    format!(
802                        "{:<16}  W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
803                        item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
804                    )
805                } else {
806                    format!("{:<16}  W:0   L:0   T:0   PnL:0.0000", item)
807                }
808            } else {
809                item.clone()
810            };
811            if idx == selected {
812                Line::from(vec![
813                    Span::styled("▶ ", Style::default().fg(Color::Yellow)),
814                    Span::styled(
815                        item_text,
816                        Style::default()
817                            .fg(Color::White)
818                            .add_modifier(Modifier::BOLD),
819                    ),
820                ])
821            } else {
822                Line::from(vec![
823                    Span::styled("  ", Style::default()),
824                    Span::styled(item_text, Style::default().fg(Color::DarkGray)),
825                ])
826            }
827        })
828        .collect();
829    lines.append(&mut item_lines);
830    if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
831        let mut strategy_sum = OrderHistoryStats::default();
832        for item in items {
833            if let Some(s) = strategy_stats_for_item(stats_map, item) {
834                strategy_sum.trade_count += s.trade_count;
835                strategy_sum.win_count += s.win_count;
836                strategy_sum.lose_count += s.lose_count;
837                strategy_sum.realized_pnl += s.realized_pnl;
838            }
839        }
840        let manual = subtract_stats(t, &strategy_sum);
841        lines.push(Line::from(vec![Span::styled(
842            format!(
843                "  MANUAL(rest)       W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
844                manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
845            ),
846            Style::default().fg(Color::LightBlue),
847        )]));
848    }
849    if let Some(t) = total_stats {
850        lines.push(Line::from(vec![Span::styled(
851            format!(
852                "  TOTAL              W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
853                t.win_count, t.lose_count, t.trade_count, t.realized_pnl
854            ),
855            Style::default()
856                .fg(Color::Yellow)
857                .add_modifier(Modifier::BOLD),
858        )]));
859    }
860
861    frame.render_widget(
862        Paragraph::new(lines).style(Style::default().fg(Color::White)),
863        inner,
864    );
865}
866
867fn strategy_stats_for_item<'a>(
868    stats_map: &'a HashMap<String, OrderHistoryStats>,
869    item: &str,
870) -> Option<&'a OrderHistoryStats> {
871    if let Some(s) = stats_map.get(item) {
872        return Some(s);
873    }
874    let source_tag = match item {
875        "MA(Config)" => Some("cfg"),
876        "MA(Fast 5/20)" => Some("fst"),
877        "MA(Slow 20/60)" => Some("slw"),
878        _ => None,
879    };
880    source_tag.and_then(|tag| {
881        stats_map
882            .get(tag)
883            .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
884    })
885}
886
887fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
888    OrderHistoryStats {
889        trade_count: total.trade_count.saturating_sub(used.trade_count),
890        win_count: total.win_count.saturating_sub(used.win_count),
891        lose_count: total.lose_count.saturating_sub(used.lose_count),
892        realized_pnl: total.realized_pnl - used.realized_pnl,
893    }
894}
895
896fn split_symbol_assets(symbol: &str) -> (String, String) {
897    const QUOTE_SUFFIXES: [&str; 10] = [
898        "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
899    ];
900    for q in QUOTE_SUFFIXES {
901        if let Some(base) = symbol.strip_suffix(q) {
902            if !base.is_empty() {
903                return (base.to_string(), q.to_string());
904            }
905        }
906    }
907    (symbol.to_string(), String::new())
908}
909
910fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
911    if fills.is_empty() {
912        return None;
913    }
914    let (base_asset, quote_asset) = split_symbol_assets(symbol);
915    let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
916    let mut notional_quote = 0.0;
917    let mut fee_quote_equiv = 0.0;
918    let mut quote_convertible = !quote_asset.is_empty();
919
920    for f in fills {
921        if f.qty > 0.0 && f.price > 0.0 {
922            notional_quote += f.qty * f.price;
923        }
924        if f.commission <= 0.0 {
925            continue;
926        }
927        *fee_by_asset.entry(f.commission_asset.clone()).or_insert(0.0) += f.commission;
928        if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&quote_asset) {
929            fee_quote_equiv += f.commission;
930        } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
931            fee_quote_equiv += f.commission * f.price.max(0.0);
932        } else {
933            quote_convertible = false;
934        }
935    }
936
937    if fee_by_asset.is_empty() {
938        return Some("0".to_string());
939    }
940
941    if quote_convertible && notional_quote > f64::EPSILON {
942        let fee_pct = fee_quote_equiv / notional_quote * 100.0;
943        return Some(format!(
944            "{:.3}% ({:.4} {})",
945            fee_pct, fee_quote_equiv, quote_asset
946        ));
947    }
948
949    let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
950    items.sort_by(|a, b| a.0.cmp(&b.0));
951    if items.len() == 1 {
952        let (asset, amount) = &items[0];
953        Some(format!("{:.6} {}", amount, asset))
954    } else {
955        Some(format!("mixed fees ({})", items.len()))
956    }
957}
958
959#[cfg(test)]
960mod tests {
961    use super::format_last_applied_fee;
962    use crate::model::order::Fill;
963
964    #[test]
965    fn fee_summary_from_quote_asset_commission() {
966        let fills = vec![Fill {
967            price: 2000.0,
968            qty: 0.5,
969            commission: 1.0,
970            commission_asset: "USDT".to_string(),
971        }];
972        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
973        assert_eq!(summary, "0.100% (1.0000 USDT)");
974    }
975
976    #[test]
977    fn fee_summary_from_base_asset_commission() {
978        let fills = vec![Fill {
979            price: 2000.0,
980            qty: 0.5,
981            commission: 0.0005,
982            commission_asset: "ETH".to_string(),
983        }];
984        let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
985        assert_eq!(summary, "0.100% (1.0000 USDT)");
986    }
987}