1use ratatui::{
4 Frame,
5 layout::{Constraint, Direction, Layout, Rect},
6 style::Style,
7 symbols,
8 text::{Line, Span},
9 widgets::{
10 Axis, Bar, BarChart, BarGroup, Block, Borders, Chart, Dataset, GraphType, List, ListItem,
11 Paragraph, Row, Table, Tabs, Wrap,
12 canvas::{Canvas, Line as CanvasLine, Rectangle},
13 },
14};
15use std::time::Instant;
16
17use scope::market::{BookSide, TradeSide};
18
19use super::config::{ChartMode, LayoutPreset, ScaleMode, WidgetVisibility};
20use super::state::MonitorState;
21use crate::cli::textfit::{cols, cut_with_ellipsis, draw_note_alone};
22use crate::cli::theme::SECONDARY;
23pub(crate) use scope::display::format::format_large_number as format_number;
24pub(crate) use scope::display::format::format_price_usd;
25use scope::display::theme::Role;
26
27pub(crate) const MIN_W: u16 = 60;
29pub(crate) const MIN_H: u16 = 16;
31
32fn too_small_note() -> String {
35 format!("Terminal too small: need at least {MIN_W}×{MIN_H}")
36}
37
38const SYNTHETIC_NOTE: &str = "(synthetic, from DEX pairs)";
40
41const VENUE_ONLY_NOTE: &str = "(not available in venue-only mode)";
43
44const NOT_AVAILABLE: &str = "n/a";
46
47pub(crate) struct LayoutAreas {
50 pub(crate) price_chart: Option<Rect>,
51 pub(crate) volume_chart: Option<Rect>,
52 pub(crate) buy_sell_gauge: Option<Rect>,
53 pub(crate) metrics_panel: Option<Rect>,
54 pub(crate) activity_feed: Option<Rect>,
55 pub(crate) order_book: Option<Rect>,
57 pub(crate) market_info: Option<Rect>,
59 pub(crate) trade_history: Option<Rect>,
61}
62
63pub(crate) fn layout_dashboard(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
75 let rows = Layout::default()
76 .direction(Direction::Vertical)
77 .constraints([
78 Constraint::Percentage(55),
79 Constraint::Percentage(20),
80 Constraint::Percentage(25),
81 ])
82 .split(area);
83
84 let top = Layout::default()
85 .direction(Direction::Horizontal)
86 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
87 .split(rows[0]);
88
89 let middle = Layout::default()
90 .direction(Direction::Horizontal)
91 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
92 .split(rows[1]);
93
94 LayoutAreas {
95 price_chart: if widgets.price_chart {
96 Some(top[0])
97 } else {
98 None
99 },
100 volume_chart: if widgets.volume_chart {
101 Some(top[1])
102 } else {
103 None
104 },
105 buy_sell_gauge: if widgets.buy_sell_pressure {
106 Some(middle[0])
107 } else {
108 None
109 },
110 metrics_panel: if widgets.metrics_panel {
111 Some(middle[1])
112 } else {
113 None
114 },
115 activity_feed: if widgets.activity_log {
116 Some(rows[2])
117 } else {
118 None
119 },
120 order_book: None,
121 market_info: None,
122 trade_history: None,
123 }
124}
125
126pub(crate) fn layout_chart_focus(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
138 let vertical = Layout::default()
139 .direction(Direction::Vertical)
140 .constraints([Constraint::Percentage(85), Constraint::Percentage(15)])
141 .split(area);
142
143 LayoutAreas {
144 price_chart: if widgets.price_chart {
145 Some(vertical[0])
146 } else {
147 None
148 },
149 volume_chart: None, buy_sell_gauge: None, metrics_panel: if widgets.metrics_panel {
152 Some(vertical[1])
153 } else {
154 None
155 },
156 activity_feed: None, order_book: None,
158 market_info: None,
159 trade_history: None,
160 }
161}
162
163pub(crate) fn layout_feed(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
175 let vertical = Layout::default()
176 .direction(Direction::Vertical)
177 .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
178 .split(area);
179
180 let top = Layout::default()
181 .direction(Direction::Horizontal)
182 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
183 .split(vertical[0]);
184
185 LayoutAreas {
186 price_chart: None, volume_chart: None, metrics_panel: if widgets.metrics_panel {
189 Some(top[0])
190 } else {
191 None
192 },
193 buy_sell_gauge: if widgets.buy_sell_pressure {
194 Some(top[1])
195 } else {
196 None
197 },
198 activity_feed: if widgets.activity_log {
199 Some(vertical[1])
200 } else {
201 None
202 },
203 order_book: None,
204 market_info: None,
205 trade_history: None,
206 }
207}
208
209pub(crate) fn layout_compact(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
217 LayoutAreas {
218 price_chart: None, volume_chart: None, buy_sell_gauge: None, metrics_panel: if widgets.metrics_panel {
222 Some(area)
223 } else {
224 None
225 },
226 activity_feed: None, order_book: None,
228 market_info: None,
229 trade_history: None,
230 }
231}
232
233pub(crate) fn layout_exchange(area: Rect, _widgets: &WidgetVisibility) -> LayoutAreas {
247 let rows = Layout::default()
248 .direction(Direction::Vertical)
249 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
250 .split(area);
251
252 let top = Layout::default()
253 .direction(Direction::Horizontal)
254 .constraints([
255 Constraint::Percentage(25),
256 Constraint::Percentage(45),
257 Constraint::Percentage(30),
258 ])
259 .split(rows[0]);
260
261 let bottom = Layout::default()
262 .direction(Direction::Horizontal)
263 .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
264 .split(rows[1]);
265
266 LayoutAreas {
267 price_chart: Some(top[1]),
268 volume_chart: None,
269 buy_sell_gauge: Some(bottom[0]),
270 metrics_panel: None,
271 activity_feed: None,
272 order_book: Some(top[0]),
273 market_info: Some(bottom[1]),
274 trade_history: Some(top[2]),
275 }
276}
277
278pub(crate) fn auto_select_layout(size: Rect) -> LayoutPreset {
280 match (size.width, size.height) {
281 (w, h) if w < 80 || h < 24 => LayoutPreset::Compact,
282 (w, _) if w < 120 => LayoutPreset::Feed,
283 (_, h) if h < 30 => LayoutPreset::ChartFocus,
284 _ => LayoutPreset::Dashboard,
285 }
286}
287
288pub(crate) fn ui(f: &mut Frame, state: &mut MonitorState) {
290 let area = f.area();
292 if area.width < MIN_W || area.height < MIN_H {
293 draw_note_alone(f, &too_small_note());
294 return;
295 }
296
297 if state.auto_layout {
299 let suggested = auto_select_layout(f.area());
300 if suggested != state.layout {
301 state.layout = suggested;
302 }
303 }
304
305 let chunks = Layout::default()
307 .direction(Direction::Vertical)
308 .constraints([
309 Constraint::Length(4), Constraint::Min(0), Constraint::Length(3), ])
313 .split(f.area());
314
315 render_header(f, chunks[0], state);
317
318 let areas = match state.layout {
320 LayoutPreset::Dashboard => layout_dashboard(chunks[1], &state.widgets),
321 LayoutPreset::ChartFocus => layout_chart_focus(chunks[1], &state.widgets),
322 LayoutPreset::Feed => layout_feed(chunks[1], &state.widgets),
323 LayoutPreset::Compact => layout_compact(chunks[1], &state.widgets),
324 LayoutPreset::Exchange => layout_exchange(chunks[1], &state.widgets),
325 };
326
327 if let Some(area) = areas.price_chart {
329 match state.chart_mode {
330 ChartMode::Line => render_price_chart(f, area, state),
331 ChartMode::Candlestick => render_candlestick_chart(f, area, state),
332 ChartMode::VolumeProfile => render_volume_profile_chart(f, area, state),
333 }
334 }
335 if let Some(area) = areas.volume_chart {
336 render_volume_chart(f, area, &*state);
337 }
338 if let Some(area) = areas.buy_sell_gauge {
339 render_buy_sell_gauge(f, area, state);
340 }
341 if let Some(area) = areas.metrics_panel {
342 if state.widgets.liquidity_depth && !state.liquidity_pairs.is_empty() {
344 let split = Layout::default()
345 .direction(Direction::Vertical)
346 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
347 .split(area);
348 render_metrics_panel(f, split[0], &*state);
349 render_liquidity_depth(f, split[1], &*state);
350 } else {
351 render_metrics_panel(f, area, &*state);
352 }
353 }
354 if let Some(area) = areas.activity_feed {
355 render_activity_feed(f, area, state);
356 }
357 if let Some(area) = areas.order_book {
359 render_order_book_panel(f, area, state);
360 }
361 if let Some(area) = areas.market_info {
362 render_market_info_panel(f, area, state);
363 }
364 if let Some(area) = areas.trade_history {
365 render_recent_trades_panel(f, area, state);
366 }
367
368 if !state.active_alerts.is_empty() {
370 let alert_height = (state.active_alerts.len() as u16 + 2).min(5);
372 let alert_area = Rect::new(
373 chunks[1].x,
374 chunks[1].y,
375 chunks[1].width,
376 alert_height.min(chunks[1].height),
377 );
378 render_alert_overlay(f, alert_area, state);
379 }
380
381 render_footer(f, chunks[2], state);
383
384 if state.show_keys {
385 render_keys_overlay(f);
386 }
387}
388
389pub(crate) fn render_header(f: &mut Frame, area: Rect, state: &MonitorState) {
391 let pal = state.palette();
392 let (price_color, trend_arrow, change_str) = match state.change_24h() {
393 Some(change) => {
394 let color = if change >= 0.0 { pal.up } else { pal.down };
395 let arrow = if change > 0.5 {
397 "▲"
398 } else if change < -0.5 {
399 "▼"
400 } else if change >= 0.0 {
401 "△"
402 } else {
403 "▽"
404 };
405 let sign = if change >= 0.0 { "+" } else { "" };
406 (color, arrow, format!("{}{:.2}%", sign, change))
407 }
408 None => (pal.neutral, "", format!("24h {NOT_AVAILABLE}")),
410 };
411
412 let title = header_title(state, area.width);
413
414 let price_str = if state.error_message.is_some() {
416 format!("{} (stale)", format_price_usd(state.current_price))
417 } else {
418 format_price_usd(state.current_price)
419 };
420
421 let header_chunks = Layout::default()
423 .direction(Direction::Vertical)
424 .constraints([Constraint::Length(3), Constraint::Length(1)])
425 .split(area);
426
427 let header = Paragraph::new(Line::from(vec![
429 Span::styled(price_str, Style::new().fg(price_color).bold()),
430 Span::raw(" "),
431 Span::styled(trend_arrow, Style::new().fg(price_color)),
432 Span::styled(format!(" {}", change_str), Style::new().fg(price_color)),
433 ]))
434 .block(
435 Block::default()
436 .title(title)
437 .borders(Borders::ALL)
438 .border_style(crate::cli::theme::ACCENT),
439 );
440
441 f.render_widget(header, header_chunks[0]);
442
443 let tab_titles = vec!["1m", "5m", "15m", "1h", "4h", "1d"];
445 let chart_label = state.chart_mode.label();
446 let tabs = Tabs::new(tab_titles)
447 .select(state.time_period.index())
448 .highlight_style(crate::cli::theme::strong(Role::Accent))
449 .divider("│")
450 .padding(" ", " ");
451 let tabs_line = Layout::default()
452 .direction(Direction::Horizontal)
453 .constraints([Constraint::Min(20), Constraint::Length(10)])
454 .split(header_chunks[1]);
455 f.render_widget(tabs, tabs_line[0]);
456 f.render_widget(
457 Paragraph::new(Span::styled(
458 format!("⊞ {}", chart_label),
459 crate::cli::theme::ACCENT,
460 )),
461 tabs_line[1],
462 );
463}
464
465fn header_title(state: &MonitorState, width: u16) -> Line<'static> {
468 let base = format!(
469 " ◈ {} ({}) │ {} ",
470 state.symbol,
471 state.name,
472 state.source_label()
473 );
474 let mut spans = vec![Span::raw(base.clone())];
475 if let Some(err) = &state.error_message {
476 let room = (width as usize).saturating_sub(2 + cols(&base) + 3);
478 let err = cut_with_ellipsis(&format!("⚠ {}", err), room);
479 spans.push(Span::styled(format!("│ {} ", err), crate::cli::theme::ERR));
480 }
481 Line::from(spans)
482}
483
484pub(crate) fn render_price_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
486 let (data, is_real) = state.get_price_data_for_period();
488
489 if data.is_empty() {
490 let empty = Paragraph::new("No price data").block(
491 Block::default()
492 .title(" Price (USD) ")
493 .borders(Borders::ALL),
494 );
495 f.render_widget(empty, area);
496 return;
497 }
498
499 let current_price = state.current_price;
501 let first_price = data.first().map(|(_, p)| *p).unwrap_or(current_price);
502 let price_change = current_price - first_price;
503 let price_change_pct = if first_price > 0.0 {
504 (price_change / first_price) * 100.0
505 } else {
506 0.0
507 };
508
509 let pal = state.palette();
511 let is_price_up = price_change >= 0.0;
512 let trend_color = if is_price_up { pal.up } else { pal.down };
513 let trend_symbol = if is_price_up { "▲" } else { "▼" };
514
515 let price_str = format_price_usd(current_price);
517 let change_str = if price_change_pct.abs() < 0.01 {
518 "0.00%".to_string()
519 } else {
520 format!(
521 "{}{:.2}%",
522 if is_price_up { "+" } else { "" },
523 price_change_pct
524 )
525 };
526
527 let chart_title = Line::from(vec![
529 Span::raw(" ◆ "),
530 Span::styled(
531 format!("{} {} ", price_str, trend_symbol),
532 Style::new().fg(trend_color).bold(),
533 ),
534 Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
535 Span::styled(format!("│{}│ ", state.time_period.label()), SECONDARY),
536 ]);
537
538 let (min_price, max_price) = data
540 .iter()
541 .fold((f64::MAX, f64::MIN), |(min, max), (_, p)| {
542 (min.min(*p), max.max(*p))
543 });
544
545 let price_range = max_price - min_price;
547 let (y_min, y_max) = if price_range < 0.0001 {
548 let padding = min_price * 0.001;
550 (min_price - padding, max_price + padding)
551 } else {
552 (min_price - price_range * 0.1, max_price + price_range * 0.1)
553 };
554
555 let x_min = data.first().map(|(t, _)| *t).unwrap_or(0.0);
556 let x_max = data.last().map(|(t, _)| *t).unwrap_or(1.0);
557 let x_max = if (x_max - x_min).abs() < 0.001 {
559 x_min + 1.0
560 } else {
561 x_max
562 };
563
564 let apply_scale = |price: f64| -> f64 {
566 match state.scale_mode {
567 ScaleMode::Linear => price,
568 ScaleMode::Log => {
569 if price > 0.0 {
570 price.ln()
571 } else {
572 0.0
573 }
574 }
575 }
576 };
577
578 let (y_min, y_max) = (apply_scale(y_min), apply_scale(y_max));
579
580 let synthetic_data: Vec<(f64, f64)> = data
582 .iter()
583 .zip(&is_real)
584 .filter(|(_, real)| !**real)
585 .map(|((t, p), _)| (*t, apply_scale(*p)))
586 .collect();
587
588 let real_data: Vec<(f64, f64)> = data
589 .iter()
590 .zip(&is_real)
591 .filter(|(_, real)| **real)
592 .map(|((t, p), _)| (*t, apply_scale(*p)))
593 .collect();
594
595 let reference_line: Vec<(f64, f64)> = vec![
597 (x_min, apply_scale(first_price)),
598 (x_max, apply_scale(first_price)),
599 ];
600
601 let mut datasets = Vec::new();
602
603 datasets.push(
605 Dataset::default()
606 .name("━Start")
607 .marker(symbols::Marker::Braille)
608 .graph_type(GraphType::Line)
609 .style(SECONDARY)
610 .data(&reference_line),
611 );
612
613 if !synthetic_data.is_empty() {
615 datasets.push(
616 Dataset::default()
617 .name("◇Est")
618 .marker(symbols::Marker::Braille)
619 .graph_type(GraphType::Line)
620 .style(crate::cli::theme::ACCENT)
621 .data(&synthetic_data),
622 );
623 }
624
625 if !real_data.is_empty() {
627 datasets.push(
628 Dataset::default()
629 .name("●Live")
630 .marker(symbols::Marker::Braille)
631 .graph_type(GraphType::Line)
632 .style(Style::new().fg(trend_color))
633 .data(&real_data),
634 );
635 }
636
637 let time_label = format!("-{}", state.time_period.label());
639
640 let mid_y = (y_min + y_max) / 2.0;
643 let y_label = |val: f64| -> String {
644 match state.scale_mode {
645 ScaleMode::Linear => format_price_usd(val),
646 ScaleMode::Log => format_price_usd(val.exp()),
647 }
648 };
649
650 let scale_label = match state.scale_mode {
651 ScaleMode::Linear => "USD",
652 ScaleMode::Log => "USD (log)",
653 };
654
655 let chart = Chart::new(datasets)
656 .block(
657 Block::default()
658 .title(chart_title)
659 .borders(Borders::ALL)
660 .border_style(Style::new().fg(trend_color)),
661 )
662 .x_axis(
663 Axis::default()
664 .title(Span::styled("Time", SECONDARY))
665 .style(SECONDARY)
666 .bounds([x_min, x_max])
667 .labels(vec![Span::raw(time_label), Span::raw("now")]),
668 )
669 .y_axis(
670 Axis::default()
671 .title(Span::styled(scale_label, SECONDARY))
672 .style(SECONDARY)
673 .bounds([y_min, y_max])
674 .labels(vec![
675 Span::raw(y_label(y_min)),
676 Span::raw(y_label(mid_y)),
677 Span::raw(y_label(y_max)),
678 ]),
679 );
680
681 f.render_widget(chart, area);
682}
683
684pub(crate) fn render_candlestick_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
686 let candles = state.get_ohlc_candles();
687
688 if candles.is_empty() {
689 let empty = Paragraph::new("No candle data (waiting for more data points)").block(
690 Block::default()
691 .title(" Candlestick (USD) ")
692 .borders(Borders::ALL),
693 );
694 f.render_widget(empty, area);
695 return;
696 }
697
698 let current_price = state.current_price;
700 let first_candle = candles.first().unwrap();
701 let last_candle = candles.last().unwrap();
702 let price_change = last_candle.close - first_candle.open;
703 let price_change_pct = if first_candle.open > 0.0 {
704 (price_change / first_candle.open) * 100.0
705 } else {
706 0.0
707 };
708
709 let pal = state.palette();
710 let is_price_up = price_change >= 0.0;
711 let trend_color = if is_price_up { pal.up } else { pal.down };
712 let trend_symbol = if is_price_up { "▲" } else { "▼" };
713
714 let price_str = format_price_usd(current_price);
715 let change_str = format!(
716 "{}{:.2}%",
717 if is_price_up { "+" } else { "" },
718 price_change_pct
719 );
720
721 let (min_price, max_price) = candles.iter().fold((f64::MAX, f64::MIN), |(min, max), c| {
723 (min.min(c.low), max.max(c.high))
724 });
725
726 let price_range = max_price - min_price;
727 let (y_min, y_max) = if price_range < 0.0001 {
728 let padding = min_price * 0.001;
729 (min_price - padding, max_price + padding)
730 } else {
731 (min_price - price_range * 0.1, max_price + price_range * 0.1)
732 };
733
734 let x_min = candles.first().map(|c| c.timestamp).unwrap_or(0.0);
735 let x_max = candles.last().map(|c| c.timestamp).unwrap_or(1.0);
736 let x_range = x_max - x_min;
737 let x_max = if x_range < 0.001 {
738 x_min + 1.0
739 } else {
740 x_max + x_range * 0.05
741 };
742
743 let candle_count = candles.len() as f64;
745 let candle_spacing = x_range / candle_count.max(1.0);
746 let candle_width = candle_spacing * 0.6; let title = Line::from(vec![
749 Span::raw(" ⬡ "),
750 Span::styled(
751 format!("{} {} ", price_str, trend_symbol),
752 Style::new().fg(trend_color).bold(),
753 ),
754 Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
755 Span::styled(format!("│{}│ ", state.time_period.label()), SECONDARY),
756 Span::styled("⊞Candles ", crate::cli::theme::ACCENT),
757 ]);
758
759 let apply_scale = |price: f64| -> f64 {
761 match state.scale_mode {
762 ScaleMode::Linear => price,
763 ScaleMode::Log => {
764 if price > 0.0 {
765 price.ln()
766 } else {
767 0.0
768 }
769 }
770 }
771 };
772 let scaled_y_min = apply_scale(y_min);
773 let scaled_y_max = apply_scale(y_max);
774 let scaled_price_range = scaled_y_max - scaled_y_min;
775
776 let candles_clone = candles.clone();
778 let is_log = matches!(state.scale_mode, ScaleMode::Log);
779 let pal_up = pal.up;
780 let pal_down = pal.down;
781
782 let canvas = Canvas::default()
783 .block(
784 Block::default()
785 .title(title)
786 .borders(Borders::ALL)
787 .border_style(Style::new().fg(trend_color)),
788 )
789 .x_bounds([x_min - candle_spacing, x_max])
790 .y_bounds([scaled_y_min, scaled_y_max])
791 .paint(move |ctx| {
792 let scale_fn = |p: f64| -> f64 { if is_log && p > 0.0 { p.ln() } else { p } };
793 for candle in &candles_clone {
794 let color = if candle.is_bullish { pal_up } else { pal_down };
795
796 ctx.draw(&CanvasLine {
798 x1: candle.timestamp,
799 y1: scale_fn(candle.low),
800 x2: candle.timestamp,
801 y2: scale_fn(candle.high),
802 color,
803 });
804
805 let body_top = scale_fn(candle.open.max(candle.close));
807 let body_bottom = scale_fn(candle.open.min(candle.close));
808 let body_height = (body_top - body_bottom).max(scaled_price_range * 0.002);
809
810 ctx.draw(&Rectangle {
811 x: candle.timestamp - candle_width / 2.0,
812 y: body_bottom,
813 width: candle_width,
814 height: body_height,
815 color,
816 });
817 }
818 });
819
820 f.render_widget(canvas, area);
821}
822
823pub(crate) fn render_volume_profile_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
829 let pal = state.palette();
830 let (price_data, _) = state.get_price_data_for_period();
831 let (volume_data, _) = state.get_volume_data_for_period();
832
833 if price_data.len() < 2 || volume_data.is_empty() {
834 let block = Block::default()
835 .title(" ◨ Volume Profile (collecting data...) ")
836 .borders(Borders::ALL)
837 .border_style(crate::cli::theme::SECONDARY);
838 f.render_widget(block, area);
839 return;
840 }
841
842 let min_price = price_data.iter().map(|(_, p)| *p).fold(f64::MAX, f64::min);
844 let max_price = price_data.iter().map(|(_, p)| *p).fold(f64::MIN, f64::max);
845
846 if (max_price - min_price).abs() < f64::EPSILON {
847 let block = Block::default()
848 .title(" ◨ Volume Profile (no price range) ")
849 .borders(Borders::ALL)
850 .border_style(crate::cli::theme::SECONDARY);
851 f.render_widget(block, area);
852 return;
853 }
854
855 let inner_height = area.height.saturating_sub(2) as usize;
857 let num_buckets = inner_height.clamp(1, 30);
858 let bucket_size = (max_price - min_price) / num_buckets as f64;
859
860 let mut bucket_volumes = vec![0.0_f64; num_buckets];
862
863 let vol_iter: Vec<f64> = volume_data.iter().map(|(_, v)| *v).collect();
865 for (i, (_, price)) in price_data.iter().enumerate() {
866 let bucket_idx =
867 (((price - min_price) / bucket_size).floor() as usize).min(num_buckets - 1);
868 let vol_contribution = if i < vol_iter.len() {
870 if i > 0 {
872 (vol_iter[i] - vol_iter[i - 1]).abs().max(1.0)
873 } else {
874 1.0
875 }
876 } else {
877 1.0
878 };
879 bucket_volumes[bucket_idx] += vol_contribution;
880 }
881
882 let max_vol = bucket_volumes
883 .iter()
884 .cloned()
885 .fold(0.0_f64, f64::max)
886 .max(1.0);
887
888 let current_bucket = (((state.current_price - min_price) / bucket_size).floor() as usize)
890 .min(num_buckets.saturating_sub(1));
891
892 let inner_width = area.width.saturating_sub(12) as usize; let lines: Vec<Line> = (0..num_buckets)
896 .rev() .map(|i| {
898 let bar_width = ((bucket_volumes[i] / max_vol) * inner_width as f64).round() as usize;
899 let price_mid = min_price + (i as f64 + 0.5) * bucket_size;
900 let label = if price_mid >= 1.0 {
901 format!("{:>8.2}", price_mid)
902 } else {
903 format!("{:>8.6}", price_mid)
904 };
905 let bar_str = "█".repeat(bar_width);
906 let style = if i == current_bucket {
907 Style::new().fg(pal.highlight).bold()
908 } else {
909 Style::new().fg(pal.sparkline)
910 };
911 Line::from(vec![
912 Span::styled(label, SECONDARY),
913 Span::raw(" "),
914 Span::styled(bar_str, style),
915 ])
916 })
917 .collect();
918
919 let block = Block::default()
920 .title(" ◨ Volume Profile (accuracy improves over time) ")
921 .borders(Borders::ALL)
922 .border_style(Style::new().fg(pal.sparkline));
923
924 let paragraph = Paragraph::new(lines).block(block);
925 f.render_widget(paragraph, area);
926}
927
928pub(crate) fn render_volume_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
930 let pal = state.palette();
931 let (data, is_real) = state.get_volume_data_for_period();
933
934 if data.is_empty() {
935 let empty = Paragraph::new("No volume data")
936 .block(Block::default().title(" 24h Volume ").borders(Borders::ALL));
937 f.render_widget(empty, area);
938 return;
939 }
940
941 let current_volume = state.volume_24h;
943 let volume_str = scope::display::format_usd(current_volume);
944
945 let has_synthetic = is_real.iter().any(|r| !r);
947 let has_real = is_real.iter().any(|r| *r);
948
949 let data_indicator = if has_synthetic && has_real {
951 "[◆ est │ ● live]"
952 } else if has_synthetic {
953 "[◆ estimated]"
954 } else {
955 "[● live]"
956 };
957
958 let chart_title = Line::from(vec![
959 Span::raw(" ▣ "),
960 Span::styled(
961 format!("24h Vol: {} ", volume_str),
962 Style::new().fg(pal.volume_bar).bold(),
963 ),
964 Span::styled(format!("│{}│ ", state.time_period.label()), SECONDARY),
965 Span::styled(data_indicator, SECONDARY),
966 ]);
967
968 let inner_width = area.width.saturating_sub(2) as usize; let max_bars = (inner_width / 3).max(1).min(data.len());
972 let bucket_size = data.len().div_ceil(max_bars);
973
974 let bars: Vec<Bar> = data
975 .chunks(bucket_size)
976 .zip(is_real.chunks(bucket_size))
977 .enumerate()
978 .map(|(i, (chunk, real_chunk))| {
979 let avg_vol = chunk.iter().map(|(_, v)| v).sum::<f64>() / chunk.len() as f64;
980 let any_real = real_chunk.iter().any(|r| *r);
981 let bar_color = if any_real {
982 pal.volume_bar
983 } else {
984 pal.neutral
985 };
986 let label = if i == 0 || i == max_bars.saturating_sub(1) || i == max_bars / 2 {
988 format_number(avg_vol)
989 } else {
990 String::new()
991 };
992 Bar::default()
993 .value(avg_vol as u64)
994 .label(Line::from(label))
995 .style(Style::new().fg(bar_color))
996 })
997 .collect();
998
999 let bar_width = if !bars.is_empty() {
1001 let total_bars = bars.len() as u16;
1002 ((inner_width as u16).saturating_sub(total_bars.saturating_sub(1))) / total_bars
1004 } else {
1005 1
1006 }
1007 .max(1);
1008
1009 let barchart = BarChart::default()
1010 .data(BarGroup::default().bars(&bars))
1011 .block(
1012 Block::default()
1013 .title(chart_title)
1014 .borders(Borders::ALL)
1015 .border_style(crate::cli::theme::INFO),
1016 )
1017 .bar_width(bar_width)
1018 .bar_gap(1)
1019 .value_style(SECONDARY);
1020
1021 f.render_widget(barchart, area);
1022}
1023
1024pub(crate) fn render_buy_sell_gauge(f: &mut Frame, area: Rect, state: &mut MonitorState) {
1026 let pal = state.palette();
1027 let ratio = state.buy_ratio();
1029 let border_color = if ratio > 0.5 { pal.up } else { pal.down };
1030
1031 let block = Block::default()
1032 .title(" ◐ Buy/Sell Ratio (24h) ")
1033 .borders(Borders::ALL)
1034 .border_style(Style::new().fg(border_color));
1035
1036 if state.venue_only {
1037 let note = format!("Buys/Sells: {NOT_AVAILABLE} (venue-only mode)");
1038 let body = Paragraph::new(Span::styled(note, SECONDARY))
1039 .wrap(Wrap { trim: true })
1040 .block(block);
1041 f.render_widget(body, area);
1042 return;
1043 }
1044
1045 let inner = block.inner(area);
1046 f.render_widget(block, area);
1047
1048 if inner.width > 0 && inner.height > 0 {
1049 let buy_width = ((ratio * inner.width as f64).round() as u16).min(inner.width);
1050 let sell_width = inner.width.saturating_sub(buy_width);
1051
1052 let buy_indicator = if ratio > 0.5 { "▶" } else { "▷" };
1053 let sell_indicator = if ratio < 0.5 { "◀" } else { "◁" };
1054 let label = format!(
1055 "{}Buys: {} │ Sells: {}{} ({:.1}%)",
1056 buy_indicator,
1057 state.buys_24h,
1058 state.sells_24h,
1059 sell_indicator,
1060 ratio * 100.0
1061 );
1062
1063 let buy_bar = "█".repeat(buy_width as usize);
1064 let sell_bar = "█".repeat(sell_width as usize);
1065 let bar_line = Line::from(vec![
1066 Span::styled(buy_bar, Style::new().fg(pal.up)),
1067 Span::styled(sell_bar, Style::new().fg(pal.down)),
1068 ]);
1069 f.render_widget(Paragraph::new(bar_line), inner);
1070
1071 let label_len = label.len() as u16;
1073 if label_len <= inner.width {
1074 let x_offset = (inner.width.saturating_sub(label_len)) / 2;
1075 let label_area = Rect::new(inner.x + x_offset, inner.y, label_len, 1);
1076 let label_widget = Paragraph::new(Span::styled(label, Style::new().bold()));
1077 f.render_widget(label_widget, label_area);
1078 }
1079 }
1080}
1081
1082pub(crate) fn render_activity_feed(f: &mut Frame, area: Rect, state: &mut MonitorState) {
1084 let log_len = state.log_messages.len();
1085 let log_title = if log_len > 0 {
1086 let selected = state.log_list_state.selected().unwrap_or(0);
1087 format!(" ◷ Activity Log [{}/{}] ", selected + 1, log_len)
1088 } else {
1089 " ◷ Activity Log ".to_string()
1090 };
1091
1092 let items: Vec<ListItem> = state
1093 .log_messages
1094 .iter()
1095 .rev()
1096 .map(|msg| ListItem::new(msg.as_str()).style(SECONDARY))
1097 .collect();
1098
1099 let log_list = List::new(items)
1100 .block(
1101 Block::default()
1102 .title(log_title)
1103 .borders(Borders::ALL)
1104 .border_style(crate::cli::theme::SECONDARY),
1105 )
1106 .highlight_style(Style::new().bold())
1107 .highlight_symbol("▸ ");
1108
1109 f.render_stateful_widget(log_list, area, &mut state.log_list_state);
1110}
1111
1112pub(crate) fn render_alert_overlay(f: &mut Frame, area: Rect, state: &MonitorState) {
1114 if state.active_alerts.is_empty() {
1115 return;
1116 }
1117
1118 let is_flash_on = state
1119 .alert_flash_until
1120 .map(|deadline| {
1121 if Instant::now() < deadline {
1122 (Instant::now().elapsed().subsec_millis() / 500).is_multiple_of(2)
1124 } else {
1125 false
1126 }
1127 })
1128 .unwrap_or(false);
1129
1130 let border_style = if is_flash_on {
1131 crate::cli::theme::strong(Role::Err)
1132 } else {
1133 crate::cli::theme::strong(Role::Warn)
1134 };
1135
1136 let alert_lines: Vec<Line> = state
1137 .active_alerts
1138 .iter()
1139 .map(|a| {
1140 Line::from(Span::styled(
1141 &a.message,
1142 crate::cli::theme::strong(Role::Err),
1143 ))
1144 })
1145 .collect();
1146
1147 let alert_widget = Paragraph::new(alert_lines).block(
1148 Block::default()
1149 .title(" ⚠ ALERTS ")
1150 .borders(Borders::ALL)
1151 .border_style(border_style),
1152 );
1153
1154 f.render_widget(alert_widget, area);
1155}
1156
1157pub(crate) fn render_liquidity_depth(f: &mut Frame, area: Rect, state: &MonitorState) {
1159 let pal = state.palette();
1160
1161 if state.liquidity_pairs.is_empty() {
1162 let block = Block::default()
1163 .title(" ◫ Liquidity Depth (no data) ")
1164 .borders(Borders::ALL)
1165 .border_style(crate::cli::theme::SECONDARY);
1166 f.render_widget(block, area);
1167 return;
1168 }
1169
1170 let max_liquidity = state
1171 .liquidity_pairs
1172 .iter()
1173 .map(|(_, liq)| *liq)
1174 .fold(0.0_f64, f64::max)
1175 .max(1.0);
1176
1177 let inner_width = area.width.saturating_sub(2) as usize;
1178
1179 let lines: Vec<Line> = state
1180 .liquidity_pairs
1181 .iter()
1182 .take(area.height.saturating_sub(2) as usize) .map(|(name, liq)| {
1184 let bar_width = ((liq / max_liquidity) * inner_width as f64 * 0.6).round() as usize;
1185 let bar_str = "█".repeat(bar_width);
1186 let label = format!(" {} {}", scope::display::format_usd(*liq), name);
1187 Line::from(vec![
1188 Span::styled(bar_str, Style::new().fg(pal.volume_bar)),
1189 Span::styled(label, Style::new().fg(pal.neutral)),
1190 ])
1191 })
1192 .collect();
1193
1194 let block = Block::default()
1195 .title(format!(
1196 " ◫ Liquidity Depth ({} pairs) ",
1197 state.liquidity_pairs.len()
1198 ))
1199 .borders(Borders::ALL)
1200 .border_style(crate::cli::theme::SECONDARY);
1201
1202 let paragraph = Paragraph::new(lines).block(block);
1203 f.render_widget(paragraph, area);
1204}
1205
1206pub(crate) fn render_metrics_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1208 let pal = state.palette();
1209 let chunks = Layout::default()
1211 .direction(Direction::Vertical)
1212 .constraints([Constraint::Length(3), Constraint::Min(0)])
1213 .split(area);
1214
1215 let trend_color = if !state.short_changes_known() {
1217 pal.neutral
1218 } else if state.price_change_5m >= 0.0 {
1219 pal.up
1220 } else {
1221 pal.down
1222 };
1223 let block = Block::default()
1224 .title(" ◉ Price Trend ")
1225 .borders(Borders::ALL)
1226 .border_style(Style::new().fg(pal.sparkline));
1227 let inner = block.inner(chunks[0]);
1228 f.render_widget(block, chunks[0]);
1229 let prices: Vec<Option<f64>> = state
1230 .price_history
1231 .iter()
1232 .map(|dp| Some(dp.value))
1233 .collect();
1234 f.render_widget(
1235 crate::cli::charts::Sparkline::new(&prices, Style::new().fg(trend_color)),
1236 inner,
1237 );
1238
1239 let change_5m_str = if !state.short_changes_known() {
1241 NOT_AVAILABLE.to_string()
1242 } else if state.price_change_5m.abs() < 0.0001 {
1243 "0.00%".to_string()
1244 } else {
1245 format!("{:+.4}%", state.price_change_5m)
1246 };
1247 let change_5m_color = if !state.short_changes_known() {
1248 pal.neutral
1249 } else if state.price_change_5m > 0.0 {
1250 pal.up
1251 } else if state.price_change_5m < 0.0 {
1252 pal.down
1253 } else {
1254 pal.neutral
1255 };
1256
1257 let now_ts = chrono::Utc::now().timestamp() as f64;
1258 let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
1259 let last_change_str = if secs_since_change < 60 {
1260 format!("{}s ago", secs_since_change)
1261 } else if secs_since_change < 3600 {
1262 format!("{}m ago", secs_since_change / 60)
1263 } else {
1264 format!("{}h ago", secs_since_change / 3600)
1265 };
1266 let last_change_color = if secs_since_change < 60 {
1267 pal.up
1268 } else {
1269 pal.highlight
1270 };
1271
1272 let change_24h_str = state
1273 .change_24h()
1274 .map(|c| format!("{}{:.2}%", if c >= 0.0 { "+" } else { "" }, c))
1275 .unwrap_or_else(|| NOT_AVAILABLE.to_string());
1276
1277 let market_cap_str = state
1278 .market_cap
1279 .map(scope::display::format_usd)
1280 .unwrap_or_else(|| "N/A".to_string());
1281
1282 let dex_stat = |value: String| {
1284 if state.venue_only {
1285 NOT_AVAILABLE.to_string()
1286 } else {
1287 value
1288 }
1289 };
1290
1291 let mut rows = vec![
1292 Row::new(vec![
1293 Span::styled("Price", SECONDARY),
1294 Span::styled(format_price_usd(state.current_price), Style::new().bold()),
1295 ]),
1296 Row::new(vec![
1297 Span::styled("5m Chg", SECONDARY),
1298 Span::styled(change_5m_str, Style::new().fg(change_5m_color)),
1299 ]),
1300 Row::new(vec![
1301 Span::styled("Last Δ", SECONDARY),
1302 Span::styled(last_change_str, Style::new().fg(last_change_color)),
1303 ]),
1304 Row::new(vec![
1305 Span::styled("24h Chg", SECONDARY),
1306 Span::raw(change_24h_str),
1307 ]),
1308 Row::new(vec![
1309 Span::styled("Liq", SECONDARY),
1310 Span::raw(dex_stat(scope::display::format_usd(state.liquidity_usd))),
1311 ]),
1312 Row::new(vec![
1313 Span::styled("Vol 24h", SECONDARY),
1314 Span::raw(
1315 state
1316 .volume_usd()
1317 .map(scope::display::format_usd)
1318 .unwrap_or_else(|| NOT_AVAILABLE.to_string()),
1319 ),
1320 ]),
1321 Row::new(vec![
1322 Span::styled("Mkt Cap", SECONDARY),
1323 Span::raw(market_cap_str),
1324 ]),
1325 Row::new(vec![
1326 Span::styled("Buys", SECONDARY),
1327 Span::styled(
1328 dex_stat(state.buys_24h.to_string()),
1329 Style::new().fg(pal.up),
1330 ),
1331 ]),
1332 Row::new(vec![
1333 Span::styled("Sells", SECONDARY),
1334 Span::styled(
1335 dex_stat(state.sells_24h.to_string()),
1336 Style::new().fg(pal.down),
1337 ),
1338 ]),
1339 ];
1340
1341 if state.widgets.holder_count
1343 && let Some(count) = state.holder_count
1344 {
1345 rows.push(Row::new(vec![
1346 Span::styled("Holders", SECONDARY),
1347 Span::styled(format_number(count as f64), Style::new().fg(pal.highlight)),
1348 ]));
1349 }
1350
1351 let table = Table::new(rows, [Constraint::Length(8), Constraint::Min(10)]).block(
1352 Block::default()
1353 .title(" ◉ Key Metrics ")
1354 .borders(Borders::ALL)
1355 .border_style(crate::cli::theme::ACCENT),
1356 );
1357
1358 f.render_widget(table, chunks[1]);
1359}
1360
1361pub(crate) fn spread_line(book: &scope::market::OrderBook) -> String {
1367 book.spread()
1368 .zip(book.spread_pct())
1369 .map(|(s, pct)| format!(" Spread: {:.6} ({:.3}%)", s, pct))
1370 .unwrap_or_else(|| " Spread: --".to_string())
1371}
1372
1373pub(crate) fn render_order_book_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1374 let pal = state.palette();
1375
1376 let book = match &state.order_book {
1377 Some(b) if !state.venue_only => b,
1378 _ => {
1379 render_empty_panel(f, area, order_book_title(state), state.venue_only);
1380 return;
1381 }
1382 };
1383
1384 let inner_height = area.height.saturating_sub(2) as usize; if inner_height < 3 {
1386 return;
1387 }
1388
1389 let ask_rows = (inner_height.saturating_sub(1)) / 2;
1391 let bid_rows = inner_height.saturating_sub(ask_rows).saturating_sub(1);
1392
1393 let max_qty = book
1395 .asks
1396 .iter()
1397 .chain(book.bids.iter())
1398 .map(|l| l.quantity)
1399 .fold(0.0_f64, f64::max)
1400 .max(0.001);
1401
1402 let inner_width = area.width.saturating_sub(2) as usize;
1403 let bar_width_max = (inner_width as f64 * 0.3).round() as usize;
1405
1406 let mut lines: Vec<Line> = Vec::with_capacity(inner_height);
1407
1408 let visible_asks: Vec<_> = book.asks.iter().take(ask_rows).collect();
1410 for _ in 0..ask_rows.saturating_sub(visible_asks.len()) {
1412 lines.push(Line::from(""));
1413 }
1414 for level in visible_asks.iter().rev() {
1415 let bar_len = ((level.quantity / max_qty) * bar_width_max as f64).round() as usize;
1416 let bar = "█".repeat(bar_len);
1417 let price_str = format!("{:.6}", level.price);
1418 let qty_str = format_number(level.quantity);
1419 let val_str = format_number(level.value());
1420 let padding = inner_width
1421 .saturating_sub(bar_len)
1422 .saturating_sub(price_str.len())
1423 .saturating_sub(qty_str.len())
1424 .saturating_sub(val_str.len())
1425 .saturating_sub(4); lines.push(Line::from(vec![
1427 Span::styled(bar, Style::new().fg(pal.down).dim()),
1428 Span::raw(" "),
1429 Span::styled(price_str, Style::new().fg(pal.down)),
1430 Span::raw(" ".repeat(padding.max(1))),
1431 Span::styled(qty_str, Style::new().fg(pal.neutral)),
1432 Span::raw(" "),
1433 Span::styled(val_str, SECONDARY),
1434 ]));
1435 }
1436
1437 let spread = spread_line(book);
1439 lines.push(Line::from(Span::styled(
1440 spread,
1441 crate::cli::theme::strong(Role::Warn),
1442 )));
1443
1444 for level in book.bids.iter().take(bid_rows) {
1446 let bar_len = ((level.quantity / max_qty) * bar_width_max as f64).round() as usize;
1447 let bar = "█".repeat(bar_len);
1448 let price_str = format!("{:.6}", level.price);
1449 let qty_str = format_number(level.quantity);
1450 let val_str = format_number(level.value());
1451 let padding = inner_width
1452 .saturating_sub(bar_len)
1453 .saturating_sub(price_str.len())
1454 .saturating_sub(qty_str.len())
1455 .saturating_sub(val_str.len())
1456 .saturating_sub(4);
1457 lines.push(Line::from(vec![
1458 Span::styled(bar, Style::new().fg(pal.up).dim()),
1459 Span::raw(" "),
1460 Span::styled(price_str, Style::new().fg(pal.up)),
1461 Span::raw(" ".repeat(padding.max(1))),
1462 Span::styled(qty_str, Style::new().fg(pal.neutral)),
1463 Span::raw(" "),
1464 Span::styled(val_str, SECONDARY),
1465 ]));
1466 }
1467
1468 let block = Block::default()
1469 .title(order_book_title(state))
1470 .borders(Borders::ALL)
1471 .border_style(crate::cli::theme::SECONDARY);
1472
1473 let paragraph = Paragraph::new(lines).block(block);
1474 f.render_widget(paragraph, area);
1475}
1476
1477pub(crate) fn order_book_title(state: &MonitorState) -> String {
1481 let Some(book) = &state.order_book else {
1482 if state.venue_only {
1483 return format!(" ◈ Order Book {VENUE_ONLY_NOTE} ");
1484 }
1485 return format!(" ◈ Order Book {SYNTHETIC_NOTE} │ no data ");
1486 };
1487 let source = match &state.book_venue {
1488 Some(venue) => format!("({venue})"),
1489 None => format!("({})", book.source.label()),
1490 };
1491 format!(
1492 " ◈ Order Book {source} │ {} │ Ask {} │ Bid {} ",
1493 book.pair,
1494 format_number(book.total_depth(BookSide::Ask)),
1495 format_number(book.total_depth(BookSide::Bid)),
1496 )
1497}
1498
1499pub(crate) fn trades_title(state: &MonitorState) -> String {
1502 let source = match &state.trades_venue {
1503 Some(venue) => format!("({venue})"),
1504 None if state.venue_only => return format!(" ◈ Recent Trades {VENUE_ONLY_NOTE} "),
1505 None => SYNTHETIC_NOTE.to_string(),
1506 };
1507 match state.recent_trades.len() {
1508 0 => format!(" ◈ Recent Trades {source} │ no data "),
1509 n => format!(" ◈ Recent Trades {source} │ {n} "),
1510 }
1511}
1512
1513fn render_empty_panel(f: &mut Frame, area: Rect, title: String, venue_only: bool) {
1516 let block = Block::default()
1517 .title(title)
1518 .borders(Borders::ALL)
1519 .border_style(crate::cli::theme::SECONDARY);
1520 let body = if venue_only {
1521 "Not available in venue-only mode."
1522 } else {
1523 ""
1524 };
1525 let body = Paragraph::new(Span::styled(body, SECONDARY))
1526 .wrap(Wrap { trim: true })
1527 .block(block);
1528 f.render_widget(body, area);
1529}
1530
1531pub(crate) fn render_recent_trades_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1536 let pal = state.palette();
1537
1538 if state.venue_only || state.recent_trades.is_empty() {
1539 render_empty_panel(f, area, trades_title(state), state.venue_only);
1540 return;
1541 }
1542
1543 let inner_height = area.height.saturating_sub(2) as usize;
1544 let inner_width = area.width.saturating_sub(2) as usize;
1545
1546 let time_width = 8; let side_width = 4; let price_width = inner_width
1550 .saturating_sub(time_width)
1551 .saturating_sub(side_width)
1552 .saturating_sub(3) / 2;
1554 let qty_width = inner_width
1555 .saturating_sub(time_width)
1556 .saturating_sub(side_width)
1557 .saturating_sub(price_width)
1558 .saturating_sub(3);
1559
1560 let mut lines: Vec<Line> = Vec::with_capacity(inner_height);
1561
1562 lines.push(Line::from(vec![
1564 Span::styled(format!("{:<time_width$}", "Time"), SECONDARY.bold()),
1565 Span::raw(" "),
1566 Span::styled(format!("{:<side_width$}", "Side"), SECONDARY.bold()),
1567 Span::raw(" "),
1568 Span::styled(format!("{:>price_width$}", "Price"), SECONDARY.bold()),
1569 Span::raw(" "),
1570 Span::styled(format!("{:>qty_width$}", "Qty"), SECONDARY.bold()),
1571 ]));
1572
1573 let visible_count = inner_height.saturating_sub(1); for trade in state.recent_trades.iter().rev().take(visible_count) {
1576 let (side_str, side_color) = match trade.side {
1577 TradeSide::Buy => ("BUY ", pal.up),
1578 TradeSide::Sell => ("SELL", pal.down),
1579 };
1580
1581 let secs = (trade.timestamp_ms / 1000) as i64;
1583 let hours = (secs / 3600) % 24;
1584 let mins = (secs / 60) % 60;
1585 let sec = secs % 60;
1586 let time_str = format!("{:02}:{:02}:{:02}", hours, mins, sec);
1587
1588 let price_str = if trade.price >= 1000.0 {
1589 format!("{:.2}", trade.price)
1590 } else if trade.price >= 1.0 {
1591 format!("{:.4}", trade.price)
1592 } else {
1593 format!("{:.6}", trade.price)
1594 };
1595
1596 let qty_str = format_number(trade.quantity);
1597
1598 lines.push(Line::from(vec![
1599 Span::styled(format!("{:<time_width$}", time_str), SECONDARY),
1600 Span::raw(" "),
1601 Span::styled(
1602 format!("{:<side_width$}", side_str),
1603 Style::new().fg(side_color),
1604 ),
1605 Span::raw(" "),
1606 Span::styled(
1607 format!("{:>price_width$}", price_str),
1608 Style::new().fg(side_color),
1609 ),
1610 Span::raw(" "),
1611 Span::styled(
1612 format!("{:>qty_width$}", qty_str),
1613 Style::new().fg(pal.neutral),
1614 ),
1615 ]));
1616 }
1617
1618 let block = Block::default()
1619 .title(trades_title(state))
1620 .borders(Borders::ALL)
1621 .border_style(crate::cli::theme::SECONDARY);
1622
1623 let paragraph = Paragraph::new(lines).block(block);
1624 f.render_widget(paragraph, area);
1625}
1626
1627pub(crate) fn render_market_info_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1632 let pal = state.palette();
1633
1634 let cols = Layout::default()
1636 .direction(Direction::Horizontal)
1637 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
1638 .split(area);
1639
1640 {
1642 let header = Row::new(vec!["DEX / Pair", "Volume 24h", "Liquidity", "Δ 24h"])
1643 .style(crate::cli::theme::strong(Role::Accent))
1644 .bottom_margin(0);
1645
1646 let rows: Vec<Row> = state
1647 .dex_pairs
1648 .iter()
1649 .take(cols[0].height.saturating_sub(3) as usize) .map(|p| {
1651 let pair_label = format!("{}/{}", p.base_token, p.quote_token);
1652 let dex_and_pair = format!("{} {}", p.dex_name, pair_label);
1653 let vol = format_number(p.volume_24h);
1654 let liq = format_number(p.liquidity_usd);
1655 let change_str = format!("{:+.1}%", p.price_change_24h);
1656 let change_color = if p.price_change_24h >= 0.0 {
1657 pal.up
1658 } else {
1659 pal.down
1660 };
1661 Row::new(vec![
1662 ratatui::text::Text::from(dex_and_pair),
1663 ratatui::text::Text::styled(vol, Style::new().fg(pal.neutral)),
1664 ratatui::text::Text::styled(liq, Style::new().fg(pal.volume_bar)),
1665 ratatui::text::Text::styled(change_str, Style::new().fg(change_color)),
1666 ])
1667 })
1668 .collect();
1669
1670 let widths = [
1671 Constraint::Percentage(40),
1672 Constraint::Percentage(22),
1673 Constraint::Percentage(22),
1674 Constraint::Percentage(16),
1675 ];
1676
1677 let table = Table::new(rows, widths).header(header).block(
1678 Block::default()
1679 .title(format!(" ◫ Trading Pairs ({}) ", state.dex_pairs.len()))
1680 .borders(Borders::ALL)
1681 .border_style(crate::cli::theme::SECONDARY),
1682 );
1683
1684 f.render_widget(table, cols[0]);
1685 }
1686
1687 {
1689 let mut info_lines: Vec<Line> = Vec::new();
1690
1691 let price_color = match state.change_24h() {
1693 Some(c) if c >= 0.0 => pal.up,
1694 Some(_) => pal.down,
1695 None => pal.neutral,
1696 };
1697 info_lines.push(Line::from(vec![
1698 Span::styled(" Price ", SECONDARY),
1699 Span::styled(format_price_usd(state.current_price), Style::new().bold()),
1700 ]));
1701
1702 let short = |v: f64| state.short_changes_known().then_some(v);
1704 let changes = [
1705 ("5m", short(state.price_change_5m)),
1706 ("1h", short(state.price_change_1h)),
1707 ("6h", short(state.price_change_6h)),
1708 ("24h", state.change_24h()),
1709 ];
1710 let change_spans: Vec<Span> = changes
1711 .iter()
1712 .flat_map(|(label, val)| {
1713 let (text, color) = match val {
1714 Some(v) if *v >= 0.0 => (format!("{:+.2}%", v), pal.up),
1715 Some(v) => (format!("{:+.2}%", v), pal.down),
1716 None => (NOT_AVAILABLE.to_string(), pal.neutral),
1717 };
1718 vec![
1719 Span::styled(format!(" {}: ", label), SECONDARY),
1720 Span::styled(text, Style::new().fg(color)),
1721 ]
1722 })
1723 .collect();
1724 info_lines.push(Line::from(change_spans));
1725
1726 info_lines.push(Line::from(""));
1727
1728 info_lines.push(Line::from(vec![
1730 Span::styled(" Vol 24h ", SECONDARY),
1731 Span::styled(
1732 state
1733 .volume_usd()
1734 .map(scope::display::format_usd)
1735 .unwrap_or_else(|| NOT_AVAILABLE.to_string()),
1736 Style::new().fg(pal.neutral),
1737 ),
1738 ]));
1739 let liq = if state.venue_only {
1740 NOT_AVAILABLE.to_string()
1741 } else {
1742 scope::display::format_usd(state.liquidity_usd)
1743 };
1744 info_lines.push(Line::from(vec![
1745 Span::styled(" Liq ", SECONDARY),
1746 Span::styled(liq, Style::new().fg(pal.volume_bar)),
1747 ]));
1748
1749 if let Some(mc) = state.market_cap {
1751 info_lines.push(Line::from(vec![
1752 Span::styled(" MCap ", SECONDARY),
1753 Span::styled(scope::display::format_usd(mc), Style::new().fg(pal.neutral)),
1754 ]));
1755 }
1756 if let Some(fdv) = state.fdv {
1757 info_lines.push(Line::from(vec![
1758 Span::styled(" FDV ", SECONDARY),
1759 Span::styled(
1760 scope::display::format_usd(fdv),
1761 Style::new().fg(pal.neutral),
1762 ),
1763 ]));
1764 }
1765
1766 info_lines.push(Line::from(""));
1768 let total_txs = state.buys_24h + state.sells_24h;
1769 let buy_pct = if total_txs > 0 {
1770 (state.buys_24h as f64 / total_txs as f64) * 100.0
1771 } else {
1772 50.0
1773 };
1774 let (buys, sells) = if state.venue_only {
1775 (NOT_AVAILABLE.to_string(), NOT_AVAILABLE.to_string())
1776 } else {
1777 (
1778 format!("{} ({:.0}%)", state.buys_24h, buy_pct),
1779 format!("{} ({:.0}%)", state.sells_24h, 100.0 - buy_pct),
1780 )
1781 };
1782 info_lines.push(Line::from(vec![
1783 Span::styled(" Buys ", SECONDARY),
1784 Span::styled(buys, Style::new().fg(pal.up)),
1785 ]));
1786 info_lines.push(Line::from(vec![
1787 Span::styled(" Sells ", SECONDARY),
1788 Span::styled(sells, Style::new().fg(pal.down)),
1789 ]));
1790
1791 if let Some(holders) = state.holder_count {
1793 info_lines.push(Line::from(vec![
1794 Span::styled(" Holders ", SECONDARY),
1795 Span::styled(format_number(holders as f64), Style::new().fg(pal.neutral)),
1796 ]));
1797 }
1798
1799 if let Some(ts) = state.earliest_pair_created_at {
1801 let dt = chrono::DateTime::from_timestamp(ts, 0)
1802 .map(|d| d.format("%Y-%m-%d").to_string())
1803 .unwrap_or_else(|| "?".to_string());
1804 info_lines.push(Line::from(vec![
1805 Span::styled(" Listed ", SECONDARY),
1806 Span::styled(dt, Style::new().fg(pal.neutral)),
1807 ]));
1808 }
1809
1810 if !state.websites.is_empty() || !state.socials.is_empty() {
1812 info_lines.push(Line::from(""));
1813 let mut link_spans = vec![Span::styled(" Links ", SECONDARY)];
1814 for (platform, _url) in &state.socials {
1815 link_spans.push(Span::styled(
1816 format!("[{}] ", platform),
1817 crate::cli::theme::ACCENT,
1818 ));
1819 }
1820 for url in &state.websites {
1821 let domain = url
1823 .trim_start_matches("https://")
1824 .trim_start_matches("http://")
1825 .split('/')
1826 .next()
1827 .unwrap_or(url);
1828 link_spans.push(Span::styled(
1829 format!("[{}] ", domain),
1830 crate::cli::theme::LINK,
1831 ));
1832 }
1833 info_lines.push(Line::from(link_spans));
1834 }
1835
1836 let title = format!(" ◉ {} ({}) ", state.symbol, state.name);
1837 let block = Block::default()
1838 .title(title)
1839 .borders(Borders::ALL)
1840 .border_style(Style::new().fg(price_color));
1841
1842 let paragraph = Paragraph::new(info_lines).block(block);
1843 f.render_widget(paragraph, cols[1]);
1844 }
1845}
1846
1847pub(crate) const MONITOR_KEYS: &[(&str, &str)] = &[
1850 ("q, Esc, Ctrl-C", "quit"),
1851 ("r", "refresh now"),
1852 ("p, Space", "pause or resume"),
1853 ("Shift-P", "auto-pause on input"),
1854 ("e", "start or stop the CSV export"),
1855 ("+ / -", "slower or faster refresh"),
1856 ("1-6", "time period 1m, 5m, 15m, 1h, 4h, 1d"),
1857 ("t, Tab", "next time period"),
1858 ("c", "chart: line, candles, volume profile"),
1859 ("s", "scale: linear or log"),
1860 ("/", "color scheme"),
1861 ("l / h", "next or previous layout"),
1862 ("a", "automatic layout"),
1863 ("w then 1-5", "show or hide a widget"),
1864 ("j / k", "scroll the activity log"),
1865 ("?", "this list (any key closes it)"),
1866];
1867
1868fn render_keys_overlay(f: &mut Frame) {
1870 let area = f.area();
1871 let width = 56.min(area.width.saturating_sub(4));
1872 let height = (MONITOR_KEYS.len() as u16 + 2).min(area.height.saturating_sub(2));
1873 let rect = Rect::new(
1874 area.x + area.width.saturating_sub(width) / 2,
1875 area.y + area.height.saturating_sub(height) / 2,
1876 width,
1877 height,
1878 );
1879 let lines: Vec<Line> = MONITOR_KEYS
1880 .iter()
1881 .map(|(k, what)| {
1882 Line::from(vec![
1883 Span::styled(format!(" {k:<16}"), crate::cli::theme::KEY),
1884 Span::styled((*what).to_string(), SECONDARY),
1885 ])
1886 })
1887 .collect();
1888 f.render_widget(ratatui::widgets::Clear, rect);
1889 f.render_widget(
1890 Paragraph::new(lines).block(
1891 Block::default()
1892 .title(" Keys ")
1893 .borders(Borders::ALL)
1894 .border_style(crate::cli::theme::ACCENT),
1895 ),
1896 rect,
1897 );
1898}
1899
1900pub(crate) fn render_footer(f: &mut Frame, area: Rect, state: &MonitorState) {
1901 let footer = Paragraph::new(Line::from(footer_spans(state)))
1902 .block(Block::default().borders(Borders::ALL));
1903 f.render_widget(footer, area);
1904}
1905
1906pub(crate) fn footer_spans(state: &MonitorState) -> Vec<Span<'static>> {
1909 let elapsed = state.last_update.elapsed().as_secs();
1910
1911 let now_ts = chrono::Utc::now().timestamp() as f64;
1913 let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
1914 let price_change_str = if secs_since_change < 60 {
1915 format!("{}s", secs_since_change)
1916 } else if secs_since_change < 3600 {
1917 format!("{}m", secs_since_change / 60)
1918 } else {
1919 format!("{}h", secs_since_change / 3600)
1920 };
1921
1922 let (synthetic_count, real_count) = state.data_stats();
1924 let memory_bytes = state.memory_usage();
1925 let memory_str = if memory_bytes >= 1024 * 1024 {
1926 format!("{:.1}MB", memory_bytes as f64 / (1024.0 * 1024.0))
1927 } else if memory_bytes >= 1024 {
1928 format!("{:.1}KB", memory_bytes as f64 / 1024.0)
1929 } else {
1930 format!("{}B", memory_bytes)
1931 };
1932
1933 let stats = Span::styled(
1934 format!(
1935 "↻ {}s of {}s │ Δ {} │ {} pts │ {}",
1936 elapsed,
1937 state.refresh_rate.as_secs(),
1938 price_change_str,
1939 synthetic_count + real_count,
1940 memory_str
1941 ),
1942 SECONDARY,
1943 );
1944
1945 let key = |k: &'static str, word: String| {
1947 [
1948 Span::raw("["),
1949 Span::styled(k, crate::cli::theme::KEY),
1950 Span::raw(format!("]{word} ")),
1951 ]
1952 };
1953 let mut spans: Vec<Span<'static>> = key("q", "uit".into()).to_vec();
1954 if state.paused {
1955 spans.push(Span::styled(
1956 "⏸ PAUSED ",
1957 crate::cli::theme::strong(Role::Warn),
1958 ));
1959 } else if state.is_auto_paused() {
1960 spans.push(Span::styled(
1961 "⏸ AUTO-PAUSED ",
1962 crate::cli::theme::strong(Role::Accent),
1963 ));
1964 }
1965
1966 if state.export_active {
1968 spans.push(Span::styled("● REC ", crate::cli::theme::strong(Role::Err)));
1969 }
1970
1971 if state.widget_toggle_mode {
1972 spans.push(Span::styled(
1973 "widget 1-5? ",
1974 crate::cli::theme::strong(Role::Warn),
1975 ));
1976 }
1977 for part in [
1978 key("r", "efresh".into()),
1979 key("p", "ause".into()),
1980 key("e", "xport".into()),
1981 key("l", format!("ayout:{}", state.layout.label())),
1982 key("w", "idgets".into()),
1983 key("c", format!("hart:{}", state.chart_mode.label())),
1984 key("s", format!("cale:{}", state.scale_mode.label())),
1985 key("/", format!("colors:{}", state.color_scheme.label())),
1986 key("t", "ime".into()),
1987 key("?", "keys".into()),
1988 ] {
1989 spans.extend(part);
1990 }
1991 spans.extend([Span::raw("║ "), stats]);
1992 spans
1993}