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