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:   ", 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:", 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}
414
415impl Widget for StatusBar<'_> {
416    fn render(self, area: Rect, buf: &mut Buffer) {
417        let fmt_update = |ts_ms: Option<u64>| -> String {
418            ts_ms
419                .and_then(|ts| chrono::Utc.timestamp_millis_opt(ts as i64).single())
420                .map(|dt| {
421                    dt.with_timezone(&chrono::Local)
422                        .format("%H:%M:%S")
423                        .to_string()
424                })
425                .unwrap_or_else(|| "--:--:--".to_string())
426        };
427        let fmt_age = |lat_ms: Option<u64>| -> String {
428            lat_ms
429                .map(|v| format!("{}ms", v))
430                .unwrap_or_else(|| "--".to_string())
431        };
432
433        let conn_status = if self.ws_connected {
434            Span::styled("CONNECTED", Style::default().fg(Color::Green))
435        } else {
436            Span::styled(
437                "DISCONNECTED",
438                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
439            )
440        };
441
442        let pause_status = if self.paused {
443            Span::styled(
444                " STRAT OFF ",
445                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
446            )
447        } else {
448            Span::styled(" STRAT ON ", Style::default().fg(Color::Green))
449        };
450
451        let line = Line::from(vec![
452            Span::styled(
453                " sandbox-quant ",
454                Style::default()
455                    .fg(Color::White)
456                    .add_modifier(Modifier::BOLD),
457            ),
458            Span::styled("| ", Style::default().fg(Color::DarkGray)),
459            Span::styled(self.symbol, Style::default().fg(Color::Cyan)),
460            Span::styled(" | ", Style::default().fg(Color::DarkGray)),
461            Span::styled(self.strategy_label, Style::default().fg(Color::Magenta)),
462            Span::styled(" | ", Style::default().fg(Color::DarkGray)),
463            Span::styled(
464                self.timeframe.to_uppercase(),
465                Style::default()
466                    .fg(Color::Yellow)
467                    .add_modifier(Modifier::BOLD),
468            ),
469            Span::styled(" | ", Style::default().fg(Color::DarkGray)),
470            conn_status,
471            Span::styled(" | ", Style::default().fg(Color::DarkGray)),
472            pause_status,
473            Span::styled(" | ", Style::default().fg(Color::DarkGray)),
474            Span::styled(
475                format!(
476                    "updated:{} lat:{}",
477                    fmt_update(self.last_price_update_ms),
478                    fmt_age(self.last_price_latency_ms)
479                ),
480                Style::default().fg(Color::Blue),
481            ),
482            Span::styled(" | ", Style::default().fg(Color::DarkGray)),
483            Span::styled(
484                format!(
485                    "order-updated:{} lat:{}",
486                    fmt_update(self.last_order_history_update_ms),
487                    fmt_age(self.last_order_history_latency_ms)
488                ),
489                Style::default().fg(Color::Cyan),
490            ),
491        ]);
492
493        buf.set_line(area.x, area.y, &line, area.width);
494    }
495}
496
497/// Scrolling order history panel that shows recent order events.
498pub struct OrderHistoryPanel<'a> {
499    open_messages: &'a [String],
500    filled_messages: &'a [String],
501}
502
503impl<'a> OrderHistoryPanel<'a> {
504    pub fn new(open_messages: &'a [String], filled_messages: &'a [String]) -> Self {
505        Self {
506            open_messages,
507            filled_messages,
508        }
509    }
510}
511
512impl Widget for OrderHistoryPanel<'_> {
513    fn render(self, area: Rect, buf: &mut Buffer) {
514        let block = Block::default()
515            .title(" Order History ")
516            .borders(Borders::ALL)
517            .border_style(Style::default().fg(Color::DarkGray));
518        let inner = block.inner(area);
519        block.render(area, buf);
520
521        let cols = Layout::default()
522            .direction(Direction::Horizontal)
523            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
524            .split(inner);
525
526        let render_list = |title: &str, messages: &[String], area: Rect, buf: &mut Buffer| {
527            let sub_block = Block::default()
528                .title(title)
529                .borders(Borders::ALL)
530                .border_style(Style::default().fg(Color::DarkGray));
531            let inner_height = sub_block.inner(area).height as usize;
532            let visible: Vec<Line> = messages
533                .iter()
534                .rev()
535                .take(inner_height)
536                .rev()
537                .map(|msg| {
538                    let color = if msg.contains("REJECTED") {
539                        Color::Red
540                    } else if msg.contains("FILLED") {
541                        Color::Green
542                    } else if msg.contains("SUBMITTED") || msg.contains("PARTIALLY_FILLED") {
543                        Color::Cyan
544                    } else {
545                        Color::DarkGray
546                    };
547                    Line::from(Span::styled(msg.as_str(), Style::default().fg(color)))
548                })
549                .collect();
550
551            Paragraph::new(visible)
552                .block(sub_block)
553                .wrap(Wrap { trim: true })
554                .render(area, buf);
555        };
556
557        render_list(" Open ", self.open_messages, cols[0], buf);
558        render_list(" Filled ", self.filled_messages, cols[1], buf);
559    }
560}
561
562/// Scrolling system log panel that shows recent events.
563pub struct LogPanel<'a> {
564    messages: &'a [String],
565}
566
567impl<'a> LogPanel<'a> {
568    pub fn new(messages: &'a [String]) -> Self {
569        Self { messages }
570    }
571}
572
573impl Widget for LogPanel<'_> {
574    fn render(self, area: Rect, buf: &mut Buffer) {
575        let block = Block::default()
576            .title(" System Log ")
577            .borders(Borders::ALL)
578            .border_style(Style::default().fg(Color::DarkGray));
579        let inner_height = block.inner(area).height as usize;
580
581        // Take the last N messages that fit in the panel
582        let visible: Vec<Line> = self
583            .messages
584            .iter()
585            .rev()
586            .take(inner_height)
587            .rev()
588            .map(|msg| {
589                let (color, text) = if msg.starts_with("[ERR]") {
590                    (Color::Red, msg.as_str())
591                } else if msg.starts_with("[WARN]") {
592                    (Color::Yellow, msg.as_str())
593                } else if msg.contains("FILLED") || msg.contains("Connected") {
594                    (Color::Green, msg.as_str())
595                } else {
596                    (Color::DarkGray, msg.as_str())
597                };
598                Line::from(Span::styled(text, Style::default().fg(color)))
599            })
600            .collect();
601
602        Paragraph::new(visible)
603            .block(block)
604            .wrap(Wrap { trim: true })
605            .render(area, buf);
606    }
607}
608
609pub struct KeybindBar;
610
611impl Widget for KeybindBar {
612    fn render(self, area: Rect, buf: &mut Buffer) {
613        let line = Line::from(vec![
614            Span::styled(" [Q]", Style::default().fg(Color::Yellow)),
615            Span::styled("quit ", Style::default().fg(Color::DarkGray)),
616            Span::styled("[P]", Style::default().fg(Color::Yellow)),
617            Span::styled("/[R] ", Style::default().fg(Color::DarkGray)),
618            Span::styled("pause/resume ", Style::default().fg(Color::DarkGray)),
619            Span::styled("[B]", Style::default().fg(Color::Green)),
620            Span::styled("/[S] ", Style::default().fg(Color::DarkGray)),
621            Span::styled("buy/sell ", Style::default().fg(Color::DarkGray)),
622            Span::styled("[G]", Style::default().fg(Color::Magenta)),
623            Span::styled(" grid ", Style::default().fg(Color::DarkGray)),
624            Span::styled("| ", Style::default().fg(Color::DarkGray)),
625            Span::styled("TF:", Style::default().fg(Color::Cyan)),
626            Span::styled(" 0/1/H/D/W/M ", Style::default().fg(Color::DarkGray)),
627            Span::styled("| ", Style::default().fg(Color::DarkGray)),
628            Span::styled("More:", Style::default().fg(Color::Magenta)),
629            Span::styled(" T/Y/A/I ", Style::default().fg(Color::DarkGray)),
630        ]);
631
632        buf.set_line(area.x, area.y, &line, area.width);
633    }
634}
635
636pub struct GridKeybindBar;
637
638impl Widget for GridKeybindBar {
639    fn render(self, area: Rect, buf: &mut Buffer) {
640        let line = Line::from(vec![
641            Span::styled(" [Q]", Style::default().fg(Color::Yellow)),
642            Span::styled("quit ", Style::default().fg(Color::DarkGray)),
643            Span::styled("[Tab]", Style::default().fg(Color::Yellow)),
644            Span::styled(" panel ", Style::default().fg(Color::DarkGray)),
645            Span::styled("[J/K]", Style::default().fg(Color::Yellow)),
646            Span::styled(" select ", Style::default().fg(Color::DarkGray)),
647            Span::styled("[H/L]", Style::default().fg(Color::Yellow)),
648            Span::styled(" symbol ", Style::default().fg(Color::DarkGray)),
649            Span::styled("[O]", Style::default().fg(Color::Yellow)),
650            Span::styled(" toggle ", Style::default().fg(Color::DarkGray)),
651            Span::styled("[N]", Style::default().fg(Color::Yellow)),
652            Span::styled(" new ", Style::default().fg(Color::DarkGray)),
653            Span::styled("[C]", Style::default().fg(Color::Yellow)),
654            Span::styled(" cfg ", Style::default().fg(Color::DarkGray)),
655            Span::styled("[X]", Style::default().fg(Color::Yellow)),
656            Span::styled(" del ", Style::default().fg(Color::DarkGray)),
657            Span::styled("[Enter]", Style::default().fg(Color::Yellow)),
658            Span::styled(" run ", Style::default().fg(Color::DarkGray)),
659            Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
660            Span::styled(" close ", Style::default().fg(Color::DarkGray)),
661        ]);
662
663        buf.set_line(area.x, area.y, &line, area.width);
664    }
665}