1use crate::chains::dex::{DexClient, DexDataSource, DexTokenData};
64use crate::chains::{ChainClient, ChainClientFactory, DexPair};
65use crate::config::Config;
66use crate::error::{Result, ScopeError};
67use crate::market::{OrderBook, OrderBookLevel, Trade, TradeSide};
68use clap::Args;
69use crossterm::{
70 event::{DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
71 execute,
72};
73use ratatui::{
74 Frame,
75 layout::{Constraint, Direction, Layout, Rect},
76 style::{Color, Style},
77 symbols,
78 text::{Line, Span},
79 widgets::{
80 Axis, Bar, BarChart, BarGroup, Block, Borders, Chart, Dataset, GraphType, List, ListItem,
81 ListState, Paragraph, Row, Sparkline, Table, Tabs,
82 canvas::{Canvas, Line as CanvasLine, Rectangle},
83 },
84};
85use serde::{Deserialize, Serialize};
86use std::collections::VecDeque;
87use std::fs;
88use std::io::{self, BufWriter, Write as _};
89use std::path::PathBuf;
90use std::time::{Duration, Instant};
91
92use super::interactive::SessionContext;
93
94#[derive(Debug, Args)]
119pub struct MonitorArgs {
120 pub token: String,
126
127 #[arg(short, long, default_value = "ethereum")]
131 pub chain: String,
132
133 #[arg(short, long)]
138 pub layout: Option<LayoutPreset>,
139
140 #[arg(short, long)]
145 pub refresh: Option<u64>,
146
147 #[arg(short, long)]
151 pub scale: Option<ScaleMode>,
152
153 #[arg(long)]
157 pub color_scheme: Option<ColorScheme>,
158
159 #[arg(short, long, value_name = "PATH")]
161 pub export: Option<PathBuf>,
162
163 #[arg(long, value_name = "VENUE")]
168 pub venue: Option<String>,
169
170 #[arg(long, value_name = "PAIR")]
176 pub pair: Option<String>,
177}
178
179const MAX_DATA_AGE_SECS: f64 = 24.0 * 3600.0; const CACHE_FILE_PREFIX: &str = "bcc_monitor_";
187
188const DEFAULT_REFRESH_SECS: u64 = 5;
190
191const MIN_REFRESH_SECS: u64 = 1;
193
194const MAX_REFRESH_SECS: u64 = 60;
196
197#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
199pub struct DataPoint {
200 pub timestamp: f64,
202 pub value: f64,
204 pub is_real: bool,
206}
207
208#[derive(Debug, Clone, Copy)]
210pub struct OhlcCandle {
211 pub timestamp: f64,
213 pub open: f64,
215 pub high: f64,
217 pub low: f64,
219 pub close: f64,
221 pub is_bullish: bool,
223}
224
225impl OhlcCandle {
226 pub fn new(timestamp: f64, price: f64) -> Self {
228 Self {
229 timestamp,
230 open: price,
231 high: price,
232 low: price,
233 close: price,
234 is_bullish: true,
235 }
236 }
237
238 pub fn update(&mut self, price: f64) {
240 self.high = self.high.max(price);
241 self.low = self.low.min(price);
242 self.close = price;
243 self.is_bullish = self.close >= self.open;
244 }
245}
246
247#[derive(Debug, Serialize, Deserialize)]
249struct CachedMonitorData {
250 token_address: String,
252 chain: String,
254 price_history: Vec<DataPoint>,
256 volume_history: Vec<DataPoint>,
258 saved_at: f64,
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum TimePeriod {
265 Min1,
267 Min5,
269 Min15,
271 Hour1,
273 Hour4,
275 Day1,
277}
278
279impl TimePeriod {
280 pub fn duration_secs(&self) -> i64 {
282 match self {
283 TimePeriod::Min1 => 60,
284 TimePeriod::Min5 => 5 * 60,
285 TimePeriod::Min15 => 15 * 60,
286 TimePeriod::Hour1 => 3600,
287 TimePeriod::Hour4 => 4 * 3600,
288 TimePeriod::Day1 => 24 * 3600,
289 }
290 }
291
292 pub fn label(&self) -> &'static str {
294 match self {
295 TimePeriod::Min1 => "1m",
296 TimePeriod::Min5 => "5m",
297 TimePeriod::Min15 => "15m",
298 TimePeriod::Hour1 => "1h",
299 TimePeriod::Hour4 => "4h",
300 TimePeriod::Day1 => "1d",
301 }
302 }
303
304 pub fn index(&self) -> usize {
306 match self {
307 TimePeriod::Min1 => 0,
308 TimePeriod::Min5 => 1,
309 TimePeriod::Min15 => 2,
310 TimePeriod::Hour1 => 3,
311 TimePeriod::Hour4 => 4,
312 TimePeriod::Day1 => 5,
313 }
314 }
315
316 pub fn exchange_interval(&self) -> &'static str {
318 match self {
319 TimePeriod::Min1 => "1m",
320 TimePeriod::Min5 => "5m",
321 TimePeriod::Min15 => "15m",
322 TimePeriod::Hour1 => "1h",
323 TimePeriod::Hour4 => "4h",
324 TimePeriod::Day1 => "1d",
325 }
326 }
327
328 pub fn next(&self) -> Self {
330 match self {
331 TimePeriod::Min1 => TimePeriod::Min5,
332 TimePeriod::Min5 => TimePeriod::Min15,
333 TimePeriod::Min15 => TimePeriod::Hour1,
334 TimePeriod::Hour1 => TimePeriod::Hour4,
335 TimePeriod::Hour4 => TimePeriod::Day1,
336 TimePeriod::Day1 => TimePeriod::Min1,
337 }
338 }
339}
340
341impl std::fmt::Display for TimePeriod {
342 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343 write!(f, "{}", self.label())
344 }
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
349pub enum ChartMode {
350 #[default]
352 Line,
353 Candlestick,
355 VolumeProfile,
357}
358
359impl ChartMode {
360 pub fn next(&self) -> Self {
362 match self {
363 ChartMode::Line => ChartMode::Candlestick,
364 ChartMode::Candlestick => ChartMode::VolumeProfile,
365 ChartMode::VolumeProfile => ChartMode::Line,
366 }
367 }
368
369 pub fn label(&self) -> &'static str {
371 match self {
372 ChartMode::Line => "Line",
373 ChartMode::Candlestick => "Candle",
374 ChartMode::VolumeProfile => "VolPro",
375 }
376 }
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
381#[serde(rename_all = "kebab-case")]
382pub enum ColorScheme {
383 #[default]
385 GreenRed,
386 BlueOrange,
388 Monochrome,
390}
391
392impl ColorScheme {
393 pub fn next(&self) -> Self {
395 match self {
396 ColorScheme::GreenRed => ColorScheme::BlueOrange,
397 ColorScheme::BlueOrange => ColorScheme::Monochrome,
398 ColorScheme::Monochrome => ColorScheme::GreenRed,
399 }
400 }
401
402 pub fn palette(&self) -> ColorPalette {
404 match self {
405 ColorScheme::GreenRed => ColorPalette {
406 up: Color::Green,
407 down: Color::Red,
408 neutral: Color::Gray,
409 header_fg: Color::White,
410 border: Color::DarkGray,
411 highlight: Color::Yellow,
412 volume_bar: Color::Blue,
413 sparkline: Color::Cyan,
414 },
415 ColorScheme::BlueOrange => ColorPalette {
416 up: Color::Blue,
417 down: Color::Rgb(255, 165, 0), neutral: Color::Gray,
419 header_fg: Color::White,
420 border: Color::DarkGray,
421 highlight: Color::Cyan,
422 volume_bar: Color::Magenta,
423 sparkline: Color::LightBlue,
424 },
425 ColorScheme::Monochrome => ColorPalette {
426 up: Color::White,
427 down: Color::DarkGray,
428 neutral: Color::Gray,
429 header_fg: Color::White,
430 border: Color::DarkGray,
431 highlight: Color::White,
432 volume_bar: Color::Gray,
433 sparkline: Color::White,
434 },
435 }
436 }
437
438 pub fn label(&self) -> &'static str {
440 match self {
441 ColorScheme::GreenRed => "G/R",
442 ColorScheme::BlueOrange => "B/O",
443 ColorScheme::Monochrome => "Mono",
444 }
445 }
446}
447
448#[derive(Debug, Clone, Copy)]
450pub struct ColorPalette {
451 pub up: Color,
453 pub down: Color,
455 pub neutral: Color,
457 pub header_fg: Color,
459 pub border: Color,
461 pub highlight: Color,
463 pub volume_bar: Color,
465 pub sparkline: Color,
467}
468
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
471#[serde(rename_all = "kebab-case")]
472pub enum ScaleMode {
473 #[default]
475 Linear,
476 Log,
478}
479
480impl ScaleMode {
481 pub fn toggle(&self) -> Self {
483 match self {
484 ScaleMode::Linear => ScaleMode::Log,
485 ScaleMode::Log => ScaleMode::Linear,
486 }
487 }
488
489 pub fn label(&self) -> &'static str {
491 match self {
492 ScaleMode::Linear => "Lin",
493 ScaleMode::Log => "Log",
494 }
495 }
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
500#[serde(default)]
501pub struct AlertConfig {
502 pub price_min: Option<f64>,
504 pub price_max: Option<f64>,
506 pub whale_min_usd: Option<f64>,
508 pub volume_spike_threshold_pct: Option<f64>,
510}
511
512impl Default for AlertConfig {
513 #[allow(clippy::derivable_impls)]
514 fn default() -> Self {
515 Self {
516 price_min: None,
517 price_max: None,
518 whale_min_usd: None,
519 volume_spike_threshold_pct: None,
520 }
521 }
522}
523
524#[derive(Debug, Clone)]
526pub struct ActiveAlert {
527 pub message: String,
529 pub triggered_at: Instant,
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
535#[serde(default)]
536pub struct ExportConfig {
537 pub path: Option<String>,
539}
540
541impl Default for ExportConfig {
542 #[allow(clippy::derivable_impls)]
543 fn default() -> Self {
544 Self { path: None }
545 }
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
553#[serde(rename_all = "kebab-case")]
554pub enum LayoutPreset {
555 #[default]
557 Dashboard,
558 ChartFocus,
560 Feed,
562 Compact,
564 Exchange,
566}
567
568impl LayoutPreset {
569 pub fn next(&self) -> Self {
571 match self {
572 LayoutPreset::Dashboard => LayoutPreset::ChartFocus,
573 LayoutPreset::ChartFocus => LayoutPreset::Feed,
574 LayoutPreset::Feed => LayoutPreset::Compact,
575 LayoutPreset::Compact => LayoutPreset::Exchange,
576 LayoutPreset::Exchange => LayoutPreset::Dashboard,
577 }
578 }
579
580 pub fn prev(&self) -> Self {
582 match self {
583 LayoutPreset::Dashboard => LayoutPreset::Exchange,
584 LayoutPreset::ChartFocus => LayoutPreset::Dashboard,
585 LayoutPreset::Feed => LayoutPreset::ChartFocus,
586 LayoutPreset::Compact => LayoutPreset::Feed,
587 LayoutPreset::Exchange => LayoutPreset::Compact,
588 }
589 }
590
591 pub fn label(&self) -> &'static str {
593 match self {
594 LayoutPreset::Dashboard => "Dashboard",
595 LayoutPreset::ChartFocus => "Chart",
596 LayoutPreset::Feed => "Feed",
597 LayoutPreset::Compact => "Compact",
598 LayoutPreset::Exchange => "Exchange",
599 }
600 }
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
608#[serde(default)]
609pub struct WidgetVisibility {
610 pub price_chart: bool,
612 pub volume_chart: bool,
614 pub buy_sell_pressure: bool,
616 pub metrics_panel: bool,
618 pub activity_log: bool,
620 pub holder_count: bool,
622 pub liquidity_depth: bool,
624}
625
626impl Default for WidgetVisibility {
627 fn default() -> Self {
628 Self {
629 price_chart: true,
630 volume_chart: true,
631 buy_sell_pressure: true,
632 metrics_panel: true,
633 activity_log: true,
634 holder_count: true,
635 liquidity_depth: true,
636 }
637 }
638}
639
640impl WidgetVisibility {
641 pub fn visible_count(&self) -> usize {
643 [
644 self.price_chart,
645 self.volume_chart,
646 self.buy_sell_pressure,
647 self.metrics_panel,
648 self.activity_log,
649 ]
650 .iter()
651 .filter(|&&v| v)
652 .count()
653 }
654
655 pub fn toggle_by_index(&mut self, index: usize) {
657 match index {
658 1 => self.price_chart = !self.price_chart,
659 2 => self.volume_chart = !self.volume_chart,
660 3 => self.buy_sell_pressure = !self.buy_sell_pressure,
661 4 => self.metrics_panel = !self.metrics_panel,
662 5 => self.activity_log = !self.activity_log,
663 _ => {}
664 }
665 }
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
673#[serde(default)]
674pub struct MonitorConfig {
675 pub layout: LayoutPreset,
677 pub refresh_seconds: u64,
679 pub widgets: WidgetVisibility,
681 pub scale: ScaleMode,
683 pub color_scheme: ColorScheme,
685 pub alerts: AlertConfig,
687 pub export: ExportConfig,
689 pub auto_pause_on_input: bool,
691 #[serde(default)]
693 pub venue: Option<String>,
694}
695
696impl Default for MonitorConfig {
697 fn default() -> Self {
698 Self {
699 layout: LayoutPreset::Dashboard,
700 refresh_seconds: DEFAULT_REFRESH_SECS,
701 widgets: WidgetVisibility::default(),
702 scale: ScaleMode::Linear,
703 color_scheme: ColorScheme::GreenRed,
704 alerts: AlertConfig::default(),
705 export: ExportConfig::default(),
706 auto_pause_on_input: false,
707 venue: None,
708 }
709 }
710}
711
712pub struct MonitorState {
714 pub token_address: String,
716
717 pub symbol: String,
719
720 pub name: String,
722
723 pub chain: String,
725
726 pub price_history: VecDeque<DataPoint>,
728
729 pub volume_history: VecDeque<DataPoint>,
731
732 pub real_data_count: usize,
734
735 pub current_price: f64,
737
738 pub price_change_24h: f64,
740
741 pub price_change_6h: f64,
743
744 pub price_change_1h: f64,
746
747 pub price_change_5m: f64,
749
750 pub last_price_change_at: f64,
752
753 pub previous_price: f64,
755
756 pub buys_24h: u64,
758
759 pub sells_24h: u64,
761
762 pub liquidity_usd: f64,
764
765 pub volume_24h: f64,
767
768 pub market_cap: Option<f64>,
770
771 pub fdv: Option<f64>,
773
774 pub last_update: Instant,
776
777 pub refresh_rate: Duration,
779
780 pub paused: bool,
782
783 pub log_messages: VecDeque<String>,
785
786 pub log_list_state: ListState,
788
789 pub error_message: Option<String>,
791
792 pub time_period: TimePeriod,
794
795 pub chart_mode: ChartMode,
797
798 pub scale_mode: ScaleMode,
800
801 pub color_scheme: ColorScheme,
803
804 pub holder_count: Option<u64>,
806
807 pub liquidity_pairs: Vec<(String, f64)>,
809
810 pub order_book: Option<OrderBook>,
812
813 pub recent_trades: VecDeque<Trade>,
815
816 pub dex_pairs: Vec<DexPair>,
818
819 pub websites: Vec<String>,
821
822 pub socials: Vec<(String, String)>,
824
825 pub earliest_pair_created_at: Option<i64>,
827
828 pub dexscreener_url: Option<String>,
830
831 pub holder_fetch_counter: u32,
833
834 pub start_timestamp: i64,
836
837 pub layout: LayoutPreset,
839
840 pub widgets: WidgetVisibility,
842
843 pub auto_layout: bool,
845
846 pub widget_toggle_mode: bool,
848
849 pub alerts: AlertConfig,
852
853 pub active_alerts: Vec<ActiveAlert>,
855
856 pub alert_flash_until: Option<Instant>,
858
859 pub export_active: bool,
862
863 pub export_path: Option<PathBuf>,
865
866 pub volume_avg: f64,
868
869 pub auto_pause_on_input: bool,
872
873 pub last_input_at: Instant,
875
876 pub auto_pause_timeout: Duration,
878
879 pub exchange_ohlc: Vec<OhlcCandle>,
883
884 pub venue_pair: Option<String>,
886}
887
888impl MonitorState {
889 pub fn new(token_data: &DexTokenData, chain: &str) -> Self {
892 let now = Instant::now();
893 let now_ts = chrono::Utc::now().timestamp() as f64;
894
895 let (price_history, volume_history, real_data_count) =
897 if let Some(cached) = Self::load_cache(&token_data.address, chain) {
898 let cutoff = now_ts - MAX_DATA_AGE_SECS;
900 let price_hist: VecDeque<DataPoint> = cached
901 .price_history
902 .into_iter()
903 .filter(|p| p.timestamp >= cutoff)
904 .collect();
905 let vol_hist: VecDeque<DataPoint> = cached
906 .volume_history
907 .into_iter()
908 .filter(|p| p.timestamp >= cutoff)
909 .collect();
910 let real_count = price_hist.iter().filter(|p| p.is_real).count();
911 (price_hist, vol_hist, real_count)
912 } else {
913 let price_hist = Self::generate_synthetic_price_history(
915 token_data.price_usd,
916 token_data.price_change_1h,
917 token_data.price_change_6h,
918 token_data.price_change_24h,
919 now_ts,
920 );
921 let vol_hist = Self::generate_synthetic_volume_history(
922 token_data.volume_24h,
923 token_data.volume_6h,
924 token_data.volume_1h,
925 now_ts,
926 );
927 (price_hist, vol_hist, 0)
928 };
929
930 Self {
931 token_address: token_data.address.clone(),
932 symbol: token_data.symbol.clone(),
933 name: token_data.name.clone(),
934 chain: chain.to_string(),
935 price_history,
936 volume_history,
937 real_data_count,
938 current_price: token_data.price_usd,
939 price_change_24h: token_data.price_change_24h,
940 price_change_6h: token_data.price_change_6h,
941 price_change_1h: token_data.price_change_1h,
942 price_change_5m: token_data.price_change_5m,
943 last_price_change_at: now_ts, previous_price: token_data.price_usd,
945 buys_24h: token_data.total_buys_24h,
946 sells_24h: token_data.total_sells_24h,
947 liquidity_usd: token_data.liquidity_usd,
948 volume_24h: token_data.volume_24h,
949 market_cap: token_data.market_cap,
950 fdv: token_data.fdv,
951 last_update: now,
952 refresh_rate: Duration::from_secs(DEFAULT_REFRESH_SECS),
953 paused: false,
954 log_messages: VecDeque::with_capacity(10),
955 log_list_state: ListState::default(),
956 error_message: None,
957 time_period: TimePeriod::Hour1, chart_mode: ChartMode::Line, scale_mode: ScaleMode::Linear, color_scheme: ColorScheme::GreenRed, holder_count: None,
962 liquidity_pairs: Vec::new(),
963 order_book: None,
964 recent_trades: VecDeque::new(),
965 dex_pairs: token_data.pairs.clone(),
966 websites: token_data.websites.clone(),
967 socials: token_data
968 .socials
969 .iter()
970 .map(|s| (s.platform.clone(), s.url.clone()))
971 .collect(),
972 earliest_pair_created_at: token_data.earliest_pair_created_at,
973 dexscreener_url: token_data.dexscreener_url.clone(),
974 holder_fetch_counter: 0,
975 start_timestamp: now_ts as i64,
976 layout: LayoutPreset::Dashboard,
977 widgets: WidgetVisibility::default(),
978 auto_layout: true,
979 widget_toggle_mode: false,
980 alerts: AlertConfig::default(),
982 active_alerts: Vec::new(),
983 alert_flash_until: None,
984 export_active: false,
986 export_path: None,
987 volume_avg: token_data.volume_24h,
988 auto_pause_on_input: false,
990 last_input_at: now,
991 auto_pause_timeout: Duration::from_secs(3),
992 exchange_ohlc: Vec::new(),
994 venue_pair: None,
995 }
996 }
997
998 pub fn apply_config(&mut self, config: &MonitorConfig) {
1000 self.layout = config.layout;
1001 self.widgets = config.widgets.clone();
1002 self.refresh_rate = Duration::from_secs(config.refresh_seconds);
1003 self.scale_mode = config.scale;
1004 self.color_scheme = config.color_scheme;
1005 self.alerts = config.alerts.clone();
1006 self.auto_pause_on_input = config.auto_pause_on_input;
1007 }
1008
1009 pub fn palette(&self) -> ColorPalette {
1012 self.color_scheme.palette()
1013 }
1014
1015 pub fn toggle_chart_mode(&mut self) {
1016 self.chart_mode = self.chart_mode.next();
1017 self.log(format!("Chart mode: {}", self.chart_mode.label()));
1018 }
1019
1020 fn cache_path(token_address: &str, chain: &str) -> PathBuf {
1022 let mut path = std::env::temp_dir();
1023 let safe_addr = token_address
1025 .chars()
1026 .filter(|c| c.is_alphanumeric())
1027 .take(16)
1028 .collect::<String>()
1029 .to_lowercase();
1030 path.push(format!("{}{}_{}.json", CACHE_FILE_PREFIX, chain, safe_addr));
1031 path
1032 }
1033
1034 fn load_cache(token_address: &str, chain: &str) -> Option<CachedMonitorData> {
1036 let path = Self::cache_path(token_address, chain);
1037 if !path.exists() {
1038 return None;
1039 }
1040
1041 match fs::read_to_string(&path) {
1042 Ok(contents) => {
1043 match serde_json::from_str::<CachedMonitorData>(&contents) {
1044 Ok(cached) => {
1045 if cached.token_address.to_lowercase() == token_address.to_lowercase()
1047 && cached.chain.to_lowercase() == chain.to_lowercase()
1048 {
1049 Some(cached)
1050 } else {
1051 None
1052 }
1053 }
1054 Err(_) => None,
1055 }
1056 }
1057 Err(_) => None,
1058 }
1059 }
1060
1061 pub fn save_cache(&self) {
1063 let cached = CachedMonitorData {
1064 token_address: self.token_address.clone(),
1065 chain: self.chain.clone(),
1066 price_history: self.price_history.iter().copied().collect(),
1067 volume_history: self.volume_history.iter().copied().collect(),
1068 saved_at: chrono::Utc::now().timestamp() as f64,
1069 };
1070
1071 let path = Self::cache_path(&self.token_address, &self.chain);
1072 if let Ok(json) = serde_json::to_string(&cached) {
1073 let _ = fs::write(&path, json);
1074 }
1075 }
1076
1077 fn generate_synthetic_price_history(
1080 current_price: f64,
1081 change_1h: f64,
1082 change_6h: f64,
1083 change_24h: f64,
1084 now_ts: f64,
1085 ) -> VecDeque<DataPoint> {
1086 let mut history = VecDeque::with_capacity(50);
1087
1088 let price_1h_ago = current_price / (1.0 + change_1h / 100.0);
1090 let price_6h_ago = current_price / (1.0 + change_6h / 100.0);
1091 let price_24h_ago = current_price / (1.0 + change_24h / 100.0);
1092
1093 let points = [
1095 (now_ts - 24.0 * 3600.0, price_24h_ago),
1096 (now_ts - 12.0 * 3600.0, (price_24h_ago + price_6h_ago) / 2.0),
1097 (now_ts - 6.0 * 3600.0, price_6h_ago),
1098 (now_ts - 3.0 * 3600.0, (price_6h_ago + price_1h_ago) / 2.0),
1099 (now_ts - 1.0 * 3600.0, price_1h_ago),
1100 (now_ts - 0.5 * 3600.0, (price_1h_ago + current_price) / 2.0),
1101 (now_ts, current_price),
1102 ];
1103
1104 for i in 0..points.len() - 1 {
1106 let (t1, p1) = points[i];
1107 let (t2, p2) = points[i + 1];
1108 let steps = 4; for j in 0..steps {
1111 let frac = j as f64 / steps as f64;
1112 let t = t1 + (t2 - t1) * frac;
1113 let p = p1 + (p2 - p1) * frac;
1114 history.push_back(DataPoint {
1115 timestamp: t,
1116 value: p,
1117 is_real: false, });
1119 }
1120 }
1121 history.push_back(DataPoint {
1123 timestamp: points[points.len() - 1].0,
1124 value: points[points.len() - 1].1,
1125 is_real: false,
1126 });
1127
1128 history
1129 }
1130
1131 fn generate_synthetic_volume_history(
1134 volume_24h: f64,
1135 volume_6h: f64,
1136 volume_1h: f64,
1137 now_ts: f64,
1138 ) -> VecDeque<DataPoint> {
1139 let mut history = VecDeque::with_capacity(24);
1140
1141 let hourly_avg = volume_24h / 24.0;
1143
1144 for i in 0..24 {
1145 let hours_ago = 24 - i;
1146 let ts = now_ts - (hours_ago as f64) * 3600.0;
1147
1148 let volume = if hours_ago <= 1 {
1150 volume_1h
1151 } else if hours_ago <= 6 {
1152 volume_6h / 6.0
1153 } else {
1154 hourly_avg * (0.8 + (i as f64 / 24.0) * 0.4)
1156 };
1157
1158 history.push_back(DataPoint {
1159 timestamp: ts,
1160 value: volume,
1161 is_real: false, });
1163 }
1164
1165 history
1166 }
1167
1168 fn generate_synthetic_order_book(
1174 pairs: &[DexPair],
1175 symbol: &str,
1176 price: f64,
1177 total_liquidity: f64,
1178 ) -> Option<OrderBook> {
1179 if price <= 0.0 || total_liquidity <= 0.0 {
1180 return None;
1181 }
1182
1183 let base_spread_bps = if total_liquidity > 1_000_000.0 {
1185 5.0 } else if total_liquidity > 100_000.0 {
1187 15.0 } else {
1189 50.0 };
1191
1192 let half_spread = price * base_spread_bps / 10_000.0;
1193 let half_liq = total_liquidity / 2.0;
1194 let num_levels: usize = 15;
1195
1196 let mut asks = Vec::with_capacity(num_levels);
1198 for i in 0..num_levels {
1199 let offset_pct = (1.0 + i as f64 * 0.3).powf(1.4) * 0.001;
1201 let ask_price = price + half_spread + price * offset_pct;
1202 let weight = (-1.5 * i as f64 / num_levels as f64).exp();
1204 let level_liq = half_liq * weight / num_levels as f64 * 2.5;
1205 let quantity = level_liq / ask_price;
1206 if quantity > 0.0 {
1207 asks.push(OrderBookLevel {
1208 price: ask_price,
1209 quantity,
1210 });
1211 }
1212 }
1213
1214 let mut bids = Vec::with_capacity(num_levels);
1216 for i in 0..num_levels {
1217 let offset_pct = (1.0 + i as f64 * 0.3).powf(1.4) * 0.001;
1218 let bid_price = price - half_spread - price * offset_pct;
1219 if bid_price <= 0.0 {
1220 break;
1221 }
1222 let weight = (-1.5 * i as f64 / num_levels as f64).exp();
1223 let level_liq = half_liq * weight / num_levels as f64 * 2.5;
1224 let quantity = level_liq / bid_price;
1225 if quantity > 0.0 {
1226 bids.push(OrderBookLevel {
1227 price: bid_price,
1228 quantity,
1229 });
1230 }
1231 }
1232
1233 let quote = pairs
1235 .first()
1236 .map(|p| p.quote_token.as_str())
1237 .unwrap_or("USD");
1238
1239 Some(OrderBook {
1240 pair: format!("{}/{}", symbol, quote),
1241 bids,
1242 asks,
1243 })
1244 }
1245
1246 pub fn update(&mut self, token_data: &DexTokenData) {
1249 let now_ts = chrono::Utc::now().timestamp() as f64;
1250
1251 self.price_history.push_back(DataPoint {
1253 timestamp: now_ts,
1254 value: token_data.price_usd,
1255 is_real: true,
1256 });
1257 self.volume_history.push_back(DataPoint {
1258 timestamp: now_ts,
1259 value: token_data.volume_24h,
1260 is_real: true,
1261 });
1262 self.real_data_count += 1;
1263
1264 let cutoff = now_ts - MAX_DATA_AGE_SECS;
1266
1267 while let Some(point) = self.price_history.front() {
1268 if point.timestamp < cutoff {
1269 self.price_history.pop_front();
1270 } else {
1271 break;
1272 }
1273 }
1274 while let Some(point) = self.volume_history.front() {
1275 if point.timestamp < cutoff {
1276 self.volume_history.pop_front();
1277 } else {
1278 break;
1279 }
1280 }
1281
1282 let price_changed = (self.previous_price - token_data.price_usd).abs() > 0.00000001;
1284 if price_changed {
1285 self.last_price_change_at = now_ts;
1286 self.previous_price = token_data.price_usd;
1287 }
1288
1289 self.current_price = token_data.price_usd;
1291 self.price_change_24h = token_data.price_change_24h;
1292 self.price_change_6h = token_data.price_change_6h;
1293 self.price_change_1h = token_data.price_change_1h;
1294 self.price_change_5m = token_data.price_change_5m;
1295 self.buys_24h = token_data.total_buys_24h;
1296 self.sells_24h = token_data.total_sells_24h;
1297 self.liquidity_usd = token_data.liquidity_usd;
1298 self.volume_24h = token_data.volume_24h;
1299 self.market_cap = token_data.market_cap;
1300 self.fdv = token_data.fdv;
1301
1302 self.liquidity_pairs = token_data
1304 .pairs
1305 .iter()
1306 .map(|p| {
1307 let label = format!("{}/{} ({})", p.base_token, p.quote_token, p.dex_name);
1308 (label, p.liquidity_usd)
1309 })
1310 .collect();
1311
1312 self.dex_pairs = token_data.pairs.clone();
1314 self.order_book = Self::generate_synthetic_order_book(
1315 &token_data.pairs,
1316 &token_data.symbol,
1317 token_data.price_usd,
1318 token_data.liquidity_usd,
1319 );
1320
1321 if token_data.price_usd > 0.0 {
1323 let side = if price_changed && token_data.price_usd > self.current_price {
1324 TradeSide::Buy
1325 } else if price_changed {
1326 TradeSide::Sell
1327 } else {
1328 if token_data.total_buys_24h >= token_data.total_sells_24h {
1330 TradeSide::Buy
1331 } else {
1332 TradeSide::Sell
1333 }
1334 };
1335 let ts_ms = (now_ts * 1000.0) as u64;
1336 let qty = if token_data.volume_24h > 0.0 && token_data.price_usd > 0.0 {
1338 let per_update_vol =
1340 token_data.volume_24h / 86400.0 * self.refresh_rate.as_secs_f64();
1341 per_update_vol / token_data.price_usd
1342 } else {
1343 1.0
1344 };
1345 self.recent_trades.push_back(Trade {
1346 price: token_data.price_usd,
1347 quantity: qty,
1348 quote_quantity: Some(qty * token_data.price_usd),
1349 timestamp_ms: ts_ms,
1350 side,
1351 id: None,
1352 });
1353 while self.recent_trades.len() > 200 {
1355 self.recent_trades.pop_front();
1356 }
1357 }
1358
1359 self.websites = token_data.websites.clone();
1361 self.socials = token_data
1362 .socials
1363 .iter()
1364 .map(|s| (s.platform.clone(), s.url.clone()))
1365 .collect();
1366 self.earliest_pair_created_at = token_data.earliest_pair_created_at;
1367 self.dexscreener_url = token_data.dexscreener_url.clone();
1368
1369 self.last_update = Instant::now();
1370 self.error_message = None;
1371
1372 self.check_alerts(token_data);
1374
1375 if self.export_active {
1377 self.write_export_row();
1378 }
1379
1380 self.volume_avg = self.volume_avg * 0.9 + token_data.volume_24h * 0.1;
1383
1384 self.log(format!("Updated: ${:.6}", token_data.price_usd));
1385
1386 if self.real_data_count.is_multiple_of(60) {
1388 self.save_cache();
1389 }
1390 }
1391
1392 fn check_alerts(&mut self, token_data: &DexTokenData) {
1394 self.active_alerts.clear();
1395
1396 if let Some(min) = self.alerts.price_min
1398 && self.current_price < min
1399 {
1400 self.active_alerts.push(ActiveAlert {
1401 message: format!("⚠ Price ${:.6} below min ${:.6}", self.current_price, min),
1402 triggered_at: Instant::now(),
1403 });
1404 }
1405
1406 if let Some(max) = self.alerts.price_max
1408 && self.current_price > max
1409 {
1410 self.active_alerts.push(ActiveAlert {
1411 message: format!("⚠ Price ${:.6} above max ${:.6}", self.current_price, max),
1412 triggered_at: Instant::now(),
1413 });
1414 }
1415
1416 if let Some(threshold_pct) = self.alerts.volume_spike_threshold_pct
1418 && self.volume_avg > 0.0
1419 {
1420 let spike_pct = ((token_data.volume_24h - self.volume_avg) / self.volume_avg) * 100.0;
1421 if spike_pct > threshold_pct {
1422 self.active_alerts.push(ActiveAlert {
1423 message: format!("⚠ Volume spike: +{:.1}% vs avg", spike_pct),
1424 triggered_at: Instant::now(),
1425 });
1426 }
1427 }
1428
1429 if let Some(whale_min) = self.alerts.whale_min_usd {
1431 let total_txs = (token_data.total_buys_24h + token_data.total_sells_24h) as f64;
1433 if total_txs > 0.0 && token_data.volume_24h / total_txs >= whale_min {
1434 let avg_tx_size = token_data.volume_24h / total_txs;
1435 self.active_alerts.push(ActiveAlert {
1436 message: format!(
1437 "🐋 Avg tx size {} ≥ whale threshold {}",
1438 crate::display::format_usd(avg_tx_size),
1439 crate::display::format_usd(whale_min)
1440 ),
1441 triggered_at: Instant::now(),
1442 });
1443 }
1444 }
1445
1446 if !self.active_alerts.is_empty() {
1448 self.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
1449 }
1450 }
1451
1452 fn write_export_row(&mut self) {
1454 if let Some(ref path) = self.export_path {
1455 if let Ok(file) = fs::OpenOptions::new().append(true).open(path) {
1457 let mut writer = BufWriter::new(file);
1458 let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
1459 let market_cap_str = self
1460 .market_cap
1461 .map(|mc| format!("{:.2}", mc))
1462 .unwrap_or_default();
1463 let row = format!(
1464 "{},{:.8},{:.2},{:.2},{},{},{}\n",
1465 timestamp,
1466 self.current_price,
1467 self.volume_24h,
1468 self.liquidity_usd,
1469 self.buys_24h,
1470 self.sells_24h,
1471 market_cap_str,
1472 );
1473 let _ = writer.write_all(row.as_bytes());
1474 }
1475 }
1476 }
1477
1478 pub fn start_export(&mut self) {
1480 let base_dir = PathBuf::from("./scope-exports");
1481 let _ = fs::create_dir_all(&base_dir);
1482 let date_str = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
1483 let filename = format!("{}_{}.csv", self.symbol, date_str);
1484 let path = base_dir.join(filename);
1485
1486 if let Ok(mut file) = fs::File::create(&path) {
1488 let header =
1489 "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap\n";
1490 let _ = file.write_all(header.as_bytes());
1491 }
1492
1493 self.export_path = Some(path.clone());
1494 self.export_active = true;
1495 self.log(format!("Export started: {}", path.display()));
1496 }
1497
1498 pub fn stop_export(&mut self) {
1500 if let Some(ref path) = self.export_path {
1501 self.log(format!("Export saved: {}", path.display()));
1502 }
1503 self.export_active = false;
1504 self.export_path = None;
1505 }
1506
1507 pub fn toggle_export(&mut self) {
1509 if self.export_active {
1510 self.stop_export();
1511 } else {
1512 self.start_export();
1513 }
1514 }
1515
1516 pub fn get_price_data_for_period(&self) -> (Vec<(f64, f64)>, Vec<bool>) {
1519 let now_ts = chrono::Utc::now().timestamp() as f64;
1520 let cutoff = now_ts - self.time_period.duration_secs() as f64;
1521
1522 let filtered: Vec<&DataPoint> = self
1523 .price_history
1524 .iter()
1525 .filter(|p| p.timestamp >= cutoff)
1526 .collect();
1527
1528 let data: Vec<(f64, f64)> = filtered.iter().map(|p| (p.timestamp, p.value)).collect();
1529 let is_real: Vec<bool> = filtered.iter().map(|p| p.is_real).collect();
1530
1531 (data, is_real)
1532 }
1533
1534 pub fn get_volume_data_for_period(&self) -> (Vec<(f64, f64)>, Vec<bool>) {
1537 let now_ts = chrono::Utc::now().timestamp() as f64;
1538 let cutoff = now_ts - self.time_period.duration_secs() as f64;
1539
1540 let filtered: Vec<&DataPoint> = self
1541 .volume_history
1542 .iter()
1543 .filter(|p| p.timestamp >= cutoff)
1544 .collect();
1545
1546 let data: Vec<(f64, f64)> = filtered.iter().map(|p| (p.timestamp, p.value)).collect();
1547 let is_real: Vec<bool> = filtered.iter().map(|p| p.is_real).collect();
1548
1549 (data, is_real)
1550 }
1551
1552 pub fn data_stats(&self) -> (usize, usize) {
1554 let now_ts = chrono::Utc::now().timestamp() as f64;
1555 let cutoff = now_ts - self.time_period.duration_secs() as f64;
1556
1557 let (synthetic, real) = self
1558 .price_history
1559 .iter()
1560 .filter(|p| p.timestamp >= cutoff)
1561 .fold(
1562 (0, 0),
1563 |(s, r), p| {
1564 if p.is_real { (s, r + 1) } else { (s + 1, r) }
1565 },
1566 );
1567
1568 (synthetic, real)
1569 }
1570
1571 pub fn memory_usage(&self) -> usize {
1573 let point_size = std::mem::size_of::<DataPoint>();
1575 (self.price_history.len() + self.volume_history.len()) * point_size
1576 }
1577
1578 pub fn get_ohlc_candles(&self) -> Vec<OhlcCandle> {
1588 if !self.exchange_ohlc.is_empty() {
1590 return self.exchange_ohlc.clone();
1591 }
1592
1593 let (data, _) = self.get_price_data_for_period();
1594
1595 if data.is_empty() {
1596 return vec![];
1597 }
1598
1599 let candle_duration_secs = match self.time_period {
1601 TimePeriod::Min1 => 5.0, TimePeriod::Min5 => 15.0, TimePeriod::Min15 => 60.0, TimePeriod::Hour1 => 300.0, TimePeriod::Hour4 => 900.0, TimePeriod::Day1 => 3600.0, };
1608
1609 let mut candles: Vec<OhlcCandle> = Vec::new();
1610
1611 for (timestamp, price) in data {
1612 let candle_start = (timestamp / candle_duration_secs).floor() * candle_duration_secs;
1614
1615 if let Some(last_candle) = candles.last_mut() {
1616 if (last_candle.timestamp - candle_start).abs() < 0.001 {
1617 last_candle.update(price);
1619 } else {
1620 candles.push(OhlcCandle::new(candle_start, price));
1622 }
1623 } else {
1624 candles.push(OhlcCandle::new(candle_start, price));
1626 }
1627 }
1628
1629 candles
1630 }
1631
1632 pub fn cycle_time_period(&mut self) {
1634 self.time_period = self.time_period.next();
1635 self.log(format!("Time period: {}", self.time_period.label()));
1636 }
1637
1638 pub fn set_time_period(&mut self, period: TimePeriod) {
1640 self.time_period = period;
1641 self.log(format!("Time period: {}", period.label()));
1642 }
1643
1644 fn log(&mut self, message: String) {
1646 let timestamp = chrono::Local::now().format("%H:%M:%S").to_string();
1647 self.log_messages
1648 .push_back(format!("[{}] {}", timestamp, message));
1649 while self.log_messages.len() > 10 {
1650 self.log_messages.pop_front();
1651 }
1652 }
1653
1654 pub fn should_refresh(&self) -> bool {
1657 if self.paused {
1658 return false;
1659 }
1660 if self.auto_pause_on_input && self.last_input_at.elapsed() < self.auto_pause_timeout {
1662 return false;
1663 }
1664 self.last_update.elapsed() >= self.refresh_rate
1665 }
1666
1667 pub fn is_auto_paused(&self) -> bool {
1669 self.auto_pause_on_input && self.last_input_at.elapsed() < self.auto_pause_timeout
1670 }
1671
1672 pub fn toggle_pause(&mut self) {
1674 self.paused = !self.paused;
1675 self.log(if self.paused {
1676 "Paused".to_string()
1677 } else {
1678 "Resumed".to_string()
1679 });
1680 }
1681
1682 pub fn force_refresh(&mut self) {
1684 self.paused = false;
1685 self.last_update = Instant::now() - self.refresh_rate;
1686 }
1687
1688 pub fn slower_refresh(&mut self) {
1690 let current_secs = self.refresh_rate.as_secs();
1691 let new_secs = (current_secs + 5).min(MAX_REFRESH_SECS);
1692 self.refresh_rate = Duration::from_secs(new_secs);
1693 self.log(format!("Refresh rate: {}s", new_secs));
1694 }
1695
1696 pub fn faster_refresh(&mut self) {
1698 let current_secs = self.refresh_rate.as_secs();
1699 let new_secs = current_secs.saturating_sub(5).max(MIN_REFRESH_SECS);
1700 self.refresh_rate = Duration::from_secs(new_secs);
1701 self.log(format!("Refresh rate: {}s", new_secs));
1702 }
1703
1704 pub fn scroll_log_down(&mut self) {
1706 let len = self.log_messages.len();
1707 if len == 0 {
1708 return;
1709 }
1710 let i = self
1711 .log_list_state
1712 .selected()
1713 .map_or(0, |i| if i + 1 < len { i + 1 } else { i });
1714 self.log_list_state.select(Some(i));
1715 }
1716
1717 pub fn scroll_log_up(&mut self) {
1719 let i = self
1720 .log_list_state
1721 .selected()
1722 .map_or(0, |i| i.saturating_sub(1));
1723 self.log_list_state.select(Some(i));
1724 }
1725
1726 pub fn refresh_rate_secs(&self) -> u64 {
1728 self.refresh_rate.as_secs()
1729 }
1730
1731 pub fn buy_ratio(&self) -> f64 {
1733 let total = self.buys_24h + self.sells_24h;
1734 if total == 0 {
1735 0.5
1736 } else {
1737 self.buys_24h as f64 / total as f64
1738 }
1739 }
1740}
1741
1742pub struct MonitorApp<B: ratatui::backend::Backend = ratatui::backend::CrosstermBackend<io::Stdout>>
1747{
1748 terminal: ratatui::Terminal<B>,
1750
1751 state: MonitorState,
1753
1754 dex_client: Box<dyn DexDataSource>,
1756
1757 chain_client: Option<Box<dyn ChainClient>>,
1759
1760 exchange_client: Option<crate::market::ExchangeClient>,
1762
1763 should_exit: bool,
1765
1766 owns_terminal: bool,
1769}
1770
1771impl MonitorApp {
1773 pub fn new(
1775 initial_data: DexTokenData,
1776 chain: &str,
1777 monitor_config: &MonitorConfig,
1778 chain_client: Option<Box<dyn ChainClient>>,
1779 exchange_client: Option<crate::market::ExchangeClient>,
1780 ) -> Result<Self> {
1781 let terminal = ratatui::init();
1783 execute!(io::stdout(), EnableMouseCapture)
1785 .map_err(|e| ScopeError::Chain(format!("Failed to enable mouse capture: {}", e)))?;
1786
1787 let mut state = MonitorState::new(&initial_data, chain);
1788 state.apply_config(monitor_config);
1789
1790 if let Some(ref ex) = exchange_client {
1792 let pair = ex.format_pair(&initial_data.symbol);
1793 state.venue_pair = Some(pair);
1794 }
1795
1796 Ok(Self {
1797 terminal,
1798 state,
1799 dex_client: Box::new(DexClient::new()),
1800 chain_client,
1801 exchange_client,
1802 should_exit: false,
1803 owns_terminal: true,
1804 })
1805 }
1806
1807 pub async fn run(&mut self) -> Result<()> {
1809 use futures::StreamExt;
1810
1811 let mut event_stream = crossterm::event::EventStream::new();
1812
1813 loop {
1814 self.terminal.draw(|f| ui(f, &mut self.state))?;
1816
1817 let refresh_delay = if self.state.paused {
1819 Duration::from_millis(200) } else {
1821 let elapsed = self.state.last_update.elapsed();
1822 self.state.refresh_rate.saturating_sub(elapsed)
1823 };
1824
1825 tokio::select! {
1827 maybe_event = event_stream.next() => {
1828 match maybe_event {
1829 Some(Ok(Event::Key(key))) => {
1830 self.handle_key_event(key);
1831 }
1832 Some(Ok(Event::Resize(_, _))) => {
1833 }
1835 _ => {}
1836 }
1837 }
1838 _ = tokio::time::sleep(refresh_delay) => {
1839 }
1841 }
1842
1843 if self.should_exit {
1844 break;
1845 }
1846
1847 if self.state.should_refresh() {
1849 self.fetch_data().await;
1850 }
1851 }
1852
1853 Ok(())
1854 }
1855}
1856
1857impl<B: ratatui::backend::Backend> Drop for MonitorApp<B> {
1858 fn drop(&mut self) {
1859 if self.owns_terminal {
1860 let _ = execute!(io::stdout(), DisableMouseCapture);
1861 ratatui::restore();
1862 }
1863 }
1864}
1865
1866impl<B: ratatui::backend::Backend> MonitorApp<B> {
1869 pub fn cleanup(&mut self) -> Result<()> {
1872 self.state.save_cache();
1874
1875 if self.owns_terminal {
1876 let _ = execute!(io::stdout(), DisableMouseCapture);
1878 ratatui::restore();
1880 }
1881 Ok(())
1882 }
1883
1884 fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) {
1887 self.state.last_input_at = Instant::now();
1889
1890 if self.state.widget_toggle_mode {
1892 self.state.widget_toggle_mode = false;
1893 if let KeyCode::Char(c @ '1'..='5') = key.code {
1894 let idx = (c as u8 - b'0') as usize;
1895 self.state.widgets.toggle_by_index(idx);
1896 return;
1897 }
1898 }
1900
1901 match key.code {
1902 KeyCode::Char('q') | KeyCode::Esc => {
1903 if self.state.export_active {
1905 self.state.stop_export();
1906 }
1907 self.should_exit = true;
1908 }
1909 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1910 if self.state.export_active {
1911 self.state.stop_export();
1912 }
1913 self.should_exit = true;
1914 }
1915 KeyCode::Char('r') => {
1916 self.state.force_refresh();
1917 }
1918 KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::SHIFT) => {
1920 self.state.auto_pause_on_input = !self.state.auto_pause_on_input;
1921 self.state.log(format!(
1922 "Auto-pause: {}",
1923 if self.state.auto_pause_on_input {
1924 "ON"
1925 } else {
1926 "OFF"
1927 }
1928 ));
1929 }
1930 KeyCode::Char('p') | KeyCode::Char(' ') => {
1931 self.state.toggle_pause();
1932 }
1933 KeyCode::Char('e') => {
1935 self.state.toggle_export();
1936 }
1937 KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
1939 self.state.slower_refresh();
1940 }
1941 KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
1943 self.state.faster_refresh();
1944 }
1945 KeyCode::Char('1') => {
1947 self.state.set_time_period(TimePeriod::Min1);
1948 }
1949 KeyCode::Char('2') => {
1950 self.state.set_time_period(TimePeriod::Min5);
1951 }
1952 KeyCode::Char('3') => {
1953 self.state.set_time_period(TimePeriod::Min15);
1954 }
1955 KeyCode::Char('4') => {
1956 self.state.set_time_period(TimePeriod::Hour1);
1957 }
1958 KeyCode::Char('5') => {
1959 self.state.set_time_period(TimePeriod::Hour4);
1960 }
1961 KeyCode::Char('6') => {
1962 self.state.set_time_period(TimePeriod::Day1);
1963 }
1964 KeyCode::Char('t') | KeyCode::Tab => {
1965 self.state.cycle_time_period();
1966 }
1967 KeyCode::Char('c') => {
1969 self.state.toggle_chart_mode();
1970 }
1971 KeyCode::Char('s') => {
1973 self.state.scale_mode = self.state.scale_mode.toggle();
1974 self.state
1975 .log(format!("Scale: {}", self.state.scale_mode.label()));
1976 }
1977 KeyCode::Char('/') => {
1979 self.state.color_scheme = self.state.color_scheme.next();
1980 self.state
1981 .log(format!("Colors: {}", self.state.color_scheme.label()));
1982 }
1983 KeyCode::Char('j') | KeyCode::Down => {
1985 self.state.scroll_log_down();
1986 }
1987 KeyCode::Char('k') | KeyCode::Up => {
1988 self.state.scroll_log_up();
1989 }
1990 KeyCode::Char('l') => {
1992 self.state.layout = self.state.layout.next();
1993 self.state.auto_layout = false;
1994 }
1995 KeyCode::Char('h') => {
1996 self.state.layout = self.state.layout.prev();
1997 self.state.auto_layout = false;
1998 }
1999 KeyCode::Char('w') => {
2001 self.state.widget_toggle_mode = true;
2002 }
2003 KeyCode::Char('a') => {
2005 self.state.auto_layout = true;
2006 }
2007 _ => {}
2008 }
2009 }
2010
2011 async fn fetch_data(&mut self) {
2013 match self
2014 .dex_client
2015 .get_token_data(&self.state.chain, &self.state.token_address)
2016 .await
2017 {
2018 Ok(data) => {
2019 self.state.update(&data);
2020 }
2021 Err(e) => {
2022 self.state.error_message = Some(format!("API Error: {}", e));
2023 self.state.last_update = Instant::now(); }
2025 }
2026
2027 if let (Some(ex), Some(pair)) = (&self.exchange_client, &self.state.venue_pair.clone())
2029 && ex.has_ohlc()
2030 {
2031 let interval = self.state.time_period.exchange_interval();
2032 let limit = 100;
2033 match ex.fetch_ohlc(pair, interval, limit).await {
2034 Ok(candles) => {
2035 self.state.exchange_ohlc = candles
2036 .into_iter()
2037 .map(|c| OhlcCandle {
2038 timestamp: c.open_time as f64 / 1000.0,
2039 open: c.open,
2040 high: c.high,
2041 low: c.low,
2042 close: c.close,
2043 is_bullish: c.close >= c.open,
2044 })
2045 .collect();
2046 }
2047 Err(e) => {
2048 tracing::debug!("Failed to fetch OHLC: {}", e);
2049 }
2051 }
2052 }
2053
2054 self.state.holder_fetch_counter += 1;
2056 if self.state.holder_fetch_counter.is_multiple_of(12)
2057 && let Some(ref client) = self.chain_client
2058 {
2059 match client
2060 .get_token_holder_count(&self.state.token_address)
2061 .await
2062 {
2063 Ok(count) if count > 0 => {
2064 self.state.holder_count = Some(count);
2065 }
2066 _ => {} }
2068 }
2069 }
2070}
2071
2072#[cfg(test)]
2075fn handle_key_event_on_state(key: crossterm::event::KeyEvent, state: &mut MonitorState) -> bool {
2076 state.last_input_at = Instant::now();
2078
2079 if state.widget_toggle_mode {
2081 state.widget_toggle_mode = false;
2082 if let KeyCode::Char(c @ '1'..='5') = key.code {
2083 let idx = (c as u8 - b'0') as usize;
2084 state.widgets.toggle_by_index(idx);
2085 return false;
2086 }
2087 }
2089
2090 match key.code {
2091 KeyCode::Char('q') | KeyCode::Esc => {
2092 if state.export_active {
2093 state.stop_export();
2094 }
2095 return true;
2096 }
2097 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2098 if state.export_active {
2099 state.stop_export();
2100 }
2101 return true;
2102 }
2103 KeyCode::Char('r') => {
2104 state.force_refresh();
2105 }
2106 KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::SHIFT) => {
2108 state.auto_pause_on_input = !state.auto_pause_on_input;
2109 state.log(format!(
2110 "Auto-pause: {}",
2111 if state.auto_pause_on_input {
2112 "ON"
2113 } else {
2114 "OFF"
2115 }
2116 ));
2117 }
2118 KeyCode::Char('p') | KeyCode::Char(' ') => {
2119 state.toggle_pause();
2120 }
2121 KeyCode::Char('e') => {
2123 state.toggle_export();
2124 }
2125 KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
2126 state.slower_refresh();
2127 }
2128 KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
2129 state.faster_refresh();
2130 }
2131 KeyCode::Char('1') => {
2132 state.set_time_period(TimePeriod::Min1);
2133 }
2134 KeyCode::Char('2') => {
2135 state.set_time_period(TimePeriod::Min5);
2136 }
2137 KeyCode::Char('3') => {
2138 state.set_time_period(TimePeriod::Min15);
2139 }
2140 KeyCode::Char('4') => {
2141 state.set_time_period(TimePeriod::Hour1);
2142 }
2143 KeyCode::Char('5') => {
2144 state.set_time_period(TimePeriod::Hour4);
2145 }
2146 KeyCode::Char('6') => {
2147 state.set_time_period(TimePeriod::Day1);
2148 }
2149 KeyCode::Char('t') | KeyCode::Tab => {
2150 state.cycle_time_period();
2151 }
2152 KeyCode::Char('c') => {
2153 state.toggle_chart_mode();
2154 }
2155 KeyCode::Char('s') => {
2156 state.scale_mode = state.scale_mode.toggle();
2157 state.log(format!("Scale: {}", state.scale_mode.label()));
2158 }
2159 KeyCode::Char('/') => {
2160 state.color_scheme = state.color_scheme.next();
2161 state.log(format!("Colors: {}", state.color_scheme.label()));
2162 }
2163 KeyCode::Char('j') | KeyCode::Down => {
2164 state.scroll_log_down();
2165 }
2166 KeyCode::Char('k') | KeyCode::Up => {
2167 state.scroll_log_up();
2168 }
2169 KeyCode::Char('l') => {
2170 state.layout = state.layout.next();
2171 state.auto_layout = false;
2172 }
2173 KeyCode::Char('h') => {
2174 state.layout = state.layout.prev();
2175 state.auto_layout = false;
2176 }
2177 KeyCode::Char('w') => {
2178 state.widget_toggle_mode = true;
2179 }
2180 KeyCode::Char('a') => {
2181 state.auto_layout = true;
2182 }
2183 _ => {}
2184 }
2185 false
2186}
2187
2188struct LayoutAreas {
2191 price_chart: Option<Rect>,
2192 volume_chart: Option<Rect>,
2193 buy_sell_gauge: Option<Rect>,
2194 metrics_panel: Option<Rect>,
2195 activity_feed: Option<Rect>,
2196 order_book: Option<Rect>,
2198 market_info: Option<Rect>,
2200 trade_history: Option<Rect>,
2202}
2203
2204fn layout_dashboard(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2216 let rows = Layout::default()
2217 .direction(Direction::Vertical)
2218 .constraints([
2219 Constraint::Percentage(55),
2220 Constraint::Percentage(20),
2221 Constraint::Percentage(25),
2222 ])
2223 .split(area);
2224
2225 let top = Layout::default()
2226 .direction(Direction::Horizontal)
2227 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
2228 .split(rows[0]);
2229
2230 let middle = Layout::default()
2231 .direction(Direction::Horizontal)
2232 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
2233 .split(rows[1]);
2234
2235 LayoutAreas {
2236 price_chart: if widgets.price_chart {
2237 Some(top[0])
2238 } else {
2239 None
2240 },
2241 volume_chart: if widgets.volume_chart {
2242 Some(top[1])
2243 } else {
2244 None
2245 },
2246 buy_sell_gauge: if widgets.buy_sell_pressure {
2247 Some(middle[0])
2248 } else {
2249 None
2250 },
2251 metrics_panel: if widgets.metrics_panel {
2252 Some(middle[1])
2253 } else {
2254 None
2255 },
2256 activity_feed: if widgets.activity_log {
2257 Some(rows[2])
2258 } else {
2259 None
2260 },
2261 order_book: None,
2262 market_info: None,
2263 trade_history: None,
2264 }
2265}
2266
2267fn layout_chart_focus(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2279 let vertical = Layout::default()
2280 .direction(Direction::Vertical)
2281 .constraints([Constraint::Percentage(85), Constraint::Percentage(15)])
2282 .split(area);
2283
2284 LayoutAreas {
2285 price_chart: if widgets.price_chart {
2286 Some(vertical[0])
2287 } else {
2288 None
2289 },
2290 volume_chart: None, buy_sell_gauge: None, metrics_panel: if widgets.metrics_panel {
2293 Some(vertical[1])
2294 } else {
2295 None
2296 },
2297 activity_feed: None, order_book: None,
2299 market_info: None,
2300 trade_history: None,
2301 }
2302}
2303
2304fn layout_feed(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2316 let vertical = Layout::default()
2317 .direction(Direction::Vertical)
2318 .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
2319 .split(area);
2320
2321 let top = Layout::default()
2322 .direction(Direction::Horizontal)
2323 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
2324 .split(vertical[0]);
2325
2326 LayoutAreas {
2327 price_chart: None, volume_chart: None, metrics_panel: if widgets.metrics_panel {
2330 Some(top[0])
2331 } else {
2332 None
2333 },
2334 buy_sell_gauge: if widgets.buy_sell_pressure {
2335 Some(top[1])
2336 } else {
2337 None
2338 },
2339 activity_feed: if widgets.activity_log {
2340 Some(vertical[1])
2341 } else {
2342 None
2343 },
2344 order_book: None,
2345 market_info: None,
2346 trade_history: None,
2347 }
2348}
2349
2350fn layout_compact(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2358 LayoutAreas {
2359 price_chart: None, volume_chart: None, buy_sell_gauge: None, metrics_panel: if widgets.metrics_panel {
2363 Some(area)
2364 } else {
2365 None
2366 },
2367 activity_feed: None, order_book: None,
2369 market_info: None,
2370 trade_history: None,
2371 }
2372}
2373
2374fn layout_exchange(area: Rect, _widgets: &WidgetVisibility) -> LayoutAreas {
2388 let rows = Layout::default()
2389 .direction(Direction::Vertical)
2390 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
2391 .split(area);
2392
2393 let top = Layout::default()
2394 .direction(Direction::Horizontal)
2395 .constraints([
2396 Constraint::Percentage(25),
2397 Constraint::Percentage(45),
2398 Constraint::Percentage(30),
2399 ])
2400 .split(rows[0]);
2401
2402 let bottom = Layout::default()
2403 .direction(Direction::Horizontal)
2404 .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
2405 .split(rows[1]);
2406
2407 LayoutAreas {
2408 price_chart: Some(top[1]),
2409 volume_chart: None,
2410 buy_sell_gauge: Some(bottom[0]),
2411 metrics_panel: None,
2412 activity_feed: None,
2413 order_book: Some(top[0]),
2414 market_info: Some(bottom[1]),
2415 trade_history: Some(top[2]),
2416 }
2417}
2418
2419fn auto_select_layout(size: Rect) -> LayoutPreset {
2421 match (size.width, size.height) {
2422 (w, h) if w < 80 || h < 24 => LayoutPreset::Compact,
2423 (w, _) if w < 120 => LayoutPreset::Feed,
2424 (_, h) if h < 30 => LayoutPreset::ChartFocus,
2425 _ => LayoutPreset::Dashboard,
2426 }
2427}
2428
2429fn ui(f: &mut Frame, state: &mut MonitorState) {
2431 if state.auto_layout {
2433 let suggested = auto_select_layout(f.area());
2434 if suggested != state.layout {
2435 state.layout = suggested;
2436 }
2437 }
2438
2439 let chunks = Layout::default()
2441 .direction(Direction::Vertical)
2442 .constraints([
2443 Constraint::Length(4), Constraint::Min(10), Constraint::Length(3), ])
2447 .split(f.area());
2448
2449 render_header(f, chunks[0], state);
2451
2452 let areas = match state.layout {
2454 LayoutPreset::Dashboard => layout_dashboard(chunks[1], &state.widgets),
2455 LayoutPreset::ChartFocus => layout_chart_focus(chunks[1], &state.widgets),
2456 LayoutPreset::Feed => layout_feed(chunks[1], &state.widgets),
2457 LayoutPreset::Compact => layout_compact(chunks[1], &state.widgets),
2458 LayoutPreset::Exchange => layout_exchange(chunks[1], &state.widgets),
2459 };
2460
2461 if let Some(area) = areas.price_chart {
2463 match state.chart_mode {
2464 ChartMode::Line => render_price_chart(f, area, state),
2465 ChartMode::Candlestick => render_candlestick_chart(f, area, state),
2466 ChartMode::VolumeProfile => render_volume_profile_chart(f, area, state),
2467 }
2468 }
2469 if let Some(area) = areas.volume_chart {
2470 render_volume_chart(f, area, &*state);
2471 }
2472 if let Some(area) = areas.buy_sell_gauge {
2473 render_buy_sell_gauge(f, area, state);
2474 }
2475 if let Some(area) = areas.metrics_panel {
2476 if state.widgets.liquidity_depth && !state.liquidity_pairs.is_empty() {
2478 let split = Layout::default()
2479 .direction(Direction::Vertical)
2480 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
2481 .split(area);
2482 render_metrics_panel(f, split[0], &*state);
2483 render_liquidity_depth(f, split[1], &*state);
2484 } else {
2485 render_metrics_panel(f, area, &*state);
2486 }
2487 }
2488 if let Some(area) = areas.activity_feed {
2489 render_activity_feed(f, area, state);
2490 }
2491 if let Some(area) = areas.order_book {
2493 render_order_book_panel(f, area, state);
2494 }
2495 if let Some(area) = areas.market_info {
2496 render_market_info_panel(f, area, state);
2497 }
2498 if let Some(area) = areas.trade_history {
2499 render_recent_trades_panel(f, area, state);
2500 }
2501
2502 if !state.active_alerts.is_empty() {
2504 let alert_height = (state.active_alerts.len() as u16 + 2).min(5);
2506 let alert_area = Rect::new(
2507 chunks[1].x,
2508 chunks[1].y,
2509 chunks[1].width,
2510 alert_height.min(chunks[1].height),
2511 );
2512 render_alert_overlay(f, alert_area, state);
2513 }
2514
2515 render_footer(f, chunks[2], state);
2517}
2518
2519fn render_header(f: &mut Frame, area: Rect, state: &MonitorState) {
2521 let price_color = if state.price_change_24h >= 0.0 {
2522 Color::Green
2523 } else {
2524 Color::Red
2525 };
2526
2527 let trend_arrow = if state.price_change_24h > 0.5 {
2529 "▲"
2530 } else if state.price_change_24h < -0.5 {
2531 "▼"
2532 } else if state.price_change_24h >= 0.0 {
2533 "△"
2534 } else {
2535 "▽"
2536 };
2537
2538 let change_str = format!(
2539 "{}{:.2}%",
2540 if state.price_change_24h >= 0.0 {
2541 "+"
2542 } else {
2543 ""
2544 },
2545 state.price_change_24h
2546 );
2547
2548 let title = format!(
2549 " ◈ {} ({}) │ {} ",
2550 state.symbol,
2551 state.name,
2552 state.chain.to_uppercase(),
2553 );
2554
2555 let price_str = format_price_usd(state.current_price);
2556
2557 let header_chunks = Layout::default()
2559 .direction(Direction::Vertical)
2560 .constraints([Constraint::Length(3), Constraint::Length(1)])
2561 .split(area);
2562
2563 let header = Paragraph::new(Line::from(vec![
2565 Span::styled(price_str, Style::new().fg(price_color).bold()),
2566 Span::raw(" "),
2567 Span::styled(trend_arrow, Style::new().fg(price_color)),
2568 Span::styled(format!(" {}", change_str), Style::new().fg(price_color)),
2569 ]))
2570 .block(
2571 Block::default()
2572 .title(title)
2573 .borders(Borders::ALL)
2574 .border_style(Style::new().cyan()),
2575 );
2576
2577 f.render_widget(header, header_chunks[0]);
2578
2579 let tab_titles = vec!["1m", "5m", "15m", "1h", "4h", "1d"];
2581 let chart_label = state.chart_mode.label();
2582 let tabs = Tabs::new(tab_titles)
2583 .select(state.time_period.index())
2584 .highlight_style(Style::new().cyan().bold())
2585 .divider("│")
2586 .padding(" ", " ");
2587 let tabs_line = Layout::default()
2588 .direction(Direction::Horizontal)
2589 .constraints([Constraint::Min(20), Constraint::Length(10)])
2590 .split(header_chunks[1]);
2591 f.render_widget(tabs, tabs_line[0]);
2592 f.render_widget(
2593 Paragraph::new(Span::styled(
2594 format!("⊞ {}", chart_label),
2595 Style::new().magenta(),
2596 )),
2597 tabs_line[1],
2598 );
2599}
2600
2601fn render_price_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
2603 let (data, is_real) = state.get_price_data_for_period();
2605
2606 if data.is_empty() {
2607 let empty = Paragraph::new("No price data").block(
2608 Block::default()
2609 .title(" Price (USD) ")
2610 .borders(Borders::ALL),
2611 );
2612 f.render_widget(empty, area);
2613 return;
2614 }
2615
2616 let current_price = state.current_price;
2618 let first_price = data.first().map(|(_, p)| *p).unwrap_or(current_price);
2619 let price_change = current_price - first_price;
2620 let price_change_pct = if first_price > 0.0 {
2621 (price_change / first_price) * 100.0
2622 } else {
2623 0.0
2624 };
2625
2626 let pal = state.palette();
2628 let is_price_up = price_change >= 0.0;
2629 let trend_color = if is_price_up { pal.up } else { pal.down };
2630 let trend_symbol = if is_price_up { "▲" } else { "▼" };
2631
2632 let price_str = format_price_usd(current_price);
2634 let change_str = if price_change_pct.abs() < 0.01 {
2635 "0.00%".to_string()
2636 } else {
2637 format!(
2638 "{}{:.2}%",
2639 if is_price_up { "+" } else { "" },
2640 price_change_pct
2641 )
2642 };
2643
2644 let chart_title = Line::from(vec![
2646 Span::raw(" ◆ "),
2647 Span::styled(
2648 format!("{} {} ", price_str, trend_symbol),
2649 Style::new().fg(trend_color).bold(),
2650 ),
2651 Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
2652 Span::styled(
2653 format!("│{}│ ", state.time_period.label()),
2654 Style::new().gray(),
2655 ),
2656 ]);
2657
2658 let (min_price, max_price) = data
2660 .iter()
2661 .fold((f64::MAX, f64::MIN), |(min, max), (_, p)| {
2662 (min.min(*p), max.max(*p))
2663 });
2664
2665 let price_range = max_price - min_price;
2667 let (y_min, y_max) = if price_range < 0.0001 {
2668 let padding = min_price * 0.001;
2670 (min_price - padding, max_price + padding)
2671 } else {
2672 (min_price - price_range * 0.1, max_price + price_range * 0.1)
2673 };
2674
2675 let x_min = data.first().map(|(t, _)| *t).unwrap_or(0.0);
2676 let x_max = data.last().map(|(t, _)| *t).unwrap_or(1.0);
2677 let x_max = if (x_max - x_min).abs() < 0.001 {
2679 x_min + 1.0
2680 } else {
2681 x_max
2682 };
2683
2684 let apply_scale = |price: f64| -> f64 {
2686 match state.scale_mode {
2687 ScaleMode::Linear => price,
2688 ScaleMode::Log => {
2689 if price > 0.0 {
2690 price.ln()
2691 } else {
2692 0.0
2693 }
2694 }
2695 }
2696 };
2697
2698 let (y_min, y_max) = (apply_scale(y_min), apply_scale(y_max));
2699
2700 let synthetic_data: Vec<(f64, f64)> = data
2702 .iter()
2703 .zip(&is_real)
2704 .filter(|(_, real)| !**real)
2705 .map(|((t, p), _)| (*t, apply_scale(*p)))
2706 .collect();
2707
2708 let real_data: Vec<(f64, f64)> = data
2709 .iter()
2710 .zip(&is_real)
2711 .filter(|(_, real)| **real)
2712 .map(|((t, p), _)| (*t, apply_scale(*p)))
2713 .collect();
2714
2715 let reference_line: Vec<(f64, f64)> = vec![
2717 (x_min, apply_scale(first_price)),
2718 (x_max, apply_scale(first_price)),
2719 ];
2720
2721 let mut datasets = Vec::new();
2722
2723 datasets.push(
2725 Dataset::default()
2726 .name("━Start")
2727 .marker(symbols::Marker::Braille)
2728 .graph_type(GraphType::Line)
2729 .style(Style::new().dark_gray())
2730 .data(&reference_line),
2731 );
2732
2733 if !synthetic_data.is_empty() {
2735 datasets.push(
2736 Dataset::default()
2737 .name("◇Est")
2738 .marker(symbols::Marker::Braille)
2739 .graph_type(GraphType::Line)
2740 .style(Style::new().cyan())
2741 .data(&synthetic_data),
2742 );
2743 }
2744
2745 if !real_data.is_empty() {
2747 datasets.push(
2748 Dataset::default()
2749 .name("●Live")
2750 .marker(symbols::Marker::Braille)
2751 .graph_type(GraphType::Line)
2752 .style(Style::new().fg(trend_color))
2753 .data(&real_data),
2754 );
2755 }
2756
2757 let time_label = format!("-{}", state.time_period.label());
2759
2760 let mid_y = (y_min + y_max) / 2.0;
2763 let y_label = |val: f64| -> String {
2764 match state.scale_mode {
2765 ScaleMode::Linear => format_price_usd(val),
2766 ScaleMode::Log => format_price_usd(val.exp()),
2767 }
2768 };
2769
2770 let scale_label = match state.scale_mode {
2771 ScaleMode::Linear => "USD",
2772 ScaleMode::Log => "USD (log)",
2773 };
2774
2775 let chart = Chart::new(datasets)
2776 .block(
2777 Block::default()
2778 .title(chart_title)
2779 .borders(Borders::ALL)
2780 .border_style(Style::new().fg(trend_color)),
2781 )
2782 .x_axis(
2783 Axis::default()
2784 .title(Span::styled("Time", Style::new().gray()))
2785 .style(Style::new().gray())
2786 .bounds([x_min, x_max])
2787 .labels(vec![Span::raw(time_label), Span::raw("now")]),
2788 )
2789 .y_axis(
2790 Axis::default()
2791 .title(Span::styled(scale_label, Style::new().gray()))
2792 .style(Style::new().gray())
2793 .bounds([y_min, y_max])
2794 .labels(vec![
2795 Span::raw(y_label(y_min)),
2796 Span::raw(y_label(mid_y)),
2797 Span::raw(y_label(y_max)),
2798 ]),
2799 );
2800
2801 f.render_widget(chart, area);
2802}
2803
2804fn is_stablecoin_price(price: f64) -> bool {
2806 (0.95..=1.05).contains(&price)
2807}
2808
2809fn format_price_usd(price: f64) -> String {
2812 if price >= 1000.0 {
2813 format!("${:.2}", price)
2814 } else if is_stablecoin_price(price) {
2815 format!("${:.6}", price)
2817 } else if price >= 1.0 {
2818 format!("${:.4}", price)
2819 } else if price >= 0.01 {
2820 format!("${:.6}", price)
2821 } else if price >= 0.0001 {
2822 format!("${:.8}", price)
2823 } else {
2824 format!("${:.10}", price)
2825 }
2826}
2827
2828fn render_candlestick_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
2830 let candles = state.get_ohlc_candles();
2831
2832 if candles.is_empty() {
2833 let empty = Paragraph::new("No candle data (waiting for more data points)").block(
2834 Block::default()
2835 .title(" Candlestick (USD) ")
2836 .borders(Borders::ALL),
2837 );
2838 f.render_widget(empty, area);
2839 return;
2840 }
2841
2842 let current_price = state.current_price;
2844 let first_candle = candles.first().unwrap();
2845 let last_candle = candles.last().unwrap();
2846 let price_change = last_candle.close - first_candle.open;
2847 let price_change_pct = if first_candle.open > 0.0 {
2848 (price_change / first_candle.open) * 100.0
2849 } else {
2850 0.0
2851 };
2852
2853 let pal = state.palette();
2854 let is_price_up = price_change >= 0.0;
2855 let trend_color = if is_price_up { pal.up } else { pal.down };
2856 let trend_symbol = if is_price_up { "▲" } else { "▼" };
2857
2858 let price_str = format_price_usd(current_price);
2859 let change_str = format!(
2860 "{}{:.2}%",
2861 if is_price_up { "+" } else { "" },
2862 price_change_pct
2863 );
2864
2865 let (min_price, max_price) = candles.iter().fold((f64::MAX, f64::MIN), |(min, max), c| {
2867 (min.min(c.low), max.max(c.high))
2868 });
2869
2870 let price_range = max_price - min_price;
2871 let (y_min, y_max) = if price_range < 0.0001 {
2872 let padding = min_price * 0.001;
2873 (min_price - padding, max_price + padding)
2874 } else {
2875 (min_price - price_range * 0.1, max_price + price_range * 0.1)
2876 };
2877
2878 let x_min = candles.first().map(|c| c.timestamp).unwrap_or(0.0);
2879 let x_max = candles.last().map(|c| c.timestamp).unwrap_or(1.0);
2880 let x_range = x_max - x_min;
2881 let x_max = if x_range < 0.001 {
2882 x_min + 1.0
2883 } else {
2884 x_max + x_range * 0.05
2885 };
2886
2887 let candle_count = candles.len() as f64;
2889 let candle_spacing = x_range / candle_count.max(1.0);
2890 let candle_width = candle_spacing * 0.6; let title = Line::from(vec![
2893 Span::raw(" ⬡ "),
2894 Span::styled(
2895 format!("{} {} ", price_str, trend_symbol),
2896 Style::new().fg(trend_color).bold(),
2897 ),
2898 Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
2899 Span::styled(
2900 format!("│{}│ ", state.time_period.label()),
2901 Style::new().gray(),
2902 ),
2903 Span::styled("⊞Candles ", Style::new().magenta()),
2904 ]);
2905
2906 let apply_scale = |price: f64| -> f64 {
2908 match state.scale_mode {
2909 ScaleMode::Linear => price,
2910 ScaleMode::Log => {
2911 if price > 0.0 {
2912 price.ln()
2913 } else {
2914 0.0
2915 }
2916 }
2917 }
2918 };
2919 let scaled_y_min = apply_scale(y_min);
2920 let scaled_y_max = apply_scale(y_max);
2921 let scaled_price_range = scaled_y_max - scaled_y_min;
2922
2923 let candles_clone = candles.clone();
2925 let is_log = matches!(state.scale_mode, ScaleMode::Log);
2926 let pal_up = pal.up;
2927 let pal_down = pal.down;
2928
2929 let canvas = Canvas::default()
2930 .block(
2931 Block::default()
2932 .title(title)
2933 .borders(Borders::ALL)
2934 .border_style(Style::new().fg(trend_color)),
2935 )
2936 .x_bounds([x_min - candle_spacing, x_max])
2937 .y_bounds([scaled_y_min, scaled_y_max])
2938 .paint(move |ctx| {
2939 let scale_fn = |p: f64| -> f64 { if is_log && p > 0.0 { p.ln() } else { p } };
2940 for candle in &candles_clone {
2941 let color = if candle.is_bullish { pal_up } else { pal_down };
2942
2943 ctx.draw(&CanvasLine {
2945 x1: candle.timestamp,
2946 y1: scale_fn(candle.low),
2947 x2: candle.timestamp,
2948 y2: scale_fn(candle.high),
2949 color,
2950 });
2951
2952 let body_top = scale_fn(candle.open.max(candle.close));
2954 let body_bottom = scale_fn(candle.open.min(candle.close));
2955 let body_height = (body_top - body_bottom).max(scaled_price_range * 0.002);
2956
2957 ctx.draw(&Rectangle {
2958 x: candle.timestamp - candle_width / 2.0,
2959 y: body_bottom,
2960 width: candle_width,
2961 height: body_height,
2962 color,
2963 });
2964 }
2965 });
2966
2967 f.render_widget(canvas, area);
2968}
2969
2970fn render_volume_profile_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
2976 let pal = state.palette();
2977 let (price_data, _) = state.get_price_data_for_period();
2978 let (volume_data, _) = state.get_volume_data_for_period();
2979
2980 if price_data.len() < 2 || volume_data.is_empty() {
2981 let block = Block::default()
2982 .title(" ◨ Volume Profile (collecting data...) ")
2983 .borders(Borders::ALL)
2984 .border_style(Style::new().fg(Color::DarkGray));
2985 f.render_widget(block, area);
2986 return;
2987 }
2988
2989 let min_price = price_data.iter().map(|(_, p)| *p).fold(f64::MAX, f64::min);
2991 let max_price = price_data.iter().map(|(_, p)| *p).fold(f64::MIN, f64::max);
2992
2993 if (max_price - min_price).abs() < f64::EPSILON {
2994 let block = Block::default()
2995 .title(" ◨ Volume Profile (no price range) ")
2996 .borders(Borders::ALL)
2997 .border_style(Style::new().fg(Color::DarkGray));
2998 f.render_widget(block, area);
2999 return;
3000 }
3001
3002 let inner_height = area.height.saturating_sub(2) as usize;
3004 let num_buckets = inner_height.clamp(1, 30);
3005 let bucket_size = (max_price - min_price) / num_buckets as f64;
3006
3007 let mut bucket_volumes = vec![0.0_f64; num_buckets];
3009
3010 let vol_iter: Vec<f64> = volume_data.iter().map(|(_, v)| *v).collect();
3012 for (i, (_, price)) in price_data.iter().enumerate() {
3013 let bucket_idx =
3014 (((price - min_price) / bucket_size).floor() as usize).min(num_buckets - 1);
3015 let vol_contribution = if i < vol_iter.len() {
3017 if i > 0 {
3019 (vol_iter[i] - vol_iter[i - 1]).abs().max(1.0)
3020 } else {
3021 1.0
3022 }
3023 } else {
3024 1.0
3025 };
3026 bucket_volumes[bucket_idx] += vol_contribution;
3027 }
3028
3029 let max_vol = bucket_volumes
3030 .iter()
3031 .cloned()
3032 .fold(0.0_f64, f64::max)
3033 .max(1.0);
3034
3035 let current_bucket = (((state.current_price - min_price) / bucket_size).floor() as usize)
3037 .min(num_buckets.saturating_sub(1));
3038
3039 let inner_width = area.width.saturating_sub(12) as usize; let lines: Vec<Line> = (0..num_buckets)
3043 .rev() .map(|i| {
3045 let bar_width = ((bucket_volumes[i] / max_vol) * inner_width as f64).round() as usize;
3046 let price_mid = min_price + (i as f64 + 0.5) * bucket_size;
3047 let label = if price_mid >= 1.0 {
3048 format!("{:>8.2}", price_mid)
3049 } else {
3050 format!("{:>8.6}", price_mid)
3051 };
3052 let bar_str = "█".repeat(bar_width);
3053 let style = if i == current_bucket {
3054 Style::new().fg(pal.highlight).bold()
3055 } else {
3056 Style::new().fg(pal.sparkline)
3057 };
3058 Line::from(vec![
3059 Span::styled(label, Style::new().dark_gray()),
3060 Span::raw(" "),
3061 Span::styled(bar_str, style),
3062 ])
3063 })
3064 .collect();
3065
3066 let block = Block::default()
3067 .title(" ◨ Volume Profile (accuracy improves over time) ")
3068 .borders(Borders::ALL)
3069 .border_style(Style::new().fg(pal.sparkline));
3070
3071 let paragraph = Paragraph::new(lines).block(block);
3072 f.render_widget(paragraph, area);
3073}
3074
3075fn render_volume_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
3077 let pal = state.palette();
3078 let (data, is_real) = state.get_volume_data_for_period();
3080
3081 if data.is_empty() {
3082 let empty = Paragraph::new("No volume data")
3083 .block(Block::default().title(" 24h Volume ").borders(Borders::ALL));
3084 f.render_widget(empty, area);
3085 return;
3086 }
3087
3088 let current_volume = state.volume_24h;
3090 let volume_str = crate::display::format_usd(current_volume);
3091
3092 let has_synthetic = is_real.iter().any(|r| !r);
3094 let has_real = is_real.iter().any(|r| *r);
3095
3096 let data_indicator = if has_synthetic && has_real {
3098 "[◆ est │ ● live]"
3099 } else if has_synthetic {
3100 "[◆ estimated]"
3101 } else {
3102 "[● live]"
3103 };
3104
3105 let chart_title = Line::from(vec![
3106 Span::raw(" ▣ "),
3107 Span::styled(
3108 format!("24h Vol: {} ", volume_str),
3109 Style::new().fg(pal.volume_bar).bold(),
3110 ),
3111 Span::styled(
3112 format!("│{}│ ", state.time_period.label()),
3113 Style::new().gray(),
3114 ),
3115 Span::styled(data_indicator, Style::new().dark_gray()),
3116 ]);
3117
3118 let inner_width = area.width.saturating_sub(2) as usize; let max_bars = (inner_width / 3).max(1).min(data.len());
3122 let bucket_size = data.len().div_ceil(max_bars);
3123
3124 let bars: Vec<Bar> = data
3125 .chunks(bucket_size)
3126 .zip(is_real.chunks(bucket_size))
3127 .enumerate()
3128 .map(|(i, (chunk, real_chunk))| {
3129 let avg_vol = chunk.iter().map(|(_, v)| v).sum::<f64>() / chunk.len() as f64;
3130 let any_real = real_chunk.iter().any(|r| *r);
3131 let bar_color = if any_real {
3132 pal.volume_bar
3133 } else {
3134 pal.neutral
3135 };
3136 let label = if i == 0 || i == max_bars.saturating_sub(1) || i == max_bars / 2 {
3138 format_number(avg_vol)
3139 } else {
3140 String::new()
3141 };
3142 Bar::default()
3143 .value(avg_vol as u64)
3144 .label(Line::from(label))
3145 .style(Style::new().fg(bar_color))
3146 })
3147 .collect();
3148
3149 let bar_width = if !bars.is_empty() {
3151 let total_bars = bars.len() as u16;
3152 ((inner_width as u16).saturating_sub(total_bars.saturating_sub(1))) / total_bars
3154 } else {
3155 1
3156 }
3157 .max(1);
3158
3159 let barchart = BarChart::default()
3160 .data(BarGroup::default().bars(&bars))
3161 .block(
3162 Block::default()
3163 .title(chart_title)
3164 .borders(Borders::ALL)
3165 .border_style(Style::new().blue()),
3166 )
3167 .bar_width(bar_width)
3168 .bar_gap(1)
3169 .value_style(Style::new().dark_gray());
3170
3171 f.render_widget(barchart, area);
3172}
3173
3174fn render_buy_sell_gauge(f: &mut Frame, area: Rect, state: &mut MonitorState) {
3176 let pal = state.palette();
3177 let ratio = state.buy_ratio();
3179 let border_color = if ratio > 0.5 { pal.up } else { pal.down };
3180
3181 let block = Block::default()
3182 .title(" ◐ Buy/Sell Ratio (24h) ")
3183 .borders(Borders::ALL)
3184 .border_style(Style::new().fg(border_color));
3185
3186 let inner = block.inner(area);
3187 f.render_widget(block, area);
3188
3189 if inner.width > 0 && inner.height > 0 {
3190 let buy_width = ((ratio * inner.width as f64).round() as u16).min(inner.width);
3191 let sell_width = inner.width.saturating_sub(buy_width);
3192
3193 let buy_indicator = if ratio > 0.5 { "▶" } else { "▷" };
3194 let sell_indicator = if ratio < 0.5 { "◀" } else { "◁" };
3195 let label = format!(
3196 "{}Buys: {} │ Sells: {}{} ({:.1}%)",
3197 buy_indicator,
3198 state.buys_24h,
3199 state.sells_24h,
3200 sell_indicator,
3201 ratio * 100.0
3202 );
3203
3204 let buy_bar = "█".repeat(buy_width as usize);
3205 let sell_bar = "█".repeat(sell_width as usize);
3206 let bar_line = Line::from(vec![
3207 Span::styled(buy_bar, Style::new().fg(pal.up)),
3208 Span::styled(sell_bar, Style::new().fg(pal.down)),
3209 ]);
3210 f.render_widget(Paragraph::new(bar_line), inner);
3211
3212 let label_len = label.len() as u16;
3214 if label_len <= inner.width {
3215 let x_offset = (inner.width.saturating_sub(label_len)) / 2;
3216 let label_area = Rect::new(inner.x + x_offset, inner.y, label_len, 1);
3217 let label_widget =
3218 Paragraph::new(Span::styled(label, Style::new().fg(Color::White).bold()));
3219 f.render_widget(label_widget, label_area);
3220 }
3221 }
3222}
3223
3224fn render_activity_feed(f: &mut Frame, area: Rect, state: &mut MonitorState) {
3226 let log_len = state.log_messages.len();
3227 let log_title = if log_len > 0 {
3228 let selected = state.log_list_state.selected().unwrap_or(0);
3229 format!(" ◷ Activity Log [{}/{}] ", selected + 1, log_len)
3230 } else {
3231 " ◷ Activity Log ".to_string()
3232 };
3233
3234 let items: Vec<ListItem> = state
3235 .log_messages
3236 .iter()
3237 .rev()
3238 .map(|msg| ListItem::new(msg.as_str()).style(Style::new().gray()))
3239 .collect();
3240
3241 let log_list = List::new(items)
3242 .block(
3243 Block::default()
3244 .title(log_title)
3245 .borders(Borders::ALL)
3246 .border_style(Style::new().dark_gray()),
3247 )
3248 .highlight_style(Style::new().white().bold())
3249 .highlight_symbol("▸ ");
3250
3251 f.render_stateful_widget(log_list, area, &mut state.log_list_state);
3252}
3253
3254fn render_alert_overlay(f: &mut Frame, area: Rect, state: &MonitorState) {
3256 if state.active_alerts.is_empty() {
3257 return;
3258 }
3259
3260 let is_flash_on = state
3261 .alert_flash_until
3262 .map(|deadline| {
3263 if Instant::now() < deadline {
3264 (Instant::now().elapsed().subsec_millis() / 500).is_multiple_of(2)
3266 } else {
3267 false
3268 }
3269 })
3270 .unwrap_or(false);
3271
3272 let border_color = if is_flash_on {
3273 Color::Red
3274 } else {
3275 Color::Yellow
3276 };
3277
3278 let alert_lines: Vec<Line> = state
3279 .active_alerts
3280 .iter()
3281 .map(|a| Line::from(Span::styled(&a.message, Style::new().fg(Color::Red).bold())))
3282 .collect();
3283
3284 let alert_widget = Paragraph::new(alert_lines).block(
3285 Block::default()
3286 .title(" ⚠ ALERTS ")
3287 .borders(Borders::ALL)
3288 .border_style(Style::new().fg(border_color).bold()),
3289 );
3290
3291 f.render_widget(alert_widget, area);
3292}
3293
3294fn render_liquidity_depth(f: &mut Frame, area: Rect, state: &MonitorState) {
3296 let pal = state.palette();
3297
3298 if state.liquidity_pairs.is_empty() {
3299 let block = Block::default()
3300 .title(" ◫ Liquidity Depth (no data) ")
3301 .borders(Borders::ALL)
3302 .border_style(Style::new().fg(Color::DarkGray));
3303 f.render_widget(block, area);
3304 return;
3305 }
3306
3307 let max_liquidity = state
3308 .liquidity_pairs
3309 .iter()
3310 .map(|(_, liq)| *liq)
3311 .fold(0.0_f64, f64::max)
3312 .max(1.0);
3313
3314 let inner_width = area.width.saturating_sub(2) as usize;
3315
3316 let lines: Vec<Line> = state
3317 .liquidity_pairs
3318 .iter()
3319 .take(area.height.saturating_sub(2) as usize) .map(|(name, liq)| {
3321 let bar_width = ((liq / max_liquidity) * inner_width as f64 * 0.6).round() as usize;
3322 let bar_str = "█".repeat(bar_width);
3323 let label = format!(" {} {}", crate::display::format_usd(*liq), name);
3324 Line::from(vec![
3325 Span::styled(bar_str, Style::new().fg(pal.volume_bar)),
3326 Span::styled(label, Style::new().fg(pal.neutral)),
3327 ])
3328 })
3329 .collect();
3330
3331 let block = Block::default()
3332 .title(format!(
3333 " ◫ Liquidity Depth ({} pairs) ",
3334 state.liquidity_pairs.len()
3335 ))
3336 .borders(Borders::ALL)
3337 .border_style(Style::new().fg(pal.border));
3338
3339 let paragraph = Paragraph::new(lines).block(block);
3340 f.render_widget(paragraph, area);
3341}
3342
3343fn render_metrics_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
3345 let pal = state.palette();
3346 let chunks = Layout::default()
3348 .direction(Direction::Vertical)
3349 .constraints([Constraint::Length(3), Constraint::Min(0)])
3350 .split(area);
3351
3352 let sparkline_data: Vec<u64> = {
3354 let points: Vec<f64> = state.price_history.iter().map(|dp| dp.value).collect();
3356 if points.len() < 2 {
3357 vec![0; chunks[0].width.saturating_sub(2) as usize]
3358 } else {
3359 let min_p = points.iter().cloned().fold(f64::MAX, f64::min);
3360 let max_p = points.iter().cloned().fold(f64::MIN, f64::max);
3361 let range = (max_p - min_p).max(0.0001);
3362 points
3363 .iter()
3364 .map(|p| (((*p - min_p) / range) * 100.0) as u64)
3365 .collect()
3366 }
3367 };
3368
3369 let trend_color = if state.price_change_5m >= 0.0 {
3370 pal.up
3371 } else {
3372 pal.down
3373 };
3374
3375 let sparkline = Sparkline::default()
3376 .block(
3377 Block::default()
3378 .title(" ◉ Price Trend ")
3379 .borders(Borders::ALL)
3380 .border_style(Style::new().fg(pal.sparkline)),
3381 )
3382 .data(&sparkline_data)
3383 .style(Style::new().fg(trend_color));
3384
3385 f.render_widget(sparkline, chunks[0]);
3386
3387 let change_5m_str = if state.price_change_5m.abs() < 0.0001 {
3389 "0.00%".to_string()
3390 } else {
3391 format!("{:+.4}%", state.price_change_5m)
3392 };
3393 let change_5m_color = if state.price_change_5m > 0.0 {
3394 pal.up
3395 } else if state.price_change_5m < 0.0 {
3396 pal.down
3397 } else {
3398 pal.neutral
3399 };
3400
3401 let now_ts = chrono::Utc::now().timestamp() as f64;
3402 let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
3403 let last_change_str = if secs_since_change < 60 {
3404 format!("{}s ago", secs_since_change)
3405 } else if secs_since_change < 3600 {
3406 format!("{}m ago", secs_since_change / 60)
3407 } else {
3408 format!("{}h ago", secs_since_change / 3600)
3409 };
3410 let last_change_color = if secs_since_change < 60 {
3411 pal.up
3412 } else {
3413 pal.highlight
3414 };
3415
3416 let change_24h_str = format!(
3417 "{}{:.2}%",
3418 if state.price_change_24h >= 0.0 {
3419 "+"
3420 } else {
3421 ""
3422 },
3423 state.price_change_24h
3424 );
3425
3426 let market_cap_str = state
3427 .market_cap
3428 .map(crate::display::format_usd)
3429 .unwrap_or_else(|| "N/A".to_string());
3430
3431 let mut rows = vec![
3432 Row::new(vec![
3433 Span::styled("Price", Style::new().gray()),
3434 Span::styled(format_price_usd(state.current_price), Style::new().bold()),
3435 ]),
3436 Row::new(vec![
3437 Span::styled("5m Chg", Style::new().gray()),
3438 Span::styled(change_5m_str, Style::new().fg(change_5m_color)),
3439 ]),
3440 Row::new(vec![
3441 Span::styled("Last Δ", Style::new().gray()),
3442 Span::styled(last_change_str, Style::new().fg(last_change_color)),
3443 ]),
3444 Row::new(vec![
3445 Span::styled("24h Chg", Style::new().gray()),
3446 Span::raw(change_24h_str),
3447 ]),
3448 Row::new(vec![
3449 Span::styled("Liq", Style::new().gray()),
3450 Span::raw(crate::display::format_usd(state.liquidity_usd)),
3451 ]),
3452 Row::new(vec![
3453 Span::styled("Vol 24h", Style::new().gray()),
3454 Span::raw(crate::display::format_usd(state.volume_24h)),
3455 ]),
3456 Row::new(vec![
3457 Span::styled("Mkt Cap", Style::new().gray()),
3458 Span::raw(market_cap_str),
3459 ]),
3460 Row::new(vec![
3461 Span::styled("Buys", Style::new().gray()),
3462 Span::styled(format!("{}", state.buys_24h), Style::new().fg(pal.up)),
3463 ]),
3464 Row::new(vec![
3465 Span::styled("Sells", Style::new().gray()),
3466 Span::styled(format!("{}", state.sells_24h), Style::new().fg(pal.down)),
3467 ]),
3468 ];
3469
3470 if state.widgets.holder_count
3472 && let Some(count) = state.holder_count
3473 {
3474 rows.push(Row::new(vec![
3475 Span::styled("Holders", Style::new().gray()),
3476 Span::styled(format_number(count as f64), Style::new().fg(pal.highlight)),
3477 ]));
3478 }
3479
3480 let table = Table::new(rows, [Constraint::Length(8), Constraint::Min(10)]).block(
3481 Block::default()
3482 .title(" ◉ Key Metrics ")
3483 .borders(Borders::ALL)
3484 .border_style(Style::new().magenta()),
3485 );
3486
3487 f.render_widget(table, chunks[1]);
3488}
3489
3490fn render_order_book_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
3494 let pal = state.palette();
3495
3496 let book = match &state.order_book {
3497 Some(b) => b,
3498 None => {
3499 let block = Block::default()
3500 .title(" ◈ Order Book (no data) ")
3501 .borders(Borders::ALL)
3502 .border_style(Style::new().fg(Color::DarkGray));
3503 f.render_widget(block, area);
3504 return;
3505 }
3506 };
3507
3508 let inner_height = area.height.saturating_sub(2) as usize; if inner_height < 3 {
3510 return;
3511 }
3512
3513 let ask_rows = (inner_height.saturating_sub(1)) / 2;
3515 let bid_rows = inner_height.saturating_sub(ask_rows).saturating_sub(1);
3516
3517 let max_qty = book
3519 .asks
3520 .iter()
3521 .chain(book.bids.iter())
3522 .map(|l| l.quantity)
3523 .fold(0.0_f64, f64::max)
3524 .max(0.001);
3525
3526 let inner_width = area.width.saturating_sub(2) as usize;
3527 let bar_width_max = (inner_width as f64 * 0.3).round() as usize;
3529
3530 let mut lines: Vec<Line> = Vec::with_capacity(inner_height);
3531
3532 let visible_asks: Vec<_> = book.asks.iter().take(ask_rows).collect();
3534 for _ in 0..ask_rows.saturating_sub(visible_asks.len()) {
3536 lines.push(Line::from(""));
3537 }
3538 for level in visible_asks.iter().rev() {
3539 let bar_len = ((level.quantity / max_qty) * bar_width_max as f64).round() as usize;
3540 let bar = "█".repeat(bar_len);
3541 let price_str = format!("{:.6}", level.price);
3542 let qty_str = format_number(level.quantity);
3543 let val_str = format_number(level.value());
3544 let padding = inner_width
3545 .saturating_sub(bar_len)
3546 .saturating_sub(price_str.len())
3547 .saturating_sub(qty_str.len())
3548 .saturating_sub(val_str.len())
3549 .saturating_sub(4); lines.push(Line::from(vec![
3551 Span::styled(bar, Style::new().fg(pal.down).dim()),
3552 Span::raw(" "),
3553 Span::styled(price_str, Style::new().fg(pal.down)),
3554 Span::raw(" ".repeat(padding.max(1))),
3555 Span::styled(qty_str, Style::new().fg(pal.neutral)),
3556 Span::raw(" "),
3557 Span::styled(val_str, Style::new().fg(Color::DarkGray)),
3558 ]));
3559 }
3560
3561 let spread = book
3563 .best_ask()
3564 .zip(book.best_bid())
3565 .map(|(ask, bid)| {
3566 let s = ask - bid;
3567 let pct = if bid > 0.0 { (s / bid) * 100.0 } else { 0.0 };
3568 format!(" Spread: {:.6} ({:.3}%)", s, pct)
3569 })
3570 .unwrap_or_else(|| " Spread: --".to_string());
3571 lines.push(Line::from(Span::styled(
3572 spread,
3573 Style::new().fg(Color::Yellow).bold(),
3574 )));
3575
3576 for level in book.bids.iter().take(bid_rows) {
3578 let bar_len = ((level.quantity / max_qty) * bar_width_max as f64).round() as usize;
3579 let bar = "█".repeat(bar_len);
3580 let price_str = format!("{:.6}", level.price);
3581 let qty_str = format_number(level.quantity);
3582 let val_str = format_number(level.value());
3583 let padding = inner_width
3584 .saturating_sub(bar_len)
3585 .saturating_sub(price_str.len())
3586 .saturating_sub(qty_str.len())
3587 .saturating_sub(val_str.len())
3588 .saturating_sub(4);
3589 lines.push(Line::from(vec![
3590 Span::styled(bar, Style::new().fg(pal.up).dim()),
3591 Span::raw(" "),
3592 Span::styled(price_str, Style::new().fg(pal.up)),
3593 Span::raw(" ".repeat(padding.max(1))),
3594 Span::styled(qty_str, Style::new().fg(pal.neutral)),
3595 Span::raw(" "),
3596 Span::styled(val_str, Style::new().fg(Color::DarkGray)),
3597 ]));
3598 }
3599
3600 let ask_depth: f64 = book.asks.iter().map(|l| l.value()).sum();
3601 let bid_depth: f64 = book.bids.iter().map(|l| l.value()).sum();
3602 let title = format!(
3603 " ◈ {} │ Ask {} │ Bid {} ",
3604 book.pair,
3605 format_number(ask_depth),
3606 format_number(bid_depth),
3607 );
3608
3609 let block = Block::default()
3610 .title(title)
3611 .borders(Borders::ALL)
3612 .border_style(Style::new().fg(pal.border));
3613
3614 let paragraph = Paragraph::new(lines).block(block);
3615 f.render_widget(paragraph, area);
3616}
3617
3618fn render_recent_trades_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
3623 let pal = state.palette();
3624
3625 if state.recent_trades.is_empty() {
3626 let block = Block::default()
3627 .title(" ◈ Recent Trades (no data) ")
3628 .borders(Borders::ALL)
3629 .border_style(Style::new().fg(Color::DarkGray));
3630 f.render_widget(block, area);
3631 return;
3632 }
3633
3634 let inner_height = area.height.saturating_sub(2) as usize;
3635 let inner_width = area.width.saturating_sub(2) as usize;
3636
3637 let time_width = 8; let side_width = 4; let price_width = inner_width
3641 .saturating_sub(time_width)
3642 .saturating_sub(side_width)
3643 .saturating_sub(3) / 2;
3645 let qty_width = inner_width
3646 .saturating_sub(time_width)
3647 .saturating_sub(side_width)
3648 .saturating_sub(price_width)
3649 .saturating_sub(3);
3650
3651 let mut lines: Vec<Line> = Vec::with_capacity(inner_height);
3652
3653 lines.push(Line::from(vec![
3655 Span::styled(
3656 format!("{:<time_width$}", "Time"),
3657 Style::new().fg(Color::DarkGray).bold(),
3658 ),
3659 Span::raw(" "),
3660 Span::styled(
3661 format!("{:<side_width$}", "Side"),
3662 Style::new().fg(Color::DarkGray).bold(),
3663 ),
3664 Span::raw(" "),
3665 Span::styled(
3666 format!("{:>price_width$}", "Price"),
3667 Style::new().fg(Color::DarkGray).bold(),
3668 ),
3669 Span::raw(" "),
3670 Span::styled(
3671 format!("{:>qty_width$}", "Qty"),
3672 Style::new().fg(Color::DarkGray).bold(),
3673 ),
3674 ]));
3675
3676 let visible_count = inner_height.saturating_sub(1); for trade in state.recent_trades.iter().rev().take(visible_count) {
3679 let (side_str, side_color) = match trade.side {
3680 TradeSide::Buy => ("BUY ", pal.up),
3681 TradeSide::Sell => ("SELL", pal.down),
3682 };
3683
3684 let secs = (trade.timestamp_ms / 1000) as i64;
3686 let hours = (secs / 3600) % 24;
3687 let mins = (secs / 60) % 60;
3688 let sec = secs % 60;
3689 let time_str = format!("{:02}:{:02}:{:02}", hours, mins, sec);
3690
3691 let price_str = if trade.price >= 1000.0 {
3692 format!("{:.2}", trade.price)
3693 } else if trade.price >= 1.0 {
3694 format!("{:.4}", trade.price)
3695 } else {
3696 format!("{:.6}", trade.price)
3697 };
3698
3699 let qty_str = format_number(trade.quantity);
3700
3701 lines.push(Line::from(vec![
3702 Span::styled(
3703 format!("{:<time_width$}", time_str),
3704 Style::new().fg(Color::DarkGray),
3705 ),
3706 Span::raw(" "),
3707 Span::styled(
3708 format!("{:<side_width$}", side_str),
3709 Style::new().fg(side_color),
3710 ),
3711 Span::raw(" "),
3712 Span::styled(
3713 format!("{:>price_width$}", price_str),
3714 Style::new().fg(side_color),
3715 ),
3716 Span::raw(" "),
3717 Span::styled(
3718 format!("{:>qty_width$}", qty_str),
3719 Style::new().fg(pal.neutral),
3720 ),
3721 ]));
3722 }
3723
3724 let title = format!(" ◈ Recent Trades ({}) ", state.recent_trades.len());
3725 let block = Block::default()
3726 .title(title)
3727 .borders(Borders::ALL)
3728 .border_style(Style::new().fg(pal.border));
3729
3730 let paragraph = Paragraph::new(lines).block(block);
3731 f.render_widget(paragraph, area);
3732}
3733
3734fn render_market_info_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
3739 let pal = state.palette();
3740
3741 let cols = Layout::default()
3743 .direction(Direction::Horizontal)
3744 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
3745 .split(area);
3746
3747 {
3749 let header = Row::new(vec!["DEX / Pair", "Volume 24h", "Liquidity", "Δ 24h"])
3750 .style(Style::new().fg(Color::Cyan).bold())
3751 .bottom_margin(0);
3752
3753 let rows: Vec<Row> = state
3754 .dex_pairs
3755 .iter()
3756 .take(cols[0].height.saturating_sub(3) as usize) .map(|p| {
3758 let pair_label = format!("{}/{}", p.base_token, p.quote_token);
3759 let dex_and_pair = format!("{} {}", p.dex_name, pair_label);
3760 let vol = format_number(p.volume_24h);
3761 let liq = format_number(p.liquidity_usd);
3762 let change_str = format!("{:+.1}%", p.price_change_24h);
3763 let change_color = if p.price_change_24h >= 0.0 {
3764 pal.up
3765 } else {
3766 pal.down
3767 };
3768 Row::new(vec![
3769 ratatui::text::Text::from(dex_and_pair),
3770 ratatui::text::Text::styled(vol, Style::new().fg(pal.neutral)),
3771 ratatui::text::Text::styled(liq, Style::new().fg(pal.volume_bar)),
3772 ratatui::text::Text::styled(change_str, Style::new().fg(change_color)),
3773 ])
3774 })
3775 .collect();
3776
3777 let widths = [
3778 Constraint::Percentage(40),
3779 Constraint::Percentage(22),
3780 Constraint::Percentage(22),
3781 Constraint::Percentage(16),
3782 ];
3783
3784 let table = Table::new(rows, widths).header(header).block(
3785 Block::default()
3786 .title(format!(" ◫ Trading Pairs ({}) ", state.dex_pairs.len()))
3787 .borders(Borders::ALL)
3788 .border_style(Style::new().fg(pal.border)),
3789 );
3790
3791 f.render_widget(table, cols[0]);
3792 }
3793
3794 {
3796 let mut info_lines: Vec<Line> = Vec::new();
3797
3798 let price_color = if state.price_change_24h >= 0.0 {
3800 pal.up
3801 } else {
3802 pal.down
3803 };
3804 info_lines.push(Line::from(vec![
3805 Span::styled(" Price ", Style::new().fg(Color::DarkGray)),
3806 Span::styled(
3807 format!("${:.6}", state.current_price),
3808 Style::new().fg(Color::White).bold(),
3809 ),
3810 ]));
3811
3812 let changes = [
3814 ("5m", state.price_change_5m),
3815 ("1h", state.price_change_1h),
3816 ("6h", state.price_change_6h),
3817 ("24h", state.price_change_24h),
3818 ];
3819 let change_spans: Vec<Span> = changes
3820 .iter()
3821 .flat_map(|(label, val)| {
3822 let color = if *val >= 0.0 { pal.up } else { pal.down };
3823 vec![
3824 Span::styled(format!(" {}: ", label), Style::new().fg(Color::DarkGray)),
3825 Span::styled(format!("{:+.2}%", val), Style::new().fg(color)),
3826 ]
3827 })
3828 .collect();
3829 info_lines.push(Line::from(change_spans));
3830
3831 info_lines.push(Line::from(""));
3832
3833 info_lines.push(Line::from(vec![
3835 Span::styled(" Vol 24h ", Style::new().fg(Color::DarkGray)),
3836 Span::styled(
3837 format!("${}", format_number(state.volume_24h)),
3838 Style::new().fg(pal.neutral),
3839 ),
3840 ]));
3841 info_lines.push(Line::from(vec![
3842 Span::styled(" Liq ", Style::new().fg(Color::DarkGray)),
3843 Span::styled(
3844 format!("${}", format_number(state.liquidity_usd)),
3845 Style::new().fg(pal.volume_bar),
3846 ),
3847 ]));
3848
3849 if let Some(mc) = state.market_cap {
3851 info_lines.push(Line::from(vec![
3852 Span::styled(" MCap ", Style::new().fg(Color::DarkGray)),
3853 Span::styled(
3854 format!("${}", format_number(mc)),
3855 Style::new().fg(pal.neutral),
3856 ),
3857 ]));
3858 }
3859 if let Some(fdv) = state.fdv {
3860 info_lines.push(Line::from(vec![
3861 Span::styled(" FDV ", Style::new().fg(Color::DarkGray)),
3862 Span::styled(
3863 format!("${}", format_number(fdv)),
3864 Style::new().fg(pal.neutral),
3865 ),
3866 ]));
3867 }
3868
3869 info_lines.push(Line::from(""));
3871 let total_txs = state.buys_24h + state.sells_24h;
3872 let buy_pct = if total_txs > 0 {
3873 (state.buys_24h as f64 / total_txs as f64) * 100.0
3874 } else {
3875 50.0
3876 };
3877 info_lines.push(Line::from(vec![
3878 Span::styled(" Buys ", Style::new().fg(Color::DarkGray)),
3879 Span::styled(
3880 format!("{} ({:.0}%)", state.buys_24h, buy_pct),
3881 Style::new().fg(pal.up),
3882 ),
3883 ]));
3884 info_lines.push(Line::from(vec![
3885 Span::styled(" Sells ", Style::new().fg(Color::DarkGray)),
3886 Span::styled(
3887 format!("{} ({:.0}%)", state.sells_24h, 100.0 - buy_pct),
3888 Style::new().fg(pal.down),
3889 ),
3890 ]));
3891
3892 if let Some(holders) = state.holder_count {
3894 info_lines.push(Line::from(vec![
3895 Span::styled(" Holders ", Style::new().fg(Color::DarkGray)),
3896 Span::styled(format_number(holders as f64), Style::new().fg(pal.neutral)),
3897 ]));
3898 }
3899
3900 if let Some(ts) = state.earliest_pair_created_at {
3902 let dt = chrono::DateTime::from_timestamp(ts, 0)
3903 .map(|d| d.format("%Y-%m-%d").to_string())
3904 .unwrap_or_else(|| "?".to_string());
3905 info_lines.push(Line::from(vec![
3906 Span::styled(" Listed ", Style::new().fg(Color::DarkGray)),
3907 Span::styled(dt, Style::new().fg(pal.neutral)),
3908 ]));
3909 }
3910
3911 if !state.websites.is_empty() || !state.socials.is_empty() {
3913 info_lines.push(Line::from(""));
3914 let mut link_spans = vec![Span::styled(" Links ", Style::new().fg(Color::DarkGray))];
3915 for (platform, _url) in &state.socials {
3916 link_spans.push(Span::styled(
3917 format!("[{}] ", platform),
3918 Style::new().fg(Color::Cyan),
3919 ));
3920 }
3921 for url in &state.websites {
3922 let domain = url
3924 .trim_start_matches("https://")
3925 .trim_start_matches("http://")
3926 .split('/')
3927 .next()
3928 .unwrap_or(url);
3929 link_spans.push(Span::styled(
3930 format!("[{}] ", domain),
3931 Style::new().fg(Color::Blue),
3932 ));
3933 }
3934 info_lines.push(Line::from(link_spans));
3935 }
3936
3937 let title = format!(" ◉ {} ({}) ", state.symbol, state.name);
3938 let block = Block::default()
3939 .title(title)
3940 .borders(Borders::ALL)
3941 .border_style(Style::new().fg(price_color));
3942
3943 let paragraph = Paragraph::new(info_lines).block(block);
3944 f.render_widget(paragraph, cols[1]);
3945 }
3946}
3947
3948fn render_footer(f: &mut Frame, area: Rect, state: &MonitorState) {
3950 let elapsed = state.last_update.elapsed().as_secs();
3951
3952 let now_ts = chrono::Utc::now().timestamp() as f64;
3954 let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
3955 let price_change_str = if secs_since_change < 60 {
3956 format!("{}s", secs_since_change)
3957 } else if secs_since_change < 3600 {
3958 format!("{}m", secs_since_change / 60)
3959 } else {
3960 format!("{}h", secs_since_change / 3600)
3961 };
3962
3963 let (synthetic_count, real_count) = state.data_stats();
3965 let memory_bytes = state.memory_usage();
3966 let memory_str = if memory_bytes >= 1024 * 1024 {
3967 format!("{:.1}MB", memory_bytes as f64 / (1024.0 * 1024.0))
3968 } else if memory_bytes >= 1024 {
3969 format!("{:.1}KB", memory_bytes as f64 / 1024.0)
3970 } else {
3971 format!("{}B", memory_bytes)
3972 };
3973
3974 let status = if let Some(ref err) = state.error_message {
3975 Span::styled(format!("⚠ {}", err), Style::new().red())
3976 } else if state.paused {
3977 Span::styled("⏸ PAUSED", Style::new().fg(Color::Yellow).bold())
3978 } else if state.is_auto_paused() {
3979 Span::styled("⏸ AUTO-PAUSED", Style::new().fg(Color::Cyan).bold())
3980 } else {
3981 Span::styled(
3982 format!(
3983 "↻ {}s │ Δ {} │ {} pts │ {}",
3984 elapsed,
3985 price_change_str,
3986 synthetic_count + real_count,
3987 memory_str
3988 ),
3989 Style::new().gray(),
3990 )
3991 };
3992
3993 let widget_hint = if state.widget_toggle_mode {
3994 Span::styled("W:1-5?", Style::new().fg(Color::Yellow).bold())
3995 } else {
3996 Span::styled("W", Style::new().fg(Color::Cyan).bold())
3997 };
3998
3999 let mut spans = vec![status, Span::raw(" ║ ")];
4000
4001 if state.export_active {
4003 spans.push(Span::styled("● REC ", Style::new().fg(Color::Red).bold()));
4004 }
4005
4006 spans.extend([
4007 Span::styled("Q", Style::new().red().bold()),
4008 Span::raw("uit "),
4009 Span::styled("R", Style::new().fg(Color::Green).bold()),
4010 Span::raw("efresh "),
4011 Span::styled("P", Style::new().fg(Color::Yellow).bold()),
4012 Span::raw("ause "),
4013 Span::styled("E", Style::new().fg(Color::LightRed).bold()),
4014 Span::raw("xport "),
4015 Span::styled("L", Style::new().fg(Color::Cyan).bold()),
4016 Span::raw(format!(":{} ", state.layout.label())),
4017 widget_hint,
4018 Span::raw("idget "),
4019 Span::styled("C", Style::new().fg(Color::LightBlue).bold()),
4020 Span::raw(format!("hart:{} ", state.chart_mode.label())),
4021 Span::styled("S", Style::new().fg(Color::LightGreen).bold()),
4022 Span::raw(format!("cale:{} ", state.scale_mode.label())),
4023 Span::styled("/", Style::new().fg(Color::LightRed).bold()),
4024 Span::raw(format!(":{} ", state.color_scheme.label())),
4025 Span::styled("T", Style::new().fg(Color::Magenta).bold()),
4026 Span::raw("ime "),
4027 ]);
4028
4029 let footer = Paragraph::new(Line::from(spans)).block(Block::default().borders(Borders::ALL));
4030
4031 f.render_widget(footer, area);
4032}
4033
4034fn format_number(n: f64) -> String {
4036 if n >= 1_000_000_000.0 {
4037 format!("{:.2}B", n / 1_000_000_000.0)
4038 } else if n >= 1_000_000.0 {
4039 format!("{:.2}M", n / 1_000_000.0)
4040 } else if n >= 1_000.0 {
4041 format!("{:.2}K", n / 1_000.0)
4042 } else {
4043 format!("{:.2}", n)
4044 }
4045}
4046
4047pub async fn run_direct(
4053 mut args: MonitorArgs,
4054 config: &Config,
4055 clients: &dyn ChainClientFactory,
4056) -> Result<()> {
4057 if let Some((address, chain)) =
4059 crate::cli::address_book::resolve_address_book_input(&args.token, config)?
4060 {
4061 args.token = address;
4062 if args.chain == "ethereum" {
4063 args.chain = chain;
4064 }
4065 }
4066
4067 let ctx = SessionContext {
4069 chain: args.chain,
4070 ..SessionContext::default()
4071 };
4072
4073 let mut monitor_config = config.monitor.clone();
4075 if let Some(layout) = args.layout {
4076 monitor_config.layout = layout;
4077 }
4078 if let Some(refresh) = args.refresh {
4079 monitor_config.refresh_seconds = refresh;
4080 }
4081 if let Some(scale) = args.scale {
4082 monitor_config.scale = scale;
4083 }
4084 if let Some(color_scheme) = args.color_scheme {
4085 monitor_config.color_scheme = color_scheme;
4086 }
4087 if let Some(ref path) = args.export {
4088 monitor_config.export.path = Some(path.to_string_lossy().into_owned());
4089 }
4090 if let Some(ref venue) = args.venue {
4091 monitor_config.venue = Some(venue.clone());
4092 }
4093
4094 let mut effective_config = config.clone();
4096 effective_config.monitor = monitor_config;
4097
4098 run(
4099 Some(args.token),
4100 args.pair,
4101 &ctx,
4102 &effective_config,
4103 clients,
4104 )
4105 .await
4106}
4107
4108pub async fn run(
4115 token: Option<String>,
4116 explicit_pair: Option<String>,
4117 ctx: &SessionContext,
4118 config: &Config,
4119 clients: &dyn ChainClientFactory,
4120) -> Result<()> {
4121 let token_input = match token {
4122 Some(t) => t,
4123 None => {
4124 return Err(ScopeError::Chain(
4125 "Token address or symbol required. Usage: monitor <token>".to_string(),
4126 ));
4127 }
4128 };
4129
4130 eprintln!(" Starting live monitor for {}...", token_input);
4131 eprintln!(" Fetching initial data...");
4132
4133 let exchange_client = config.monitor.venue.as_ref().and_then(|venue_id| {
4135 crate::market::VenueRegistry::load()
4136 .ok()
4137 .and_then(|r| r.get(venue_id).cloned())
4138 .map(|desc| crate::market::ExchangeClient::from_descriptor(&desc))
4139 });
4140
4141 let initial_data = if let Some(ref pair_str) = explicit_pair {
4144 let ex = exchange_client.as_ref().ok_or_else(|| {
4145 ScopeError::Chain("--pair requires --venue to be specified".to_string())
4146 })?;
4147
4148 let ticker = ex.fetch_ticker(pair_str).await.map_err(|e| {
4150 ScopeError::Chain(format!("Failed to fetch ticker for {}: {}", pair_str, e))
4151 })?;
4152
4153 let base_symbol = ticker
4155 .pair
4156 .split('/')
4157 .next()
4158 .unwrap_or(&token_input)
4159 .to_string();
4160
4161 eprintln!(
4162 " Exchange-only mode: {} @ ${:.6}",
4163 pair_str,
4164 ticker.last_price.unwrap_or(0.0)
4165 );
4166
4167 build_exchange_token_data(&base_symbol, pair_str, &ticker)
4169 } else {
4170 let dex_client = clients.create_dex_client();
4172 let token_address =
4173 resolve_token_address(&token_input, &ctx.chain, config, dex_client.as_ref()).await?;
4174
4175 dex_client
4176 .get_token_data(&ctx.chain, &token_address)
4177 .await?
4178 };
4179
4180 println!(
4181 "Monitoring {} ({}) on {}",
4182 initial_data.symbol, initial_data.name, ctx.chain
4183 );
4184 println!("Press Q to quit, R to refresh, P to pause...\n");
4185
4186 tokio::time::sleep(Duration::from_millis(500)).await;
4188
4189 let chain_client = clients.create_chain_client(&ctx.chain).ok();
4191
4192 let mut app = MonitorApp::new(
4194 initial_data,
4195 &ctx.chain,
4196 &config.monitor,
4197 chain_client,
4198 exchange_client,
4199 )?;
4200
4201 if let Some(ref pair_str) = explicit_pair {
4203 app.state.venue_pair = Some(pair_str.clone());
4204 }
4205
4206 let result = app.run().await;
4207
4208 if let Err(e) = app.cleanup() {
4210 eprintln!("Warning: Failed to cleanup terminal: {}", e);
4211 }
4212
4213 result
4214}
4215
4216fn build_exchange_token_data(
4221 symbol: &str,
4222 pair_label: &str,
4223 ticker: &crate::market::Ticker,
4224) -> DexTokenData {
4225 let price = ticker.last_price.unwrap_or(0.0);
4226 DexTokenData {
4227 address: format!("exchange:{}", pair_label),
4228 symbol: symbol.to_string(),
4229 name: symbol.to_string(),
4230 price_usd: price,
4231 price_change_24h: 0.0,
4232 price_change_6h: 0.0,
4233 price_change_1h: 0.0,
4234 price_change_5m: 0.0,
4235 volume_24h: ticker.volume_24h.unwrap_or(0.0),
4236 volume_6h: 0.0,
4237 volume_1h: 0.0,
4238 liquidity_usd: 0.0,
4239 market_cap: None,
4240 fdv: None,
4241 pairs: vec![],
4242 price_history: vec![],
4243 volume_history: vec![],
4244 total_buys_24h: 0,
4245 total_sells_24h: 0,
4246 total_buys_6h: 0,
4247 total_sells_6h: 0,
4248 total_buys_1h: 0,
4249 total_sells_1h: 0,
4250 earliest_pair_created_at: None,
4251 image_url: None,
4252 websites: vec![],
4253 socials: vec![],
4254 dexscreener_url: None,
4255 }
4256}
4257
4258async fn resolve_token_address(
4265 input: &str,
4266 chain: &str,
4267 _config: &Config,
4268 dex_client: &dyn DexDataSource,
4269) -> Result<String> {
4270 if crate::tokens::TokenAliases::is_address(input) {
4272 return Ok(input.to_string());
4273 }
4274
4275 let chain_filter = if chain != "ethereum" {
4277 Some(chain)
4278 } else {
4279 None
4280 };
4281 let aliases = crate::tokens::TokenAliases::load();
4282 if let Some(alias) = aliases.get(input, chain_filter) {
4283 return Ok(alias.address.clone());
4284 }
4285
4286 let mut results = dex_client.search_tokens(input, chain_filter).await?;
4289
4290 if results.is_empty()
4292 && let Some(fallback) = try_cex_fallback(input, chain).await
4293 {
4294 eprintln!(
4295 " Not found on DexScreener; found {} on {} (CEX)",
4296 fallback.symbol, fallback.chain
4297 );
4298 results.push(fallback);
4299 }
4300
4301 if results.is_empty() {
4302 return Err(ScopeError::NotFound(format!(
4303 "No token found matching '{}' on {} (checked DexScreener and CEX venues)",
4304 input, chain
4305 )));
4306 }
4307
4308 if results.len() == 1 {
4310 let token = &results[0];
4311 println!(
4312 "Found: {} ({}) - ${:.6}",
4313 token.symbol,
4314 token.name,
4315 token.price_usd.unwrap_or(0.0)
4316 );
4317 return Ok(token.address.clone());
4318 }
4319
4320 let selected = select_token_interactive(&results)?;
4322 Ok(selected.address.clone())
4323}
4324
4325async fn try_cex_fallback(symbol: &str, chain: &str) -> Option<crate::chains::TokenSearchResult> {
4331 let registry = crate::market::VenueRegistry::load().ok()?;
4332 let descriptor = registry.get("binance")?;
4333 let client = crate::market::ExchangeClient::from_descriptor(&descriptor.clone());
4334 let pair = client.format_pair(&format!("{}USDT", symbol.to_uppercase()));
4335 let ticker = client.fetch_ticker(&pair).await.ok()?;
4336 let price = ticker.last_price.unwrap_or(0.0);
4337 Some(crate::chains::TokenSearchResult {
4338 address: String::new(),
4339 symbol: symbol.to_uppercase(),
4340 name: symbol.to_uppercase(),
4341 chain: chain.to_string(),
4342 price_usd: Some(price),
4343 volume_24h: ticker.volume_24h.unwrap_or(0.0),
4344 liquidity_usd: 0.0,
4345 market_cap: None,
4346 })
4347}
4348
4349fn abbreviate_address(addr: &str) -> String {
4351 if addr.len() > 16 {
4352 format!("{}...{}", &addr[..8], &addr[addr.len() - 6..])
4353 } else {
4354 addr.to_string()
4355 }
4356}
4357
4358fn select_token_interactive(
4360 results: &[crate::chains::dex::TokenSearchResult],
4361) -> Result<&crate::chains::dex::TokenSearchResult> {
4362 let stdin = io::stdin();
4363 let stdout = io::stdout();
4364 select_token_impl(results, &mut stdin.lock(), &mut stdout.lock())
4365}
4366
4367fn select_token_impl<'a>(
4369 results: &'a [crate::chains::dex::TokenSearchResult],
4370 reader: &mut impl io::BufRead,
4371 writer: &mut impl io::Write,
4372) -> Result<&'a crate::chains::dex::TokenSearchResult> {
4373 writeln!(
4374 writer,
4375 "\nFound {} tokens matching your query:\n",
4376 results.len()
4377 )
4378 .map_err(|e| ScopeError::Io(e.to_string()))?;
4379
4380 writeln!(
4381 writer,
4382 "{:>3} {:>8} {:<22} {:<16} {:>12} {:>12}",
4383 "#", "Symbol", "Name", "Address", "Price", "Liquidity"
4384 )
4385 .map_err(|e| ScopeError::Io(e.to_string()))?;
4386
4387 writeln!(writer, "{}", "─".repeat(82)).map_err(|e| ScopeError::Io(e.to_string()))?;
4388
4389 for (i, token) in results.iter().enumerate() {
4390 let price = token
4391 .price_usd
4392 .map(|p| format!("${:.6}", p))
4393 .unwrap_or_else(|| "N/A".to_string());
4394
4395 let liquidity = format_monitor_number(token.liquidity_usd);
4396 let addr = abbreviate_address(&token.address);
4397
4398 let name = if token.name.len() > 20 {
4400 format!("{}...", &token.name[..17])
4401 } else {
4402 token.name.clone()
4403 };
4404
4405 writeln!(
4406 writer,
4407 "{:>3} {:>8} {:<22} {:<16} {:>12} {:>12}",
4408 i + 1,
4409 token.symbol,
4410 name,
4411 addr,
4412 price,
4413 liquidity
4414 )
4415 .map_err(|e| ScopeError::Io(e.to_string()))?;
4416 }
4417
4418 writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
4419 write!(writer, "Select token (1-{}): ", results.len())
4420 .map_err(|e| ScopeError::Io(e.to_string()))?;
4421 writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
4422
4423 let mut input = String::new();
4424 reader
4425 .read_line(&mut input)
4426 .map_err(|e| ScopeError::Io(e.to_string()))?;
4427
4428 let selection: usize = input
4429 .trim()
4430 .parse()
4431 .map_err(|_| ScopeError::Api("Invalid selection".to_string()))?;
4432
4433 if selection < 1 || selection > results.len() {
4434 return Err(ScopeError::Api(format!(
4435 "Selection must be between 1 and {}",
4436 results.len()
4437 )));
4438 }
4439
4440 let selected = &results[selection - 1];
4441 writeln!(
4442 writer,
4443 "Selected: {} ({}) at {}",
4444 selected.symbol, selected.name, selected.address
4445 )
4446 .map_err(|e| ScopeError::Io(e.to_string()))?;
4447
4448 Ok(selected)
4449}
4450
4451fn format_monitor_number(value: f64) -> String {
4453 if value >= 1_000_000_000.0 {
4454 format!("${:.2}B", value / 1_000_000_000.0)
4455 } else if value >= 1_000_000.0 {
4456 format!("${:.2}M", value / 1_000_000.0)
4457 } else if value >= 1_000.0 {
4458 format!("${:.2}K", value / 1_000.0)
4459 } else {
4460 format!("${:.2}", value)
4461 }
4462}
4463
4464#[cfg(test)]
4469mod tests {
4470 use super::*;
4471
4472 fn create_test_token_data() -> DexTokenData {
4473 DexTokenData {
4474 address: "0x1234".to_string(),
4475 symbol: "TEST".to_string(),
4476 name: "Test Token".to_string(),
4477 price_usd: 1.0,
4478 price_change_24h: 5.0,
4479 price_change_6h: 2.0,
4480 price_change_1h: 0.5,
4481 price_change_5m: 0.1,
4482 volume_24h: 1_000_000.0,
4483 volume_6h: 250_000.0,
4484 volume_1h: 50_000.0,
4485 liquidity_usd: 500_000.0,
4486 market_cap: Some(10_000_000.0),
4487 fdv: Some(100_000_000.0),
4488 pairs: vec![],
4489 price_history: vec![],
4490 volume_history: vec![],
4491 total_buys_24h: 100,
4492 total_sells_24h: 50,
4493 total_buys_6h: 25,
4494 total_sells_6h: 12,
4495 total_buys_1h: 5,
4496 total_sells_1h: 3,
4497 earliest_pair_created_at: Some(1700000000000),
4498 image_url: None,
4499 websites: vec![],
4500 socials: vec![],
4501 dexscreener_url: None,
4502 }
4503 }
4504
4505 #[test]
4506 fn test_monitor_state_new() {
4507 let token_data = create_test_token_data();
4508 let state = MonitorState::new(&token_data, "ethereum");
4509
4510 assert_eq!(state.symbol, "TEST");
4511 assert_eq!(state.chain, "ethereum");
4512 assert_eq!(state.current_price, 1.0);
4513 assert_eq!(state.buys_24h, 100);
4514 assert_eq!(state.sells_24h, 50);
4515 assert!(!state.paused);
4516 }
4517
4518 #[test]
4519 fn test_monitor_state_buy_ratio() {
4520 let token_data = create_test_token_data();
4521 let state = MonitorState::new(&token_data, "ethereum");
4522
4523 let ratio = state.buy_ratio();
4524 assert!((ratio - 0.6666).abs() < 0.01); }
4526
4527 #[test]
4528 fn test_monitor_state_buy_ratio_zero() {
4529 let mut token_data = create_test_token_data();
4530 token_data.total_buys_24h = 0;
4531 token_data.total_sells_24h = 0;
4532 let state = MonitorState::new(&token_data, "ethereum");
4533
4534 assert_eq!(state.buy_ratio(), 0.5); }
4536
4537 #[test]
4538 fn test_monitor_state_toggle_pause() {
4539 let token_data = create_test_token_data();
4540 let mut state = MonitorState::new(&token_data, "ethereum");
4541
4542 assert!(!state.paused);
4543 state.toggle_pause();
4544 assert!(state.paused);
4545 state.toggle_pause();
4546 assert!(!state.paused);
4547 }
4548
4549 #[test]
4550 fn test_monitor_state_should_refresh() {
4551 let token_data = create_test_token_data();
4552 let mut state = MonitorState::new(&token_data, "ethereum");
4553 state.refresh_rate = Duration::from_secs(60);
4554
4555 assert!(!state.should_refresh());
4557
4558 state.last_update = Instant::now() - Duration::from_secs(120);
4560 assert!(state.should_refresh());
4561
4562 state.paused = true;
4564 assert!(!state.should_refresh());
4565 }
4566
4567 #[test]
4568 fn test_format_number() {
4569 assert_eq!(format_number(500.0), "500.00");
4570 assert_eq!(format_number(1_500.0), "1.50K");
4571 assert_eq!(format_number(1_500_000.0), "1.50M");
4572 assert_eq!(format_number(1_500_000_000.0), "1.50B");
4573 }
4574
4575 #[test]
4576 fn test_format_usd() {
4577 assert_eq!(crate::display::format_usd(500.0), "$500.00");
4578 assert_eq!(crate::display::format_usd(1_500.0), "$1.50K");
4579 assert_eq!(crate::display::format_usd(1_500_000.0), "$1.50M");
4580 assert_eq!(crate::display::format_usd(1_500_000_000.0), "$1.50B");
4581 }
4582
4583 #[test]
4584 fn test_monitor_state_update() {
4585 let token_data = create_test_token_data();
4586 let mut state = MonitorState::new(&token_data, "ethereum");
4587
4588 let initial_len = state.price_history.len();
4589
4590 let mut updated_data = token_data.clone();
4591 updated_data.price_usd = 1.5;
4592 updated_data.total_buys_24h = 150;
4593
4594 state.update(&updated_data);
4595
4596 assert_eq!(state.current_price, 1.5);
4597 assert_eq!(state.buys_24h, 150);
4598 assert_eq!(state.price_history.len(), initial_len + 1);
4600 }
4601
4602 #[test]
4603 fn test_monitor_state_refresh_rate_adjustment() {
4604 let token_data = create_test_token_data();
4605 let mut state = MonitorState::new(&token_data, "ethereum");
4606
4607 assert_eq!(state.refresh_rate_secs(), 5);
4609
4610 state.slower_refresh();
4612 assert_eq!(state.refresh_rate_secs(), 10);
4613
4614 state.faster_refresh();
4616 assert_eq!(state.refresh_rate_secs(), 5);
4617
4618 state.faster_refresh();
4620 assert_eq!(state.refresh_rate_secs(), 1);
4621
4622 state.faster_refresh();
4624 assert_eq!(state.refresh_rate_secs(), 1);
4625
4626 for _ in 0..20 {
4628 state.slower_refresh();
4629 }
4630 assert_eq!(state.refresh_rate_secs(), 60);
4631 }
4632
4633 #[test]
4634 fn test_time_period() {
4635 assert_eq!(TimePeriod::Min1.label(), "1m");
4636 assert_eq!(TimePeriod::Min5.label(), "5m");
4637 assert_eq!(TimePeriod::Min15.label(), "15m");
4638 assert_eq!(TimePeriod::Hour1.label(), "1h");
4639 assert_eq!(TimePeriod::Hour4.label(), "4h");
4640 assert_eq!(TimePeriod::Day1.label(), "1d");
4641
4642 assert_eq!(TimePeriod::Min1.duration_secs(), 60);
4643 assert_eq!(TimePeriod::Min5.duration_secs(), 300);
4644 assert_eq!(TimePeriod::Min15.duration_secs(), 15 * 60);
4645 assert_eq!(TimePeriod::Hour1.duration_secs(), 3600);
4646 assert_eq!(TimePeriod::Hour4.duration_secs(), 4 * 3600);
4647 assert_eq!(TimePeriod::Day1.duration_secs(), 24 * 3600);
4648
4649 assert_eq!(TimePeriod::Min1.next(), TimePeriod::Min5);
4651 assert_eq!(TimePeriod::Min5.next(), TimePeriod::Min15);
4652 assert_eq!(TimePeriod::Min15.next(), TimePeriod::Hour1);
4653 assert_eq!(TimePeriod::Hour1.next(), TimePeriod::Hour4);
4654 assert_eq!(TimePeriod::Hour4.next(), TimePeriod::Day1);
4655 assert_eq!(TimePeriod::Day1.next(), TimePeriod::Min1);
4656 }
4657
4658 #[test]
4659 fn test_time_period_exchange_interval() {
4660 assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
4661 assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
4662 assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
4663 assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
4664 assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
4665 assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
4666 }
4667
4668 #[test]
4669 fn test_monitor_state_time_period() {
4670 let token_data = create_test_token_data();
4671 let mut state = MonitorState::new(&token_data, "ethereum");
4672
4673 assert_eq!(state.time_period, TimePeriod::Hour1);
4675
4676 state.cycle_time_period();
4678 assert_eq!(state.time_period, TimePeriod::Hour4);
4679
4680 state.set_time_period(TimePeriod::Day1);
4681 assert_eq!(state.time_period, TimePeriod::Day1);
4682 }
4683
4684 #[test]
4685 fn test_synthetic_history_generation() {
4686 let token_data = create_test_token_data();
4687 let state = MonitorState::new(&token_data, "ethereum");
4688
4689 assert!(state.price_history.len() > 1);
4691 assert!(state.volume_history.len() > 1);
4692
4693 if let (Some(first), Some(last)) = (state.price_history.front(), state.price_history.back())
4695 {
4696 let span = last.timestamp - first.timestamp;
4697 assert!(span > 0.0); }
4699 }
4700
4701 #[test]
4702 fn test_real_data_marking() {
4703 let token_data = create_test_token_data();
4704 let mut state = MonitorState::new(&token_data, "ethereum");
4705
4706 let (synthetic, real) = state.data_stats();
4708 assert!(synthetic > 0);
4709 assert_eq!(real, 0);
4710
4711 let mut updated_data = token_data.clone();
4713 updated_data.price_usd = 1.5;
4714 state.update(&updated_data);
4715
4716 let (synthetic2, real2) = state.data_stats();
4717 assert!(synthetic2 > 0);
4718 assert_eq!(real2, 1);
4719 assert_eq!(state.real_data_count, 1);
4720
4721 assert!(
4723 state
4724 .price_history
4725 .back()
4726 .map(|p| p.is_real)
4727 .unwrap_or(false)
4728 );
4729 }
4730
4731 #[test]
4732 fn test_memory_usage() {
4733 let token_data = create_test_token_data();
4734 let state = MonitorState::new(&token_data, "ethereum");
4735
4736 let mem = state.memory_usage();
4737 assert!(mem > 0);
4739
4740 let expected_point_size = std::mem::size_of::<DataPoint>();
4742 assert_eq!(expected_point_size, 24);
4743 }
4744
4745 #[test]
4746 fn test_get_data_for_period_returns_flags() {
4747 let token_data = create_test_token_data();
4748 let mut state = MonitorState::new(&token_data, "ethereum");
4749
4750 let (data, is_real) = state.get_price_data_for_period();
4752 assert_eq!(data.len(), is_real.len());
4753
4754 state.update(&token_data);
4756
4757 let (_data2, is_real2) = state.get_price_data_for_period();
4758 assert!(is_real2.iter().any(|r| *r));
4760 }
4761
4762 #[test]
4763 fn test_cache_path_generation() {
4764 let path =
4765 MonitorState::cache_path("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "ethereum");
4766 assert!(path.to_string_lossy().contains("bcc_monitor_"));
4767 assert!(path.to_string_lossy().contains("ethereum"));
4768 let temp_dir = std::env::temp_dir();
4770 assert!(path.starts_with(temp_dir));
4771 }
4772
4773 #[test]
4774 fn test_cache_save_and_load() {
4775 let token_data = create_test_token_data();
4776 let mut state = MonitorState::new(&token_data, "test_chain");
4777
4778 state.update(&token_data);
4780 state.update(&token_data);
4781
4782 state.save_cache();
4784
4785 let path = MonitorState::cache_path(&state.token_address, &state.chain);
4787 assert!(path.exists(), "Cache file should exist after save");
4788
4789 let loaded = MonitorState::load_cache(&state.token_address, &state.chain);
4791 assert!(loaded.is_some(), "Should be able to load saved cache");
4792
4793 let cached = loaded.unwrap();
4794 assert_eq!(cached.token_address, state.token_address);
4795 assert_eq!(cached.chain, state.chain);
4796 assert!(!cached.price_history.is_empty());
4797
4798 let _ = std::fs::remove_file(path);
4800 }
4801
4802 #[test]
4807 fn test_format_price_usd_high() {
4808 let formatted = format_price_usd(2500.50);
4809 assert!(formatted.starts_with("$2500.50"));
4810 }
4811
4812 #[test]
4813 fn test_format_price_usd_stablecoin() {
4814 let formatted = format_price_usd(1.0001);
4815 assert!(formatted.contains("1.000100"));
4816 assert!(is_stablecoin_price(1.0001));
4817 }
4818
4819 #[test]
4820 fn test_format_price_usd_medium() {
4821 let formatted = format_price_usd(5.1234);
4822 assert!(formatted.starts_with("$5.1234"));
4823 }
4824
4825 #[test]
4826 fn test_format_price_usd_small() {
4827 let formatted = format_price_usd(0.05);
4828 assert!(formatted.starts_with("$0.0500"));
4829 }
4830
4831 #[test]
4832 fn test_format_price_usd_micro() {
4833 let formatted = format_price_usd(0.001);
4834 assert!(formatted.starts_with("$0.0010"));
4835 }
4836
4837 #[test]
4838 fn test_format_price_usd_nano() {
4839 let formatted = format_price_usd(0.00001);
4840 assert!(formatted.contains("0.0000100"));
4841 }
4842
4843 #[test]
4844 fn test_is_stablecoin_price() {
4845 assert!(is_stablecoin_price(1.0));
4846 assert!(is_stablecoin_price(0.999));
4847 assert!(is_stablecoin_price(1.001));
4848 assert!(is_stablecoin_price(0.95));
4849 assert!(is_stablecoin_price(1.05));
4850 assert!(!is_stablecoin_price(0.94));
4851 assert!(!is_stablecoin_price(1.06));
4852 assert!(!is_stablecoin_price(100.0));
4853 }
4854
4855 #[test]
4860 fn test_ohlc_candle_new() {
4861 let candle = OhlcCandle::new(1000.0, 50.0);
4862 assert_eq!(candle.open, 50.0);
4863 assert_eq!(candle.high, 50.0);
4864 assert_eq!(candle.low, 50.0);
4865 assert_eq!(candle.close, 50.0);
4866 assert!(candle.is_bullish);
4867 }
4868
4869 #[test]
4870 fn test_ohlc_candle_update() {
4871 let mut candle = OhlcCandle::new(1000.0, 50.0);
4872 candle.update(55.0);
4873 assert_eq!(candle.high, 55.0);
4874 assert_eq!(candle.close, 55.0);
4875 assert!(candle.is_bullish);
4876
4877 candle.update(45.0);
4878 assert_eq!(candle.low, 45.0);
4879 assert_eq!(candle.close, 45.0);
4880 assert!(!candle.is_bullish); }
4882
4883 #[test]
4884 fn test_get_ohlc_candles() {
4885 let token_data = create_test_token_data();
4886 let mut state = MonitorState::new(&token_data, "ethereum");
4887 for i in 0..20 {
4889 let mut data = token_data.clone();
4890 data.price_usd = 1.0 + (i as f64 * 0.01);
4891 state.update(&data);
4892 }
4893 let candles = state.get_ohlc_candles();
4894 assert!(!candles.is_empty());
4896 }
4897
4898 #[test]
4899 fn test_get_ohlc_candles_returns_exchange_ohlc_when_populated() {
4900 let token_data = create_test_token_data();
4901 let mut state = MonitorState::new(&token_data, "ethereum");
4902 let exchange_candles = vec![
4904 OhlcCandle::new(1700000000.0, 100.0),
4905 OhlcCandle::new(1700003600.0, 101.0),
4906 ];
4907 state.exchange_ohlc = exchange_candles.clone();
4908 let candles = state.get_ohlc_candles();
4909 assert_eq!(candles.len(), 2);
4910 assert_eq!(candles[0].timestamp, 1700000000.0);
4911 assert_eq!(candles[0].open, 100.0);
4912 assert_eq!(candles[1].timestamp, 1700003600.0);
4913 assert_eq!(candles[1].open, 101.0);
4914 }
4915
4916 #[test]
4921 fn test_chart_mode_cycle() {
4922 let mode = ChartMode::Line;
4923 assert_eq!(mode.next(), ChartMode::Candlestick);
4924 assert_eq!(ChartMode::Candlestick.next(), ChartMode::VolumeProfile);
4925 assert_eq!(ChartMode::VolumeProfile.next(), ChartMode::Line);
4926 }
4927
4928 #[test]
4929 fn test_chart_mode_label() {
4930 assert_eq!(ChartMode::Line.label(), "Line");
4931 assert_eq!(ChartMode::Candlestick.label(), "Candle");
4932 assert_eq!(ChartMode::VolumeProfile.label(), "VolPro");
4933 }
4934
4935 use ratatui::Terminal;
4940 use ratatui::backend::TestBackend;
4941
4942 fn create_test_terminal() -> Terminal<TestBackend> {
4943 let backend = TestBackend::new(120, 40);
4944 Terminal::new(backend).unwrap()
4945 }
4946
4947 fn create_populated_state() -> MonitorState {
4948 let token_data = create_test_token_data();
4949 let mut state = MonitorState::new(&token_data, "ethereum");
4950 for i in 0..30 {
4952 let mut data = token_data.clone();
4953 data.price_usd = 1.0 + (i as f64 * 0.01);
4954 data.volume_24h = 1_000_000.0 + (i as f64 * 10_000.0);
4955 state.update(&data);
4956 }
4957 state
4958 }
4959
4960 #[test]
4961 fn test_render_header_no_panic() {
4962 let mut terminal = create_test_terminal();
4963 let state = create_populated_state();
4964 terminal
4965 .draw(|f| render_header(f, f.area(), &state))
4966 .unwrap();
4967 }
4968
4969 #[test]
4970 fn test_render_price_chart_no_panic() {
4971 let mut terminal = create_test_terminal();
4972 let state = create_populated_state();
4973 terminal
4974 .draw(|f| render_price_chart(f, f.area(), &state))
4975 .unwrap();
4976 }
4977
4978 #[test]
4979 fn test_render_price_chart_line_mode() {
4980 let mut terminal = create_test_terminal();
4981 let mut state = create_populated_state();
4982 state.chart_mode = ChartMode::Line;
4983 terminal
4984 .draw(|f| render_price_chart(f, f.area(), &state))
4985 .unwrap();
4986 }
4987
4988 #[test]
4989 fn test_render_candlestick_chart_no_panic() {
4990 let mut terminal = create_test_terminal();
4991 let state = create_populated_state();
4992 terminal
4993 .draw(|f| render_candlestick_chart(f, f.area(), &state))
4994 .unwrap();
4995 }
4996
4997 #[test]
4998 fn test_render_candlestick_chart_empty() {
4999 let mut terminal = create_test_terminal();
5000 let token_data = create_test_token_data();
5001 let state = MonitorState::new(&token_data, "ethereum");
5002 terminal
5003 .draw(|f| render_candlestick_chart(f, f.area(), &state))
5004 .unwrap();
5005 }
5006
5007 #[test]
5008 fn test_render_volume_chart_no_panic() {
5009 let mut terminal = create_test_terminal();
5010 let state = create_populated_state();
5011 terminal
5012 .draw(|f| render_volume_chart(f, f.area(), &state))
5013 .unwrap();
5014 }
5015
5016 #[test]
5017 fn test_render_volume_chart_empty() {
5018 let mut terminal = create_test_terminal();
5019 let token_data = create_test_token_data();
5020 let state = MonitorState::new(&token_data, "ethereum");
5021 terminal
5022 .draw(|f| render_volume_chart(f, f.area(), &state))
5023 .unwrap();
5024 }
5025
5026 #[test]
5027 fn test_render_buy_sell_gauge_no_panic() {
5028 let mut terminal = create_test_terminal();
5029 let mut state = create_populated_state();
5030 terminal
5031 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5032 .unwrap();
5033 }
5034
5035 #[test]
5036 fn test_render_buy_sell_gauge_balanced() {
5037 let mut terminal = create_test_terminal();
5038 let mut token_data = create_test_token_data();
5039 token_data.total_buys_24h = 100;
5040 token_data.total_sells_24h = 100;
5041 let mut state = MonitorState::new(&token_data, "ethereum");
5042 terminal
5043 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5044 .unwrap();
5045 }
5046
5047 #[test]
5048 fn test_render_metrics_panel_no_panic() {
5049 let mut terminal = create_test_terminal();
5050 let state = create_populated_state();
5051 terminal
5052 .draw(|f| render_metrics_panel(f, f.area(), &state))
5053 .unwrap();
5054 }
5055
5056 #[test]
5057 fn test_render_metrics_panel_no_market_cap() {
5058 let mut terminal = create_test_terminal();
5059 let mut token_data = create_test_token_data();
5060 token_data.market_cap = None;
5061 token_data.fdv = None;
5062 let state = MonitorState::new(&token_data, "ethereum");
5063 terminal
5064 .draw(|f| render_metrics_panel(f, f.area(), &state))
5065 .unwrap();
5066 }
5067
5068 #[test]
5069 fn test_render_footer_no_panic() {
5070 let mut terminal = create_test_terminal();
5071 let state = create_populated_state();
5072 terminal
5073 .draw(|f| render_footer(f, f.area(), &state))
5074 .unwrap();
5075 }
5076
5077 #[test]
5078 fn test_render_footer_paused() {
5079 let mut terminal = create_test_terminal();
5080 let token_data = create_test_token_data();
5081 let mut state = MonitorState::new(&token_data, "ethereum");
5082 state.paused = true;
5083 terminal
5084 .draw(|f| render_footer(f, f.area(), &state))
5085 .unwrap();
5086 }
5087
5088 #[test]
5089 fn test_render_all_components() {
5090 let mut terminal = create_test_terminal();
5092 let mut state = create_populated_state();
5093 terminal
5094 .draw(|f| {
5095 let area = f.area();
5096 let chunks = Layout::default()
5097 .direction(Direction::Vertical)
5098 .constraints([
5099 Constraint::Length(3),
5100 Constraint::Min(10),
5101 Constraint::Length(5),
5102 Constraint::Length(3),
5103 Constraint::Length(3),
5104 ])
5105 .split(area);
5106 render_header(f, chunks[0], &state);
5107 render_price_chart(f, chunks[1], &state);
5108 render_volume_chart(f, chunks[2], &state);
5109 render_buy_sell_gauge(f, chunks[3], &mut state);
5110 render_footer(f, chunks[4], &state);
5111 })
5112 .unwrap();
5113 }
5114
5115 #[test]
5116 fn test_render_candlestick_mode() {
5117 let mut terminal = create_test_terminal();
5118 let mut state = create_populated_state();
5119 state.chart_mode = ChartMode::Candlestick;
5120 terminal
5121 .draw(|f| {
5122 let area = f.area();
5123 let chunks = Layout::default()
5124 .direction(Direction::Vertical)
5125 .constraints([Constraint::Length(3), Constraint::Min(10)])
5126 .split(area);
5127 render_header(f, chunks[0], &state);
5128 render_candlestick_chart(f, chunks[1], &state);
5129 })
5130 .unwrap();
5131 }
5132
5133 #[test]
5134 fn test_render_with_different_time_periods() {
5135 let mut terminal = create_test_terminal();
5136 let mut state = create_populated_state();
5137
5138 for period in [
5139 TimePeriod::Min1,
5140 TimePeriod::Min5,
5141 TimePeriod::Min15,
5142 TimePeriod::Hour1,
5143 TimePeriod::Hour4,
5144 TimePeriod::Day1,
5145 ] {
5146 state.time_period = period;
5147 terminal
5148 .draw(|f| render_price_chart(f, f.area(), &state))
5149 .unwrap();
5150 }
5151 }
5152
5153 #[test]
5154 fn test_render_metrics_with_stablecoin() {
5155 let mut terminal = create_test_terminal();
5156 let mut token_data = create_test_token_data();
5157 token_data.price_usd = 0.999;
5158 token_data.symbol = "USDC".to_string();
5159 let state = MonitorState::new(&token_data, "ethereum");
5160 terminal
5161 .draw(|f| render_metrics_panel(f, f.area(), &state))
5162 .unwrap();
5163 }
5164
5165 #[test]
5166 fn test_render_header_with_negative_change() {
5167 let mut terminal = create_test_terminal();
5168 let mut token_data = create_test_token_data();
5169 token_data.price_change_24h = -15.5;
5170 token_data.price_change_1h = -2.3;
5171 let state = MonitorState::new(&token_data, "ethereum");
5172 terminal
5173 .draw(|f| render_header(f, f.area(), &state))
5174 .unwrap();
5175 }
5176
5177 #[test]
5182 fn test_toggle_chart_mode_roundtrip() {
5183 let token_data = create_test_token_data();
5184 let mut state = MonitorState::new(&token_data, "ethereum");
5185 assert_eq!(state.chart_mode, ChartMode::Line);
5186 state.toggle_chart_mode();
5187 assert_eq!(state.chart_mode, ChartMode::Candlestick);
5188 state.toggle_chart_mode();
5189 assert_eq!(state.chart_mode, ChartMode::VolumeProfile);
5190 state.toggle_chart_mode();
5191 assert_eq!(state.chart_mode, ChartMode::Line);
5192 }
5193
5194 #[test]
5195 fn test_cycle_all_time_periods() {
5196 let token_data = create_test_token_data();
5197 let mut state = MonitorState::new(&token_data, "ethereum");
5198 assert_eq!(state.time_period, TimePeriod::Hour1);
5199 state.cycle_time_period();
5200 assert_eq!(state.time_period, TimePeriod::Hour4);
5201 state.cycle_time_period();
5202 assert_eq!(state.time_period, TimePeriod::Day1);
5203 state.cycle_time_period();
5204 assert_eq!(state.time_period, TimePeriod::Min1);
5205 state.cycle_time_period();
5206 assert_eq!(state.time_period, TimePeriod::Min5);
5207 state.cycle_time_period();
5208 assert_eq!(state.time_period, TimePeriod::Min15);
5209 state.cycle_time_period();
5210 assert_eq!(state.time_period, TimePeriod::Hour1);
5211 }
5212
5213 #[test]
5214 fn test_set_specific_time_period() {
5215 let token_data = create_test_token_data();
5216 let mut state = MonitorState::new(&token_data, "ethereum");
5217 state.set_time_period(TimePeriod::Day1);
5218 assert_eq!(state.time_period, TimePeriod::Day1);
5219 }
5220
5221 #[test]
5222 fn test_pause_resume_roundtrip() {
5223 let token_data = create_test_token_data();
5224 let mut state = MonitorState::new(&token_data, "ethereum");
5225 assert!(!state.paused);
5226 state.toggle_pause();
5227 assert!(state.paused);
5228 state.toggle_pause();
5229 assert!(!state.paused);
5230 }
5231
5232 #[test]
5233 fn test_force_refresh_unpauses() {
5234 let token_data = create_test_token_data();
5235 let mut state = MonitorState::new(&token_data, "ethereum");
5236 state.paused = true;
5237 state.force_refresh();
5238 assert!(!state.paused);
5239 assert!(state.should_refresh());
5240 }
5241
5242 #[test]
5243 fn test_refresh_rate_adjust() {
5244 let token_data = create_test_token_data();
5245 let mut state = MonitorState::new(&token_data, "ethereum");
5246 assert_eq!(state.refresh_rate_secs(), 5);
5247
5248 state.slower_refresh();
5249 assert_eq!(state.refresh_rate_secs(), 10);
5250
5251 state.faster_refresh();
5252 assert_eq!(state.refresh_rate_secs(), 5);
5253 }
5254
5255 #[test]
5256 fn test_faster_refresh_clamped_min() {
5257 let token_data = create_test_token_data();
5258 let mut state = MonitorState::new(&token_data, "ethereum");
5259 for _ in 0..10 {
5260 state.faster_refresh();
5261 }
5262 assert!(state.refresh_rate_secs() >= 1);
5263 }
5264
5265 #[test]
5266 fn test_slower_refresh_clamped_max() {
5267 let token_data = create_test_token_data();
5268 let mut state = MonitorState::new(&token_data, "ethereum");
5269 for _ in 0..20 {
5270 state.slower_refresh();
5271 }
5272 assert!(state.refresh_rate_secs() <= 60);
5273 }
5274
5275 #[test]
5276 fn test_buy_ratio_balanced() {
5277 let mut token_data = create_test_token_data();
5278 token_data.total_buys_24h = 100;
5279 token_data.total_sells_24h = 100;
5280 let state = MonitorState::new(&token_data, "ethereum");
5281 assert!((state.buy_ratio() - 0.5).abs() < 0.01);
5282 }
5283
5284 #[test]
5285 fn test_buy_ratio_no_trades() {
5286 let mut token_data = create_test_token_data();
5287 token_data.total_buys_24h = 0;
5288 token_data.total_sells_24h = 0;
5289 let state = MonitorState::new(&token_data, "ethereum");
5290 assert!((state.buy_ratio() - 0.5).abs() < 0.01);
5291 }
5292
5293 #[test]
5294 fn test_data_stats_initial() {
5295 let token_data = create_test_token_data();
5296 let state = MonitorState::new(&token_data, "ethereum");
5297 let (synthetic, real) = state.data_stats();
5298 assert!(synthetic > 0 || real == 0);
5299 }
5300
5301 #[test]
5302 fn test_memory_usage_nonzero() {
5303 let token_data = create_test_token_data();
5304 let state = MonitorState::new(&token_data, "ethereum");
5305 let usage = state.memory_usage();
5306 assert!(usage > 0);
5307 }
5308
5309 #[test]
5310 fn test_price_data_for_period() {
5311 let token_data = create_test_token_data();
5312 let state = MonitorState::new(&token_data, "ethereum");
5313 let (data, is_real) = state.get_price_data_for_period();
5314 assert_eq!(data.len(), is_real.len());
5315 }
5316
5317 #[test]
5318 fn test_volume_data_for_period() {
5319 let token_data = create_test_token_data();
5320 let state = MonitorState::new(&token_data, "ethereum");
5321 let (data, is_real) = state.get_volume_data_for_period();
5322 assert_eq!(data.len(), is_real.len());
5323 }
5324
5325 #[test]
5326 fn test_ohlc_candles_generation() {
5327 let token_data = create_test_token_data();
5328 let state = MonitorState::new(&token_data, "ethereum");
5329 let candles = state.get_ohlc_candles();
5330 for candle in &candles {
5331 assert!(candle.high >= candle.low);
5332 }
5333 }
5334
5335 #[test]
5336 fn test_state_update_with_new_data() {
5337 let token_data = create_test_token_data();
5338 let mut state = MonitorState::new(&token_data, "ethereum");
5339 let initial_count = state.real_data_count;
5340
5341 let mut updated_data = create_test_token_data();
5342 updated_data.price_usd = 2.0;
5343 updated_data.volume_24h = 2_000_000.0;
5344
5345 state.update(&updated_data);
5346 assert_eq!(state.current_price, 2.0);
5347 assert_eq!(state.real_data_count, initial_count + 1);
5348 assert!(state.error_message.is_none());
5349 }
5350
5351 #[test]
5352 fn test_cache_roundtrip_save_load() {
5353 let token_data = create_test_token_data();
5354 let state = MonitorState::new(&token_data, "ethereum");
5355
5356 state.save_cache();
5357
5358 let cache_path = MonitorState::cache_path(&token_data.address, "ethereum");
5359 assert!(cache_path.exists());
5360
5361 let cached = MonitorState::load_cache(&token_data.address, "ethereum");
5362 assert!(cached.is_some());
5363
5364 let _ = std::fs::remove_file(cache_path);
5365 }
5366
5367 #[test]
5368 fn test_should_refresh_when_paused() {
5369 let token_data = create_test_token_data();
5370 let mut state = MonitorState::new(&token_data, "ethereum");
5371 assert!(!state.should_refresh());
5372 state.paused = true;
5373 assert!(!state.should_refresh());
5374 }
5375
5376 #[test]
5377 fn test_ohlc_candle_lifecycle() {
5378 let mut candle = OhlcCandle::new(1700000000.0, 100.0);
5379 assert_eq!(candle.open, 100.0);
5380 assert!(candle.is_bullish);
5381 candle.update(110.0);
5382 assert_eq!(candle.high, 110.0);
5383 assert!(candle.is_bullish);
5384 candle.update(90.0);
5385 assert_eq!(candle.low, 90.0);
5386 assert!(!candle.is_bullish);
5387 }
5388
5389 #[test]
5390 fn test_time_period_display_impl() {
5391 assert_eq!(format!("{}", TimePeriod::Min1), "1m");
5392 assert_eq!(format!("{}", TimePeriod::Min15), "15m");
5393 assert_eq!(format!("{}", TimePeriod::Day1), "1d");
5394 }
5395
5396 #[test]
5397 fn test_log_messages_accumulate() {
5398 let token_data = create_test_token_data();
5399 let mut state = MonitorState::new(&token_data, "ethereum");
5400 state.toggle_pause();
5402 state.toggle_pause();
5403 state.cycle_time_period();
5404 state.toggle_chart_mode();
5405 assert!(!state.log_messages.is_empty());
5406 }
5407
5408 #[test]
5409 fn test_ui_function_full_render() {
5410 let mut terminal = create_test_terminal();
5412 let mut state = create_populated_state();
5413 terminal.draw(|f| ui(f, &mut state)).unwrap();
5414 }
5415
5416 #[test]
5417 fn test_ui_function_candlestick_mode() {
5418 let mut terminal = create_test_terminal();
5419 let mut state = create_populated_state();
5420 state.chart_mode = ChartMode::Candlestick;
5421 terminal.draw(|f| ui(f, &mut state)).unwrap();
5422 }
5423
5424 #[test]
5425 fn test_ui_function_with_error_message() {
5426 let mut terminal = create_test_terminal();
5427 let mut state = create_populated_state();
5428 state.error_message = Some("Test error".to_string());
5429 terminal.draw(|f| ui(f, &mut state)).unwrap();
5430 }
5431
5432 #[test]
5433 fn test_render_header_with_small_positive_change() {
5434 let mut terminal = create_test_terminal();
5435 let mut state = create_populated_state();
5436 state.price_change_24h = 0.3; terminal
5438 .draw(|f| render_header(f, f.area(), &state))
5439 .unwrap();
5440 }
5441
5442 #[test]
5443 fn test_render_header_with_small_negative_change() {
5444 let mut terminal = create_test_terminal();
5445 let mut state = create_populated_state();
5446 state.price_change_24h = -0.3; terminal
5448 .draw(|f| render_header(f, f.area(), &state))
5449 .unwrap();
5450 }
5451
5452 #[test]
5453 fn test_render_buy_sell_gauge_high_buy_ratio() {
5454 let mut terminal = create_test_terminal();
5455 let token_data = create_test_token_data();
5456 let mut state = MonitorState::new(&token_data, "ethereum");
5457 state.buys_24h = 100;
5458 state.sells_24h = 10;
5459 terminal
5460 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5461 .unwrap();
5462 }
5463
5464 #[test]
5465 fn test_render_buy_sell_gauge_zero_total() {
5466 let mut terminal = create_test_terminal();
5467 let token_data = create_test_token_data();
5468 let mut state = MonitorState::new(&token_data, "ethereum");
5469 state.buys_24h = 0;
5470 state.sells_24h = 0;
5471 terminal
5472 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5473 .unwrap();
5474 }
5475
5476 #[test]
5477 fn test_render_metrics_with_market_cap() {
5478 let mut terminal = create_test_terminal();
5479 let token_data = create_test_token_data();
5480 let mut state = MonitorState::new(&token_data, "ethereum");
5481 state.market_cap = Some(1_000_000_000.0);
5482 state.fdv = Some(2_000_000_000.0);
5483 terminal
5484 .draw(|f| render_metrics_panel(f, f.area(), &state))
5485 .unwrap();
5486 }
5487
5488 #[test]
5489 fn test_render_footer_with_error() {
5490 let mut terminal = create_test_terminal();
5491 let mut state = create_populated_state();
5492 state.error_message = Some("Connection failed".to_string());
5493 terminal
5494 .draw(|f| render_footer(f, f.area(), &state))
5495 .unwrap();
5496 }
5497
5498 #[test]
5499 fn test_format_price_usd_various() {
5500 assert!(!format_price_usd(0.0000001).is_empty());
5502 assert!(!format_price_usd(0.001).is_empty());
5503 assert!(!format_price_usd(1.0).is_empty());
5504 assert!(!format_price_usd(100.0).is_empty());
5505 assert!(!format_price_usd(10000.0).is_empty());
5506 assert!(!format_price_usd(1000000.0).is_empty());
5507 }
5508
5509 #[test]
5510 fn test_format_usd_various() {
5511 assert!(!crate::display::format_usd(0.0).is_empty());
5512 assert!(!crate::display::format_usd(999.0).is_empty());
5513 assert!(!crate::display::format_usd(1500.0).is_empty());
5514 assert!(!crate::display::format_usd(1_500_000.0).is_empty());
5515 assert!(!crate::display::format_usd(1_500_000_000.0).is_empty());
5516 assert!(!crate::display::format_usd(1_500_000_000_000.0).is_empty());
5517 }
5518
5519 #[test]
5520 fn test_format_number_various() {
5521 assert!(!format_number(0.0).is_empty());
5522 assert!(!format_number(999.0).is_empty());
5523 assert!(!format_number(1500.0).is_empty());
5524 assert!(!format_number(1_500_000.0).is_empty());
5525 assert!(!format_number(1_500_000_000.0).is_empty());
5526 }
5527
5528 #[test]
5529 fn test_render_with_min15_period() {
5530 let mut terminal = create_test_terminal();
5531 let mut state = create_populated_state();
5532 state.set_time_period(TimePeriod::Min15);
5533 terminal.draw(|f| ui(f, &mut state)).unwrap();
5534 }
5535
5536 #[test]
5537 fn test_render_with_hour6_period() {
5538 let mut terminal = create_test_terminal();
5539 let mut state = create_populated_state();
5540 state.set_time_period(TimePeriod::Hour4);
5541 terminal.draw(|f| ui(f, &mut state)).unwrap();
5542 }
5543
5544 #[test]
5545 fn test_ui_with_fresh_state_no_real_data() {
5546 let mut terminal = create_test_terminal();
5547 let token_data = create_test_token_data();
5548 let mut state = MonitorState::new(&token_data, "ethereum");
5549 terminal.draw(|f| ui(f, &mut state)).unwrap();
5551 }
5552
5553 #[test]
5554 fn test_ui_with_paused_state() {
5555 let mut terminal = create_test_terminal();
5556 let mut state = create_populated_state();
5557 state.toggle_pause();
5558 terminal.draw(|f| ui(f, &mut state)).unwrap();
5559 }
5560
5561 #[test]
5562 fn test_render_all_with_different_time_periods_and_modes() {
5563 let mut terminal = create_test_terminal();
5564 let mut state = create_populated_state();
5565
5566 for period in &[
5568 TimePeriod::Min1,
5569 TimePeriod::Min5,
5570 TimePeriod::Min15,
5571 TimePeriod::Hour1,
5572 TimePeriod::Hour4,
5573 TimePeriod::Day1,
5574 ] {
5575 for mode in &[
5576 ChartMode::Line,
5577 ChartMode::Candlestick,
5578 ChartMode::VolumeProfile,
5579 ] {
5580 state.set_time_period(*period);
5581 state.chart_mode = *mode;
5582 terminal.draw(|f| ui(f, &mut state)).unwrap();
5583 }
5584 }
5585 }
5586
5587 #[test]
5588 fn test_render_metrics_with_large_values() {
5589 let mut terminal = create_test_terminal();
5590 let mut state = create_populated_state();
5591 state.market_cap = Some(50_000_000_000.0); state.fdv = Some(100_000_000_000.0); state.volume_24h = 5_000_000_000.0; state.liquidity_usd = 500_000_000.0; terminal
5596 .draw(|f| render_metrics_panel(f, f.area(), &state))
5597 .unwrap();
5598 }
5599
5600 #[test]
5601 fn test_render_header_large_positive_change() {
5602 let mut terminal = create_test_terminal();
5603 let mut state = create_populated_state();
5604 state.price_change_24h = 50.0; terminal
5606 .draw(|f| render_header(f, f.area(), &state))
5607 .unwrap();
5608 }
5609
5610 #[test]
5611 fn test_render_header_large_negative_change() {
5612 let mut terminal = create_test_terminal();
5613 let mut state = create_populated_state();
5614 state.price_change_24h = -50.0; terminal
5616 .draw(|f| render_header(f, f.area(), &state))
5617 .unwrap();
5618 }
5619
5620 #[test]
5621 fn test_render_price_chart_empty_data() {
5622 let mut terminal = create_test_terminal();
5623 let token_data = create_test_token_data();
5624 let mut state = MonitorState::new(&token_data, "ethereum");
5626 state.price_history.clear();
5627 terminal
5628 .draw(|f| render_price_chart(f, f.area(), &state))
5629 .unwrap();
5630 }
5631
5632 #[test]
5633 fn test_render_price_chart_price_down() {
5634 let mut terminal = create_test_terminal();
5635 let mut state = create_populated_state();
5636 state.price_change_24h = -15.0;
5638 state.current_price = 0.5; terminal
5640 .draw(|f| render_price_chart(f, f.area(), &state))
5641 .unwrap();
5642 }
5643
5644 #[test]
5645 fn test_render_price_chart_zero_first_price() {
5646 let mut terminal = create_test_terminal();
5647 let mut token_data = create_test_token_data();
5648 token_data.price_usd = 0.0;
5649 let state = MonitorState::new(&token_data, "ethereum");
5650 terminal
5651 .draw(|f| render_price_chart(f, f.area(), &state))
5652 .unwrap();
5653 }
5654
5655 #[test]
5656 fn test_render_metrics_panel_zero_5m_change() {
5657 let mut terminal = create_test_terminal();
5658 let mut state = create_populated_state();
5659 state.price_change_5m = 0.0; terminal
5661 .draw(|f| render_metrics_panel(f, f.area(), &state))
5662 .unwrap();
5663 }
5664
5665 #[test]
5666 fn test_render_metrics_panel_positive_5m_change() {
5667 let mut terminal = create_test_terminal();
5668 let mut state = create_populated_state();
5669 state.price_change_5m = 5.0; terminal
5671 .draw(|f| render_metrics_panel(f, f.area(), &state))
5672 .unwrap();
5673 }
5674
5675 #[test]
5676 fn test_render_metrics_panel_negative_5m_change() {
5677 let mut terminal = create_test_terminal();
5678 let mut state = create_populated_state();
5679 state.price_change_5m = -3.0; terminal
5681 .draw(|f| render_metrics_panel(f, f.area(), &state))
5682 .unwrap();
5683 }
5684
5685 #[test]
5686 fn test_render_metrics_panel_negative_24h_change() {
5687 let mut terminal = create_test_terminal();
5688 let mut state = create_populated_state();
5689 state.price_change_24h = -10.0;
5690 terminal
5691 .draw(|f| render_metrics_panel(f, f.area(), &state))
5692 .unwrap();
5693 }
5694
5695 #[test]
5696 fn test_render_metrics_panel_old_last_change() {
5697 let mut terminal = create_test_terminal();
5698 let mut state = create_populated_state();
5699 state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 7200.0; terminal
5702 .draw(|f| render_metrics_panel(f, f.area(), &state))
5703 .unwrap();
5704 }
5705
5706 #[test]
5707 fn test_render_metrics_panel_minutes_ago_change() {
5708 let mut terminal = create_test_terminal();
5709 let mut state = create_populated_state();
5710 state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 300.0; terminal
5713 .draw(|f| render_metrics_panel(f, f.area(), &state))
5714 .unwrap();
5715 }
5716
5717 #[test]
5718 fn test_render_candlestick_empty_fresh_state() {
5719 let mut terminal = create_test_terminal();
5720 let token_data = create_test_token_data();
5721 let mut state = MonitorState::new(&token_data, "ethereum");
5722 state.price_history.clear();
5723 state.chart_mode = ChartMode::Candlestick;
5724 terminal
5725 .draw(|f| render_candlestick_chart(f, f.area(), &state))
5726 .unwrap();
5727 }
5728
5729 #[test]
5730 fn test_render_candlestick_price_down() {
5731 let mut terminal = create_test_terminal();
5732 let token_data = create_test_token_data();
5733 let mut state = MonitorState::new(&token_data, "ethereum");
5734 for i in 0..20 {
5736 let mut data = token_data.clone();
5737 data.price_usd = 2.0 - (i as f64 * 0.05);
5738 state.update(&data);
5739 }
5740 state.chart_mode = ChartMode::Candlestick;
5741 terminal
5742 .draw(|f| render_candlestick_chart(f, f.area(), &state))
5743 .unwrap();
5744 }
5745
5746 #[test]
5747 fn test_render_volume_chart_with_many_points() {
5748 let mut terminal = create_test_terminal();
5749 let token_data = create_test_token_data();
5750 let mut state = MonitorState::new(&token_data, "ethereum");
5751 for i in 0..100 {
5753 let mut data = token_data.clone();
5754 data.volume_24h = 1_000_000.0 + (i as f64 * 50_000.0);
5755 data.price_usd = 1.0 + (i as f64 * 0.001);
5756 state.update(&data);
5757 }
5758 terminal
5759 .draw(|f| render_volume_chart(f, f.area(), &state))
5760 .unwrap();
5761 }
5762
5763 fn make_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
5768 crossterm::event::KeyEvent::new(code, KeyModifiers::NONE)
5769 }
5770
5771 fn make_ctrl_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
5772 crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)
5773 }
5774
5775 #[test]
5776 fn test_handle_key_quit_q() {
5777 let token_data = create_test_token_data();
5778 let mut state = MonitorState::new(&token_data, "ethereum");
5779 assert!(handle_key_event_on_state(
5780 make_key_event(KeyCode::Char('q')),
5781 &mut state
5782 ));
5783 }
5784
5785 #[test]
5786 fn test_handle_key_quit_esc() {
5787 let token_data = create_test_token_data();
5788 let mut state = MonitorState::new(&token_data, "ethereum");
5789 assert!(handle_key_event_on_state(
5790 make_key_event(KeyCode::Esc),
5791 &mut state
5792 ));
5793 }
5794
5795 #[test]
5796 fn test_handle_key_quit_ctrl_c() {
5797 let token_data = create_test_token_data();
5798 let mut state = MonitorState::new(&token_data, "ethereum");
5799 assert!(handle_key_event_on_state(
5800 make_ctrl_key_event(KeyCode::Char('c')),
5801 &mut state
5802 ));
5803 }
5804
5805 #[test]
5806 fn test_handle_key_refresh() {
5807 let token_data = create_test_token_data();
5808 let mut state = MonitorState::new(&token_data, "ethereum");
5809 state.refresh_rate = Duration::from_secs(60);
5810 let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('r')), &mut state);
5812 assert!(!exit);
5813 assert!(state.should_refresh());
5815 }
5816
5817 #[test]
5818 fn test_handle_key_pause_toggle() {
5819 let token_data = create_test_token_data();
5820 let mut state = MonitorState::new(&token_data, "ethereum");
5821 assert!(!state.paused);
5822
5823 handle_key_event_on_state(make_key_event(KeyCode::Char('p')), &mut state);
5824 assert!(state.paused);
5825
5826 handle_key_event_on_state(make_key_event(KeyCode::Char(' ')), &mut state);
5827 assert!(!state.paused);
5828 }
5829
5830 #[test]
5831 fn test_handle_key_slower_refresh() {
5832 let token_data = create_test_token_data();
5833 let mut state = MonitorState::new(&token_data, "ethereum");
5834 let initial = state.refresh_rate;
5835
5836 handle_key_event_on_state(make_key_event(KeyCode::Char('+')), &mut state);
5837 assert!(state.refresh_rate > initial);
5838
5839 state.refresh_rate = initial;
5840 handle_key_event_on_state(make_key_event(KeyCode::Char('=')), &mut state);
5841 assert!(state.refresh_rate > initial);
5842
5843 state.refresh_rate = initial;
5844 handle_key_event_on_state(make_key_event(KeyCode::Char(']')), &mut state);
5845 assert!(state.refresh_rate > initial);
5846 }
5847
5848 #[test]
5849 fn test_handle_key_faster_refresh() {
5850 let token_data = create_test_token_data();
5851 let mut state = MonitorState::new(&token_data, "ethereum");
5852 state.refresh_rate = Duration::from_secs(30);
5854 let initial = state.refresh_rate;
5855
5856 handle_key_event_on_state(make_key_event(KeyCode::Char('-')), &mut state);
5857 assert!(state.refresh_rate < initial);
5858
5859 state.refresh_rate = initial;
5860 handle_key_event_on_state(make_key_event(KeyCode::Char('_')), &mut state);
5861 assert!(state.refresh_rate < initial);
5862
5863 state.refresh_rate = initial;
5864 handle_key_event_on_state(make_key_event(KeyCode::Char('[')), &mut state);
5865 assert!(state.refresh_rate < initial);
5866 }
5867
5868 #[test]
5869 fn test_handle_key_time_periods() {
5870 let token_data = create_test_token_data();
5871 let mut state = MonitorState::new(&token_data, "ethereum");
5872
5873 handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
5874 assert!(matches!(state.time_period, TimePeriod::Min1));
5875
5876 handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
5877 assert!(matches!(state.time_period, TimePeriod::Min5));
5878
5879 handle_key_event_on_state(make_key_event(KeyCode::Char('3')), &mut state);
5880 assert!(matches!(state.time_period, TimePeriod::Min15));
5881
5882 handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
5883 assert!(matches!(state.time_period, TimePeriod::Hour1));
5884
5885 handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
5886 assert!(matches!(state.time_period, TimePeriod::Hour4));
5887
5888 handle_key_event_on_state(make_key_event(KeyCode::Char('6')), &mut state);
5889 assert!(matches!(state.time_period, TimePeriod::Day1));
5890 }
5891
5892 #[test]
5893 fn test_handle_key_cycle_time_period() {
5894 let token_data = create_test_token_data();
5895 let mut state = MonitorState::new(&token_data, "ethereum");
5896
5897 handle_key_event_on_state(make_key_event(KeyCode::Char('t')), &mut state);
5898 let first = state.time_period;
5900
5901 handle_key_event_on_state(make_key_event(KeyCode::Tab), &mut state);
5902 let _ = state.time_period;
5905 let _ = first;
5906 }
5907
5908 #[test]
5909 fn test_handle_key_toggle_chart_mode() {
5910 let token_data = create_test_token_data();
5911 let mut state = MonitorState::new(&token_data, "ethereum");
5912 let initial_mode = state.chart_mode;
5913
5914 handle_key_event_on_state(make_key_event(KeyCode::Char('c')), &mut state);
5915 assert!(state.chart_mode != initial_mode);
5916 }
5917
5918 #[test]
5919 fn test_handle_key_unknown_no_op() {
5920 let token_data = create_test_token_data();
5921 let mut state = MonitorState::new(&token_data, "ethereum");
5922 let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
5923 assert!(!exit);
5924 }
5925
5926 #[test]
5931 fn test_save_and_load_cache() {
5932 let token_data = create_test_token_data();
5933 let mut state = MonitorState::new(&token_data, "ethereum");
5934 state.price_history.push_back(DataPoint {
5935 timestamp: 1.0,
5936 value: 100.0,
5937 is_real: true,
5938 });
5939 state.price_history.push_back(DataPoint {
5940 timestamp: 2.0,
5941 value: 101.0,
5942 is_real: true,
5943 });
5944 state.volume_history.push_back(DataPoint {
5945 timestamp: 1.0,
5946 value: 5000.0,
5947 is_real: true,
5948 });
5949
5950 state.save_cache();
5953 let cached = MonitorState::load_cache(&state.token_address, &state.chain);
5954 if let Some(c) = cached {
5956 assert_eq!(
5957 c.token_address.to_lowercase(),
5958 state.token_address.to_lowercase()
5959 );
5960 }
5961 }
5962
5963 #[test]
5964 fn test_load_cache_nonexistent_token() {
5965 let cached = MonitorState::load_cache("0xNONEXISTENT_TOKEN_ADDR", "nonexistent_chain");
5966 assert!(cached.is_none());
5967 }
5968
5969 #[test]
5974 fn test_render_volume_barchart_with_populated_data() {
5975 let mut terminal = create_test_terminal();
5978 let mut state = create_populated_state();
5979 for period in [
5980 TimePeriod::Min1,
5981 TimePeriod::Min5,
5982 TimePeriod::Min15,
5983 TimePeriod::Hour1,
5984 TimePeriod::Hour4,
5985 TimePeriod::Day1,
5986 ] {
5987 state.set_time_period(period);
5988 terminal
5989 .draw(|f| render_volume_chart(f, f.area(), &state))
5990 .unwrap();
5991 }
5992 }
5993
5994 #[test]
5995 fn test_render_volume_barchart_narrow_terminal() {
5996 let backend = TestBackend::new(20, 10);
5998 let mut terminal = Terminal::new(backend).unwrap();
5999 let state = create_populated_state();
6000 terminal
6001 .draw(|f| render_volume_chart(f, f.area(), &state))
6002 .unwrap();
6003 }
6004
6005 #[test]
6006 fn test_render_metrics_table_sparkline_no_panic() {
6007 let mut terminal = create_test_terminal();
6009 let state = create_populated_state();
6010 terminal
6011 .draw(|f| render_metrics_panel(f, f.area(), &state))
6012 .unwrap();
6013 }
6014
6015 #[test]
6016 fn test_render_metrics_table_sparkline_all_periods() {
6017 let mut terminal = create_test_terminal();
6019 let mut state = create_populated_state();
6020 for period in [
6021 TimePeriod::Min1,
6022 TimePeriod::Min5,
6023 TimePeriod::Min15,
6024 TimePeriod::Hour1,
6025 TimePeriod::Hour4,
6026 TimePeriod::Day1,
6027 ] {
6028 state.set_time_period(period);
6029 terminal
6030 .draw(|f| render_metrics_panel(f, f.area(), &state))
6031 .unwrap();
6032 }
6033 }
6034
6035 #[test]
6036 fn test_render_metrics_sparkline_trend_direction() {
6037 let mut terminal = create_test_terminal();
6039 let mut state = create_populated_state();
6040 state.price_change_5m = -3.5;
6041 terminal
6042 .draw(|f| render_metrics_panel(f, f.area(), &state))
6043 .unwrap();
6044
6045 state.price_change_5m = 2.0;
6047 terminal
6048 .draw(|f| render_metrics_panel(f, f.area(), &state))
6049 .unwrap();
6050
6051 state.price_change_5m = 0.0;
6053 terminal
6054 .draw(|f| render_metrics_panel(f, f.area(), &state))
6055 .unwrap();
6056 }
6057
6058 #[test]
6059 fn test_render_tabs_time_period() {
6060 let mut terminal = create_test_terminal();
6062 let mut state = create_populated_state();
6063 for period in [
6064 TimePeriod::Min1,
6065 TimePeriod::Min5,
6066 TimePeriod::Min15,
6067 TimePeriod::Hour1,
6068 TimePeriod::Hour4,
6069 TimePeriod::Day1,
6070 ] {
6071 state.set_time_period(period);
6072 terminal
6073 .draw(|f| render_header(f, f.area(), &state))
6074 .unwrap();
6075 }
6076 }
6077
6078 #[test]
6079 fn test_time_period_index() {
6080 assert_eq!(TimePeriod::Min1.index(), 0);
6081 assert_eq!(TimePeriod::Min5.index(), 1);
6082 assert_eq!(TimePeriod::Min15.index(), 2);
6083 assert_eq!(TimePeriod::Hour1.index(), 3);
6084 assert_eq!(TimePeriod::Hour4.index(), 4);
6085 assert_eq!(TimePeriod::Day1.index(), 5);
6086 }
6087
6088 #[test]
6089 fn test_scroll_log_down_from_start() {
6090 let token_data = create_test_token_data();
6091 let mut state = MonitorState::new(&token_data, "ethereum");
6092 state.log_messages.push_back("msg 1".to_string());
6093 state.log_messages.push_back("msg 2".to_string());
6094 state.log_messages.push_back("msg 3".to_string());
6095
6096 assert_eq!(state.log_list_state.selected(), None);
6098
6099 state.scroll_log_down();
6101 assert_eq!(state.log_list_state.selected(), Some(0));
6102
6103 state.scroll_log_down();
6105 assert_eq!(state.log_list_state.selected(), Some(1));
6106
6107 state.scroll_log_down();
6109 assert_eq!(state.log_list_state.selected(), Some(2));
6110
6111 state.scroll_log_down();
6113 assert_eq!(state.log_list_state.selected(), Some(2));
6114 }
6115
6116 #[test]
6117 fn test_scroll_log_up_from_start() {
6118 let token_data = create_test_token_data();
6119 let mut state = MonitorState::new(&token_data, "ethereum");
6120 state.log_messages.push_back("msg 1".to_string());
6121 state.log_messages.push_back("msg 2".to_string());
6122 state.log_messages.push_back("msg 3".to_string());
6123
6124 state.scroll_log_up();
6126 assert_eq!(state.log_list_state.selected(), Some(0));
6127
6128 state.scroll_log_up();
6130 assert_eq!(state.log_list_state.selected(), Some(0));
6131 }
6132
6133 #[test]
6134 fn test_scroll_log_up_down_roundtrip() {
6135 let token_data = create_test_token_data();
6136 let mut state = MonitorState::new(&token_data, "ethereum");
6137 for i in 0..10 {
6138 state.log_messages.push_back(format!("msg {}", i));
6139 }
6140
6141 for _ in 0..5 {
6143 state.scroll_log_down();
6144 }
6145 assert_eq!(state.log_list_state.selected(), Some(4));
6146
6147 for _ in 0..3 {
6149 state.scroll_log_up();
6150 }
6151 assert_eq!(state.log_list_state.selected(), Some(1));
6152 }
6153
6154 #[test]
6155 fn test_scroll_log_empty_no_panic() {
6156 let token_data = create_test_token_data();
6157 let mut state = MonitorState::new(&token_data, "ethereum");
6158 state.scroll_log_down();
6160 state.scroll_log_up();
6161 assert!(
6162 state.log_list_state.selected().is_none() || state.log_list_state.selected() == Some(0)
6163 );
6164 }
6165
6166 #[test]
6167 fn test_render_scrollable_activity_log() {
6168 let mut terminal = create_test_terminal();
6170 let mut state = create_populated_state();
6171 for i in 0..20 {
6172 state
6173 .log_messages
6174 .push_back(format!("Activity event #{}", i));
6175 }
6176 state.scroll_log_down();
6178 state.scroll_log_down();
6179 state.scroll_log_down();
6180
6181 terminal
6182 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
6183 .unwrap();
6184 }
6185
6186 #[test]
6187 fn test_handle_key_scroll_log_j_k() {
6188 let token_data = create_test_token_data();
6189 let mut state = MonitorState::new(&token_data, "ethereum");
6190 state.log_messages.push_back("line 1".to_string());
6191 state.log_messages.push_back("line 2".to_string());
6192
6193 handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
6195 assert_eq!(state.log_list_state.selected(), Some(0));
6196
6197 handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
6198 assert_eq!(state.log_list_state.selected(), Some(1));
6199
6200 handle_key_event_on_state(make_key_event(KeyCode::Char('k')), &mut state);
6202 assert_eq!(state.log_list_state.selected(), Some(0));
6203 }
6204
6205 #[test]
6206 fn test_handle_key_scroll_log_arrow_keys() {
6207 let token_data = create_test_token_data();
6208 let mut state = MonitorState::new(&token_data, "ethereum");
6209 state.log_messages.push_back("line 1".to_string());
6210 state.log_messages.push_back("line 2".to_string());
6211 state.log_messages.push_back("line 3".to_string());
6212
6213 handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
6215 assert_eq!(state.log_list_state.selected(), Some(0));
6216
6217 handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
6218 assert_eq!(state.log_list_state.selected(), Some(1));
6219
6220 handle_key_event_on_state(make_key_event(KeyCode::Up), &mut state);
6222 assert_eq!(state.log_list_state.selected(), Some(0));
6223 }
6224
6225 #[test]
6226 fn test_render_ui_with_scrolled_log() {
6227 let mut terminal = create_test_terminal();
6229 let mut state = create_populated_state();
6230 for i in 0..15 {
6231 state.log_messages.push_back(format!("Log entry {}", i));
6232 }
6233 state.scroll_log_down();
6234 state.scroll_log_down();
6235 state.scroll_log_down();
6236 state.scroll_log_down();
6237 state.scroll_log_down();
6238
6239 terminal.draw(|f| ui(f, &mut state)).unwrap();
6240 }
6241
6242 fn make_monitor_search_results() -> Vec<crate::chains::dex::TokenSearchResult> {
6247 vec![
6248 crate::chains::dex::TokenSearchResult {
6249 symbol: "USDC".to_string(),
6250 name: "USD Coin".to_string(),
6251 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
6252 chain: "ethereum".to_string(),
6253 price_usd: Some(1.0),
6254 volume_24h: 1_000_000.0,
6255 liquidity_usd: 500_000_000.0,
6256 market_cap: Some(30_000_000_000.0),
6257 },
6258 crate::chains::dex::TokenSearchResult {
6259 symbol: "USDC".to_string(),
6260 name: "Bridged USD Coin".to_string(),
6261 address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(),
6262 chain: "ethereum".to_string(),
6263 price_usd: Some(0.9998),
6264 volume_24h: 500_000.0,
6265 liquidity_usd: 100_000_000.0,
6266 market_cap: None,
6267 },
6268 crate::chains::dex::TokenSearchResult {
6269 symbol: "USDC".to_string(),
6270 name: "A Very Long Token Name That Exceeds The Limit".to_string(),
6271 address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
6272 chain: "ethereum".to_string(),
6273 price_usd: None,
6274 volume_24h: 0.0,
6275 liquidity_usd: 50_000.0,
6276 market_cap: None,
6277 },
6278 ]
6279 }
6280
6281 #[test]
6282 fn test_abbreviate_address_long() {
6283 let addr = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
6284 let abbr = abbreviate_address(addr);
6285 assert_eq!(abbr, "0xA0b869...06eB48");
6286 assert!(abbr.contains("..."));
6287 }
6288
6289 #[test]
6290 fn test_abbreviate_address_short() {
6291 let addr = "0x1234abcd";
6292 let abbr = abbreviate_address(addr);
6293 assert_eq!(abbr, "0x1234abcd");
6295 }
6296
6297 #[test]
6298 fn test_select_token_impl_first() {
6299 let results = make_monitor_search_results();
6300 let input = b"1\n";
6301 let mut reader = std::io::Cursor::new(&input[..]);
6302 let mut writer = Vec::new();
6303
6304 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
6305 assert_eq!(selected.name, "USD Coin");
6306 assert_eq!(
6307 selected.address,
6308 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
6309 );
6310
6311 let output = String::from_utf8(writer).unwrap();
6312 assert!(output.contains("Found 3 tokens"));
6313 assert!(output.contains("USDC"));
6314 assert!(output.contains("0xA0b869...06eB48"));
6315 assert!(output.contains("Selected:"));
6316 }
6317
6318 #[test]
6319 fn test_select_token_impl_second() {
6320 let results = make_monitor_search_results();
6321 let input = b"2\n";
6322 let mut reader = std::io::Cursor::new(&input[..]);
6323 let mut writer = Vec::new();
6324
6325 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
6326 assert_eq!(selected.name, "Bridged USD Coin");
6327 assert_eq!(
6328 selected.address,
6329 "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
6330 );
6331 }
6332
6333 #[test]
6334 fn test_select_token_impl_shows_address_column() {
6335 let results = make_monitor_search_results();
6336 let input = b"1\n";
6337 let mut reader = std::io::Cursor::new(&input[..]);
6338 let mut writer = Vec::new();
6339
6340 select_token_impl(&results, &mut reader, &mut writer).unwrap();
6341 let output = String::from_utf8(writer).unwrap();
6342
6343 assert!(output.contains("Address"));
6345 assert!(output.contains("0xA0b869...06eB48"));
6347 assert!(output.contains("0x2791Bc...a84174"));
6348 assert!(output.contains("0x123456...345678"));
6349 }
6350
6351 #[test]
6352 fn test_select_token_impl_truncates_long_name() {
6353 let results = make_monitor_search_results();
6354 let input = b"3\n";
6355 let mut reader = std::io::Cursor::new(&input[..]);
6356 let mut writer = Vec::new();
6357
6358 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
6359 assert_eq!(
6360 selected.address,
6361 "0x1234567890abcdef1234567890abcdef12345678"
6362 );
6363
6364 let output = String::from_utf8(writer).unwrap();
6365 assert!(output.contains("A Very Long Token..."));
6366 }
6367
6368 #[test]
6369 fn test_select_token_impl_invalid_input() {
6370 let results = make_monitor_search_results();
6371 let input = b"xyz\n";
6372 let mut reader = std::io::Cursor::new(&input[..]);
6373 let mut writer = Vec::new();
6374
6375 let result = select_token_impl(&results, &mut reader, &mut writer);
6376 assert!(result.is_err());
6377 assert!(
6378 result
6379 .unwrap_err()
6380 .to_string()
6381 .contains("Invalid selection")
6382 );
6383 }
6384
6385 #[test]
6386 fn test_select_token_impl_out_of_range_zero() {
6387 let results = make_monitor_search_results();
6388 let input = b"0\n";
6389 let mut reader = std::io::Cursor::new(&input[..]);
6390 let mut writer = Vec::new();
6391
6392 let result = select_token_impl(&results, &mut reader, &mut writer);
6393 assert!(result.is_err());
6394 assert!(
6395 result
6396 .unwrap_err()
6397 .to_string()
6398 .contains("Selection must be between")
6399 );
6400 }
6401
6402 #[test]
6403 fn test_select_token_impl_out_of_range_high() {
6404 let results = make_monitor_search_results();
6405 let input = b"99\n";
6406 let mut reader = std::io::Cursor::new(&input[..]);
6407 let mut writer = Vec::new();
6408
6409 let result = select_token_impl(&results, &mut reader, &mut writer);
6410 assert!(result.is_err());
6411 }
6412
6413 #[test]
6414 fn test_format_monitor_number() {
6415 assert_eq!(format_monitor_number(1_500_000_000.0), "$1.50B");
6416 assert_eq!(format_monitor_number(250_000_000.0), "$250.00M");
6417 assert_eq!(format_monitor_number(75_000.0), "$75.00K");
6418 assert_eq!(format_monitor_number(42.5), "$42.50");
6419 }
6420
6421 #[test]
6426 fn test_monitor_config_defaults() {
6427 let config = MonitorConfig::default();
6428 assert_eq!(config.layout, LayoutPreset::Dashboard);
6429 assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
6430 assert!(config.widgets.price_chart);
6431 assert!(config.widgets.volume_chart);
6432 assert!(config.widgets.buy_sell_pressure);
6433 assert!(config.widgets.metrics_panel);
6434 assert!(config.widgets.activity_log);
6435 }
6436
6437 #[test]
6438 fn test_layout_preset_next_cycles() {
6439 assert_eq!(LayoutPreset::Dashboard.next(), LayoutPreset::ChartFocus);
6440 assert_eq!(LayoutPreset::ChartFocus.next(), LayoutPreset::Feed);
6441 assert_eq!(LayoutPreset::Feed.next(), LayoutPreset::Compact);
6442 assert_eq!(LayoutPreset::Compact.next(), LayoutPreset::Exchange);
6443 assert_eq!(LayoutPreset::Exchange.next(), LayoutPreset::Dashboard);
6444 }
6445
6446 #[test]
6447 fn test_layout_preset_prev_cycles() {
6448 assert_eq!(LayoutPreset::Dashboard.prev(), LayoutPreset::Exchange);
6449 assert_eq!(LayoutPreset::Exchange.prev(), LayoutPreset::Compact);
6450 assert_eq!(LayoutPreset::Compact.prev(), LayoutPreset::Feed);
6451 assert_eq!(LayoutPreset::Feed.prev(), LayoutPreset::ChartFocus);
6452 assert_eq!(LayoutPreset::ChartFocus.prev(), LayoutPreset::Dashboard);
6453 }
6454
6455 #[test]
6456 fn test_layout_preset_full_cycle() {
6457 let start = LayoutPreset::Dashboard;
6458 let mut preset = start;
6459 for _ in 0..5 {
6460 preset = preset.next();
6461 }
6462 assert_eq!(preset, start);
6463 }
6464
6465 #[test]
6466 fn test_layout_preset_labels() {
6467 assert_eq!(LayoutPreset::Dashboard.label(), "Dashboard");
6468 assert_eq!(LayoutPreset::ChartFocus.label(), "Chart");
6469 assert_eq!(LayoutPreset::Feed.label(), "Feed");
6470 assert_eq!(LayoutPreset::Compact.label(), "Compact");
6471 assert_eq!(LayoutPreset::Exchange.label(), "Exchange");
6472 }
6473
6474 #[test]
6475 fn test_widget_visibility_default_all_visible() {
6476 let vis = WidgetVisibility::default();
6477 assert_eq!(vis.visible_count(), 5);
6478 }
6479
6480 #[test]
6481 fn test_widget_visibility_toggle_by_index() {
6482 let mut vis = WidgetVisibility::default();
6483 vis.toggle_by_index(1);
6484 assert!(!vis.price_chart);
6485 assert_eq!(vis.visible_count(), 4);
6486
6487 vis.toggle_by_index(2);
6488 assert!(!vis.volume_chart);
6489 assert_eq!(vis.visible_count(), 3);
6490
6491 vis.toggle_by_index(3);
6492 assert!(!vis.buy_sell_pressure);
6493 assert_eq!(vis.visible_count(), 2);
6494
6495 vis.toggle_by_index(4);
6496 assert!(!vis.metrics_panel);
6497 assert_eq!(vis.visible_count(), 1);
6498
6499 vis.toggle_by_index(5);
6500 assert!(!vis.activity_log);
6501 assert_eq!(vis.visible_count(), 0);
6502
6503 vis.toggle_by_index(1);
6505 assert!(vis.price_chart);
6506 assert_eq!(vis.visible_count(), 1);
6507 }
6508
6509 #[test]
6510 fn test_widget_visibility_toggle_invalid_index() {
6511 let mut vis = WidgetVisibility::default();
6512 vis.toggle_by_index(0);
6513 vis.toggle_by_index(6);
6514 vis.toggle_by_index(100);
6515 assert_eq!(vis.visible_count(), 5); }
6517
6518 #[test]
6519 fn test_auto_select_layout_small_terminal() {
6520 let size = Rect::new(0, 0, 60, 20);
6521 assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
6522 }
6523
6524 #[test]
6525 fn test_auto_select_layout_narrow_terminal() {
6526 let size = Rect::new(0, 0, 100, 40);
6527 assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
6528 }
6529
6530 #[test]
6531 fn test_auto_select_layout_short_terminal() {
6532 let size = Rect::new(0, 0, 140, 28);
6533 assert_eq!(auto_select_layout(size), LayoutPreset::ChartFocus);
6534 }
6535
6536 #[test]
6537 fn test_auto_select_layout_large_terminal() {
6538 let size = Rect::new(0, 0, 160, 50);
6539 assert_eq!(auto_select_layout(size), LayoutPreset::Dashboard);
6540 }
6541
6542 #[test]
6543 fn test_auto_select_layout_edge_80x24() {
6544 let size = Rect::new(0, 0, 80, 24);
6546 assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
6547 }
6548
6549 #[test]
6550 fn test_auto_select_layout_edge_79() {
6551 let size = Rect::new(0, 0, 79, 50);
6552 assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
6553 }
6554
6555 #[test]
6556 fn test_auto_select_layout_edge_23_height() {
6557 let size = Rect::new(0, 0, 160, 23);
6558 assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
6559 }
6560
6561 #[test]
6562 fn test_layout_dashboard_all_visible() {
6563 let area = Rect::new(0, 0, 120, 40);
6564 let vis = WidgetVisibility::default();
6565 let areas = layout_dashboard(area, &vis);
6566 assert!(areas.price_chart.is_some());
6567 assert!(areas.volume_chart.is_some());
6568 assert!(areas.buy_sell_gauge.is_some());
6569 assert!(areas.metrics_panel.is_some());
6570 assert!(areas.activity_feed.is_some());
6571 }
6572
6573 #[test]
6574 fn test_layout_dashboard_hidden_widget() {
6575 let area = Rect::new(0, 0, 120, 40);
6576 let vis = WidgetVisibility {
6577 price_chart: false,
6578 ..WidgetVisibility::default()
6579 };
6580 let areas = layout_dashboard(area, &vis);
6581 assert!(areas.price_chart.is_none());
6582 assert!(areas.volume_chart.is_some());
6583 }
6584
6585 #[test]
6586 fn test_layout_chart_focus_minimal_overlay() {
6587 let area = Rect::new(0, 0, 120, 40);
6588 let vis = WidgetVisibility::default();
6589 let areas = layout_chart_focus(area, &vis);
6590 assert!(areas.price_chart.is_some());
6591 assert!(areas.volume_chart.is_none()); assert!(areas.buy_sell_gauge.is_none()); assert!(areas.metrics_panel.is_some()); assert!(areas.activity_feed.is_none()); }
6596
6597 #[test]
6598 fn test_layout_feed_activity_priority() {
6599 let area = Rect::new(0, 0, 120, 40);
6600 let vis = WidgetVisibility::default();
6601 let areas = layout_feed(area, &vis);
6602 assert!(areas.price_chart.is_none()); assert!(areas.volume_chart.is_none()); assert!(areas.buy_sell_gauge.is_some()); assert!(areas.metrics_panel.is_some()); assert!(areas.activity_feed.is_some()); }
6608
6609 #[test]
6610 fn test_layout_compact_metrics_only() {
6611 let area = Rect::new(0, 0, 60, 20);
6612 let vis = WidgetVisibility::default();
6613 let areas = layout_compact(area, &vis);
6614 assert!(areas.price_chart.is_none()); assert!(areas.volume_chart.is_none()); assert!(areas.buy_sell_gauge.is_none()); assert!(areas.metrics_panel.is_some()); assert!(areas.activity_feed.is_none()); }
6620
6621 #[test]
6622 fn test_layout_exchange_has_order_book_and_market_info() {
6623 let area = Rect::new(0, 0, 160, 50);
6624 let vis = WidgetVisibility::default();
6625 let areas = layout_exchange(area, &vis);
6626 assert!(areas.order_book.is_some());
6627 assert!(areas.market_info.is_some());
6628 assert!(areas.price_chart.is_some());
6629 assert!(areas.buy_sell_gauge.is_some());
6630 assert!(areas.volume_chart.is_none()); assert!(areas.metrics_panel.is_none()); assert!(areas.activity_feed.is_none()); }
6634
6635 #[test]
6636 fn test_ui_render_all_layouts_no_panic() {
6637 let presets = [
6638 LayoutPreset::Dashboard,
6639 LayoutPreset::ChartFocus,
6640 LayoutPreset::Feed,
6641 LayoutPreset::Compact,
6642 LayoutPreset::Exchange,
6643 ];
6644 for preset in &presets {
6645 let mut terminal = create_test_terminal();
6646 let mut state = create_populated_state();
6647 state.layout = *preset;
6648 state.auto_layout = false; terminal.draw(|f| ui(f, &mut state)).unwrap();
6650 }
6651 }
6652
6653 #[test]
6654 fn test_ui_render_compact_small_terminal() {
6655 let backend = TestBackend::new(60, 20);
6656 let mut terminal = Terminal::new(backend).unwrap();
6657 let mut state = create_populated_state();
6658 state.layout = LayoutPreset::Compact;
6659 state.auto_layout = false;
6660 terminal.draw(|f| ui(f, &mut state)).unwrap();
6661 }
6662
6663 #[test]
6664 fn test_ui_auto_layout_selects_compact_for_small() {
6665 let backend = TestBackend::new(60, 20);
6666 let mut terminal = Terminal::new(backend).unwrap();
6667 let mut state = create_populated_state();
6668 state.layout = LayoutPreset::Dashboard;
6669 state.auto_layout = true;
6670 terminal.draw(|f| ui(f, &mut state)).unwrap();
6671 assert_eq!(state.layout, LayoutPreset::Compact);
6672 }
6673
6674 #[test]
6675 fn test_ui_auto_layout_disabled_keeps_preset() {
6676 let backend = TestBackend::new(60, 20);
6677 let mut terminal = Terminal::new(backend).unwrap();
6678 let mut state = create_populated_state();
6679 state.layout = LayoutPreset::Dashboard;
6680 state.auto_layout = false;
6681 terminal.draw(|f| ui(f, &mut state)).unwrap();
6682 assert_eq!(state.layout, LayoutPreset::Dashboard); }
6684
6685 #[test]
6686 fn test_keybinding_l_cycles_layout_forward() {
6687 let mut state = create_populated_state();
6688 state.layout = LayoutPreset::Dashboard;
6689 state.auto_layout = true;
6690
6691 handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
6692 assert_eq!(state.layout, LayoutPreset::ChartFocus);
6693 assert!(!state.auto_layout); handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
6696 assert_eq!(state.layout, LayoutPreset::Feed);
6697 }
6698
6699 #[test]
6700 fn test_keybinding_h_cycles_layout_backward() {
6701 let mut state = create_populated_state();
6702 state.layout = LayoutPreset::Dashboard;
6703 state.auto_layout = true;
6704
6705 handle_key_event_on_state(make_key_event(KeyCode::Char('h')), &mut state);
6706 assert_eq!(state.layout, LayoutPreset::Exchange);
6707 assert!(!state.auto_layout);
6708 }
6709
6710 #[test]
6711 fn test_keybinding_a_enables_auto_layout() {
6712 let mut state = create_populated_state();
6713 state.auto_layout = false;
6714
6715 handle_key_event_on_state(make_key_event(KeyCode::Char('a')), &mut state);
6716 assert!(state.auto_layout);
6717 }
6718
6719 #[test]
6720 fn test_keybinding_w_widget_toggle_mode() {
6721 let mut state = create_populated_state();
6722 assert!(!state.widget_toggle_mode);
6723
6724 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6726 assert!(state.widget_toggle_mode);
6727
6728 handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
6730 assert!(!state.widget_toggle_mode);
6731 assert!(!state.widgets.price_chart);
6732 }
6733
6734 #[test]
6735 fn test_keybinding_w_cancel_with_non_digit() {
6736 let mut state = create_populated_state();
6737
6738 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6740 assert!(state.widget_toggle_mode);
6741
6742 handle_key_event_on_state(make_key_event(KeyCode::Char('x')), &mut state);
6744 assert!(!state.widget_toggle_mode);
6745 assert!(state.widgets.price_chart); }
6747
6748 #[test]
6749 fn test_keybinding_w_toggle_multiple_widgets() {
6750 let mut state = create_populated_state();
6751
6752 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6754 handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
6755 assert!(!state.widgets.volume_chart);
6756
6757 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6759 handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
6760 assert!(!state.widgets.metrics_panel);
6761
6762 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6764 handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
6765 assert!(!state.widgets.activity_log);
6766 }
6767
6768 #[test]
6769 fn test_monitor_config_serde_roundtrip() {
6770 let config = MonitorConfig {
6771 layout: LayoutPreset::ChartFocus,
6772 refresh_seconds: 5,
6773 widgets: WidgetVisibility {
6774 price_chart: true,
6775 volume_chart: false,
6776 buy_sell_pressure: true,
6777 metrics_panel: false,
6778 activity_log: true,
6779 holder_count: true,
6780 liquidity_depth: true,
6781 },
6782 scale: ScaleMode::Log,
6783 color_scheme: ColorScheme::BlueOrange,
6784 alerts: AlertConfig::default(),
6785 export: ExportConfig::default(),
6786 auto_pause_on_input: false,
6787 venue: None,
6788 };
6789
6790 let yaml = serde_yaml::to_string(&config).unwrap();
6791 let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
6792 assert_eq!(parsed.layout, LayoutPreset::ChartFocus);
6793 assert_eq!(parsed.refresh_seconds, 5);
6794 assert!(parsed.widgets.price_chart);
6795 assert!(!parsed.widgets.volume_chart);
6796 assert!(parsed.widgets.buy_sell_pressure);
6797 assert!(!parsed.widgets.metrics_panel);
6798 assert!(parsed.widgets.activity_log);
6799 }
6800
6801 #[test]
6802 fn test_monitor_config_serde_kebab_case() {
6803 let yaml = r#"
6804layout: chart-focus
6805refresh_seconds: 15
6806widgets:
6807 price_chart: true
6808 volume_chart: true
6809 buy_sell_pressure: false
6810 metrics_panel: true
6811 activity_log: false
6812"#;
6813 let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
6814 assert_eq!(config.layout, LayoutPreset::ChartFocus);
6815 assert_eq!(config.refresh_seconds, 15);
6816 assert!(!config.widgets.buy_sell_pressure);
6817 assert!(!config.widgets.activity_log);
6818 }
6819
6820 #[test]
6821 fn test_monitor_config_serde_default_missing_fields() {
6822 let yaml = "layout: feed\n";
6823 let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
6824 assert_eq!(config.layout, LayoutPreset::Feed);
6825 assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
6826 assert!(config.widgets.price_chart); }
6828
6829 #[test]
6830 fn test_state_apply_config() {
6831 let mut state = create_populated_state();
6832 let config = MonitorConfig {
6833 layout: LayoutPreset::Feed,
6834 refresh_seconds: 5,
6835 widgets: WidgetVisibility {
6836 price_chart: false,
6837 volume_chart: true,
6838 buy_sell_pressure: true,
6839 metrics_panel: false,
6840 activity_log: true,
6841 holder_count: true,
6842 liquidity_depth: true,
6843 },
6844 scale: ScaleMode::Log,
6845 color_scheme: ColorScheme::Monochrome,
6846 alerts: AlertConfig::default(),
6847 export: ExportConfig::default(),
6848 auto_pause_on_input: false,
6849 venue: None,
6850 };
6851 state.apply_config(&config);
6852 assert_eq!(state.layout, LayoutPreset::Feed);
6853 assert!(!state.widgets.price_chart);
6854 assert!(!state.widgets.metrics_panel);
6855 assert_eq!(state.refresh_rate, Duration::from_secs(5));
6856 }
6857
6858 #[test]
6859 fn test_layout_all_widgets_hidden_dashboard() {
6860 let area = Rect::new(0, 0, 120, 40);
6861 let vis = WidgetVisibility {
6862 price_chart: false,
6863 volume_chart: false,
6864 buy_sell_pressure: false,
6865 metrics_panel: false,
6866 activity_log: false,
6867 holder_count: false,
6868 liquidity_depth: false,
6869 };
6870 let areas = layout_dashboard(area, &vis);
6871 assert!(areas.price_chart.is_none());
6872 assert!(areas.volume_chart.is_none());
6873 assert!(areas.buy_sell_gauge.is_none());
6874 assert!(areas.metrics_panel.is_none());
6875 assert!(areas.activity_feed.is_none());
6876 }
6877
6878 #[test]
6879 fn test_ui_render_with_hidden_widgets() {
6880 let mut terminal = create_test_terminal();
6881 let mut state = create_populated_state();
6882 state.auto_layout = false;
6883 state.widgets.price_chart = false;
6884 state.widgets.volume_chart = false;
6885 terminal.draw(|f| ui(f, &mut state)).unwrap();
6886 }
6887
6888 #[test]
6889 fn test_ui_render_widget_toggle_mode_footer() {
6890 let mut terminal = create_test_terminal();
6891 let mut state = create_populated_state();
6892 state.auto_layout = false;
6893 state.widget_toggle_mode = true;
6894 terminal.draw(|f| ui(f, &mut state)).unwrap();
6895 }
6896
6897 #[test]
6898 fn test_monitor_state_new_has_layout_fields() {
6899 let token_data = create_test_token_data();
6900 let state = MonitorState::new(&token_data, "ethereum");
6901 assert_eq!(state.layout, LayoutPreset::Dashboard);
6902 assert!(state.auto_layout);
6903 assert!(!state.widget_toggle_mode);
6904 assert_eq!(state.widgets.visible_count(), 5);
6905 }
6906
6907 #[test]
6912 fn test_monitor_state_has_holder_count_field() {
6913 let token_data = create_test_token_data();
6914 let state = MonitorState::new(&token_data, "ethereum");
6915 assert_eq!(state.holder_count, None);
6916 assert!(state.liquidity_pairs.is_empty());
6917 assert_eq!(state.holder_fetch_counter, 0);
6918 }
6919
6920 #[test]
6921 fn test_liquidity_pairs_extracted_on_update() {
6922 let mut token_data = create_test_token_data();
6923 token_data.pairs = vec![
6924 crate::chains::DexPair {
6925 dex_name: "Uniswap V3".to_string(),
6926 pair_address: "0xpair1".to_string(),
6927 base_token: "TEST".to_string(),
6928 quote_token: "WETH".to_string(),
6929 price_usd: 1.0,
6930 volume_24h: 500_000.0,
6931 liquidity_usd: 250_000.0,
6932 price_change_24h: 5.0,
6933 buys_24h: 50,
6934 sells_24h: 25,
6935 buys_6h: 10,
6936 sells_6h: 5,
6937 buys_1h: 3,
6938 sells_1h: 2,
6939 pair_created_at: None,
6940 url: None,
6941 },
6942 crate::chains::DexPair {
6943 dex_name: "SushiSwap".to_string(),
6944 pair_address: "0xpair2".to_string(),
6945 base_token: "TEST".to_string(),
6946 quote_token: "USDC".to_string(),
6947 price_usd: 1.0,
6948 volume_24h: 300_000.0,
6949 liquidity_usd: 150_000.0,
6950 price_change_24h: 3.0,
6951 buys_24h: 30,
6952 sells_24h: 15,
6953 buys_6h: 8,
6954 sells_6h: 4,
6955 buys_1h: 2,
6956 sells_1h: 1,
6957 pair_created_at: None,
6958 url: None,
6959 },
6960 ];
6961
6962 let mut state = MonitorState::new(&token_data, "ethereum");
6963 state.update(&token_data);
6964
6965 assert_eq!(state.liquidity_pairs.len(), 2);
6966 assert!(state.liquidity_pairs[0].0.contains("Uniswap V3"));
6967 assert!((state.liquidity_pairs[0].1 - 250_000.0).abs() < 0.01);
6968 }
6969
6970 #[test]
6971 fn test_render_liquidity_depth_no_panic() {
6972 let mut terminal = create_test_terminal();
6973 let mut state = create_populated_state();
6974 state.liquidity_pairs = vec![
6975 ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
6976 ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
6977 ];
6978 terminal
6979 .draw(|f| render_liquidity_depth(f, f.area(), &state))
6980 .unwrap();
6981 }
6982
6983 #[test]
6984 fn test_render_liquidity_depth_empty() {
6985 let mut terminal = create_test_terminal();
6986 let state = create_populated_state();
6987 terminal
6988 .draw(|f| render_liquidity_depth(f, f.area(), &state))
6989 .unwrap();
6990 }
6991
6992 #[test]
6993 fn test_render_metrics_with_holder_count() {
6994 let mut terminal = create_test_terminal();
6995 let mut state = create_populated_state();
6996 state.holder_count = Some(42_000);
6997 terminal
6998 .draw(|f| render_metrics_panel(f, f.area(), &state))
6999 .unwrap();
7000 }
7001
7002 #[test]
7007 fn test_alert_config_default() {
7008 let config = AlertConfig::default();
7009 assert!(config.price_min.is_none());
7010 assert!(config.price_max.is_none());
7011 assert!(config.whale_min_usd.is_none());
7012 assert!(config.volume_spike_threshold_pct.is_none());
7013 }
7014
7015 #[test]
7016 fn test_alert_price_min_triggers() {
7017 let token_data = create_test_token_data();
7018 let mut state = MonitorState::new(&token_data, "ethereum");
7019 state.alerts.price_min = Some(2.0); state.update(&token_data);
7021 assert!(
7022 !state.active_alerts.is_empty(),
7023 "Should have price-min alert"
7024 );
7025 assert!(state.active_alerts[0].message.contains("below min"));
7026 }
7027
7028 #[test]
7029 fn test_alert_price_max_triggers() {
7030 let mut token_data = create_test_token_data();
7031 token_data.price_usd = 100.0;
7032 let mut state = MonitorState::new(&token_data, "ethereum");
7033 state.alerts.price_max = Some(50.0); state.update(&token_data);
7035 assert!(
7036 !state.active_alerts.is_empty(),
7037 "Should have price-max alert"
7038 );
7039 assert!(state.active_alerts[0].message.contains("above max"));
7040 }
7041
7042 #[test]
7043 fn test_alert_no_trigger_within_bounds() {
7044 let token_data = create_test_token_data();
7045 let mut state = MonitorState::new(&token_data, "ethereum");
7046 state.alerts.price_min = Some(0.5); state.alerts.price_max = Some(2.0); state.update(&token_data);
7049 assert!(
7050 state.active_alerts.is_empty(),
7051 "Should have no alerts when price is within bounds"
7052 );
7053 }
7054
7055 #[test]
7056 fn test_alert_volume_spike_triggers() {
7057 let token_data = create_test_token_data();
7058 let mut state = MonitorState::new(&token_data, "ethereum");
7059 state.alerts.volume_spike_threshold_pct = Some(10.0);
7060 state.volume_avg = 500_000.0; state.update(&token_data);
7064 let spike_alerts: Vec<_> = state
7065 .active_alerts
7066 .iter()
7067 .filter(|a| a.message.contains("spike"))
7068 .collect();
7069 assert!(!spike_alerts.is_empty(), "Should have volume spike alert");
7070 }
7071
7072 #[test]
7073 fn test_alert_flash_timer_set() {
7074 let token_data = create_test_token_data();
7075 let mut state = MonitorState::new(&token_data, "ethereum");
7076 state.alerts.price_min = Some(2.0);
7077 state.update(&token_data);
7078 assert!(state.alert_flash_until.is_some());
7079 }
7080
7081 #[test]
7082 fn test_render_alert_overlay_no_panic() {
7083 let mut terminal = create_test_terminal();
7084 let mut state = create_populated_state();
7085 state.active_alerts.push(ActiveAlert {
7086 message: "⚠ Test alert".to_string(),
7087 triggered_at: Instant::now(),
7088 });
7089 state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
7090 terminal
7091 .draw(|f| render_alert_overlay(f, f.area(), &state))
7092 .unwrap();
7093 }
7094
7095 #[test]
7096 fn test_render_alert_overlay_empty() {
7097 let mut terminal = create_test_terminal();
7098 let state = create_populated_state();
7099 terminal
7100 .draw(|f| render_alert_overlay(f, f.area(), &state))
7101 .unwrap();
7102 }
7103
7104 #[test]
7105 fn test_alert_config_serde_roundtrip() {
7106 let config = AlertConfig {
7107 price_min: Some(0.5),
7108 price_max: Some(2.0),
7109 whale_min_usd: Some(10_000.0),
7110 volume_spike_threshold_pct: Some(50.0),
7111 };
7112 let yaml = serde_yaml::to_string(&config).unwrap();
7113 let parsed: AlertConfig = serde_yaml::from_str(&yaml).unwrap();
7114 assert_eq!(parsed.price_min, Some(0.5));
7115 assert_eq!(parsed.price_max, Some(2.0));
7116 assert_eq!(parsed.whale_min_usd, Some(10_000.0));
7117 assert_eq!(parsed.volume_spike_threshold_pct, Some(50.0));
7118 }
7119
7120 #[test]
7121 fn test_ui_with_active_alerts() {
7122 let mut terminal = create_test_terminal();
7123 let mut state = create_populated_state();
7124 state.active_alerts.push(ActiveAlert {
7125 message: "⚠ Price below min".to_string(),
7126 triggered_at: Instant::now(),
7127 });
7128 state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
7129 terminal.draw(|f| ui(f, &mut state)).unwrap();
7130 }
7131
7132 #[test]
7137 fn test_export_config_default() {
7138 let config = ExportConfig::default();
7139 assert!(config.path.is_none());
7140 }
7141
7142 fn start_export_in_temp(state: &mut MonitorState) -> PathBuf {
7144 use std::sync::atomic::{AtomicU64, Ordering};
7145 static COUNTER: AtomicU64 = AtomicU64::new(0);
7146 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
7147 let dir =
7148 std::env::temp_dir().join(format!("scope_test_export_{}_{}", std::process::id(), id));
7149 let _ = fs::create_dir_all(&dir);
7150 let filename = format!("{}_test_{}.csv", state.symbol, id);
7151 let path = dir.join(filename);
7152
7153 let mut file = fs::File::create(&path).expect("failed to create export test file");
7154 let header = "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap\n";
7155 file.write_all(header.as_bytes())
7156 .expect("failed to write header");
7157 drop(file); state.export_path = Some(path.clone());
7160 state.export_active = true;
7161 path
7162 }
7163
7164 #[test]
7165 fn test_export_start_creates_file() {
7166 let token_data = create_test_token_data();
7167 let mut state = MonitorState::new(&token_data, "ethereum");
7168 let path = start_export_in_temp(&mut state);
7169
7170 assert!(state.export_active);
7171 assert!(state.export_path.is_some());
7172 assert!(path.exists(), "Export file should exist");
7173
7174 let _ = std::fs::remove_file(&path);
7176 }
7177
7178 #[test]
7179 fn test_export_stop() {
7180 let token_data = create_test_token_data();
7181 let mut state = MonitorState::new(&token_data, "ethereum");
7182 let path = start_export_in_temp(&mut state);
7183 state.stop_export();
7184
7185 assert!(!state.export_active);
7186 assert!(state.export_path.is_none());
7187
7188 let _ = std::fs::remove_file(&path);
7190 }
7191
7192 #[test]
7193 fn test_export_toggle() {
7194 let token_data = create_test_token_data();
7195 let mut state = MonitorState::new(&token_data, "ethereum");
7196
7197 state.toggle_export();
7198 assert!(state.export_active);
7199 let path = state.export_path.clone().unwrap();
7200
7201 state.toggle_export();
7202 assert!(!state.export_active);
7203
7204 let _ = std::fs::remove_file(path);
7206 }
7207
7208 #[test]
7209 fn test_export_writes_csv_rows() {
7210 let token_data = create_test_token_data();
7211 let mut state = MonitorState::new(&token_data, "ethereum");
7212 let path = start_export_in_temp(&mut state);
7213
7214 state.update(&token_data);
7216 state.update(&token_data);
7217
7218 let contents = std::fs::read_to_string(&path).unwrap();
7219 let lines: Vec<&str> = contents.lines().collect();
7220
7221 assert!(
7222 lines.len() >= 3,
7223 "Should have header + 2 data rows, got {}",
7224 lines.len()
7225 );
7226 assert!(lines[0].starts_with("timestamp,price_usd"));
7227
7228 state.stop_export();
7230 let _ = std::fs::remove_file(path);
7231 }
7232
7233 #[test]
7234 fn test_keybinding_e_toggles_export() {
7235 let token_data = create_test_token_data();
7236 let mut state = MonitorState::new(&token_data, "ethereum");
7237
7238 handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
7239 assert!(state.export_active);
7240 let path = state.export_path.clone().unwrap();
7241
7242 handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
7243 assert!(!state.export_active);
7244
7245 let _ = std::fs::remove_file(path);
7247 }
7248
7249 #[test]
7250 fn test_render_footer_with_export_active() {
7251 let mut terminal = create_test_terminal();
7252 let mut state = create_populated_state();
7253 state.export_active = true;
7254 terminal
7255 .draw(|f| render_footer(f, f.area(), &state))
7256 .unwrap();
7257 }
7258
7259 #[test]
7260 fn test_export_config_serde_roundtrip() {
7261 let config = ExportConfig {
7262 path: Some("./my-exports".to_string()),
7263 };
7264 let yaml = serde_yaml::to_string(&config).unwrap();
7265 let parsed: ExportConfig = serde_yaml::from_str(&yaml).unwrap();
7266 assert_eq!(parsed.path, Some("./my-exports".to_string()));
7267 }
7268
7269 #[test]
7274 fn test_auto_pause_default_disabled() {
7275 let token_data = create_test_token_data();
7276 let state = MonitorState::new(&token_data, "ethereum");
7277 assert!(!state.auto_pause_on_input);
7278 assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
7279 }
7280
7281 #[test]
7282 fn test_auto_pause_blocks_refresh() {
7283 let token_data = create_test_token_data();
7284 let mut state = MonitorState::new(&token_data, "ethereum");
7285 state.auto_pause_on_input = true;
7286 state.refresh_rate = Duration::from_secs(1);
7287
7288 state.last_input_at = Instant::now();
7290 state.last_update = Instant::now() - Duration::from_secs(10); assert!(!state.should_refresh());
7294 }
7295
7296 #[test]
7297 fn test_auto_pause_allows_refresh_after_timeout() {
7298 let token_data = create_test_token_data();
7299 let mut state = MonitorState::new(&token_data, "ethereum");
7300 state.auto_pause_on_input = true;
7301 state.refresh_rate = Duration::from_secs(1);
7302 state.auto_pause_timeout = Duration::from_millis(1); state.last_input_at = Instant::now() - Duration::from_secs(10);
7306 state.last_update = Instant::now() - Duration::from_secs(10);
7307
7308 assert!(state.should_refresh());
7310 }
7311
7312 #[test]
7313 fn test_auto_pause_disabled_does_not_block() {
7314 let token_data = create_test_token_data();
7315 let mut state = MonitorState::new(&token_data, "ethereum");
7316 state.auto_pause_on_input = false;
7317 state.refresh_rate = Duration::from_secs(1);
7318
7319 state.last_input_at = Instant::now(); state.last_update = Instant::now() - Duration::from_secs(10);
7321
7322 assert!(state.should_refresh());
7324 }
7325
7326 #[test]
7327 fn test_is_auto_paused() {
7328 let token_data = create_test_token_data();
7329 let mut state = MonitorState::new(&token_data, "ethereum");
7330
7331 state.auto_pause_on_input = false;
7333 state.last_input_at = Instant::now();
7334 assert!(!state.is_auto_paused());
7335
7336 state.auto_pause_on_input = true;
7338 state.last_input_at = Instant::now();
7339 assert!(state.is_auto_paused());
7340
7341 state.last_input_at = Instant::now() - Duration::from_secs(10);
7343 assert!(!state.is_auto_paused());
7344 }
7345
7346 #[test]
7347 fn test_keybinding_shift_p_toggles_auto_pause() {
7348 let token_data = create_test_token_data();
7349 let mut state = MonitorState::new(&token_data, "ethereum");
7350 assert!(!state.auto_pause_on_input);
7351
7352 let shift_p = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
7353 handle_key_event_on_state(shift_p, &mut state);
7354 assert!(state.auto_pause_on_input);
7355
7356 handle_key_event_on_state(shift_p, &mut state);
7357 assert!(!state.auto_pause_on_input);
7358 }
7359
7360 #[test]
7361 fn test_keybinding_updates_last_input_at() {
7362 let token_data = create_test_token_data();
7363 let mut state = MonitorState::new(&token_data, "ethereum");
7364
7365 state.last_input_at = Instant::now() - Duration::from_secs(60);
7367 let old_input = state.last_input_at;
7368
7369 handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
7371 assert!(state.last_input_at > old_input);
7372 }
7373
7374 #[test]
7375 fn test_render_footer_auto_paused() {
7376 let mut terminal = create_test_terminal();
7377 let mut state = create_populated_state();
7378 state.auto_pause_on_input = true;
7379 state.last_input_at = Instant::now(); terminal
7381 .draw(|f| render_footer(f, f.area(), &state))
7382 .unwrap();
7383 }
7384
7385 #[test]
7386 fn test_config_auto_pause_applied() {
7387 let mut state = create_populated_state();
7388 let config = MonitorConfig {
7389 auto_pause_on_input: true,
7390 ..MonitorConfig::default()
7391 };
7392 state.apply_config(&config);
7393 assert!(state.auto_pause_on_input);
7394 }
7395
7396 #[test]
7401 fn test_ui_render_all_layouts_with_alerts_and_export() {
7402 for preset in &[
7403 LayoutPreset::Dashboard,
7404 LayoutPreset::ChartFocus,
7405 LayoutPreset::Feed,
7406 LayoutPreset::Compact,
7407 ] {
7408 let mut terminal = create_test_terminal();
7409 let mut state = create_populated_state();
7410 state.layout = *preset;
7411 state.auto_layout = false;
7412 state.export_active = true;
7413 state.active_alerts.push(ActiveAlert {
7414 message: "⚠ Test alert".to_string(),
7415 triggered_at: Instant::now(),
7416 });
7417 terminal.draw(|f| ui(f, &mut state)).unwrap();
7418 }
7419 }
7420
7421 #[test]
7422 fn test_ui_render_with_liquidity_data() {
7423 let mut terminal = create_test_terminal();
7424 let mut state = create_populated_state();
7425 state.liquidity_pairs = vec![
7426 ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
7427 ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
7428 ];
7429 terminal.draw(|f| ui(f, &mut state)).unwrap();
7430 }
7431
7432 #[test]
7433 fn test_monitor_config_full_serde_roundtrip() {
7434 let config = MonitorConfig {
7435 layout: LayoutPreset::Dashboard,
7436 refresh_seconds: 10,
7437 widgets: WidgetVisibility::default(),
7438 scale: ScaleMode::Log,
7439 color_scheme: ColorScheme::BlueOrange,
7440 alerts: AlertConfig {
7441 price_min: Some(0.5),
7442 price_max: Some(10.0),
7443 whale_min_usd: Some(50_000.0),
7444 volume_spike_threshold_pct: Some(100.0),
7445 },
7446 export: ExportConfig {
7447 path: Some("./exports".to_string()),
7448 },
7449 auto_pause_on_input: true,
7450 venue: Some("binance".to_string()),
7451 };
7452
7453 let yaml = serde_yaml::to_string(&config).unwrap();
7454 let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
7455 assert_eq!(parsed.layout, LayoutPreset::Dashboard);
7456 assert_eq!(parsed.refresh_seconds, 10);
7457 assert_eq!(parsed.venue, Some("binance".to_string()));
7458 assert_eq!(parsed.alerts.price_min, Some(0.5));
7459 assert_eq!(parsed.alerts.price_max, Some(10.0));
7460 assert_eq!(parsed.export.path, Some("./exports".to_string()));
7461 assert!(parsed.auto_pause_on_input);
7462 }
7463
7464 #[test]
7465 fn test_monitor_config_serde_defaults_for_new_fields() {
7466 let yaml = r#"
7468layout: dashboard
7469refresh_seconds: 5
7470"#;
7471 let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
7472 assert!(config.alerts.price_min.is_none());
7473 assert!(config.export.path.is_none());
7474 assert!(!config.auto_pause_on_input);
7475 }
7476
7477 #[test]
7478 fn test_quit_stops_export() {
7479 let token_data = create_test_token_data();
7480 let mut state = MonitorState::new(&token_data, "ethereum");
7481 let path = start_export_in_temp(&mut state);
7482
7483 let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('q')), &mut state);
7484 assert!(exit);
7485 assert!(!state.export_active);
7486
7487 let _ = std::fs::remove_file(path);
7489 }
7490
7491 #[test]
7492 fn test_monitor_state_new_has_alert_export_autopause_fields() {
7493 let token_data = create_test_token_data();
7494 let state = MonitorState::new(&token_data, "ethereum");
7495
7496 assert!(state.active_alerts.is_empty());
7498 assert!(state.alert_flash_until.is_none());
7499 assert!(state.alerts.price_min.is_none());
7500
7501 assert!(!state.export_active);
7503 assert!(state.export_path.is_none());
7504
7505 assert!(!state.auto_pause_on_input);
7507 assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
7508 }
7509
7510 #[test]
7515 fn test_scale_mode_serde_roundtrip() {
7516 for mode in &[ScaleMode::Linear, ScaleMode::Log] {
7517 let yaml = serde_yaml::to_string(mode).unwrap();
7518 let parsed: ScaleMode = serde_yaml::from_str(&yaml).unwrap();
7519 assert_eq!(&parsed, mode);
7520 }
7521 }
7522
7523 #[test]
7524 fn test_scale_mode_serde_kebab_case() {
7525 let parsed: ScaleMode = serde_yaml::from_str("linear").unwrap();
7526 assert_eq!(parsed, ScaleMode::Linear);
7527 let parsed: ScaleMode = serde_yaml::from_str("log").unwrap();
7528 assert_eq!(parsed, ScaleMode::Log);
7529 }
7530
7531 #[test]
7532 fn test_scale_mode_toggle() {
7533 assert_eq!(ScaleMode::Linear.toggle(), ScaleMode::Log);
7534 assert_eq!(ScaleMode::Log.toggle(), ScaleMode::Linear);
7535 }
7536
7537 #[test]
7538 fn test_scale_mode_label() {
7539 assert_eq!(ScaleMode::Linear.label(), "Lin");
7540 assert_eq!(ScaleMode::Log.label(), "Log");
7541 }
7542
7543 #[test]
7544 fn test_color_scheme_serde_roundtrip() {
7545 for scheme in &[
7546 ColorScheme::GreenRed,
7547 ColorScheme::BlueOrange,
7548 ColorScheme::Monochrome,
7549 ] {
7550 let yaml = serde_yaml::to_string(scheme).unwrap();
7551 let parsed: ColorScheme = serde_yaml::from_str(&yaml).unwrap();
7552 assert_eq!(&parsed, scheme);
7553 }
7554 }
7555
7556 #[test]
7557 fn test_color_scheme_serde_kebab_case() {
7558 let parsed: ColorScheme = serde_yaml::from_str("green-red").unwrap();
7559 assert_eq!(parsed, ColorScheme::GreenRed);
7560 let parsed: ColorScheme = serde_yaml::from_str("blue-orange").unwrap();
7561 assert_eq!(parsed, ColorScheme::BlueOrange);
7562 let parsed: ColorScheme = serde_yaml::from_str("monochrome").unwrap();
7563 assert_eq!(parsed, ColorScheme::Monochrome);
7564 }
7565
7566 #[test]
7567 fn test_color_scheme_cycle() {
7568 assert_eq!(ColorScheme::GreenRed.next(), ColorScheme::BlueOrange);
7569 assert_eq!(ColorScheme::BlueOrange.next(), ColorScheme::Monochrome);
7570 assert_eq!(ColorScheme::Monochrome.next(), ColorScheme::GreenRed);
7571 }
7572
7573 #[test]
7574 fn test_color_scheme_label() {
7575 assert_eq!(ColorScheme::GreenRed.label(), "G/R");
7576 assert_eq!(ColorScheme::BlueOrange.label(), "B/O");
7577 assert_eq!(ColorScheme::Monochrome.label(), "Mono");
7578 }
7579
7580 #[test]
7581 fn test_color_palette_fields_populated() {
7582 for scheme in &[
7584 ColorScheme::GreenRed,
7585 ColorScheme::BlueOrange,
7586 ColorScheme::Monochrome,
7587 ] {
7588 let pal = scheme.palette();
7589 assert_ne!(
7591 format!("{:?}", pal.up),
7592 format!("{:?}", pal.down),
7593 "Up/down should differ for {:?}",
7594 scheme
7595 );
7596 }
7597 }
7598
7599 #[test]
7600 fn test_layout_preset_serde_roundtrip() {
7601 for preset in &[
7602 LayoutPreset::Dashboard,
7603 LayoutPreset::ChartFocus,
7604 LayoutPreset::Feed,
7605 LayoutPreset::Compact,
7606 ] {
7607 let yaml = serde_yaml::to_string(preset).unwrap();
7608 let parsed: LayoutPreset = serde_yaml::from_str(&yaml).unwrap();
7609 assert_eq!(&parsed, preset);
7610 }
7611 }
7612
7613 #[test]
7614 fn test_layout_preset_serde_kebab_case() {
7615 let parsed: LayoutPreset = serde_yaml::from_str("dashboard").unwrap();
7616 assert_eq!(parsed, LayoutPreset::Dashboard);
7617 let parsed: LayoutPreset = serde_yaml::from_str("chart-focus").unwrap();
7618 assert_eq!(parsed, LayoutPreset::ChartFocus);
7619 let parsed: LayoutPreset = serde_yaml::from_str("feed").unwrap();
7620 assert_eq!(parsed, LayoutPreset::Feed);
7621 let parsed: LayoutPreset = serde_yaml::from_str("compact").unwrap();
7622 assert_eq!(parsed, LayoutPreset::Compact);
7623 }
7624
7625 #[test]
7626 fn test_widget_visibility_serde_roundtrip() {
7627 let vis = WidgetVisibility {
7628 price_chart: false,
7629 volume_chart: true,
7630 buy_sell_pressure: false,
7631 metrics_panel: true,
7632 activity_log: false,
7633 holder_count: false,
7634 liquidity_depth: true,
7635 };
7636 let yaml = serde_yaml::to_string(&vis).unwrap();
7637 let parsed: WidgetVisibility = serde_yaml::from_str(&yaml).unwrap();
7638 assert!(!parsed.price_chart);
7639 assert!(parsed.volume_chart);
7640 assert!(!parsed.buy_sell_pressure);
7641 assert!(parsed.metrics_panel);
7642 assert!(!parsed.activity_log);
7643 assert!(!parsed.holder_count);
7644 assert!(parsed.liquidity_depth);
7645 }
7646
7647 #[test]
7648 fn test_data_point_serde_roundtrip() {
7649 let dp = DataPoint {
7650 timestamp: 1700000000.5,
7651 value: 42.123456,
7652 is_real: true,
7653 };
7654 let json = serde_json::to_string(&dp).unwrap();
7655 let parsed: DataPoint = serde_json::from_str(&json).unwrap();
7656 assert!((parsed.timestamp - dp.timestamp).abs() < 0.001);
7657 assert!((parsed.value - dp.value).abs() < 0.001);
7658 assert_eq!(parsed.is_real, dp.is_real);
7659 }
7660
7661 #[test]
7666 fn test_handle_key_scale_toggle_s() {
7667 let token_data = create_test_token_data();
7668 let mut state = MonitorState::new(&token_data, "ethereum");
7669 assert_eq!(state.scale_mode, ScaleMode::Linear);
7670
7671 handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
7672 assert_eq!(state.scale_mode, ScaleMode::Log);
7673
7674 handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
7675 assert_eq!(state.scale_mode, ScaleMode::Linear);
7676 }
7677
7678 #[test]
7679 fn test_handle_key_color_scheme_cycle_slash() {
7680 let token_data = create_test_token_data();
7681 let mut state = MonitorState::new(&token_data, "ethereum");
7682 assert_eq!(state.color_scheme, ColorScheme::GreenRed);
7683
7684 handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
7685 assert_eq!(state.color_scheme, ColorScheme::BlueOrange);
7686
7687 handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
7688 assert_eq!(state.color_scheme, ColorScheme::Monochrome);
7689
7690 handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
7691 assert_eq!(state.color_scheme, ColorScheme::GreenRed);
7692 }
7693
7694 #[test]
7699 fn test_render_volume_profile_chart_no_panic() {
7700 let mut terminal = create_test_terminal();
7701 let state = create_populated_state();
7702 terminal
7703 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7704 .unwrap();
7705 }
7706
7707 #[test]
7708 fn test_render_volume_profile_chart_empty_data() {
7709 let mut terminal = create_test_terminal();
7710 let token_data = create_test_token_data();
7711 let mut state = MonitorState::new(&token_data, "ethereum");
7712 state.price_history.clear();
7713 state.volume_history.clear();
7714 terminal
7715 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7716 .unwrap();
7717 }
7718
7719 #[test]
7720 fn test_render_volume_profile_chart_single_price() {
7721 let mut terminal = create_test_terminal();
7723 let mut token_data = create_test_token_data();
7724 token_data.price_usd = 1.0;
7725 let mut state = MonitorState::new(&token_data, "ethereum");
7726 state.price_history.clear();
7728 state.volume_history.clear();
7729 let now = chrono::Utc::now().timestamp() as f64;
7730 for i in 0..5 {
7731 state.price_history.push_back(DataPoint {
7732 timestamp: now - (5.0 - i as f64) * 60.0,
7733 value: 1.0, is_real: true,
7735 });
7736 state.volume_history.push_back(DataPoint {
7737 timestamp: now - (5.0 - i as f64) * 60.0,
7738 value: 1000.0,
7739 is_real: true,
7740 });
7741 }
7742 terminal
7743 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7744 .unwrap();
7745 }
7746
7747 #[test]
7748 fn test_render_volume_profile_chart_narrow_terminal() {
7749 let backend = TestBackend::new(30, 15);
7750 let mut terminal = Terminal::new(backend).unwrap();
7751 let state = create_populated_state();
7752 terminal
7753 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7754 .unwrap();
7755 }
7756
7757 #[test]
7762 fn test_render_price_chart_log_scale() {
7763 let mut terminal = create_test_terminal();
7764 let mut state = create_populated_state();
7765 state.scale_mode = ScaleMode::Log;
7766 terminal
7767 .draw(|f| render_price_chart(f, f.area(), &state))
7768 .unwrap();
7769 }
7770
7771 #[test]
7772 fn test_render_candlestick_chart_log_scale() {
7773 let mut terminal = create_test_terminal();
7774 let mut state = create_populated_state();
7775 state.scale_mode = ScaleMode::Log;
7776 state.chart_mode = ChartMode::Candlestick;
7777 terminal
7778 .draw(|f| render_candlestick_chart(f, f.area(), &state))
7779 .unwrap();
7780 }
7781
7782 #[test]
7783 fn test_render_price_chart_log_scale_zero_price() {
7784 let mut terminal = create_test_terminal();
7786 let mut token_data = create_test_token_data();
7787 token_data.price_usd = 0.0001;
7788 let mut state = MonitorState::new(&token_data, "ethereum");
7789 state.scale_mode = ScaleMode::Log;
7790 for i in 0..10 {
7791 let mut data = token_data.clone();
7792 data.price_usd = 0.0001 + (i as f64 * 0.00001);
7793 state.update(&data);
7794 }
7795 terminal
7796 .draw(|f| render_price_chart(f, f.area(), &state))
7797 .unwrap();
7798 }
7799
7800 #[test]
7805 fn test_render_ui_with_all_color_schemes() {
7806 for scheme in &[
7807 ColorScheme::GreenRed,
7808 ColorScheme::BlueOrange,
7809 ColorScheme::Monochrome,
7810 ] {
7811 let mut terminal = create_test_terminal();
7812 let mut state = create_populated_state();
7813 state.color_scheme = *scheme;
7814 terminal.draw(|f| ui(f, &mut state)).unwrap();
7815 }
7816 }
7817
7818 #[test]
7819 fn test_render_volume_chart_all_color_schemes() {
7820 for scheme in &[
7821 ColorScheme::GreenRed,
7822 ColorScheme::BlueOrange,
7823 ColorScheme::Monochrome,
7824 ] {
7825 let mut terminal = create_test_terminal();
7826 let mut state = create_populated_state();
7827 state.color_scheme = *scheme;
7828 terminal
7829 .draw(|f| render_volume_chart(f, f.area(), &state))
7830 .unwrap();
7831 }
7832 }
7833
7834 #[test]
7839 fn test_render_activity_feed_no_panic() {
7840 let mut terminal = create_test_terminal();
7841 let mut state = create_populated_state();
7842 for i in 0..5 {
7843 state.log_messages.push_back(format!("Event {}", i));
7844 }
7845 terminal
7846 .draw(|f| render_activity_feed(f, f.area(), &mut state))
7847 .unwrap();
7848 }
7849
7850 #[test]
7851 fn test_render_activity_feed_empty_log() {
7852 let mut terminal = create_test_terminal();
7853 let token_data = create_test_token_data();
7854 let mut state = MonitorState::new(&token_data, "ethereum");
7855 state.log_messages.clear();
7856 terminal
7857 .draw(|f| render_activity_feed(f, f.area(), &mut state))
7858 .unwrap();
7859 }
7860
7861 #[test]
7862 fn test_render_activity_feed_with_selection() {
7863 let mut terminal = create_test_terminal();
7864 let mut state = create_populated_state();
7865 for i in 0..10 {
7866 state.log_messages.push_back(format!("Event {}", i));
7867 }
7868 state.scroll_log_down();
7869 state.scroll_log_down();
7870 state.scroll_log_down();
7871 terminal
7872 .draw(|f| render_activity_feed(f, f.area(), &mut state))
7873 .unwrap();
7874 }
7875
7876 #[test]
7881 fn test_alert_whale_zero_transactions() {
7882 let mut token_data = create_test_token_data();
7883 token_data.total_buys_24h = 0;
7884 token_data.total_sells_24h = 0;
7885 let mut state = MonitorState::new(&token_data, "ethereum");
7886 state.alerts.whale_min_usd = Some(100.0);
7887 state.update(&token_data);
7888 let whale_alerts: Vec<_> = state
7890 .active_alerts
7891 .iter()
7892 .filter(|a| a.message.contains("whale") || a.message.contains("🐋"))
7893 .collect();
7894 assert!(
7895 whale_alerts.is_empty(),
7896 "Whale alert should not fire with zero transactions"
7897 );
7898 }
7899
7900 #[test]
7901 fn test_alert_multiple_simultaneous() {
7902 let mut token_data = create_test_token_data();
7903 token_data.price_usd = 0.1; let mut state = MonitorState::new(&token_data, "ethereum");
7905 state.alerts.price_min = Some(0.5); state.alerts.price_max = Some(0.05); state.alerts.volume_spike_threshold_pct = Some(1.0);
7908 state.volume_avg = 100.0; state.update(&token_data);
7911 assert!(
7913 state.active_alerts.len() >= 2,
7914 "Expected multiple alerts, got {}",
7915 state.active_alerts.len()
7916 );
7917 }
7918
7919 #[test]
7920 fn test_alert_clears_on_next_update() {
7921 let token_data = create_test_token_data();
7922 let mut state = MonitorState::new(&token_data, "ethereum");
7923 state.alerts.price_min = Some(2.0); state.update(&token_data);
7925 assert!(!state.active_alerts.is_empty());
7926
7927 let mut above_min = token_data.clone();
7929 above_min.price_usd = 3.0;
7930 state.alerts.price_min = Some(2.0);
7931 state.update(&above_min);
7932 let price_min_alerts: Vec<_> = state
7934 .active_alerts
7935 .iter()
7936 .filter(|a| a.message.contains("below min"))
7937 .collect();
7938 assert!(
7939 price_min_alerts.is_empty(),
7940 "Price-min alert should clear when price goes above min"
7941 );
7942 }
7943
7944 #[test]
7945 fn test_render_alert_overlay_multiple_alerts() {
7946 let mut terminal = create_test_terminal();
7947 let mut state = create_populated_state();
7948 state.active_alerts.push(ActiveAlert {
7949 message: "⚠ Price below min".to_string(),
7950 triggered_at: Instant::now(),
7951 });
7952 state.active_alerts.push(ActiveAlert {
7953 message: "🐋 Whale detected".to_string(),
7954 triggered_at: Instant::now(),
7955 });
7956 state.active_alerts.push(ActiveAlert {
7957 message: "⚠ Volume spike".to_string(),
7958 triggered_at: Instant::now(),
7959 });
7960 state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
7961 terminal
7962 .draw(|f| render_alert_overlay(f, f.area(), &state))
7963 .unwrap();
7964 }
7965
7966 #[test]
7967 fn test_render_alert_overlay_flash_expired() {
7968 let mut terminal = create_test_terminal();
7969 let mut state = create_populated_state();
7970 state.active_alerts.push(ActiveAlert {
7971 message: "⚠ Test".to_string(),
7972 triggered_at: Instant::now(),
7973 });
7974 state.alert_flash_until = Some(Instant::now() - Duration::from_secs(5));
7976 terminal
7977 .draw(|f| render_alert_overlay(f, f.area(), &state))
7978 .unwrap();
7979 }
7980
7981 #[test]
7986 fn test_render_liquidity_depth_many_pairs() {
7987 let mut terminal = create_test_terminal();
7988 let mut state = create_populated_state();
7989 for i in 0..20 {
7991 state.liquidity_pairs.push((
7992 format!("TEST/TOKEN{} (DEX{})", i, i),
7993 (100_000.0 + i as f64 * 50_000.0),
7994 ));
7995 }
7996 terminal
7997 .draw(|f| render_liquidity_depth(f, f.area(), &state))
7998 .unwrap();
7999 }
8000
8001 #[test]
8002 fn test_render_liquidity_depth_narrow_terminal() {
8003 let backend = TestBackend::new(30, 10);
8004 let mut terminal = Terminal::new(backend).unwrap();
8005 let mut state = create_populated_state();
8006 state.liquidity_pairs = vec![
8007 ("TEST/WETH (Uniswap)".to_string(), 500_000.0),
8008 ("TEST/USDC (Sushi)".to_string(), 100_000.0),
8009 ];
8010 terminal
8011 .draw(|f| render_liquidity_depth(f, f.area(), &state))
8012 .unwrap();
8013 }
8014
8015 #[test]
8020 fn test_render_metrics_panel_holder_count_disabled() {
8021 let mut terminal = create_test_terminal();
8022 let mut state = create_populated_state();
8023 state.holder_count = Some(42_000);
8024 state.widgets.holder_count = false; terminal
8026 .draw(|f| render_metrics_panel(f, f.area(), &state))
8027 .unwrap();
8028 }
8029
8030 #[test]
8031 fn test_render_metrics_panel_sparkline_single_point() {
8032 let mut terminal = create_test_terminal();
8033 let mut token_data = create_test_token_data();
8034 token_data.price_usd = 1.0;
8035 let mut state = MonitorState::new(&token_data, "ethereum");
8036 state.price_history.clear();
8037 state.price_history.push_back(DataPoint {
8038 timestamp: 1.0,
8039 value: 1.0,
8040 is_real: true,
8041 });
8042 terminal
8043 .draw(|f| render_metrics_panel(f, f.area(), &state))
8044 .unwrap();
8045 }
8046
8047 #[test]
8052 fn test_render_buy_sell_gauge_tiny_area() {
8053 let backend = TestBackend::new(5, 3);
8055 let mut terminal = Terminal::new(backend).unwrap();
8056 let mut state = create_populated_state();
8057 terminal
8058 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
8059 .unwrap();
8060 }
8061
8062 #[test]
8067 fn test_log_message_queue_overflow() {
8068 let token_data = create_test_token_data();
8069 let mut state = MonitorState::new(&token_data, "ethereum");
8070 for i in 0..20 {
8072 state.toggle_pause(); let _ = i;
8074 }
8075 assert!(
8076 state.log_messages.len() <= 10,
8077 "Log queue should cap at 10, got {}",
8078 state.log_messages.len()
8079 );
8080 }
8081
8082 #[test]
8087 fn test_export_writes_csv_row_content_format() {
8088 let token_data = create_test_token_data();
8089 let mut state = MonitorState::new(&token_data, "ethereum");
8090 let path = start_export_in_temp(&mut state);
8091
8092 state.update(&token_data);
8093
8094 let contents = std::fs::read_to_string(&path).unwrap();
8095 let lines: Vec<&str> = contents.lines().collect();
8096 assert!(lines.len() >= 2);
8097
8098 assert_eq!(
8100 lines[0],
8101 "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap"
8102 );
8103
8104 let data_cols: Vec<&str> = lines[1].split(',').collect();
8106 assert_eq!(
8107 data_cols.len(),
8108 7,
8109 "Expected 7 CSV columns, got {}",
8110 data_cols.len()
8111 );
8112
8113 assert!(data_cols[0].contains('T'));
8115 assert!(data_cols[0].ends_with('Z'));
8116
8117 state.stop_export();
8119 let _ = std::fs::remove_file(path);
8120 }
8121
8122 #[test]
8123 fn test_export_writes_csv_row_market_cap_none() {
8124 let mut token_data = create_test_token_data();
8125 token_data.market_cap = None;
8126 let mut state = MonitorState::new(&token_data, "ethereum");
8127 let path = start_export_in_temp(&mut state);
8128
8129 state.update(&token_data);
8130
8131 let contents = std::fs::read_to_string(&path).unwrap();
8132 let lines: Vec<&str> = contents.lines().collect();
8133 assert!(lines.len() >= 2);
8134
8135 let data_cols: Vec<&str> = lines[1].split(',').collect();
8137 assert_eq!(data_cols.len(), 7);
8138 assert!(
8139 data_cols[6].is_empty(),
8140 "Market cap column should be empty when None"
8141 );
8142
8143 state.stop_export();
8145 let _ = std::fs::remove_file(path);
8146 }
8147
8148 #[test]
8153 fn test_ui_render_log_scale_all_chart_modes() {
8154 for mode in &[
8155 ChartMode::Line,
8156 ChartMode::Candlestick,
8157 ChartMode::VolumeProfile,
8158 ] {
8159 let mut terminal = create_test_terminal();
8160 let mut state = create_populated_state();
8161 state.scale_mode = ScaleMode::Log;
8162 state.chart_mode = *mode;
8163 terminal.draw(|f| ui(f, &mut state)).unwrap();
8164 }
8165 }
8166
8167 #[test]
8172 fn test_render_footer_widget_toggle_mode_active() {
8173 let mut terminal = create_test_terminal();
8174 let mut state = create_populated_state();
8175 state.widget_toggle_mode = true;
8176 terminal
8177 .draw(|f| render_footer(f, f.area(), &state))
8178 .unwrap();
8179 }
8180
8181 #[test]
8182 fn test_render_footer_all_status_indicators() {
8183 let mut terminal = create_test_terminal();
8185 let mut state = create_populated_state();
8186 state.export_active = true;
8187 state.auto_pause_on_input = true;
8188 state.last_input_at = Instant::now(); terminal
8190 .draw(|f| render_footer(f, f.area(), &state))
8191 .unwrap();
8192 }
8193
8194 #[test]
8199 fn test_generate_synthetic_price_history_zero_price() {
8200 let mut token_data = create_test_token_data();
8201 token_data.price_usd = 0.0;
8202 token_data.price_change_1h = 0.0;
8203 token_data.price_change_6h = 0.0;
8204 token_data.price_change_24h = 0.0;
8205 let state = MonitorState::new(&token_data, "ethereum");
8206 assert!(!state.price_history.is_empty());
8208 }
8209
8210 #[test]
8211 fn test_generate_synthetic_volume_history_zero_volume() {
8212 let mut token_data = create_test_token_data();
8213 token_data.volume_24h = 0.0;
8214 token_data.volume_6h = 0.0;
8215 token_data.volume_1h = 0.0;
8216 let state = MonitorState::new(&token_data, "ethereum");
8217 assert!(!state.volume_history.is_empty());
8218 }
8219
8220 #[test]
8221 fn test_generate_synthetic_order_book() {
8222 let pairs = vec![crate::chains::DexPair {
8223 dex_name: "Uniswap V3".to_string(),
8224 pair_address: "0xabc".to_string(),
8225 base_token: "PUSD".to_string(),
8226 quote_token: "USDT".to_string(),
8227 price_usd: 1.0,
8228 volume_24h: 50_000.0,
8229 liquidity_usd: 200_000.0,
8230 price_change_24h: 0.1,
8231 buys_24h: 100,
8232 sells_24h: 90,
8233 buys_6h: 30,
8234 sells_6h: 25,
8235 buys_1h: 10,
8236 sells_1h: 8,
8237 pair_created_at: None,
8238 url: None,
8239 }];
8240 let book = MonitorState::generate_synthetic_order_book(&pairs, "PUSD", 1.0, 200_000.0);
8241 assert!(book.is_some());
8242 let book = book.unwrap();
8243 assert_eq!(book.pair, "PUSD/USDT");
8244 assert!(!book.asks.is_empty());
8245 assert!(!book.bids.is_empty());
8246 for w in book.asks.windows(2) {
8248 assert!(w[0].price <= w[1].price);
8249 }
8250 for w in book.bids.windows(2) {
8252 assert!(w[0].price >= w[1].price);
8253 }
8254 }
8255
8256 #[test]
8257 fn test_generate_synthetic_order_book_zero_price() {
8258 let book = MonitorState::generate_synthetic_order_book(&[], "TEST", 0.0, 100_000.0);
8259 assert!(book.is_none());
8260 }
8261
8262 #[test]
8263 fn test_generate_synthetic_order_book_zero_liquidity() {
8264 let book = MonitorState::generate_synthetic_order_book(&[], "TEST", 1.0, 0.0);
8265 assert!(book.is_none());
8266 }
8267
8268 #[test]
8273 fn test_auto_pause_custom_timeout() {
8274 let token_data = create_test_token_data();
8275 let mut state = MonitorState::new(&token_data, "ethereum");
8276 state.auto_pause_on_input = true;
8277 state.auto_pause_timeout = Duration::from_secs(10);
8278 state.refresh_rate = Duration::from_secs(1);
8279
8280 state.last_input_at = Instant::now();
8282 state.last_update = Instant::now() - Duration::from_secs(5);
8283 assert!(!state.should_refresh()); assert!(state.is_auto_paused());
8285 }
8286
8287 #[test]
8292 fn test_render_price_chart_stablecoin_flat_range() {
8293 let mut terminal = create_test_terminal();
8294 let mut token_data = create_test_token_data();
8295 token_data.price_usd = 1.0;
8296 let mut state = MonitorState::new(&token_data, "ethereum");
8297 for i in 0..20 {
8299 let mut data = token_data.clone();
8300 data.price_usd = 1.0 + (i as f64 * 0.000001); state.update(&data);
8302 }
8303 terminal
8304 .draw(|f| render_price_chart(f, f.area(), &state))
8305 .unwrap();
8306 }
8307
8308 #[test]
8313 fn test_load_cache_corrupted_json() {
8314 let path = MonitorState::cache_path("0xCORRUPTED_TEST", "test_chain");
8315 let _ = std::fs::write(&path, "not valid json {{{");
8317 let cached = MonitorState::load_cache("0xCORRUPTED_TEST", "test_chain");
8318 assert!(cached.is_none(), "Corrupted JSON should return None");
8319 let _ = std::fs::remove_file(path);
8320 }
8321
8322 #[test]
8323 fn test_load_cache_wrong_token() {
8324 let token_data = create_test_token_data();
8325 let state = MonitorState::new(&token_data, "ethereum");
8326 state.save_cache();
8327
8328 let cached = MonitorState::load_cache("0xDIFFERENT_ADDRESS", "ethereum");
8330 assert!(
8331 cached.is_none(),
8332 "Loading cache with wrong token address should return None"
8333 );
8334
8335 let path = MonitorState::cache_path(&token_data.address, "ethereum");
8337 let _ = std::fs::remove_file(path);
8338 }
8339
8340 use crate::chains::dex::TokenSearchResult;
8345
8346 struct MockDexDataSource {
8348 token_data_result: std::sync::Mutex<Result<DexTokenData>>,
8350 }
8351
8352 impl MockDexDataSource {
8353 fn new(data: DexTokenData) -> Self {
8354 Self {
8355 token_data_result: std::sync::Mutex::new(Ok(data)),
8356 }
8357 }
8358
8359 fn failing(msg: &str) -> Self {
8360 Self {
8361 token_data_result: std::sync::Mutex::new(Err(ScopeError::Api(msg.to_string()))),
8362 }
8363 }
8364 }
8365
8366 #[async_trait::async_trait]
8367 impl DexDataSource for MockDexDataSource {
8368 async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
8369 self.token_data_result
8370 .lock()
8371 .unwrap()
8372 .as_ref()
8373 .ok()
8374 .map(|d| d.price_usd)
8375 }
8376
8377 async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
8378 Some(2000.0)
8379 }
8380
8381 async fn get_token_data(&self, _chain: &str, _address: &str) -> Result<DexTokenData> {
8382 let guard = self.token_data_result.lock().unwrap();
8383 match &*guard {
8384 Ok(data) => Ok(data.clone()),
8385 Err(e) => Err(ScopeError::Api(e.to_string())),
8386 }
8387 }
8388
8389 async fn search_tokens(
8390 &self,
8391 _query: &str,
8392 _chain: Option<&str>,
8393 ) -> Result<Vec<TokenSearchResult>> {
8394 Ok(vec![])
8395 }
8396 }
8397
8398 struct MockChainClient {
8400 holder_count: u64,
8401 }
8402
8403 impl MockChainClient {
8404 fn new(holder_count: u64) -> Self {
8405 Self { holder_count }
8406 }
8407 }
8408
8409 #[async_trait::async_trait]
8410 impl ChainClient for MockChainClient {
8411 fn chain_name(&self) -> &str {
8412 "ethereum"
8413 }
8414 fn native_token_symbol(&self) -> &str {
8415 "ETH"
8416 }
8417 async fn get_balance(&self, _address: &str) -> Result<crate::chains::Balance> {
8418 unimplemented!("not needed for monitor tests")
8419 }
8420 async fn enrich_balance_usd(&self, _balance: &mut crate::chains::Balance) {}
8421 async fn get_transaction(&self, _hash: &str) -> Result<crate::chains::Transaction> {
8422 unimplemented!("not needed for monitor tests")
8423 }
8424 async fn get_transactions(
8425 &self,
8426 _address: &str,
8427 _limit: u32,
8428 ) -> Result<Vec<crate::chains::Transaction>> {
8429 Ok(vec![])
8430 }
8431 async fn get_block_number(&self) -> Result<u64> {
8432 Ok(1000000)
8433 }
8434 async fn get_token_balances(
8435 &self,
8436 _address: &str,
8437 ) -> Result<Vec<crate::chains::TokenBalance>> {
8438 Ok(vec![])
8439 }
8440 async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
8441 Ok(self.holder_count)
8442 }
8443 }
8444
8445 fn create_test_app(
8447 dex: Box<dyn DexDataSource>,
8448 chain_client: Option<Box<dyn ChainClient>>,
8449 ) -> MonitorApp<TestBackend> {
8450 let token_data = create_test_token_data();
8451 let state = MonitorState::new(&token_data, "ethereum");
8452 let backend = TestBackend::new(120, 40);
8453 let terminal = ratatui::Terminal::new(backend).unwrap();
8454 MonitorApp {
8455 terminal,
8456 state,
8457 dex_client: dex,
8458 chain_client,
8459 exchange_client: None,
8460 should_exit: false,
8461 owns_terminal: false,
8462 }
8463 }
8464
8465 fn create_test_app_with_state(
8466 state: MonitorState,
8467 dex: Box<dyn DexDataSource>,
8468 chain_client: Option<Box<dyn ChainClient>>,
8469 ) -> MonitorApp<TestBackend> {
8470 let backend = TestBackend::new(120, 40);
8471 let terminal = ratatui::Terminal::new(backend).unwrap();
8472 MonitorApp {
8473 terminal,
8474 state,
8475 dex_client: dex,
8476 chain_client,
8477 exchange_client: None,
8478 should_exit: false,
8479 owns_terminal: false,
8480 }
8481 }
8482
8483 #[test]
8488 fn test_app_handle_key_quit_q() {
8489 let data = create_test_token_data();
8490 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8491 assert!(!app.should_exit);
8492 app.handle_key_event(make_key_event(KeyCode::Char('q')));
8493 assert!(app.should_exit);
8494 }
8495
8496 #[test]
8497 fn test_app_handle_key_quit_esc() {
8498 let data = create_test_token_data();
8499 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8500 app.handle_key_event(make_key_event(KeyCode::Esc));
8501 assert!(app.should_exit);
8502 }
8503
8504 #[test]
8505 fn test_app_handle_key_quit_ctrl_c() {
8506 let data = create_test_token_data();
8507 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8508 let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
8509 app.handle_key_event(key);
8510 assert!(app.should_exit);
8511 }
8512
8513 #[test]
8514 fn test_app_handle_key_quit_stops_active_export() {
8515 let data = create_test_token_data();
8516 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8517 let path = start_export_in_temp(&mut app.state);
8518 assert!(app.state.export_active);
8519
8520 app.handle_key_event(make_key_event(KeyCode::Char('q')));
8521 assert!(app.should_exit);
8522 assert!(!app.state.export_active);
8523 let _ = std::fs::remove_file(path);
8524 }
8525
8526 #[test]
8527 fn test_app_handle_key_ctrl_c_stops_active_export() {
8528 let data = create_test_token_data();
8529 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8530 let path = start_export_in_temp(&mut app.state);
8531 assert!(app.state.export_active);
8532
8533 let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
8534 app.handle_key_event(key);
8535 assert!(app.should_exit);
8536 assert!(!app.state.export_active);
8537 let _ = std::fs::remove_file(path);
8538 }
8539
8540 #[test]
8541 fn test_app_handle_key_updates_last_input_time() {
8542 let data = create_test_token_data();
8543 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8544 let before = Instant::now();
8545 app.handle_key_event(make_key_event(KeyCode::Char('p')));
8546 assert!(app.state.last_input_at >= before);
8547 }
8548
8549 #[test]
8550 fn test_app_handle_key_widget_toggle_mode() {
8551 let data = create_test_token_data();
8552 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8553 assert!(app.state.widgets.price_chart);
8554
8555 app.handle_key_event(make_key_event(KeyCode::Char('w')));
8557 assert!(app.state.widget_toggle_mode);
8558
8559 app.handle_key_event(make_key_event(KeyCode::Char('1')));
8561 assert!(!app.state.widget_toggle_mode);
8562 assert!(!app.state.widgets.price_chart);
8563 }
8564
8565 #[test]
8566 fn test_app_handle_key_widget_toggle_mode_cancel() {
8567 let data = create_test_token_data();
8568 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8569
8570 app.handle_key_event(make_key_event(KeyCode::Char('w')));
8572 assert!(app.state.widget_toggle_mode);
8573
8574 app.handle_key_event(make_key_event(KeyCode::Char('x')));
8576 assert!(!app.state.widget_toggle_mode);
8577 }
8578
8579 #[test]
8580 fn test_app_handle_key_all_keybindings() {
8581 let data = create_test_token_data();
8582 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8583
8584 app.handle_key_event(make_key_event(KeyCode::Char('r')));
8586 assert!(!app.should_exit);
8587
8588 let key = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
8590 app.handle_key_event(key);
8591 assert!(app.state.auto_pause_on_input);
8592 app.handle_key_event(key);
8593 assert!(!app.state.auto_pause_on_input);
8594
8595 app.handle_key_event(make_key_event(KeyCode::Char('p')));
8597 assert!(app.state.paused);
8598
8599 app.handle_key_event(make_key_event(KeyCode::Char(' ')));
8601 assert!(!app.state.paused);
8602
8603 app.handle_key_event(make_key_event(KeyCode::Char('e')));
8605 assert!(app.state.export_active);
8606 app.state.stop_export();
8608
8609 let before_rate = app.state.refresh_rate;
8611 app.handle_key_event(make_key_event(KeyCode::Char('+')));
8612 assert!(app.state.refresh_rate >= before_rate);
8613
8614 let before_rate = app.state.refresh_rate;
8616 app.handle_key_event(make_key_event(KeyCode::Char('-')));
8617 assert!(app.state.refresh_rate <= before_rate);
8618
8619 app.handle_key_event(make_key_event(KeyCode::Char('1')));
8621 assert_eq!(app.state.time_period, TimePeriod::Min1);
8622 app.handle_key_event(make_key_event(KeyCode::Char('2')));
8623 assert_eq!(app.state.time_period, TimePeriod::Min5);
8624 app.handle_key_event(make_key_event(KeyCode::Char('3')));
8625 assert_eq!(app.state.time_period, TimePeriod::Min15);
8626 app.handle_key_event(make_key_event(KeyCode::Char('4')));
8627 assert_eq!(app.state.time_period, TimePeriod::Hour1);
8628 app.handle_key_event(make_key_event(KeyCode::Char('5')));
8629 assert_eq!(app.state.time_period, TimePeriod::Hour4);
8630 app.handle_key_event(make_key_event(KeyCode::Char('6')));
8631 assert_eq!(app.state.time_period, TimePeriod::Day1);
8632
8633 app.handle_key_event(make_key_event(KeyCode::Char('t')));
8635 assert_eq!(app.state.time_period, TimePeriod::Min1); app.handle_key_event(make_key_event(KeyCode::Char('c')));
8639 assert_eq!(app.state.chart_mode, ChartMode::Candlestick);
8640
8641 app.handle_key_event(make_key_event(KeyCode::Char('s')));
8643 assert_eq!(app.state.scale_mode, ScaleMode::Log);
8644
8645 app.handle_key_event(make_key_event(KeyCode::Char('/')));
8647 assert_eq!(app.state.color_scheme, ColorScheme::BlueOrange);
8648
8649 app.handle_key_event(make_key_event(KeyCode::Char('j')));
8651
8652 app.handle_key_event(make_key_event(KeyCode::Char('k')));
8654
8655 app.handle_key_event(make_key_event(KeyCode::Char('l')));
8657 assert!(!app.state.auto_layout);
8658
8659 app.handle_key_event(make_key_event(KeyCode::Char('h')));
8661
8662 app.handle_key_event(make_key_event(KeyCode::Char('a')));
8664 assert!(app.state.auto_layout);
8665
8666 app.handle_key_event(make_key_event(KeyCode::Char('w')));
8668 assert!(app.state.widget_toggle_mode);
8669 app.handle_key_event(make_key_event(KeyCode::Char('z')));
8671
8672 app.handle_key_event(make_key_event(KeyCode::F(12)));
8674 assert!(!app.should_exit);
8675 }
8676
8677 #[tokio::test]
8682 async fn test_app_fetch_data_success() {
8683 let data = create_test_token_data();
8684 let initial_price = data.price_usd;
8685 let mut updated = data.clone();
8686 updated.price_usd = 2.5;
8687 let mut app = create_test_app(Box::new(MockDexDataSource::new(updated)), None);
8688
8689 assert!((app.state.current_price - initial_price).abs() < 0.001);
8690 app.fetch_data().await;
8691 assert!((app.state.current_price - 2.5).abs() < 0.001);
8692 assert!(app.state.error_message.is_none());
8693 }
8694
8695 #[tokio::test]
8696 async fn test_app_fetch_data_api_error() {
8697 let mut app = create_test_app(Box::new(MockDexDataSource::failing("rate limited")), None);
8698
8699 app.fetch_data().await;
8700 assert!(app.state.error_message.is_some());
8701 assert!(
8702 app.state
8703 .error_message
8704 .as_ref()
8705 .unwrap()
8706 .contains("API Error")
8707 );
8708 }
8709
8710 #[tokio::test]
8711 async fn test_app_fetch_data_holder_count_on_12th_tick() {
8712 let data = create_test_token_data();
8713 let mock_chain = MockChainClient::new(42_000);
8714 let mut app = create_test_app(
8715 Box::new(MockDexDataSource::new(data)),
8716 Some(Box::new(mock_chain)),
8717 );
8718
8719 for _ in 0..11 {
8721 app.fetch_data().await;
8722 }
8723 assert!(app.state.holder_count.is_none());
8724
8725 app.fetch_data().await;
8727 assert_eq!(app.state.holder_count, Some(42_000));
8728 }
8729
8730 #[tokio::test]
8731 async fn test_app_fetch_data_holder_count_zero_not_stored() {
8732 let data = create_test_token_data();
8733 let mock_chain = MockChainClient::new(0); let mut app = create_test_app(
8735 Box::new(MockDexDataSource::new(data)),
8736 Some(Box::new(mock_chain)),
8737 );
8738
8739 app.state.holder_fetch_counter = 11;
8741 app.fetch_data().await;
8742 assert!(app.state.holder_count.is_none());
8744 }
8745
8746 #[tokio::test]
8747 async fn test_app_fetch_data_no_chain_client_skips_holders() {
8748 let data = create_test_token_data();
8749 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8750
8751 app.state.holder_fetch_counter = 11;
8753 app.fetch_data().await;
8754 assert!(app.state.holder_count.is_none());
8756 }
8757
8758 #[tokio::test]
8759 async fn test_app_fetch_data_preserves_holder_on_subsequent_failure() {
8760 let data = create_test_token_data();
8761 let mock_chain = MockChainClient::new(42_000);
8762 let mut app = create_test_app(
8763 Box::new(MockDexDataSource::new(data)),
8764 Some(Box::new(mock_chain)),
8765 );
8766
8767 app.state.holder_fetch_counter = 11;
8769 app.fetch_data().await;
8770 assert_eq!(app.state.holder_count, Some(42_000));
8771
8772 app.chain_client = Some(Box::new(MockChainClient::new(0)));
8774 app.state.holder_fetch_counter = 23;
8776 app.fetch_data().await;
8777 assert_eq!(app.state.holder_count, Some(42_000));
8779 }
8780
8781 #[test]
8786 fn test_app_cleanup_does_not_panic_test_backend() {
8787 let data = create_test_token_data();
8788 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8789 let result = app.cleanup();
8791 assert!(result.is_ok());
8792 }
8793
8794 #[test]
8799 fn test_app_draw_renders_ui() {
8800 let data = create_test_token_data();
8801 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8802 app.terminal
8804 .draw(|f| ui(f, &mut app.state))
8805 .expect("should render without panic");
8806 }
8807
8808 fn make_search_results() -> Vec<TokenSearchResult> {
8813 vec![
8814 TokenSearchResult {
8815 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
8816 symbol: "USDC".to_string(),
8817 name: "USD Coin".to_string(),
8818 chain: "ethereum".to_string(),
8819 price_usd: Some(1.0),
8820 volume_24h: 5_000_000_000.0,
8821 liquidity_usd: 2_000_000_000.0,
8822 market_cap: Some(32_000_000_000.0),
8823 },
8824 TokenSearchResult {
8825 address: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(),
8826 symbol: "USDT".to_string(),
8827 name: "Tether USD".to_string(),
8828 chain: "ethereum".to_string(),
8829 price_usd: Some(1.0),
8830 volume_24h: 6_000_000_000.0,
8831 liquidity_usd: 3_000_000_000.0,
8832 market_cap: Some(83_000_000_000.0),
8833 },
8834 TokenSearchResult {
8835 address: "0x6B175474E89094C44Da98b954EedeAC495271d0F".to_string(),
8836 symbol: "DAI".to_string(),
8837 name: "Dai Stablecoin".to_string(),
8838 chain: "ethereum".to_string(),
8839 price_usd: Some(1.0),
8840 volume_24h: 200_000_000.0,
8841 liquidity_usd: 500_000_000.0,
8842 market_cap: Some(5_000_000_000.0),
8843 },
8844 ]
8845 }
8846
8847 #[test]
8848 fn test_select_token_impl_valid_first() {
8849 let results = make_search_results();
8850 let mut reader = io::Cursor::new(b"1\n");
8851 let mut writer = Vec::new();
8852 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8853 assert_eq!(selected.symbol, "USDC");
8854 assert_eq!(selected.address, results[0].address);
8855 let output = String::from_utf8(writer).unwrap();
8856 assert!(output.contains("Found 3 tokens"));
8857 assert!(output.contains("Selected: USDC"));
8858 }
8859
8860 #[test]
8861 fn test_select_token_impl_valid_last() {
8862 let results = make_search_results();
8863 let mut reader = io::Cursor::new(b"3\n");
8864 let mut writer = Vec::new();
8865 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8866 assert_eq!(selected.symbol, "DAI");
8867 }
8868
8869 #[test]
8870 fn test_select_token_impl_valid_middle() {
8871 let results = make_search_results();
8872 let mut reader = io::Cursor::new(b"2\n");
8873 let mut writer = Vec::new();
8874 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8875 assert_eq!(selected.symbol, "USDT");
8876 }
8877
8878 #[test]
8879 fn test_select_token_impl_out_of_bounds_zero() {
8880 let results = make_search_results();
8881 let mut reader = io::Cursor::new(b"0\n");
8882 let mut writer = Vec::new();
8883 let result = select_token_impl(&results, &mut reader, &mut writer);
8884 assert!(result.is_err());
8885 let err = result.unwrap_err().to_string();
8886 assert!(err.contains("Selection must be between 1 and 3"));
8887 }
8888
8889 #[test]
8890 fn test_select_token_impl_out_of_bounds_high() {
8891 let results = make_search_results();
8892 let mut reader = io::Cursor::new(b"99\n");
8893 let mut writer = Vec::new();
8894 let result = select_token_impl(&results, &mut reader, &mut writer);
8895 assert!(result.is_err());
8896 }
8897
8898 #[test]
8899 fn test_select_token_impl_non_numeric_input() {
8900 let results = make_search_results();
8901 let mut reader = io::Cursor::new(b"abc\n");
8902 let mut writer = Vec::new();
8903 let result = select_token_impl(&results, &mut reader, &mut writer);
8904 assert!(result.is_err());
8905 let err = result.unwrap_err().to_string();
8906 assert!(err.contains("Invalid selection"));
8907 }
8908
8909 #[test]
8910 fn test_select_token_impl_empty_input() {
8911 let results = make_search_results();
8912 let mut reader = io::Cursor::new(b"\n");
8913 let mut writer = Vec::new();
8914 let result = select_token_impl(&results, &mut reader, &mut writer);
8915 assert!(result.is_err());
8916 }
8917
8918 #[test]
8919 fn test_select_token_impl_long_name_truncation() {
8920 let results = vec![TokenSearchResult {
8921 address: "0xABCDEF1234567890ABCDEF1234567890ABCDEF12".to_string(),
8922 symbol: "LONG".to_string(),
8923 name: "A Very Long Token Name That Exceeds Twenty Characters".to_string(),
8924 chain: "ethereum".to_string(),
8925 price_usd: None,
8926 volume_24h: 100.0,
8927 liquidity_usd: 50.0,
8928 market_cap: None,
8929 }];
8930 let mut reader = io::Cursor::new(b"1\n");
8931 let mut writer = Vec::new();
8932 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8933 assert_eq!(selected.symbol, "LONG");
8934 let output = String::from_utf8(writer).unwrap();
8935 assert!(output.contains("A Very Long Token..."));
8937 assert!(output.contains("N/A"));
8939 }
8940
8941 #[test]
8942 fn test_select_token_impl_output_format() {
8943 let results = make_search_results();
8944 let mut reader = io::Cursor::new(b"1\n");
8945 let mut writer = Vec::new();
8946 let _ = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8947 let output = String::from_utf8(writer).unwrap();
8948
8949 assert!(output.contains("#"));
8951 assert!(output.contains("Symbol"));
8952 assert!(output.contains("Name"));
8953 assert!(output.contains("Address"));
8954 assert!(output.contains("Price"));
8955 assert!(output.contains("Liquidity"));
8956 assert!(output.contains("─"));
8958 assert!(output.contains("Select token (1-3):"));
8960 }
8961
8962 #[test]
8967 fn test_format_monitor_number_billions() {
8968 assert_eq!(format_monitor_number(5_000_000_000.0), "$5.00B");
8969 assert_eq!(format_monitor_number(1_234_567_890.0), "$1.23B");
8970 }
8971
8972 #[test]
8973 fn test_format_monitor_number_millions() {
8974 assert_eq!(format_monitor_number(5_000_000.0), "$5.00M");
8975 assert_eq!(format_monitor_number(42_500_000.0), "$42.50M");
8976 }
8977
8978 #[test]
8979 fn test_format_monitor_number_thousands() {
8980 assert_eq!(format_monitor_number(5_000.0), "$5.00K");
8981 assert_eq!(format_monitor_number(999_999.0), "$1000.00K");
8982 }
8983
8984 #[test]
8985 fn test_format_monitor_number_small() {
8986 assert_eq!(format_monitor_number(42.0), "$42.00");
8987 assert_eq!(format_monitor_number(0.5), "$0.50");
8988 assert_eq!(format_monitor_number(0.0), "$0.00");
8989 }
8990
8991 #[test]
8996 fn test_abbreviate_address_exactly_16_chars() {
8997 let addr = "0123456789ABCDEF"; assert_eq!(abbreviate_address(addr), addr);
8999 }
9000
9001 #[test]
9002 fn test_abbreviate_address_17_chars() {
9003 let addr = "0123456789ABCDEFG"; assert_eq!(abbreviate_address(addr), "01234567...BCDEFG");
9005 }
9006
9007 #[tokio::test]
9012 async fn test_app_full_scenario_fetch_render_quit() {
9013 let data = create_test_token_data();
9014 let mut updated = data.clone();
9015 updated.price_usd = 3.0;
9016 let mock_chain = MockChainClient::new(10_000);
9017 let state = MonitorState::new(&data, "ethereum");
9018 let mut app = create_test_app_with_state(
9019 state,
9020 Box::new(MockDexDataSource::new(updated)),
9021 Some(Box::new(mock_chain)),
9022 );
9023
9024 app.fetch_data().await;
9026 assert!((app.state.current_price - 3.0).abs() < 0.001);
9027
9028 app.terminal
9030 .draw(|f| ui(f, &mut app.state))
9031 .expect("render");
9032
9033 app.handle_key_event(make_key_event(KeyCode::Char('e')));
9035 assert!(app.state.export_active);
9036
9037 app.handle_key_event(make_key_event(KeyCode::Char('q')));
9039 assert!(app.should_exit);
9040 assert!(!app.state.export_active);
9041 }
9042
9043 #[tokio::test]
9044 async fn test_app_fetch_data_error_then_recovery() {
9045 let mut app = create_test_app(Box::new(MockDexDataSource::failing("server down")), None);
9046
9047 app.fetch_data().await;
9049 assert!(app.state.error_message.is_some());
9050
9051 let mut recovered = create_test_token_data();
9053 recovered.price_usd = 5.0;
9054 app.dex_client = Box::new(MockDexDataSource::new(recovered));
9055
9056 app.fetch_data().await;
9058 assert!((app.state.current_price - 5.0).abs() < 0.001);
9059 }
9061
9062 #[test]
9067 fn test_monitor_args_defaults() {
9068 use super::super::Cli;
9069 use clap::Parser;
9070 let cli = Cli::try_parse_from(["scope", "monitor", "USDC"]).unwrap();
9072 if let super::super::Commands::Monitor(args) = cli.command {
9073 assert_eq!(args.token, "USDC");
9074 assert_eq!(args.chain, "ethereum");
9075 assert!(args.layout.is_none());
9076 assert!(args.refresh.is_none());
9077 assert!(args.scale.is_none());
9078 assert!(args.color_scheme.is_none());
9079 assert!(args.export.is_none());
9080 } else {
9081 panic!("Expected Monitor command");
9082 }
9083 }
9084
9085 #[test]
9086 fn test_monitor_args_all_flags() {
9087 use super::super::Cli;
9088 use clap::Parser;
9089 let cli = Cli::try_parse_from([
9090 "scope",
9091 "monitor",
9092 "PEPE",
9093 "--chain",
9094 "solana",
9095 "--layout",
9096 "feed",
9097 "--refresh",
9098 "2",
9099 "--scale",
9100 "log",
9101 "--color-scheme",
9102 "monochrome",
9103 "--export",
9104 "/tmp/data.csv",
9105 ])
9106 .unwrap();
9107 if let super::super::Commands::Monitor(args) = cli.command {
9108 assert_eq!(args.token, "PEPE");
9109 assert_eq!(args.chain, "solana");
9110 assert_eq!(args.layout, Some(LayoutPreset::Feed));
9111 assert_eq!(args.refresh, Some(2));
9112 assert_eq!(args.scale, Some(ScaleMode::Log));
9113 assert_eq!(args.color_scheme, Some(ColorScheme::Monochrome));
9114 assert_eq!(args.export, Some(PathBuf::from("/tmp/data.csv")));
9115 } else {
9116 panic!("Expected Monitor command");
9117 }
9118 }
9119
9120 #[test]
9121 fn test_run_direct_config_override_layout() {
9122 let config = Config::default();
9124 assert_eq!(config.monitor.layout, LayoutPreset::Dashboard);
9125
9126 let args = MonitorArgs {
9127 token: "USDC".to_string(),
9128 chain: "ethereum".to_string(),
9129 layout: Some(LayoutPreset::ChartFocus),
9130 refresh: None,
9131 scale: None,
9132 color_scheme: None,
9133 export: None,
9134 venue: None,
9135 pair: None,
9136 };
9137
9138 let mut monitor_config = config.monitor.clone();
9140 if let Some(layout) = args.layout {
9141 monitor_config.layout = layout;
9142 }
9143 assert_eq!(monitor_config.layout, LayoutPreset::ChartFocus);
9144 }
9145
9146 #[test]
9147 fn test_run_direct_config_override_all_fields() {
9148 let config = Config::default();
9149 let args = MonitorArgs {
9150 token: "PEPE".to_string(),
9151 chain: "solana".to_string(),
9152 layout: Some(LayoutPreset::Compact),
9153 refresh: Some(2),
9154 scale: Some(ScaleMode::Log),
9155 color_scheme: Some(ColorScheme::BlueOrange),
9156 export: Some(PathBuf::from("/tmp/test.csv")),
9157 venue: None,
9158 pair: None,
9159 };
9160
9161 let mut mc = config.monitor.clone();
9162 if let Some(layout) = args.layout {
9163 mc.layout = layout;
9164 }
9165 if let Some(refresh) = args.refresh {
9166 mc.refresh_seconds = refresh;
9167 }
9168 if let Some(scale) = args.scale {
9169 mc.scale = scale;
9170 }
9171 if let Some(color_scheme) = args.color_scheme {
9172 mc.color_scheme = color_scheme;
9173 }
9174 if let Some(ref path) = args.export {
9175 mc.export.path = Some(path.to_string_lossy().into_owned());
9176 }
9177
9178 assert_eq!(mc.layout, LayoutPreset::Compact);
9179 assert_eq!(mc.refresh_seconds, 2);
9180 assert_eq!(mc.scale, ScaleMode::Log);
9181 assert_eq!(mc.color_scheme, ColorScheme::BlueOrange);
9182 assert_eq!(mc.export.path, Some("/tmp/test.csv".to_string()));
9183 }
9184
9185 #[test]
9186 fn test_run_direct_config_no_overrides_preserves_defaults() {
9187 let config = Config::default();
9188 let args = MonitorArgs {
9189 token: "USDC".to_string(),
9190 chain: "ethereum".to_string(),
9191 layout: None,
9192 refresh: None,
9193 scale: None,
9194 color_scheme: None,
9195 export: None,
9196 venue: None,
9197 pair: None,
9198 };
9199
9200 let mut mc = config.monitor.clone();
9201 if let Some(layout) = args.layout {
9202 mc.layout = layout;
9203 }
9204 if let Some(refresh) = args.refresh {
9205 mc.refresh_seconds = refresh;
9206 }
9207 if let Some(scale) = args.scale {
9208 mc.scale = scale;
9209 }
9210 if let Some(color_scheme) = args.color_scheme {
9211 mc.color_scheme = color_scheme;
9212 }
9213
9214 assert_eq!(mc.layout, LayoutPreset::Dashboard);
9216 assert_eq!(mc.refresh_seconds, DEFAULT_REFRESH_SECS);
9217 assert_eq!(mc.scale, ScaleMode::Linear);
9218 assert_eq!(mc.color_scheme, ColorScheme::GreenRed);
9219 assert!(mc.export.path.is_none());
9220 }
9221
9222 #[test]
9227 fn test_exchange_interval_mapping() {
9228 assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
9229 assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
9230 assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
9231 assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
9232 assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
9233 assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
9234 }
9235
9236 #[test]
9237 fn test_monitor_state_exchange_ohlc_default_empty() {
9238 let token_data = create_test_token_data();
9239 let state = MonitorState::new(&token_data, "ethereum");
9240 assert!(state.exchange_ohlc.is_empty());
9241 assert!(state.venue_pair.is_none());
9242 }
9243
9244 #[test]
9245 fn test_get_ohlc_candles_prefers_exchange_data() {
9246 let token_data = create_test_token_data();
9247 let mut state = MonitorState::new(&token_data, "ethereum");
9248
9249 let now = std::time::SystemTime::now()
9251 .duration_since(std::time::UNIX_EPOCH)
9252 .unwrap()
9253 .as_secs_f64();
9254 state.price_history.push_back(DataPoint {
9255 timestamp: now,
9256 value: 1.0,
9257 is_real: true,
9258 });
9259 state.price_history.push_back(DataPoint {
9260 timestamp: now + 5.0,
9261 value: 1.01,
9262 is_real: true,
9263 });
9264
9265 let candles_before = state.get_ohlc_candles();
9267
9268 state.exchange_ohlc = vec![
9270 OhlcCandle {
9271 timestamp: 1700000000.0,
9272 open: 50000.0,
9273 high: 50500.0,
9274 low: 49800.0,
9275 close: 50200.0,
9276 is_bullish: true,
9277 },
9278 OhlcCandle {
9279 timestamp: 1700003600.0,
9280 open: 50200.0,
9281 high: 50800.0,
9282 low: 50100.0,
9283 close: 50700.0,
9284 is_bullish: true,
9285 },
9286 ];
9287
9288 let candles_after = state.get_ohlc_candles();
9289 assert_eq!(candles_after.len(), 2);
9290 assert_eq!(candles_after[0].open, 50000.0);
9291 assert_eq!(candles_after[1].close, 50700.0);
9292
9293 if !candles_before.is_empty() {
9295 assert_ne!(candles_after[0].open, candles_before[0].open);
9296 }
9297 }
9298
9299 #[test]
9300 fn test_monitor_args_with_venue() {
9301 let args = MonitorArgs {
9302 token: "BTC".to_string(),
9303 chain: "ethereum".to_string(),
9304 refresh: None,
9305 layout: None,
9306 scale: None,
9307 color_scheme: None,
9308 export: None,
9309 venue: Some("binance".to_string()),
9310 pair: None,
9311 };
9312 assert_eq!(args.venue, Some("binance".to_string()));
9313 }
9314
9315 #[test]
9316 fn test_ohlc_candle_is_bullish_calculation() {
9317 let bullish = OhlcCandle {
9318 timestamp: 1700000000.0,
9319 open: 100.0,
9320 high: 110.0,
9321 low: 95.0,
9322 close: 105.0,
9323 is_bullish: true,
9324 };
9325 assert!(bullish.is_bullish);
9326
9327 let bearish = OhlcCandle {
9328 timestamp: 1700000000.0,
9329 open: 100.0,
9330 high: 105.0,
9331 low: 90.0,
9332 close: 95.0,
9333 is_bullish: false,
9334 };
9335 assert!(!bearish.is_bullish);
9336 }
9337
9338 #[test]
9339 fn test_build_exchange_token_data_from_ticker() {
9340 let ticker = crate::market::Ticker {
9341 pair: "PUSD/USDT".to_string(),
9342 last_price: Some(1.001),
9343 high_24h: Some(1.005),
9344 low_24h: Some(0.998),
9345 volume_24h: Some(500_000.0),
9346 quote_volume_24h: Some(500_500.0),
9347 best_bid: Some(1.0005),
9348 best_ask: Some(1.0015),
9349 };
9350
9351 let data = build_exchange_token_data("PUSD", "PUSD_USDT", &ticker);
9352
9353 assert_eq!(data.symbol, "PUSD");
9354 assert_eq!(data.name, "PUSD");
9355 assert_eq!(data.price_usd, 1.001);
9356 assert_eq!(data.volume_24h, 500_000.0);
9357 assert!(data.address.contains("exchange:"));
9358 assert!(data.pairs.is_empty());
9359 assert!(data.price_history.is_empty());
9360 assert!(data.dexscreener_url.is_none());
9361 }
9362
9363 #[test]
9364 fn test_build_exchange_token_data_missing_price() {
9365 let ticker = crate::market::Ticker {
9366 pair: "FOO/USDT".to_string(),
9367 last_price: None,
9368 high_24h: None,
9369 low_24h: None,
9370 volume_24h: None,
9371 quote_volume_24h: None,
9372 best_bid: None,
9373 best_ask: None,
9374 };
9375
9376 let data = build_exchange_token_data("FOO", "FOO_USDT", &ticker);
9377 assert_eq!(data.price_usd, 0.0);
9378 assert_eq!(data.volume_24h, 0.0);
9379 }
9380
9381 #[test]
9382 fn test_monitor_args_with_pair() {
9383 let args = MonitorArgs {
9384 token: "PUSD".to_string(),
9385 chain: "ethereum".to_string(),
9386 refresh: None,
9387 layout: None,
9388 scale: None,
9389 color_scheme: None,
9390 export: None,
9391 venue: Some("biconomy".to_string()),
9392 pair: Some("PUSD_USDT".to_string()),
9393 };
9394 assert_eq!(args.pair, Some("PUSD_USDT".to_string()));
9395 assert_eq!(args.venue, Some("biconomy".to_string()));
9396 }
9397
9398 #[test]
9399 fn test_monitor_args_pair_none_by_default() {
9400 let args = MonitorArgs {
9401 token: "BTC".to_string(),
9402 chain: "ethereum".to_string(),
9403 refresh: None,
9404 layout: None,
9405 scale: None,
9406 color_scheme: None,
9407 export: None,
9408 venue: None,
9409 pair: None,
9410 };
9411 assert!(args.pair.is_none());
9412 }
9413
9414 #[test]
9415 fn test_build_exchange_token_data_extracts_base_symbol() {
9416 let ticker = crate::market::Ticker {
9417 pair: "DOGE/USDT".to_string(),
9418 last_price: Some(0.123),
9419 high_24h: Some(0.13),
9420 low_24h: Some(0.12),
9421 volume_24h: Some(1_000_000.0),
9422 quote_volume_24h: Some(123_000.0),
9423 best_bid: Some(0.1225),
9424 best_ask: Some(0.1235),
9425 };
9426
9427 let data = build_exchange_token_data("DOGE", "DOGE_USDT", &ticker);
9428 assert_eq!(data.symbol, "DOGE");
9429 assert_eq!(data.name, "DOGE");
9430 assert_eq!(data.price_usd, 0.123);
9431 assert_eq!(data.volume_24h, 1_000_000.0);
9432 assert_eq!(data.price_change_24h, 0.0);
9434 assert_eq!(data.price_change_6h, 0.0);
9435 assert_eq!(data.price_change_1h, 0.0);
9436 assert_eq!(data.price_change_5m, 0.0);
9437 assert_eq!(data.volume_6h, 0.0);
9438 assert_eq!(data.volume_1h, 0.0);
9439 assert_eq!(data.liquidity_usd, 0.0);
9440 assert!(data.market_cap.is_none());
9441 assert!(data.fdv.is_none());
9442 assert!(data.earliest_pair_created_at.is_none());
9443 assert!(data.image_url.is_none());
9444 assert!(data.websites.is_empty());
9445 assert!(data.socials.is_empty());
9446 assert_eq!(data.total_buys_24h, 0);
9447 assert_eq!(data.total_sells_24h, 0);
9448 }
9449
9450 #[test]
9451 fn test_build_exchange_token_data_address_format() {
9452 let ticker = crate::market::Ticker {
9453 pair: "X/Y".to_string(),
9454 last_price: Some(1.0),
9455 high_24h: None,
9456 low_24h: None,
9457 volume_24h: None,
9458 quote_volume_24h: None,
9459 best_bid: None,
9460 best_ask: None,
9461 };
9462
9463 let data = build_exchange_token_data("X", "X_Y", &ticker);
9464 assert_eq!(data.address, "exchange:X_Y");
9465 }
9466
9467 #[test]
9468 fn test_monitor_args_pair_requires_venue_conceptually() {
9469 let args = MonitorArgs {
9472 token: "PUSD".to_string(),
9473 chain: "ethereum".to_string(),
9474 refresh: None,
9475 layout: None,
9476 scale: None,
9477 color_scheme: None,
9478 export: None,
9479 venue: Some("biconomy".to_string()),
9480 pair: Some("PUSD_USDT".to_string()),
9481 };
9482 assert!(args.venue.is_some());
9483 assert!(args.pair.is_some());
9484 }
9485
9486 #[test]
9487 fn test_run_direct_config_pair_passthrough() {
9488 let config = Config::default();
9490 let args = MonitorArgs {
9491 token: "PUSD".to_string(),
9492 chain: "ethereum".to_string(),
9493 layout: None,
9494 refresh: None,
9495 scale: None,
9496 color_scheme: None,
9497 export: None,
9498 venue: Some("biconomy".to_string()),
9499 pair: Some("PUSD_USDT".to_string()),
9500 };
9501
9502 let mut mc = config.monitor.clone();
9504 if let Some(ref venue) = args.venue {
9505 mc.venue = Some(venue.clone());
9506 }
9507 assert_eq!(mc.venue, Some("biconomy".to_string()));
9508 assert_eq!(args.pair, Some("PUSD_USDT".to_string()));
9510 }
9511
9512 #[test]
9517 fn test_chain_filter_logic_ethereum_default() {
9518 let chain = "ethereum";
9521 let chain_filter: Option<&str> = if chain != "ethereum" {
9522 Some(chain)
9523 } else {
9524 None
9525 };
9526 assert!(chain_filter.is_none());
9527 }
9528
9529 #[test]
9530 fn test_chain_filter_logic_explicit_chain() {
9531 let chain = "solana";
9533 let chain_filter: Option<&str> = if chain != "ethereum" {
9534 Some(chain)
9535 } else {
9536 None
9537 };
9538 assert_eq!(chain_filter, Some("solana"));
9539 }
9540
9541 #[test]
9542 fn test_chain_filter_logic_bsc() {
9543 let chain = "bsc";
9544 let chain_filter: Option<&str> = if chain != "ethereum" {
9545 Some(chain)
9546 } else {
9547 None
9548 };
9549 assert_eq!(chain_filter, Some("bsc"));
9550 }
9551
9552 #[tokio::test]
9553 async fn test_resolve_token_address_with_address_input() {
9554 use crate::chains::dex::DexClient;
9555 let config = Config::default();
9556 let dex = DexClient::new();
9557 let result = resolve_token_address(
9559 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
9560 "ethereum",
9561 &config,
9562 &dex,
9563 )
9564 .await
9565 .unwrap();
9566 assert_eq!(result, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
9567 }
9568
9569 #[tokio::test]
9570 async fn test_resolve_token_address_solana_address() {
9571 use crate::chains::dex::DexClient;
9572 let config = Config::default();
9573 let dex = DexClient::new();
9574 let addr = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
9576 let result = resolve_token_address(addr, "solana", &config, &dex)
9577 .await
9578 .unwrap();
9579 assert_eq!(result, addr);
9580 }
9581
9582 #[test]
9583 fn test_try_cex_fallback_returns_correct_structure() {
9584 let result = crate::chains::TokenSearchResult {
9587 address: String::new(),
9588 symbol: "TEST".to_string(),
9589 name: "TEST".to_string(),
9590 chain: "ethereum".to_string(),
9591 price_usd: Some(1.0),
9592 volume_24h: 100.0,
9593 liquidity_usd: 0.0,
9594 market_cap: None,
9595 };
9596 assert_eq!(result.symbol, "TEST");
9597 assert!(result.address.is_empty());
9598 assert_eq!(result.liquidity_usd, 0.0);
9599 }
9600}