Skip to main content

sandbox_quant/ui/
dashboard.rs

1use std::collections::HashMap;
2
3use chrono::TimeZone;
4use ratatui::{
5    buffer::Buffer,
6    layout::{Constraint, Direction, Layout, Rect},
7    style::{Color, Modifier, Style},
8    text::{Line, Span},
9    widgets::{Block, Borders, Paragraph, Widget, Wrap},
10};
11
12use crate::model::order::OrderSide;
13use crate::model::position::Position;
14use crate::model::signal::Signal;
15use crate::order_manager::OrderUpdate;
16
17pub struct PositionPanel<'a> {
18    position: &'a Position,
19    current_price: Option<f64>,
20    balances: &'a HashMap<String, f64>,
21    initial_equity_usdt: Option<f64>,
22    current_equity_usdt: Option<f64>,
23    history_trade_count: u32,
24    history_realized_pnl: f64,
25    last_applied_fee: &'a str,
26}
27
28impl<'a> PositionPanel<'a> {
29    pub fn new(
30        position: &'a Position,
31        current_price: Option<f64>,
32        balances: &'a HashMap<String, f64>,
33        initial_equity_usdt: Option<f64>,
34        current_equity_usdt: Option<f64>,
35        history_trade_count: u32,
36        history_realized_pnl: f64,
37        last_applied_fee: &'a str,
38    ) -> Self {
39        Self {
40            position,
41            current_price,
42            balances,
43            initial_equity_usdt,
44            current_equity_usdt,
45            history_trade_count,
46            history_realized_pnl,
47            last_applied_fee,
48        }
49    }
50}
51
52impl Widget for PositionPanel<'_> {
53    fn render(self, area: Rect, buf: &mut Buffer) {
54        let side_str = match self.position.side {
55            Some(OrderSide::Buy) => "LONG",
56            Some(OrderSide::Sell) => "SHORT",
57            None => "FLAT",
58        };
59        let side_color = match self.position.side {
60            Some(OrderSide::Buy) => Color::Green,
61            Some(OrderSide::Sell) => Color::Red,
62            None => Color::DarkGray,
63        };
64
65        let pnl_color = |val: f64| {
66            if val > 0.0 {
67                Color::Green
68            } else if val < 0.0 {
69                Color::Red
70            } else {
71                Color::White
72            }
73        };
74
75        let price_str = self
76            .current_price
77            .map(|p| format!("{:.2}", p))
78            .unwrap_or_else(|| "---".to_string());
79
80        let usdt_bal = self.balances.get("USDT").copied().unwrap_or(0.0);
81        let btc_bal = self.balances.get("BTC").copied().unwrap_or(0.0);
82        let equity_text = self
83            .current_equity_usdt
84            .map(|v| format!("{:.2}", v))
85            .unwrap_or_else(|| "---".to_string());
86        let equity_delta = match (self.current_equity_usdt, self.initial_equity_usdt) {
87            (Some(cur), Some(init)) => Some(cur - init),
88            _ => None,
89        };
90        let equity_delta_text = equity_delta
91            .map(|v| format!("{:+.2}", v))
92            .unwrap_or_else(|| "---".to_string());
93        let equity_roi_pct = match (self.current_equity_usdt, self.initial_equity_usdt) {
94            (Some(cur), Some(init)) if init.abs() > f64::EPSILON => {
95                Some((cur - init) / init * 100.0)
96            }
97            _ => None,
98        };
99        let equity_roi_text = equity_roi_pct
100            .map(|v| format!("{:+.2}%", v))
101            .unwrap_or_else(|| "---".to_string());
102        // Always prefer persisted history stats so a fresh fill does not make
103        // the panel appear "reset" to session-local counters.
104        let display_realized_pnl = if self.history_trade_count > 0 {
105            self.history_realized_pnl
106        } else {
107            self.position.realized_pnl
108        };
109        let display_trade_count = if self.history_trade_count > 0 {
110            self.history_trade_count
111        } else {
112            self.position.trade_count
113        };
114        let total_pnl = display_realized_pnl + self.position.unrealized_pnl;
115        let total_pnl_text = format!("{:+.4}", total_pnl);
116
117        let lines = vec![
118            Line::from(vec![
119                Span::styled("USDT: ", Style::default().fg(Color::DarkGray)),
120                Span::styled(
121                    format!("{:.2}", usdt_bal),
122                    Style::default()
123                        .fg(Color::Yellow)
124                        .add_modifier(Modifier::BOLD),
125                ),
126            ]),
127            Line::from(vec![
128                Span::styled("BTC:  ", Style::default().fg(Color::DarkGray)),
129                Span::styled(
130                    format!("{:.5}", btc_bal),
131                    Style::default().fg(Color::Yellow),
132                ),
133            ]),
134            Line::from(vec![
135                Span::styled("Eq$:  ", Style::default().fg(Color::DarkGray)),
136                Span::styled(equity_text, Style::default().fg(Color::Cyan)),
137            ]),
138            Line::from(vec![
139                Span::styled("EqΔ:  ", Style::default().fg(Color::DarkGray)),
140                Span::styled(
141                    equity_delta_text,
142                    Style::default().fg(equity_delta.map(pnl_color).unwrap_or(Color::White)),
143                ),
144            ]),
145            Line::from(vec![
146                Span::styled("TotalPnL:", Style::default().fg(Color::DarkGray)),
147                Span::styled(
148                    format!(" {}", total_pnl_text),
149                    Style::default().fg(pnl_color(total_pnl)),
150                ),
151            ]),
152            Line::from(vec![
153                Span::styled("EqROI:", Style::default().fg(Color::DarkGray)),
154                Span::styled(
155                    format!(" {}", equity_roi_text),
156                    Style::default().fg(equity_roi_pct.map(pnl_color).unwrap_or(Color::White)),
157                ),
158            ]),
159            Line::from(Span::styled(
160                "──────────────────────",
161                Style::default().fg(Color::DarkGray),
162            )),
163            Line::from(vec![
164                Span::styled("Price:", Style::default().fg(Color::DarkGray)),
165                Span::styled(
166                    format!(" {}", price_str),
167                    Style::default()
168                        .fg(Color::White)
169                        .add_modifier(Modifier::BOLD),
170                ),
171            ]),
172            Line::from(vec![
173                Span::styled("Side: ", Style::default().fg(Color::DarkGray)),
174                Span::styled(
175                    side_str,
176                    Style::default().fg(side_color).add_modifier(Modifier::BOLD),
177                ),
178            ]),
179            Line::from(vec![
180                Span::styled("Qty:  ", Style::default().fg(Color::DarkGray)),
181                Span::styled(
182                    format!("{:.5}", self.position.qty),
183                    Style::default().fg(Color::White),
184                ),
185            ]),
186            Line::from(vec![
187                Span::styled("Entry:", Style::default().fg(Color::DarkGray)),
188                Span::styled(
189                    format!(" {:.2}", self.position.entry_price),
190                    Style::default().fg(Color::White),
191                ),
192            ]),
193            Line::from(vec![
194                Span::styled("UnrPL:", Style::default().fg(Color::DarkGray)),
195                Span::styled(
196                    format!(" {:.4}", self.position.unrealized_pnl),
197                    Style::default().fg(pnl_color(self.position.unrealized_pnl)),
198                ),
199            ]),
200            Line::from(vec![
201                Span::styled("RlzPL:", Style::default().fg(Color::DarkGray)),
202                Span::styled(
203                    format!(" {:.4}", display_realized_pnl),
204                    Style::default().fg(pnl_color(display_realized_pnl)),
205                ),
206            ]),
207            Line::from(vec![
208                Span::styled("Trades:", Style::default().fg(Color::DarkGray)),
209                Span::styled(
210                    format!(" {}", display_trade_count),
211                    Style::default().fg(Color::White),
212                ),
213            ]),
214            Line::from(vec![
215                Span::styled("Fee:  ", Style::default().fg(Color::DarkGray)),
216                Span::styled(self.last_applied_fee, Style::default().fg(Color::LightBlue)),
217            ]),
218        ];
219
220        let block = Block::default()
221            .title(" Position ")
222            .borders(Borders::ALL)
223            .border_style(Style::default().fg(Color::DarkGray));
224
225        Paragraph::new(lines).block(block).render(area, buf);
226    }
227}
228
229pub struct OrderLogPanel<'a> {
230    last_signal: &'a Option<Signal>,
231    last_order: &'a Option<OrderUpdate>,
232    fast_sma: Option<f64>,
233    slow_sma: Option<f64>,
234    trade_count: u32,
235    win_count: u32,
236    lose_count: u32,
237    realized_pnl: f64,
238}
239
240impl<'a> OrderLogPanel<'a> {
241    pub fn new(
242        last_signal: &'a Option<Signal>,
243        last_order: &'a Option<OrderUpdate>,
244        fast_sma: Option<f64>,
245        slow_sma: Option<f64>,
246        trade_count: u32,
247        win_count: u32,
248        lose_count: u32,
249        realized_pnl: f64,
250    ) -> Self {
251        Self {
252            last_signal,
253            last_order,
254            fast_sma,
255            slow_sma,
256            trade_count,
257            win_count,
258            lose_count,
259            realized_pnl,
260        }
261    }
262}
263
264impl Widget for OrderLogPanel<'_> {
265    fn render(self, area: Rect, buf: &mut Buffer) {
266        let signal_str = match self.last_signal {
267            Some(Signal::Buy { .. }) => "BUY".to_string(),
268            Some(Signal::Sell { .. }) => "SELL".to_string(),
269            Some(Signal::Hold) | None => "---".to_string(),
270        };
271
272        let order_str = match self.last_order {
273            Some(OrderUpdate::Filled {
274                client_order_id,
275                avg_price,
276                ..
277            }) => format!(
278                "FILLED {} @ {:.2}",
279                &client_order_id[..client_order_id.len().min(12)],
280                avg_price
281            ),
282            Some(OrderUpdate::Submitted {
283                client_order_id, ..
284            }) => format!(
285                "SUBMITTED {}",
286                &client_order_id[..client_order_id.len().min(12)]
287            ),
288            Some(OrderUpdate::Rejected { reason, .. }) => {
289                format!("REJECTED: {}", &reason[..reason.len().min(30)])
290            }
291            None => "---".to_string(),
292        };
293
294        let fast_str = self
295            .fast_sma
296            .map(|v| format!("{:.2}", v))
297            .unwrap_or_else(|| "---".to_string());
298        let slow_str = self
299            .slow_sma
300            .map(|v| format!("{:.2}", v))
301            .unwrap_or_else(|| "---".to_string());
302
303        let lines = vec![
304            Line::from(vec![
305                Span::styled("Signal: ", Style::default().fg(Color::DarkGray)),
306                Span::styled(&signal_str, Style::default().fg(Color::Yellow)),
307            ]),
308            Line::from(vec![
309                Span::styled("Order:  ", Style::default().fg(Color::DarkGray)),
310                Span::styled(&order_str, Style::default().fg(Color::Cyan)),
311            ]),
312            Line::from(vec![
313                Span::styled("Fast SMA: ", Style::default().fg(Color::Green)),
314                Span::styled(&fast_str, Style::default().fg(Color::White)),
315                Span::styled("  Slow SMA: ", Style::default().fg(Color::Yellow)),
316                Span::styled(&slow_str, Style::default().fg(Color::White)),
317            ]),
318            Line::from(vec![
319                Span::styled("Trades: ", Style::default().fg(Color::DarkGray)),
320                Span::styled(
321                    format!("{}", self.trade_count),
322                    Style::default().fg(Color::White),
323                ),
324                Span::styled("  Win: ", Style::default().fg(Color::DarkGray)),
325                Span::styled(
326                    format!("{}", self.win_count),
327                    Style::default().fg(Color::Green),
328                ),
329                Span::styled("  Lose: ", Style::default().fg(Color::DarkGray)),
330                Span::styled(
331                    format!("{}", self.lose_count),
332                    Style::default().fg(Color::Red),
333                ),
334                Span::styled("  PnL: ", Style::default().fg(Color::DarkGray)),
335                Span::styled(
336                    format!("{:.4}", self.realized_pnl),
337                    Style::default().fg(if self.realized_pnl >= 0.0 {
338                        Color::Green
339                    } else {
340                        Color::Red
341                    }),
342                ),
343            ]),
344        ];
345
346        let block = Block::default()
347            .title(" Orders & Signals ")
348            .borders(Borders::ALL)
349            .border_style(Style::default().fg(Color::DarkGray));
350
351        Paragraph::new(lines).block(block).render(area, buf);
352    }
353}
354
355pub struct StatusBar<'a> {
356    pub symbol: &'a str,
357    pub strategy_label: &'a str,
358    pub ws_connected: bool,
359    pub paused: bool,
360    pub timeframe: &'a str,
361    pub last_price_update_ms: Option<u64>,
362    pub last_price_latency_ms: Option<u64>,
363    pub last_order_history_update_ms: Option<u64>,
364    pub last_order_history_latency_ms: Option<u64>,
365}
366
367impl Widget for StatusBar<'_> {
368    fn render(self, area: Rect, buf: &mut Buffer) {
369        let fmt_update = |ts_ms: Option<u64>| -> String {
370            ts_ms
371                .and_then(|ts| chrono::Utc.timestamp_millis_opt(ts as i64).single())
372                .map(|dt| {
373                    dt.with_timezone(&chrono::Local)
374                        .format("%H:%M:%S")
375                        .to_string()
376                })
377                .unwrap_or_else(|| "--:--:--".to_string())
378        };
379        let fmt_age = |lat_ms: Option<u64>| -> String {
380            lat_ms
381                .map(|v| format!("{}ms", v))
382                .unwrap_or_else(|| "--".to_string())
383        };
384
385        let conn_status = if self.ws_connected {
386            Span::styled("CONNECTED", Style::default().fg(Color::Green))
387        } else {
388            Span::styled(
389                "DISCONNECTED",
390                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
391            )
392        };
393
394        let pause_status = if self.paused {
395            Span::styled(
396                " STRAT OFF ",
397                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
398            )
399        } else {
400            Span::styled(" STRAT ON ", Style::default().fg(Color::Green))
401        };
402
403        let line = Line::from(vec![
404            Span::styled(
405                " sandbox-quant ",
406                Style::default()
407                    .fg(Color::White)
408                    .add_modifier(Modifier::BOLD),
409            ),
410            Span::styled("| ", Style::default().fg(Color::DarkGray)),
411            Span::styled(self.symbol, Style::default().fg(Color::Cyan)),
412            Span::styled(" | ", Style::default().fg(Color::DarkGray)),
413            Span::styled(self.strategy_label, Style::default().fg(Color::Magenta)),
414            Span::styled(" | ", Style::default().fg(Color::DarkGray)),
415            Span::styled(
416                self.timeframe.to_uppercase(),
417                Style::default()
418                    .fg(Color::Yellow)
419                    .add_modifier(Modifier::BOLD),
420            ),
421            Span::styled(" | ", Style::default().fg(Color::DarkGray)),
422            conn_status,
423            Span::styled(" | ", Style::default().fg(Color::DarkGray)),
424            pause_status,
425            Span::styled(" | ", Style::default().fg(Color::DarkGray)),
426            Span::styled(
427                format!(
428                    "updated:{} lat:{}",
429                    fmt_update(self.last_price_update_ms),
430                    fmt_age(self.last_price_latency_ms)
431                ),
432                Style::default().fg(Color::Blue),
433            ),
434            Span::styled(" | ", Style::default().fg(Color::DarkGray)),
435            Span::styled(
436                format!(
437                    "order-updated:{} lat:{}",
438                    fmt_update(self.last_order_history_update_ms),
439                    fmt_age(self.last_order_history_latency_ms)
440                ),
441                Style::default().fg(Color::Cyan),
442            ),
443        ]);
444
445        buf.set_line(area.x, area.y, &line, area.width);
446    }
447}
448
449/// Scrolling order history panel that shows recent order events.
450pub struct OrderHistoryPanel<'a> {
451    open_messages: &'a [String],
452    filled_messages: &'a [String],
453}
454
455impl<'a> OrderHistoryPanel<'a> {
456    pub fn new(open_messages: &'a [String], filled_messages: &'a [String]) -> Self {
457        Self {
458            open_messages,
459            filled_messages,
460        }
461    }
462}
463
464impl Widget for OrderHistoryPanel<'_> {
465    fn render(self, area: Rect, buf: &mut Buffer) {
466        let block = Block::default()
467            .title(" Order History ")
468            .borders(Borders::ALL)
469            .border_style(Style::default().fg(Color::DarkGray));
470        let inner = block.inner(area);
471        block.render(area, buf);
472
473        let cols = Layout::default()
474            .direction(Direction::Horizontal)
475            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
476            .split(inner);
477
478        let render_list = |title: &str, messages: &[String], area: Rect, buf: &mut Buffer| {
479            let sub_block = Block::default()
480                .title(title)
481                .borders(Borders::ALL)
482                .border_style(Style::default().fg(Color::DarkGray));
483            let inner_height = sub_block.inner(area).height as usize;
484            let visible: Vec<Line> = messages
485                .iter()
486                .rev()
487                .take(inner_height)
488                .rev()
489                .map(|msg| {
490                    let color = if msg.contains("REJECTED") {
491                        Color::Red
492                    } else if msg.contains("FILLED") {
493                        Color::Green
494                    } else if msg.contains("SUBMITTED") || msg.contains("PARTIALLY_FILLED") {
495                        Color::Cyan
496                    } else {
497                        Color::DarkGray
498                    };
499                    Line::from(Span::styled(msg.as_str(), Style::default().fg(color)))
500                })
501                .collect();
502
503            Paragraph::new(visible)
504                .block(sub_block)
505                .wrap(Wrap { trim: true })
506                .render(area, buf);
507        };
508
509        render_list(" Open ", self.open_messages, cols[0], buf);
510        render_list(" Filled ", self.filled_messages, cols[1], buf);
511    }
512}
513
514/// Scrolling system log panel that shows recent events.
515pub struct LogPanel<'a> {
516    messages: &'a [String],
517}
518
519impl<'a> LogPanel<'a> {
520    pub fn new(messages: &'a [String]) -> Self {
521        Self { messages }
522    }
523}
524
525impl Widget for LogPanel<'_> {
526    fn render(self, area: Rect, buf: &mut Buffer) {
527        let block = Block::default()
528            .title(" System Log ")
529            .borders(Borders::ALL)
530            .border_style(Style::default().fg(Color::DarkGray));
531        let inner_height = block.inner(area).height as usize;
532
533        // Take the last N messages that fit in the panel
534        let visible: Vec<Line> = self
535            .messages
536            .iter()
537            .rev()
538            .take(inner_height)
539            .rev()
540            .map(|msg| {
541                let (color, text) = if msg.starts_with("[ERR]") {
542                    (Color::Red, msg.as_str())
543                } else if msg.starts_with("[WARN]") {
544                    (Color::Yellow, msg.as_str())
545                } else if msg.contains("FILLED") || msg.contains("Connected") {
546                    (Color::Green, msg.as_str())
547                } else {
548                    (Color::DarkGray, msg.as_str())
549                };
550                Line::from(Span::styled(text, Style::default().fg(color)))
551            })
552            .collect();
553
554        Paragraph::new(visible)
555            .block(block)
556            .wrap(Wrap { trim: true })
557            .render(area, buf);
558    }
559}
560
561pub struct KeybindBar;
562
563impl Widget for KeybindBar {
564    fn render(self, area: Rect, buf: &mut Buffer) {
565        let line = Line::from(vec![
566            Span::styled(" [Q]", Style::default().fg(Color::Yellow)),
567            Span::styled("uit ", Style::default().fg(Color::DarkGray)),
568            Span::styled("[P]", Style::default().fg(Color::Yellow)),
569            Span::styled("ause ", Style::default().fg(Color::DarkGray)),
570            Span::styled("[R]", Style::default().fg(Color::Yellow)),
571            Span::styled("esume ", Style::default().fg(Color::DarkGray)),
572            Span::styled("[B]", Style::default().fg(Color::Green)),
573            Span::styled("uy ", Style::default().fg(Color::DarkGray)),
574            Span::styled("[S]", Style::default().fg(Color::Red)),
575            Span::styled("ell ", Style::default().fg(Color::DarkGray)),
576            Span::styled("│ ", Style::default().fg(Color::DarkGray)),
577            Span::styled("[1]", Style::default().fg(Color::Cyan)),
578            Span::styled("min ", Style::default().fg(Color::DarkGray)),
579            Span::styled("[0]", Style::default().fg(Color::Cyan)),
580            Span::styled("sec ", Style::default().fg(Color::DarkGray)),
581            Span::styled("[H]", Style::default().fg(Color::Cyan)),
582            Span::styled("our ", Style::default().fg(Color::DarkGray)),
583            Span::styled("[D]", Style::default().fg(Color::Cyan)),
584            Span::styled("ay ", Style::default().fg(Color::DarkGray)),
585            Span::styled("[W]", Style::default().fg(Color::Cyan)),
586            Span::styled("eek ", Style::default().fg(Color::DarkGray)),
587            Span::styled("[M]", Style::default().fg(Color::Cyan)),
588            Span::styled("onth ", Style::default().fg(Color::DarkGray)),
589            Span::styled("│ ", Style::default().fg(Color::DarkGray)),
590            Span::styled("[T]", Style::default().fg(Color::Magenta)),
591            Span::styled("icker ", Style::default().fg(Color::DarkGray)),
592            Span::styled("[Y]", Style::default().fg(Color::Magenta)),
593            Span::styled("strategy ", Style::default().fg(Color::DarkGray)),
594            Span::styled("[A]", Style::default().fg(Color::Magenta)),
595            Span::styled("ccount ", Style::default().fg(Color::DarkGray)),
596            Span::styled("[I]", Style::default().fg(Color::Magenta)),
597            Span::styled("history", Style::default().fg(Color::DarkGray)),
598        ]);
599
600        buf.set_line(area.x, area.y, &line, area.width);
601    }
602}