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