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
497pub 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
562pub 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 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}