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
470pub 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
535pub 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 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}