Skip to main content

sandbox_quant/ui/
dashboard.rs

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