use crate::ui::state::{DashboardState, HealthStatus, PriceDir}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, widgets::{block::Position, Block, Borders, Cell, Gauge, Paragraph, Row, Sparkline, Table}, Frame, }; fn border_style() -> Style { Style::default().fg(Color::Rgb(40, 40, 60)) } fn title_style() -> Style { Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD) } fn label_style() -> Style { Style::default().fg(Color::Gray) } fn profit_color(pnl: f64) -> Color { if pnl > 0.0 { Color::LightGreen } else if pnl < 0.0 { Color::Red } else { Color::White } } fn dir_style(dir: PriceDir) -> Style { match dir { PriceDir::Up => Style::default().fg(Color::Green), PriceDir::Down => Style::default().fg(Color::Red), PriceDir::Flat => Style::default().fg(Color::DarkGray), } } fn health_color(status: HealthStatus) -> Color { match status { HealthStatus::Ok => Color::Green, HealthStatus::Warn => Color::Yellow, HealthStatus::Err => Color::Red, } } pub fn draw(frame: &mut Frame, app: &DashboardState) { let area = frame.area(); let root = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Length(11), Constraint::Min(8), Constraint::Length(9), Constraint::Length(3), ]) .split(area); draw_header(frame, root[0], app); draw_hero_profit(frame, root[1], app); draw_body(frame, root[2], app); draw_ticker(frame, root[3], app); draw_status_bar(frame, root[4], app); } fn draw_header(frame: &mut Frame, area: Rect, app: &DashboardState) { let block = Block::default() .borders(Borders::ALL) .border_style(border_style()) .title(Span::styled( " POLYMARKET ARB BOT ", Style::default().fg(Color::Cyan).bold(), )) .title_alignment(Alignment::Center); let inner = block.inner(area); frame.render_widget(block, area); let cols = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(35), Constraint::Percentage(35), Constraint::Percentage(30), ]) .split(inner); let status = if app.connected { Span::styled(" ● LIVE ", Style::default().fg(Color::Green).bold()) } else { Span::styled(" ○ INIT ", Style::default().fg(Color::Yellow)) }; frame.render_widget( Paragraph::new(Line::from(vec![ status, Span::styled(format!(" uptime {}", app.uptime()), Style::default().fg(Color::White)), ])), cols[0], ); frame.render_widget( Paragraph::new("Automated YES+NO Spread Arbitrage") .alignment(Alignment::Center) .style(Style::default().fg(Color::Blue).italic()), cols[1], ); frame.render_widget( Paragraph::new(format!("UTC {}", app.utc_now().format("%H:%M:%S"))) .alignment(Alignment::Right) .style(Style::default().fg(Color::White)), cols[2], ); } fn draw_hero_profit(frame: &mut Frame, area: Rect, app: &DashboardState) { let pulse = app.profit_pulse > 0; let bg = if pulse { Color::Rgb(0, 55, 20) } else { Color::Rgb(10, 10, 25) }; let block = Block::default() .borders(Borders::ALL) .border_style(if pulse { Style::default().fg(Color::LightGreen) } else { Style::default().fg(Color::Yellow) }) .style(Style::default().bg(bg)) .title(Span::styled( " 💰 SESSION PROFIT 💰 ", Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), )) .title_alignment(Alignment::Center); let inner = block.inner(area); frame.render_widget(block, area); let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Length(2), Constraint::Length(2), Constraint::Min(2), ]) .split(inner); let sign = if app.session_pnl >= 0.0 { "+" } else { "" }; let hero = format!("{sign}${:.2}", app.session_pnl); let dollar_glow = if pulse { " $$$ " } else { " $ " }; frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled(dollar_glow, Style::default().fg(Color::Yellow).bold()), Span::styled( hero, Style::default() .fg(profit_color(app.session_pnl)) .add_modifier(Modifier::BOLD), ), Span::styled(dollar_glow, Style::default().fg(Color::Yellow).bold()), ])) .alignment(Alignment::Center), rows[0], ); let last_trade = if app.last_trade_pnl > 0.0 && !app.last_trade_symbol.is_empty() { format!( "▲ +${:.2} last trade ({})", app.last_trade_pnl, app.last_trade_symbol ) } else if app.total_trades > 0 { "Scanning for next opportunity…".to_string() } else { "Waiting for first arbitrage capture…".to_string() }; frame.render_widget( Paragraph::new(Line::from(Span::styled( last_trade, Style::default().fg(Color::Green), ))) .alignment(Alignment::Center), rows[1], ); let cols = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(33), Constraint::Percentage(34), Constraint::Percentage(33), ]) .split(rows[2]); let window_sign = if app.window_pnl >= 0.0 { "+" } else { "" }; frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled("Window PnL ", label_style()), Span::styled( format!("{window_sign}${:.2}", app.window_pnl), Style::default().fg(profit_color(app.window_pnl)).bold(), ), ])) .alignment(Alignment::Center), cols[0], ); frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled("Trades ", label_style()), Span::styled( format!( "{} / {} wins", app.successful_trades, app.total_trades ), Style::default().fg(Color::White).bold(), ), Span::styled( format!(" ({:.0}%)", app.win_rate()), Style::default().fg(Color::Cyan), ), ])) .alignment(Alignment::Center), cols[1], ); frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled("Best trade ", label_style()), Span::styled( format!("+${:.2}", app.best_trade), Style::default().fg(Color::LightGreen).bold(), ), ])) .alignment(Alignment::Center), cols[2], ); let curve_cols = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(rows[3]); draw_sparkline_block( frame, curve_cols[0], " SESSION PNL ", &app.pnl_sparkline, Color::LightGreen, ); draw_sparkline_block( frame, curve_cols[1], " WINDOW PNL ", &app.window_pnl_sparkline, Color::Yellow, ); } fn draw_body(frame: &mut Frame, area: Rect, app: &DashboardState) { let cols = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) .split(area); draw_markets_panel(frame, cols[0], app); draw_side_panel(frame, cols[1], app); } fn draw_markets_panel(frame: &mut Frame, area: Rect, app: &DashboardState) { let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Percentage(48), Constraint::Length(5), Constraint::Min(4), ]) .split(area); draw_market_table(frame, rows[0], app); draw_global_charts(frame, rows[1], app); draw_market_edge_curves(frame, rows[2], app); } fn draw_market_table(frame: &mut Frame, area: Rect, app: &DashboardState) { let header = Row::new(vec!["SYM", "YES", "NO", "Σ", "EDGE"]) .style(Style::default().fg(Color::Yellow).bold()) .height(1); let table_rows: Vec = if app.markets.is_empty() { vec![Row::new(vec![Cell::from("—"), Cell::from("waiting…"), Cell::from(""), Cell::from(""), Cell::from("")])] } else { app.markets .iter() .enumerate() .map(|(i, m)| { let total = m.yes_price + m.no_price; let edge = app.profit_pct(m); let edge_str = if edge > 0.0 { format!("+{edge:.2}%") } else { "—".to_string() }; let mut style = Style::default().fg(Color::White); if m.is_arb && app.flash_arb { style = style.bg(Color::Rgb(0, 48, 0)); } if i == app.selected_market { style = style.fg(Color::Cyan).bold(); } Row::new(vec![ Cell::from(m.symbol.clone()), Cell::from(format!("{:.3}{}", m.yes_price, m.yes_dir.arrow())) .style(dir_style(m.yes_dir)), Cell::from(format!("{:.3}{}", m.no_price, m.no_dir.arrow())) .style(dir_style(m.no_dir)), Cell::from(format!("{total:.3}")), Cell::from(edge_str).style(if edge > 0.3 { Style::default().fg(Color::LightGreen).bold() } else { Style::default().fg(Color::DarkGray) }), ]) .style(style) .height(1) }) .collect() }; let table = Table::new( table_rows, [ Constraint::Length(5), Constraint::Length(11), Constraint::Length(11), Constraint::Length(7), Constraint::Min(8), ], ) .header(header) .block( Block::default() .title(" LIVE MARKETS ") .borders(Borders::ALL) .border_style(border_style()) .title_style(title_style()), ); frame.render_widget(table, area); } fn draw_sparkline_block( frame: &mut Frame, area: Rect, title: &str, data: &[u64], color: Color, ) { let spark = Sparkline::default() .block( Block::default() .title(title) .title_style(title_style()) .borders(Borders::ALL) .border_style(border_style()) .title_position(Position::Top), ) .data(data) .style(Style::default().fg(color)); frame.render_widget(spark, area); } fn draw_global_charts(frame: &mut Frame, area: Rect, app: &DashboardState) { let cols = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(25), Constraint::Percentage(25), Constraint::Percentage(25), Constraint::Percentage(25), ]) .split(area); draw_sparkline_block( frame, cols[0], " EDGE ", &app.edge_sparkline, Color::Magenta, ); draw_sparkline_block( frame, cols[1], " EXPOSURE ", &app.exposure_sparkline, Color::Cyan, ); draw_sparkline_block( frame, cols[2], " SCAN RATE ", &app.scan_rate_sparkline, Color::Blue, ); draw_sparkline_block( frame, cols[3], " PNL FLOW ", &app.pnl_sparkline, Color::LightGreen, ); } fn draw_market_edge_curves(frame: &mut Frame, area: Rect, app: &DashboardState) { let block = Block::default() .title(" MARKET EDGE CURVES ") .title_style(title_style()) .borders(Borders::ALL) .border_style(border_style()); let inner = block.inner(area); frame.render_widget(block, area); if app.markets.is_empty() { frame.render_widget( Paragraph::new("Waiting for market data…").style(label_style()), inner, ); return; } let row_h = inner.height.saturating_sub(1) / app.markets.len().max(1) as u16; let row_h = row_h.max(2); for (i, market) in app.markets.iter().enumerate() { let y = inner.y + (i as u16 * row_h); if y >= inner.bottom() { break; } let row_area = Rect { x: inner.x, y, width: inner.width, height: row_h.min(inner.bottom().saturating_sub(y)), }; let cols = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Length(5), Constraint::Min(8), Constraint::Length(8)]) .split(row_area); let edge = app.profit_pct(market); let edge_label = if edge > 0.0 { format!("+{edge:.2}%") } else { "—".to_string() }; let sym_style = if i == app.selected_market { Style::default().fg(Color::Cyan).bold() } else if market.is_arb { Style::default().fg(Color::LightGreen).bold() } else { Style::default().fg(Color::Yellow) }; frame.render_widget(Paragraph::new(market.symbol.as_str()).style(sym_style), cols[0]); let curve_color = if market.is_arb { Color::LightGreen } else { Color::Rgb(80, 120, 200) }; let spark = Sparkline::default() .block(Block::default().borders(Borders::NONE)) .data(market.sparkline.as_slice()) .style(Style::default().fg(curve_color)); frame.render_widget(spark, cols[1]); frame.render_widget( Paragraph::new(edge_label) .alignment(Alignment::Right) .style(if edge > 0.3 { Style::default().fg(Color::Green).bold() } else { label_style() }), cols[2], ); } } fn draw_side_panel(frame: &mut Frame, area: Rect, app: &DashboardState) { let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Percentage(42), Constraint::Length(4), Constraint::Min(4), ]) .split(area); draw_risk(frame, rows[0], app); draw_sparkline_block( frame, rows[1], " EXPOSURE TREND ", &app.exposure_sparkline, Color::Cyan, ); draw_system(frame, rows[2], app); } fn draw_risk(frame: &mut Frame, area: Rect, app: &DashboardState) { let block = Block::default() .title(" RISK ") .borders(Borders::ALL) .border_style(border_style()) .title_style(title_style()); let inner = block.inner(area); frame.render_widget(block, area); let rows = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(2), Constraint::Length(3), Constraint::Min(2)]) .split(inner); frame.render_widget( Paragraph::new(format!( "Exposure ${:.2} / ${:.0}", app.exposure, app.exposure_limit )), rows[0], ); let pct = app.exposure_pct(); let gauge_color = if pct > 90.0 { Color::Red } else if pct > 75.0 { Color::Yellow } else { Color::Green }; let gauge = Gauge::default() .block(Block::default().borders(Borders::NONE)) .gauge_style(Style::default().fg(gauge_color)) .percent(pct.clamp(0.0, 100.0) as u16) .label(format!("{pct:.0}%")); frame.render_widget(gauge, rows[1]); frame.render_widget( Paragraph::new(vec![ Line::from(format!("Positions: {}", app.positions)), Line::from(format!( "Last trade: {:.0}s ago", app.last_trade_secs.min(999.0) )), ]), rows[2], ); } fn draw_system(frame: &mut Frame, area: Rect, app: &DashboardState) { let lines: Vec = app .services .iter() .map(|svc| { Line::from(vec![ Span::styled( format!("{} ", svc.status.dot()), Style::default().fg(health_color(svc.status)), ), Span::styled(format!("{:<10}", svc.name), Style::default().fg(Color::White)), Span::styled( if svc.latency_ms > 0 { format!("{}ms", svc.latency_ms) } else { "ok".to_string() }, label_style(), ), ]) }) .chain(std::iter::once(Line::from(vec![ Span::styled("Merge ", label_style()), Span::styled(&app.merge_status, Style::default().fg(Color::Cyan)), ]))) .collect(); frame.render_widget( Paragraph::new(lines).block( Block::default() .title(" SYSTEM ") .borders(Borders::ALL) .border_style(border_style()) .title_style(title_style()), ), area, ); } fn draw_ticker(frame: &mut Frame, area: Rect, app: &DashboardState) { let inner_h = area.height.saturating_sub(2) as usize; let max_lines = inner_h.saturating_sub(1).max(4); let recent = app.recent_events(max_lines); let last = recent.len().saturating_sub(1); let lines: Vec = recent .into_iter() .enumerate() .map(|(i, msg)| { let style = if i == last { Style::default().fg(Color::White) } else if msg.contains('💰') || msg.contains("ARB") || msg.contains('⚡') { Style::default().fg(Color::LightGreen) } else if msg.contains('❌') || msg.contains('⚠') { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::DarkGray) }; Line::from(vec![ Span::styled(" › ", Style::default().fg(Color::Rgb(60, 60, 80))), Span::styled(msg, style), ]) }) .collect(); frame.render_widget( Paragraph::new(lines).block( Block::default() .title(" EVENT LOG ") .title_style(title_style()) .borders(Borders::ALL) .border_style(border_style()), ), area, ); } fn draw_status_bar(frame: &mut Frame, area: Rect, app: &DashboardState) { let block = Block::default() .borders(Borders::ALL) .border_style(border_style()); let inner = block.inner(area); frame.render_widget(block, area); let cols = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(40), Constraint::Percentage(35), Constraint::Percentage(25), ]) .split(inner); frame.render_widget( Paragraph::new(vec![ Line::from(vec![ Span::styled("WINDOW ", label_style()), Span::styled(&app.window_label, Style::default().fg(Color::Cyan)), ]), Line::from(vec![ Span::styled("ends ", label_style()), Span::styled(app.window_countdown(), Style::default().fg(Color::Yellow).bold()), ]), ]), cols[0], ); frame.render_widget( Paragraph::new(vec![ Line::from(vec![ Span::styled("scans ", label_style()), Span::styled(format!("{}", app.arb_scans), Style::default().fg(Color::White).bold()), ]), Line::from(vec![ Span::styled("mode ", label_style()), Span::styled(&app.order_mode, Style::default().fg(Color::Magenta)), ]), ]) .alignment(Alignment::Center), cols[1], ); frame.render_widget( Paragraph::new("q quit bot") .alignment(Alignment::Right) .style(Style::default().fg(Color::DarkGray)), cols[2], ); }