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