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(
247 self.trade_count.to_string(),
248 Style::default().fg(Color::White),
249 ),
250 ]),
251 Line::from(vec![
252 Span::styled("Win: ", Style::default().fg(Color::DarkGray)),
253 Span::styled(
254 self.win_count.to_string(),
255 Style::default().fg(Color::Green),
256 ),
257 ]),
258 Line::from(vec![
259 Span::styled("Lose: ", Style::default().fg(Color::DarkGray)),
260 Span::styled(self.lose_count.to_string(), Style::default().fg(Color::Red)),
261 ]),
262 Line::from(vec![
263 Span::styled("WinRate: ", Style::default().fg(Color::DarkGray)),
264 Span::styled(
265 format!("{:.1}%", win_rate),
266 Style::default().fg(Color::Cyan),
267 ),
268 ]),
269 Line::from(vec![
270 Span::styled("RlzPL: ", Style::default().fg(Color::DarkGray)),
271 Span::styled(
272 format!("{:+.4}", self.realized_pnl),
273 Style::default().fg(pnl_color),
274 ),
275 ]),
276 ];
277
278 let block = Block::default()
279 .title(" Strategy Metrics ")
280 .borders(Borders::ALL)
281 .border_style(Style::default().fg(Color::DarkGray));
282 Paragraph::new(lines).block(block).render(area, buf);
283 }
284}
285
286pub struct OrderLogPanel<'a> {
287 last_signal: &'a Option<Signal>,
288 last_order: &'a Option<OrderUpdate>,
289 fast_sma: Option<f64>,
290 slow_sma: Option<f64>,
291 trade_count: u32,
292 win_count: u32,
293 lose_count: u32,
294 realized_pnl: f64,
295}
296
297impl<'a> OrderLogPanel<'a> {
298 pub fn new(
299 last_signal: &'a Option<Signal>,
300 last_order: &'a Option<OrderUpdate>,
301 fast_sma: Option<f64>,
302 slow_sma: Option<f64>,
303 trade_count: u32,
304 win_count: u32,
305 lose_count: u32,
306 realized_pnl: f64,
307 ) -> Self {
308 Self {
309 last_signal,
310 last_order,
311 fast_sma,
312 slow_sma,
313 trade_count,
314 win_count,
315 lose_count,
316 realized_pnl,
317 }
318 }
319}
320
321impl Widget for OrderLogPanel<'_> {
322 fn render(self, area: Rect, buf: &mut Buffer) {
323 let signal_str = match self.last_signal {
324 Some(Signal::Buy { .. }) => "BUY".to_string(),
325 Some(Signal::Sell { .. }) => "SELL".to_string(),
326 Some(Signal::Hold) | None => "---".to_string(),
327 };
328
329 let order_str = match self.last_order {
330 Some(OrderUpdate::Filled {
331 client_order_id,
332 avg_price,
333 ..
334 }) => format!(
335 "FILLED {} @ {:.2}",
336 &client_order_id[..client_order_id.len().min(12)],
337 avg_price
338 ),
339 Some(OrderUpdate::Submitted {
340 client_order_id, ..
341 }) => format!(
342 "SUBMITTED {}",
343 &client_order_id[..client_order_id.len().min(12)]
344 ),
345 Some(OrderUpdate::Rejected { reason, .. }) => {
346 format!("REJECTED: {}", &reason[..reason.len().min(30)])
347 }
348 None => "---".to_string(),
349 };
350
351 let fast_str = self
352 .fast_sma
353 .map(|v| format!("{:.2}", v))
354 .unwrap_or_else(|| "---".to_string());
355 let slow_str = self
356 .slow_sma
357 .map(|v| format!("{:.2}", v))
358 .unwrap_or_else(|| "---".to_string());
359
360 let lines = vec![
361 Line::from(vec![
362 Span::styled("Signal: ", Style::default().fg(Color::DarkGray)),
363 Span::styled(&signal_str, Style::default().fg(Color::Yellow)),
364 ]),
365 Line::from(vec![
366 Span::styled("Order: ", Style::default().fg(Color::DarkGray)),
367 Span::styled(&order_str, Style::default().fg(Color::Cyan)),
368 ]),
369 Line::from(vec![
370 Span::styled("Fast SMA: ", Style::default().fg(Color::Green)),
371 Span::styled(&fast_str, Style::default().fg(Color::White)),
372 Span::styled(" Slow SMA: ", Style::default().fg(Color::Yellow)),
373 Span::styled(&slow_str, Style::default().fg(Color::White)),
374 ]),
375 Line::from(vec![
376 Span::styled("Trades: ", Style::default().fg(Color::DarkGray)),
377 Span::styled(
378 format!("{}", self.trade_count),
379 Style::default().fg(Color::White),
380 ),
381 Span::styled(" Win: ", Style::default().fg(Color::DarkGray)),
382 Span::styled(
383 format!("{}", self.win_count),
384 Style::default().fg(Color::Green),
385 ),
386 Span::styled(" Lose: ", Style::default().fg(Color::DarkGray)),
387 Span::styled(
388 format!("{}", self.lose_count),
389 Style::default().fg(Color::Red),
390 ),
391 Span::styled(" PnL: ", Style::default().fg(Color::DarkGray)),
392 Span::styled(
393 format!("{:.4}", self.realized_pnl),
394 Style::default().fg(if self.realized_pnl >= 0.0 {
395 Color::Green
396 } else {
397 Color::Red
398 }),
399 ),
400 ]),
401 ];
402
403 let block = Block::default()
404 .title(" Orders & Signals ")
405 .borders(Borders::ALL)
406 .border_style(Style::default().fg(Color::DarkGray));
407
408 Paragraph::new(lines).block(block).render(area, buf);
409 }
410}
411
412pub struct StatusBar<'a> {
413 pub symbol: &'a str,
414 pub strategy_label: &'a str,
415 pub ws_connected: bool,
416 pub paused: bool,
417 pub timeframe: &'a str,
418 pub last_price_update_ms: Option<u64>,
419 pub last_price_latency_ms: Option<u64>,
420 pub last_order_history_update_ms: Option<u64>,
421 pub last_order_history_latency_ms: Option<u64>,
422 pub close_all_status: Option<&'a str>,
423 pub close_all_running: bool,
424}
425
426impl Widget for StatusBar<'_> {
427 fn render(self, area: Rect, buf: &mut Buffer) {
428 let fmt_update = |ts_ms: Option<u64>| -> String {
429 ts_ms
430 .and_then(|ts| chrono::Utc.timestamp_millis_opt(ts as i64).single())
431 .map(|dt| {
432 dt.with_timezone(&chrono::Local)
433 .format("%H:%M:%S")
434 .to_string()
435 })
436 .unwrap_or_else(|| "--:--:--".to_string())
437 };
438 let fmt_age = |lat_ms: Option<u64>| -> String {
439 lat_ms
440 .map(|v| format!("{}ms", v))
441 .unwrap_or_else(|| "--".to_string())
442 };
443
444 let conn_status = if self.ws_connected {
445 Span::styled("CONNECTED", Style::default().fg(Color::Green))
446 } else {
447 Span::styled(
448 "DISCONNECTED",
449 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
450 )
451 };
452
453 let pause_status = if self.paused {
454 Span::styled(
455 " STRAT OFF ",
456 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
457 )
458 } else {
459 Span::styled(" STRAT ON ", Style::default().fg(Color::Green))
460 };
461
462 let mut spans = vec![
463 Span::styled(
464 " sandbox-quant ",
465 Style::default()
466 .fg(Color::White)
467 .add_modifier(Modifier::BOLD),
468 ),
469 Span::styled("| ", Style::default().fg(Color::DarkGray)),
470 Span::styled(self.symbol, Style::default().fg(Color::Cyan)),
471 Span::styled(" | ", Style::default().fg(Color::DarkGray)),
472 Span::styled(self.strategy_label, Style::default().fg(Color::Magenta)),
473 Span::styled(" | ", Style::default().fg(Color::DarkGray)),
474 Span::styled(
475 self.timeframe.to_uppercase(),
476 Style::default()
477 .fg(Color::Yellow)
478 .add_modifier(Modifier::BOLD),
479 ),
480 Span::styled(" | ", Style::default().fg(Color::DarkGray)),
481 conn_status,
482 Span::styled(" | ", Style::default().fg(Color::DarkGray)),
483 pause_status,
484 Span::styled(" | ", Style::default().fg(Color::DarkGray)),
485 Span::styled(
486 format!(
487 "updated:{} lat:{}",
488 fmt_update(self.last_price_update_ms),
489 fmt_age(self.last_price_latency_ms)
490 ),
491 Style::default().fg(Color::Blue),
492 ),
493 Span::styled(" | ", Style::default().fg(Color::DarkGray)),
494 Span::styled(
495 format!(
496 "order-updated:{} lat:{}",
497 fmt_update(self.last_order_history_update_ms),
498 fmt_age(self.last_order_history_latency_ms)
499 ),
500 Style::default().fg(Color::Cyan),
501 ),
502 ];
503 if let Some(status) = self.close_all_status {
504 spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray)));
505 spans.push(Span::styled(
506 status,
507 Style::default().fg(if self.close_all_running {
508 Color::Yellow
509 } else {
510 Color::LightGreen
511 }),
512 ));
513 }
514 let line = Line::from(spans);
515
516 buf.set_line(area.x, area.y, &line, area.width);
517 }
518}
519
520pub struct OrderHistoryPanel<'a> {
522 open_messages: &'a [String],
523 filled_messages: &'a [String],
524}
525
526impl<'a> OrderHistoryPanel<'a> {
527 pub fn new(open_messages: &'a [String], filled_messages: &'a [String]) -> Self {
528 Self {
529 open_messages,
530 filled_messages,
531 }
532 }
533}
534
535impl Widget for OrderHistoryPanel<'_> {
536 fn render(self, area: Rect, buf: &mut Buffer) {
537 let block = Block::default()
538 .title(" Order History ")
539 .borders(Borders::ALL)
540 .border_style(Style::default().fg(Color::DarkGray));
541 let inner = block.inner(area);
542 block.render(area, buf);
543
544 let cols = Layout::default()
545 .direction(Direction::Horizontal)
546 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
547 .split(inner);
548
549 let render_list = |title: &str, messages: &[String], area: Rect, buf: &mut Buffer| {
550 let sub_block = Block::default()
551 .title(title)
552 .borders(Borders::ALL)
553 .border_style(Style::default().fg(Color::DarkGray));
554 let inner_height = sub_block.inner(area).height as usize;
555 let visible: Vec<Line> = messages
556 .iter()
557 .rev()
558 .take(inner_height)
559 .rev()
560 .map(|msg| {
561 let color = if msg.contains("REJECTED") {
562 Color::Red
563 } else if msg.contains("FILLED") {
564 Color::Green
565 } else if msg.contains("SUBMITTED") || msg.contains("PARTIALLY_FILLED") {
566 Color::Cyan
567 } else {
568 Color::DarkGray
569 };
570 Line::from(Span::styled(msg.as_str(), Style::default().fg(color)))
571 })
572 .collect();
573
574 Paragraph::new(visible)
575 .block(sub_block)
576 .wrap(Wrap { trim: true })
577 .render(area, buf);
578 };
579
580 render_list(" Open ", self.open_messages, cols[0], buf);
581 render_list(" Filled ", self.filled_messages, cols[1], buf);
582 }
583}
584
585pub struct LogPanel<'a> {
587 messages: &'a [String],
588}
589
590impl<'a> LogPanel<'a> {
591 pub fn new(messages: &'a [String]) -> Self {
592 Self { messages }
593 }
594}
595
596impl Widget for LogPanel<'_> {
597 fn render(self, area: Rect, buf: &mut Buffer) {
598 let block = Block::default()
599 .title(" System Log ")
600 .borders(Borders::ALL)
601 .border_style(Style::default().fg(Color::DarkGray));
602 let inner_height = block.inner(area).height as usize;
603
604 let visible: Vec<Line> = self
606 .messages
607 .iter()
608 .rev()
609 .take(inner_height)
610 .rev()
611 .map(|msg| {
612 let (color, text) = if msg.starts_with("[ERR]") {
613 (Color::Red, msg.as_str())
614 } else if msg.starts_with("[WARN]") {
615 (Color::Yellow, msg.as_str())
616 } else if msg.contains("FILLED") || msg.contains("Connected") {
617 (Color::Green, msg.as_str())
618 } else {
619 (Color::DarkGray, msg.as_str())
620 };
621 Line::from(Span::styled(text, Style::default().fg(color)))
622 })
623 .collect();
624
625 Paragraph::new(visible)
626 .block(block)
627 .wrap(Wrap { trim: true })
628 .render(area, buf);
629 }
630}
631
632pub struct KeybindBar;
633
634impl Widget for KeybindBar {
635 fn render(self, area: Rect, buf: &mut Buffer) {
636 let line = Line::from(vec![
637 Span::styled(" [Q]", Style::default().fg(Color::Yellow)),
638 Span::styled("quit ", Style::default().fg(Color::DarkGray)),
639 Span::styled("[P]", Style::default().fg(Color::Yellow)),
640 Span::styled("/[R] ", Style::default().fg(Color::DarkGray)),
641 Span::styled("pause/resume ", Style::default().fg(Color::DarkGray)),
642 Span::styled("[B]", Style::default().fg(Color::Green)),
643 Span::styled("/[S] ", Style::default().fg(Color::DarkGray)),
644 Span::styled("buy/sell ", Style::default().fg(Color::DarkGray)),
645 Span::styled("[Z] ", Style::default().fg(Color::Red)),
646 Span::styled("close-all ", Style::default().fg(Color::DarkGray)),
647 Span::styled("[G]", Style::default().fg(Color::Magenta)),
648 Span::styled(" grid ", Style::default().fg(Color::DarkGray)),
649 Span::styled("| ", Style::default().fg(Color::DarkGray)),
650 Span::styled("TF:", Style::default().fg(Color::Cyan)),
651 Span::styled(" 0/1/H/D/W/M ", Style::default().fg(Color::DarkGray)),
652 Span::styled("| ", Style::default().fg(Color::DarkGray)),
653 Span::styled("More:", Style::default().fg(Color::Magenta)),
654 Span::styled(" T/Y/A/I ", Style::default().fg(Color::DarkGray)),
655 ]);
656
657 buf.set_line(area.x, area.y, &line, area.width);
658 }
659}
660
661pub struct GridKeybindBar;
662
663impl Widget for GridKeybindBar {
664 fn render(self, area: Rect, buf: &mut Buffer) {
665 let line = Line::from(vec![
666 Span::styled(" [Q]", Style::default().fg(Color::Yellow)),
667 Span::styled("quit ", Style::default().fg(Color::DarkGray)),
668 Span::styled("[Tab]", Style::default().fg(Color::Yellow)),
669 Span::styled(" panel ", Style::default().fg(Color::DarkGray)),
670 Span::styled("[J/K]", Style::default().fg(Color::Yellow)),
671 Span::styled(" select ", Style::default().fg(Color::DarkGray)),
672 Span::styled("[H/L]", Style::default().fg(Color::Yellow)),
673 Span::styled(" symbol ", Style::default().fg(Color::DarkGray)),
674 Span::styled("[O]", Style::default().fg(Color::Yellow)),
675 Span::styled(" toggle ", Style::default().fg(Color::DarkGray)),
676 Span::styled("[N]", Style::default().fg(Color::Yellow)),
677 Span::styled(" new ", Style::default().fg(Color::DarkGray)),
678 Span::styled("[C]", Style::default().fg(Color::Yellow)),
679 Span::styled(" cfg ", Style::default().fg(Color::DarkGray)),
680 Span::styled("[X]", Style::default().fg(Color::Yellow)),
681 Span::styled(" del ", Style::default().fg(Color::DarkGray)),
682 Span::styled("[Enter]", Style::default().fg(Color::Yellow)),
683 Span::styled(" run ", Style::default().fg(Color::DarkGray)),
684 Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
685 Span::styled(" close ", Style::default().fg(Color::DarkGray)),
686 Span::styled("[Z]", Style::default().fg(Color::Red)),
687 Span::styled(" close-all ", Style::default().fg(Color::DarkGray)),
688 ]);
689
690 buf.set_line(area.x, area.y, &line, area.width);
691 }
692}