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