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
419pub 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
484pub 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 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}