Skip to main content

scope/cli/
monitor.rs

1//! # Live Token Monitor
2//!
3//! This module implements a real-time terminal UI for monitoring token metrics.
4//! It displays live-updating charts for price, volume, transactions, and liquidity.
5//!
6//! ## Usage
7//!
8//! From interactive mode:
9//! ```text
10//! scope> monitor USDC
11//! scope> mon 0x1234...
12//! ```
13//!
14//! ## Features
15//!
16//! - Real-time price chart with sliding window
17//! - Volume bar chart
18//! - Buy/sell ratio gauge
19//! - Key metrics panel (price, liquidity, market cap, 24h volume)
20//! - Keyboard controls: Q=quit, R=refresh, P=pause
21
22use crate::chains::ChainClientFactory;
23use crate::chains::dex::{DexClient, DexDataSource, DexTokenData};
24use crate::config::Config;
25use crate::error::{Result, ScopeError};
26use crossterm::{
27    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
28    execute,
29    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
30};
31use ratatui::{
32    Frame, Terminal,
33    backend::CrosstermBackend,
34    layout::{Constraint, Direction, Layout, Rect},
35    style::{Color, Modifier, Style},
36    symbols,
37    text::{Line, Span},
38    widgets::{
39        Axis, Block, Borders, Chart, Dataset, Gauge, GraphType, List, ListItem, Paragraph,
40        canvas::{Canvas, Line as CanvasLine, Rectangle},
41    },
42};
43use serde::{Deserialize, Serialize};
44use std::collections::VecDeque;
45use std::fs;
46use std::io::{self, Stdout};
47use std::path::PathBuf;
48use std::time::{Duration, Instant};
49
50use super::interactive::SessionContext;
51
52/// Maximum data retention: 24 hours.
53/// At 5-second intervals: 24 * 60 * 12 = 17,280 points max per history.
54/// With DataPoint at 24 bytes: ~415 KB per history, ~830 KB total.
55/// Data is persisted to OS temp folder for session continuity.
56const MAX_DATA_AGE_SECS: f64 = 24.0 * 3600.0; // 24 hours
57
58/// Cache file prefix in temp directory.
59const CACHE_FILE_PREFIX: &str = "bcc_monitor_";
60
61/// Default refresh interval in seconds.
62const DEFAULT_REFRESH_SECS: u64 = 5;
63
64/// Minimum refresh interval in seconds.
65const MIN_REFRESH_SECS: u64 = 1;
66
67/// Maximum refresh interval in seconds.
68const MAX_REFRESH_SECS: u64 = 60;
69
70/// A data point with timestamp, value, and whether it's real (from API) or synthetic.
71#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
72pub struct DataPoint {
73    /// Unix timestamp in seconds.
74    pub timestamp: f64,
75    /// Value (price or volume).
76    pub value: f64,
77    /// True if this is real data from API, false if synthetic/estimated.
78    pub is_real: bool,
79}
80
81/// OHLC (Open-High-Low-Close) candlestick data for a time period.
82#[derive(Debug, Clone, Copy)]
83pub struct OhlcCandle {
84    /// Start timestamp of this candle period.
85    pub timestamp: f64,
86    /// Opening price.
87    pub open: f64,
88    /// Highest price during the period.
89    pub high: f64,
90    /// Lowest price during the period.
91    pub low: f64,
92    /// Closing price.
93    pub close: f64,
94    /// Whether this candle is bullish (close >= open).
95    pub is_bullish: bool,
96}
97
98impl OhlcCandle {
99    /// Creates a new candle from a single price point.
100    pub fn new(timestamp: f64, price: f64) -> Self {
101        Self {
102            timestamp,
103            open: price,
104            high: price,
105            low: price,
106            close: price,
107            is_bullish: true,
108        }
109    }
110
111    /// Updates the candle with a new price.
112    pub fn update(&mut self, price: f64) {
113        self.high = self.high.max(price);
114        self.low = self.low.min(price);
115        self.close = price;
116        self.is_bullish = self.close >= self.open;
117    }
118}
119
120/// Cached monitor data that persists between sessions.
121#[derive(Debug, Serialize, Deserialize)]
122struct CachedMonitorData {
123    /// Token address this cache is for.
124    token_address: String,
125    /// Chain identifier.
126    chain: String,
127    /// Price history data points.
128    price_history: Vec<DataPoint>,
129    /// Volume history data points.
130    volume_history: Vec<DataPoint>,
131    /// Timestamp when cache was saved.
132    saved_at: f64,
133}
134
135/// Time period for chart display (limited to 24 hours of data retention).
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum TimePeriod {
138    /// Last 15 minutes
139    Min15,
140    /// Last 1 hour
141    Hour1,
142    /// Last 6 hours
143    Hour6,
144    /// Last 24 hours
145    Hour24,
146}
147
148impl TimePeriod {
149    /// Returns the duration in seconds for this period.
150    pub fn duration_secs(&self) -> i64 {
151        match self {
152            TimePeriod::Min15 => 15 * 60,
153            TimePeriod::Hour1 => 3600,
154            TimePeriod::Hour6 => 6 * 3600,
155            TimePeriod::Hour24 => 24 * 3600,
156        }
157    }
158
159    /// Returns a display label for this period.
160    pub fn label(&self) -> &'static str {
161        match self {
162            TimePeriod::Min15 => "15m",
163            TimePeriod::Hour1 => "1h",
164            TimePeriod::Hour6 => "6h",
165            TimePeriod::Hour24 => "24h",
166        }
167    }
168
169    /// Cycles to the next time period.
170    pub fn next(&self) -> Self {
171        match self {
172            TimePeriod::Min15 => TimePeriod::Hour1,
173            TimePeriod::Hour1 => TimePeriod::Hour6,
174            TimePeriod::Hour6 => TimePeriod::Hour24,
175            TimePeriod::Hour24 => TimePeriod::Min15,
176        }
177    }
178}
179
180impl std::fmt::Display for TimePeriod {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        write!(f, "{}", self.label())
183    }
184}
185
186/// Chart display mode.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
188pub enum ChartMode {
189    /// Line chart showing price over time.
190    #[default]
191    Line,
192    /// Candlestick chart showing OHLC data.
193    Candlestick,
194}
195
196impl ChartMode {
197    /// Cycles to the next chart mode.
198    pub fn next(&self) -> Self {
199        match self {
200            ChartMode::Line => ChartMode::Candlestick,
201            ChartMode::Candlestick => ChartMode::Line,
202        }
203    }
204
205    /// Returns a display label for this mode.
206    pub fn label(&self) -> &'static str {
207        match self {
208            ChartMode::Line => "Line",
209            ChartMode::Candlestick => "Candle",
210        }
211    }
212}
213
214/// State for the live token monitor.
215pub struct MonitorState {
216    /// Token contract address.
217    pub token_address: String,
218
219    /// Token symbol.
220    pub symbol: String,
221
222    /// Token name.
223    pub name: String,
224
225    /// Blockchain network.
226    pub chain: String,
227
228    /// Historical price data points with real/synthetic indicator.
229    pub price_history: VecDeque<DataPoint>,
230
231    /// Historical volume data points with real/synthetic indicator.
232    pub volume_history: VecDeque<DataPoint>,
233
234    /// Count of real (non-synthetic) data points.
235    pub real_data_count: usize,
236
237    /// Current price in USD.
238    pub current_price: f64,
239
240    /// 24-hour price change percentage.
241    pub price_change_24h: f64,
242
243    /// 6-hour price change percentage.
244    pub price_change_6h: f64,
245
246    /// 1-hour price change percentage.
247    pub price_change_1h: f64,
248
249    /// 5-minute price change percentage.
250    pub price_change_5m: f64,
251
252    /// Timestamp when the price last changed (Unix timestamp).
253    pub last_price_change_at: f64,
254
255    /// Previous price for change detection.
256    pub previous_price: f64,
257
258    /// Total buy transactions in 24 hours.
259    pub buys_24h: u64,
260
261    /// Total sell transactions in 24 hours.
262    pub sells_24h: u64,
263
264    /// Total liquidity in USD.
265    pub liquidity_usd: f64,
266
267    /// 24-hour volume in USD.
268    pub volume_24h: f64,
269
270    /// Market capitalization.
271    pub market_cap: Option<f64>,
272
273    /// Fully diluted valuation.
274    pub fdv: Option<f64>,
275
276    /// Last update timestamp.
277    pub last_update: Instant,
278
279    /// Refresh rate.
280    pub refresh_rate: Duration,
281
282    /// Whether monitoring is paused.
283    pub paused: bool,
284
285    /// Recent log messages.
286    pub log_messages: VecDeque<String>,
287
288    /// Error message to display (if any).
289    pub error_message: Option<String>,
290
291    /// Selected time period for chart display.
292    pub time_period: TimePeriod,
293
294    /// Chart display mode (line or candlestick).
295    pub chart_mode: ChartMode,
296
297    /// Unix timestamp when monitoring started.
298    pub start_timestamp: i64,
299}
300
301impl MonitorState {
302    /// Creates a new monitor state from initial token data.
303    /// Attempts to load cached data from disk first.
304    pub fn new(token_data: &DexTokenData, chain: &str) -> Self {
305        let now = Instant::now();
306        let now_ts = chrono::Utc::now().timestamp() as f64;
307
308        // Try to load cached data first
309        let (price_history, volume_history, real_data_count) =
310            if let Some(cached) = Self::load_cache(&token_data.address, chain) {
311                // Filter out data older than 24 hours
312                let cutoff = now_ts - MAX_DATA_AGE_SECS;
313                let price_hist: VecDeque<DataPoint> = cached
314                    .price_history
315                    .into_iter()
316                    .filter(|p| p.timestamp >= cutoff)
317                    .collect();
318                let vol_hist: VecDeque<DataPoint> = cached
319                    .volume_history
320                    .into_iter()
321                    .filter(|p| p.timestamp >= cutoff)
322                    .collect();
323                let real_count = price_hist.iter().filter(|p| p.is_real).count();
324                (price_hist, vol_hist, real_count)
325            } else {
326                // Generate synthetic historical data from price change percentages
327                let price_hist = Self::generate_synthetic_price_history(
328                    token_data.price_usd,
329                    token_data.price_change_1h,
330                    token_data.price_change_6h,
331                    token_data.price_change_24h,
332                    now_ts,
333                );
334                let vol_hist = Self::generate_synthetic_volume_history(
335                    token_data.volume_24h,
336                    token_data.volume_6h,
337                    token_data.volume_1h,
338                    now_ts,
339                );
340                (price_hist, vol_hist, 0)
341            };
342
343        Self {
344            token_address: token_data.address.clone(),
345            symbol: token_data.symbol.clone(),
346            name: token_data.name.clone(),
347            chain: chain.to_string(),
348            price_history,
349            volume_history,
350            real_data_count,
351            current_price: token_data.price_usd,
352            price_change_24h: token_data.price_change_24h,
353            price_change_6h: token_data.price_change_6h,
354            price_change_1h: token_data.price_change_1h,
355            price_change_5m: token_data.price_change_5m,
356            last_price_change_at: now_ts, // Initialize to current time
357            previous_price: token_data.price_usd,
358            buys_24h: token_data.total_buys_24h,
359            sells_24h: token_data.total_sells_24h,
360            liquidity_usd: token_data.liquidity_usd,
361            volume_24h: token_data.volume_24h,
362            market_cap: token_data.market_cap,
363            fdv: token_data.fdv,
364            last_update: now,
365            refresh_rate: Duration::from_secs(DEFAULT_REFRESH_SECS),
366            paused: false,
367            log_messages: VecDeque::with_capacity(10),
368            error_message: None,
369            time_period: TimePeriod::Hour1, // Default to 1 hour view
370            chart_mode: ChartMode::Line,    // Default to line chart
371            start_timestamp: now_ts as i64,
372        }
373    }
374
375    /// Toggles between line and candlestick chart modes.
376    pub fn toggle_chart_mode(&mut self) {
377        self.chart_mode = self.chart_mode.next();
378        self.log(format!("Chart mode: {}", self.chart_mode.label()));
379    }
380
381    /// Returns the path to the cache file for a token.
382    fn cache_path(token_address: &str, chain: &str) -> PathBuf {
383        let mut path = std::env::temp_dir();
384        // Create a safe filename from address (first 16 chars) and chain
385        let safe_addr = token_address
386            .chars()
387            .filter(|c| c.is_alphanumeric())
388            .take(16)
389            .collect::<String>()
390            .to_lowercase();
391        path.push(format!("{}{}_{}.json", CACHE_FILE_PREFIX, chain, safe_addr));
392        path
393    }
394
395    /// Loads cached monitor data from disk.
396    fn load_cache(token_address: &str, chain: &str) -> Option<CachedMonitorData> {
397        let path = Self::cache_path(token_address, chain);
398        if !path.exists() {
399            return None;
400        }
401
402        match fs::read_to_string(&path) {
403            Ok(contents) => {
404                match serde_json::from_str::<CachedMonitorData>(&contents) {
405                    Ok(cached) => {
406                        // Verify this is for the same token
407                        if cached.token_address.to_lowercase() == token_address.to_lowercase()
408                            && cached.chain.to_lowercase() == chain.to_lowercase()
409                        {
410                            Some(cached)
411                        } else {
412                            None
413                        }
414                    }
415                    Err(_) => None,
416                }
417            }
418            Err(_) => None,
419        }
420    }
421
422    /// Saves monitor data to cache file.
423    pub fn save_cache(&self) {
424        let cached = CachedMonitorData {
425            token_address: self.token_address.clone(),
426            chain: self.chain.clone(),
427            price_history: self.price_history.iter().copied().collect(),
428            volume_history: self.volume_history.iter().copied().collect(),
429            saved_at: chrono::Utc::now().timestamp() as f64,
430        };
431
432        let path = Self::cache_path(&self.token_address, &self.chain);
433        if let Ok(json) = serde_json::to_string(&cached) {
434            let _ = fs::write(&path, json);
435        }
436    }
437
438    /// Generates synthetic price history from percentage changes.
439    /// All generated points are marked as synthetic (is_real = false).
440    fn generate_synthetic_price_history(
441        current_price: f64,
442        change_1h: f64,
443        change_6h: f64,
444        change_24h: f64,
445        now_ts: f64,
446    ) -> VecDeque<DataPoint> {
447        let mut history = VecDeque::with_capacity(50);
448
449        // Calculate prices at known points (working backwards from current)
450        let price_1h_ago = current_price / (1.0 + change_1h / 100.0);
451        let price_6h_ago = current_price / (1.0 + change_6h / 100.0);
452        let price_24h_ago = current_price / (1.0 + change_24h / 100.0);
453
454        // Generate points: 24h ago, 12h ago, 6h ago, 3h ago, 1h ago, 30m ago, now
455        let points = [
456            (now_ts - 24.0 * 3600.0, price_24h_ago),
457            (now_ts - 12.0 * 3600.0, (price_24h_ago + price_6h_ago) / 2.0),
458            (now_ts - 6.0 * 3600.0, price_6h_ago),
459            (now_ts - 3.0 * 3600.0, (price_6h_ago + price_1h_ago) / 2.0),
460            (now_ts - 1.0 * 3600.0, price_1h_ago),
461            (now_ts - 0.5 * 3600.0, (price_1h_ago + current_price) / 2.0),
462            (now_ts, current_price),
463        ];
464
465        // Interpolate to create more points for smoother charts
466        for i in 0..points.len() - 1 {
467            let (t1, p1) = points[i];
468            let (t2, p2) = points[i + 1];
469            let steps = 4; // Number of interpolated points between each pair
470
471            for j in 0..steps {
472                let frac = j as f64 / steps as f64;
473                let t = t1 + (t2 - t1) * frac;
474                let p = p1 + (p2 - p1) * frac;
475                history.push_back(DataPoint {
476                    timestamp: t,
477                    value: p,
478                    is_real: false, // Synthetic data
479                });
480            }
481        }
482        // Add the final point (also synthetic since it's estimated)
483        history.push_back(DataPoint {
484            timestamp: points[points.len() - 1].0,
485            value: points[points.len() - 1].1,
486            is_real: false,
487        });
488
489        history
490    }
491
492    /// Generates synthetic volume history from known data points.
493    /// All generated points are marked as synthetic (is_real = false).
494    fn generate_synthetic_volume_history(
495        volume_24h: f64,
496        volume_6h: f64,
497        volume_1h: f64,
498        now_ts: f64,
499    ) -> VecDeque<DataPoint> {
500        let mut history = VecDeque::with_capacity(24);
501
502        // Create hourly volume estimates
503        let hourly_avg = volume_24h / 24.0;
504
505        for i in 0..24 {
506            let hours_ago = 24 - i;
507            let ts = now_ts - (hours_ago as f64) * 3600.0;
508
509            // Use more accurate data for recent hours
510            let volume = if hours_ago <= 1 {
511                volume_1h
512            } else if hours_ago <= 6 {
513                volume_6h / 6.0
514            } else {
515                // Estimate with some variation
516                hourly_avg * (0.8 + (i as f64 / 24.0) * 0.4)
517            };
518
519            history.push_back(DataPoint {
520                timestamp: ts,
521                value: volume,
522                is_real: false, // Synthetic data
523            });
524        }
525
526        history
527    }
528
529    /// Updates the state with new token data.
530    /// New data points are marked as real (is_real = true).
531    pub fn update(&mut self, token_data: &DexTokenData) {
532        let now_ts = chrono::Utc::now().timestamp() as f64;
533
534        // Add new REAL data points
535        self.price_history.push_back(DataPoint {
536            timestamp: now_ts,
537            value: token_data.price_usd,
538            is_real: true,
539        });
540        self.volume_history.push_back(DataPoint {
541            timestamp: now_ts,
542            value: token_data.volume_24h,
543            is_real: true,
544        });
545        self.real_data_count += 1;
546
547        // Trim data points older than 24 hours
548        let cutoff = now_ts - MAX_DATA_AGE_SECS;
549
550        while let Some(point) = self.price_history.front() {
551            if point.timestamp < cutoff {
552                self.price_history.pop_front();
553            } else {
554                break;
555            }
556        }
557        while let Some(point) = self.volume_history.front() {
558            if point.timestamp < cutoff {
559                self.volume_history.pop_front();
560            } else {
561                break;
562            }
563        }
564
565        // Track when price actually changes (using 8 decimal precision for stablecoins)
566        let price_changed = (self.previous_price - token_data.price_usd).abs() > 0.00000001;
567        if price_changed {
568            self.last_price_change_at = now_ts;
569            self.previous_price = token_data.price_usd;
570        }
571
572        // Update current values
573        self.current_price = token_data.price_usd;
574        self.price_change_24h = token_data.price_change_24h;
575        self.price_change_6h = token_data.price_change_6h;
576        self.price_change_1h = token_data.price_change_1h;
577        self.price_change_5m = token_data.price_change_5m;
578        self.buys_24h = token_data.total_buys_24h;
579        self.sells_24h = token_data.total_sells_24h;
580        self.liquidity_usd = token_data.liquidity_usd;
581        self.volume_24h = token_data.volume_24h;
582        self.market_cap = token_data.market_cap;
583        self.fdv = token_data.fdv;
584
585        self.last_update = Instant::now();
586        self.error_message = None;
587
588        self.log(format!("Updated: ${:.6}", token_data.price_usd));
589
590        // Periodically save to cache (every 60 updates, ~5 minutes at 5s refresh)
591        if self.real_data_count.is_multiple_of(60) {
592            self.save_cache();
593        }
594    }
595
596    /// Returns data points filtered by the current time period.
597    /// Returns tuples for chart compatibility, plus a separate vector of is_real flags.
598    pub fn get_price_data_for_period(&self) -> (Vec<(f64, f64)>, Vec<bool>) {
599        let now_ts = chrono::Utc::now().timestamp() as f64;
600        let cutoff = now_ts - self.time_period.duration_secs() as f64;
601
602        let filtered: Vec<&DataPoint> = self
603            .price_history
604            .iter()
605            .filter(|p| p.timestamp >= cutoff)
606            .collect();
607
608        let data: Vec<(f64, f64)> = filtered.iter().map(|p| (p.timestamp, p.value)).collect();
609        let is_real: Vec<bool> = filtered.iter().map(|p| p.is_real).collect();
610
611        (data, is_real)
612    }
613
614    /// Returns volume data filtered by the current time period.
615    /// Returns tuples for chart compatibility, plus a separate vector of is_real flags.
616    pub fn get_volume_data_for_period(&self) -> (Vec<(f64, f64)>, Vec<bool>) {
617        let now_ts = chrono::Utc::now().timestamp() as f64;
618        let cutoff = now_ts - self.time_period.duration_secs() as f64;
619
620        let filtered: Vec<&DataPoint> = self
621            .volume_history
622            .iter()
623            .filter(|p| p.timestamp >= cutoff)
624            .collect();
625
626        let data: Vec<(f64, f64)> = filtered.iter().map(|p| (p.timestamp, p.value)).collect();
627        let is_real: Vec<bool> = filtered.iter().map(|p| p.is_real).collect();
628
629        (data, is_real)
630    }
631
632    /// Returns count of synthetic vs real data points in the current view.
633    pub fn data_stats(&self) -> (usize, usize) {
634        let now_ts = chrono::Utc::now().timestamp() as f64;
635        let cutoff = now_ts - self.time_period.duration_secs() as f64;
636
637        let (synthetic, real) = self
638            .price_history
639            .iter()
640            .filter(|p| p.timestamp >= cutoff)
641            .fold(
642                (0, 0),
643                |(s, r), p| {
644                    if p.is_real { (s, r + 1) } else { (s + 1, r) }
645                },
646            );
647
648        (synthetic, real)
649    }
650
651    /// Estimates memory usage of stored data in bytes.
652    pub fn memory_usage(&self) -> usize {
653        // DataPoint is 24 bytes (f64 + f64 + bool + padding)
654        let point_size = std::mem::size_of::<DataPoint>();
655        (self.price_history.len() + self.volume_history.len()) * point_size
656    }
657
658    /// Generates OHLC candles from price history for the current time period.
659    ///
660    /// The candle duration is automatically determined based on the selected time period:
661    /// - 15m view: 1-minute candles
662    /// - 1h view: 5-minute candles
663    /// - 6h view: 15-minute candles  
664    /// - 24h view: 1-hour candles
665    pub fn get_ohlc_candles(&self) -> Vec<OhlcCandle> {
666        let (data, _) = self.get_price_data_for_period();
667
668        if data.is_empty() {
669            return vec![];
670        }
671
672        // Determine candle duration based on time period
673        let candle_duration_secs = match self.time_period {
674            TimePeriod::Min15 => 60.0,    // 1-minute candles
675            TimePeriod::Hour1 => 300.0,   // 5-minute candles
676            TimePeriod::Hour6 => 900.0,   // 15-minute candles
677            TimePeriod::Hour24 => 3600.0, // 1-hour candles
678        };
679
680        let mut candles: Vec<OhlcCandle> = Vec::new();
681
682        for (timestamp, price) in data {
683            // Determine which candle this point belongs to
684            let candle_start = (timestamp / candle_duration_secs).floor() * candle_duration_secs;
685
686            if let Some(last_candle) = candles.last_mut() {
687                if (last_candle.timestamp - candle_start).abs() < 0.001 {
688                    // Same candle, update it
689                    last_candle.update(price);
690                } else {
691                    // New candle
692                    candles.push(OhlcCandle::new(candle_start, price));
693                }
694            } else {
695                // First candle
696                candles.push(OhlcCandle::new(candle_start, price));
697            }
698        }
699
700        candles
701    }
702
703    /// Cycles to the next time period.
704    pub fn cycle_time_period(&mut self) {
705        self.time_period = self.time_period.next();
706        self.log(format!("Time period: {}", self.time_period.label()));
707    }
708
709    /// Sets a specific time period.
710    pub fn set_time_period(&mut self, period: TimePeriod) {
711        self.time_period = period;
712        self.log(format!("Time period: {}", period.label()));
713    }
714
715    /// Logs a message to the log panel.
716    fn log(&mut self, message: String) {
717        let timestamp = chrono::Local::now().format("%H:%M:%S").to_string();
718        self.log_messages
719            .push_back(format!("[{}] {}", timestamp, message));
720        while self.log_messages.len() > 10 {
721            self.log_messages.pop_front();
722        }
723    }
724
725    /// Returns whether a refresh is needed.
726    pub fn should_refresh(&self) -> bool {
727        !self.paused && self.last_update.elapsed() >= self.refresh_rate
728    }
729
730    /// Toggles pause state.
731    pub fn toggle_pause(&mut self) {
732        self.paused = !self.paused;
733        self.log(if self.paused {
734            "Paused".to_string()
735        } else {
736            "Resumed".to_string()
737        });
738    }
739
740    /// Forces an immediate refresh.
741    pub fn force_refresh(&mut self) {
742        self.paused = false;
743        self.last_update = Instant::now() - self.refresh_rate;
744    }
745
746    /// Increases refresh interval (slower updates).
747    pub fn slower_refresh(&mut self) {
748        let current_secs = self.refresh_rate.as_secs();
749        let new_secs = (current_secs + 5).min(MAX_REFRESH_SECS);
750        self.refresh_rate = Duration::from_secs(new_secs);
751        self.log(format!("Refresh rate: {}s", new_secs));
752    }
753
754    /// Decreases refresh interval (faster updates).
755    pub fn faster_refresh(&mut self) {
756        let current_secs = self.refresh_rate.as_secs();
757        let new_secs = current_secs.saturating_sub(5).max(MIN_REFRESH_SECS);
758        self.refresh_rate = Duration::from_secs(new_secs);
759        self.log(format!("Refresh rate: {}s", new_secs));
760    }
761
762    /// Returns the current refresh rate in seconds.
763    pub fn refresh_rate_secs(&self) -> u64 {
764        self.refresh_rate.as_secs()
765    }
766
767    /// Returns the buy/sell ratio as a percentage (0.0 to 1.0).
768    pub fn buy_ratio(&self) -> f64 {
769        let total = self.buys_24h + self.sells_24h;
770        if total == 0 {
771            0.5
772        } else {
773            self.buys_24h as f64 / total as f64
774        }
775    }
776}
777
778/// Main monitor application.
779pub struct MonitorApp {
780    /// Terminal backend.
781    terminal: Terminal<CrosstermBackend<Stdout>>,
782
783    /// Monitor state.
784    state: MonitorState,
785
786    /// DEX client for fetching data.
787    dex_client: DexClient,
788
789    /// Whether to exit the application.
790    should_exit: bool,
791}
792
793impl MonitorApp {
794    /// Creates a new monitor application.
795    pub fn new(initial_data: DexTokenData, chain: &str) -> Result<Self> {
796        // Setup terminal
797        enable_raw_mode()
798            .map_err(|e| ScopeError::Chain(format!("Failed to enable raw mode: {}", e)))?;
799        let mut stdout = io::stdout();
800        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
801            .map_err(|e| ScopeError::Chain(format!("Failed to enter alternate screen: {}", e)))?;
802        let backend = CrosstermBackend::new(stdout);
803        let terminal = Terminal::new(backend)
804            .map_err(|e| ScopeError::Chain(format!("Failed to create terminal: {}", e)))?;
805
806        Ok(Self {
807            terminal,
808            state: MonitorState::new(&initial_data, chain),
809            dex_client: DexClient::new(),
810            should_exit: false,
811        })
812    }
813
814    /// Runs the main event loop.
815    pub async fn run(&mut self) -> Result<()> {
816        loop {
817            // Render UI
818            self.terminal.draw(|f| ui(f, &self.state))?;
819
820            // Handle events with timeout
821            if crossterm::event::poll(Duration::from_millis(100))
822                .map_err(|e| ScopeError::Chain(format!("Event poll error: {}", e)))?
823                && let Event::Key(key) = event::read()
824                    .map_err(|e| ScopeError::Chain(format!("Event read error: {}", e)))?
825            {
826                self.handle_key_event(key);
827            }
828
829            if self.should_exit {
830                break;
831            }
832
833            // Check if refresh needed
834            if self.state.should_refresh() {
835                self.fetch_data().await;
836            }
837        }
838
839        Ok(())
840    }
841
842    /// Handles a single key event, updating state accordingly.
843    /// Extracted from the event loop for testability.
844    fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) {
845        match key.code {
846            KeyCode::Char('q') | KeyCode::Esc => {
847                self.should_exit = true;
848            }
849            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
850                self.should_exit = true;
851            }
852            KeyCode::Char('r') => {
853                self.state.force_refresh();
854            }
855            KeyCode::Char('p') | KeyCode::Char(' ') => {
856                self.state.toggle_pause();
857            }
858            // Increase refresh interval (slower)
859            KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
860                self.state.slower_refresh();
861            }
862            // Decrease refresh interval (faster)
863            KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
864                self.state.faster_refresh();
865            }
866            // Time period selection (1=15m, 2=1h, 3=6h, 4=24h)
867            KeyCode::Char('1') => {
868                self.state.set_time_period(TimePeriod::Min15);
869            }
870            KeyCode::Char('2') => {
871                self.state.set_time_period(TimePeriod::Hour1);
872            }
873            KeyCode::Char('3') => {
874                self.state.set_time_period(TimePeriod::Hour6);
875            }
876            KeyCode::Char('4') => {
877                self.state.set_time_period(TimePeriod::Hour24);
878            }
879            KeyCode::Char('t') | KeyCode::Tab => {
880                self.state.cycle_time_period();
881            }
882            // Toggle chart mode (line/candlestick)
883            KeyCode::Char('c') => {
884                self.state.toggle_chart_mode();
885            }
886            _ => {}
887        }
888    }
889
890    /// Fetches new data from the API.
891    async fn fetch_data(&mut self) {
892        match self
893            .dex_client
894            .get_token_data(&self.state.chain, &self.state.token_address)
895            .await
896        {
897            Ok(data) => {
898                self.state.update(&data);
899            }
900            Err(e) => {
901                self.state.error_message = Some(format!("API Error: {}", e));
902                self.state.last_update = Instant::now(); // Prevent rapid retries
903            }
904        }
905    }
906
907    /// Cleans up terminal state.
908    pub fn cleanup(&mut self) -> Result<()> {
909        // Save cache before exiting
910        self.state.save_cache();
911
912        disable_raw_mode()
913            .map_err(|e| ScopeError::Chain(format!("Failed to disable raw mode: {}", e)))?;
914        execute!(
915            self.terminal.backend_mut(),
916            LeaveAlternateScreen,
917            DisableMouseCapture
918        )
919        .map_err(|e| ScopeError::Chain(format!("Failed to leave alternate screen: {}", e)))?;
920        self.terminal
921            .show_cursor()
922            .map_err(|e| ScopeError::Chain(format!("Failed to show cursor: {}", e)))?;
923        Ok(())
924    }
925}
926
927impl Drop for MonitorApp {
928    fn drop(&mut self) {
929        let _ = self.cleanup();
930    }
931}
932
933/// Handles a key event by mutating state. Standalone version for testability.
934/// Returns true if the application should exit.
935#[cfg(test)]
936fn handle_key_event_on_state(key: crossterm::event::KeyEvent, state: &mut MonitorState) -> bool {
937    match key.code {
938        KeyCode::Char('q') | KeyCode::Esc => {
939            return true;
940        }
941        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
942            return true;
943        }
944        KeyCode::Char('r') => {
945            state.force_refresh();
946        }
947        KeyCode::Char('p') | KeyCode::Char(' ') => {
948            state.toggle_pause();
949        }
950        KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
951            state.slower_refresh();
952        }
953        KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
954            state.faster_refresh();
955        }
956        KeyCode::Char('1') => {
957            state.set_time_period(TimePeriod::Min15);
958        }
959        KeyCode::Char('2') => {
960            state.set_time_period(TimePeriod::Hour1);
961        }
962        KeyCode::Char('3') => {
963            state.set_time_period(TimePeriod::Hour6);
964        }
965        KeyCode::Char('4') => {
966            state.set_time_period(TimePeriod::Hour24);
967        }
968        KeyCode::Char('t') | KeyCode::Tab => {
969            state.cycle_time_period();
970        }
971        KeyCode::Char('c') => {
972            state.toggle_chart_mode();
973        }
974        _ => {}
975    }
976    false
977}
978
979/// Renders the UI.
980fn ui(f: &mut Frame, state: &MonitorState) {
981    // Main layout: header, content, footer
982    let chunks = Layout::default()
983        .direction(Direction::Vertical)
984        .constraints([
985            Constraint::Length(3), // Header
986            Constraint::Min(10),   // Content
987            Constraint::Length(3), // Footer
988        ])
989        .split(f.area());
990
991    // Render header
992    render_header(f, chunks[0], state);
993
994    // Content layout: 2x2 grid
995    let content_chunks = Layout::default()
996        .direction(Direction::Horizontal)
997        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
998        .split(chunks[1]);
999
1000    let left_chunks = Layout::default()
1001        .direction(Direction::Vertical)
1002        .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
1003        .split(content_chunks[0]);
1004
1005    let right_chunks = Layout::default()
1006        .direction(Direction::Vertical)
1007        .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
1008        .split(content_chunks[1]);
1009
1010    // Render panels - dispatch to appropriate chart type
1011    match state.chart_mode {
1012        ChartMode::Line => render_price_chart(f, left_chunks[0], state),
1013        ChartMode::Candlestick => render_candlestick_chart(f, left_chunks[0], state),
1014    }
1015    render_buy_sell_gauge(f, left_chunks[1], state);
1016    render_volume_chart(f, right_chunks[0], state);
1017    render_metrics_panel(f, right_chunks[1], state);
1018
1019    // Render footer
1020    render_footer(f, chunks[2], state);
1021}
1022
1023/// Renders the header with token info.
1024fn render_header(f: &mut Frame, area: Rect, state: &MonitorState) {
1025    let price_color = if state.price_change_24h >= 0.0 {
1026        Color::Green
1027    } else {
1028        Color::Red
1029    };
1030
1031    // Use Unicode arrows for trend indication
1032    let trend_arrow = if state.price_change_24h > 0.5 {
1033        "▲"
1034    } else if state.price_change_24h < -0.5 {
1035        "▼"
1036    } else if state.price_change_24h >= 0.0 {
1037        "△"
1038    } else {
1039        "▽"
1040    };
1041
1042    let change_str = format!(
1043        "{}{:.2}%",
1044        if state.price_change_24h >= 0.0 {
1045            "+"
1046        } else {
1047            ""
1048        },
1049        state.price_change_24h
1050    );
1051
1052    let title = format!(
1053        " ◈ {} ({}) │ {} │ {} ",
1054        state.symbol,
1055        state.name,
1056        state.chain.to_uppercase(),
1057        state.time_period.label()
1058    );
1059
1060    let price_str = format_price_usd(state.current_price);
1061
1062    let header = Paragraph::new(Line::from(vec![
1063        Span::styled(
1064            price_str,
1065            Style::default()
1066                .fg(price_color)
1067                .add_modifier(Modifier::BOLD),
1068        ),
1069        Span::raw(" "),
1070        Span::styled(trend_arrow, Style::default().fg(price_color)),
1071        Span::styled(format!(" {}", change_str), Style::default().fg(price_color)),
1072    ]))
1073    .block(
1074        Block::default()
1075            .title(title)
1076            .borders(Borders::ALL)
1077            .border_style(Style::default().fg(Color::Cyan)),
1078    );
1079
1080    f.render_widget(header, area);
1081}
1082
1083/// Renders the price chart with visual differentiation between real and synthetic data.
1084fn render_price_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
1085    // Get data filtered by selected time period
1086    let (data, is_real) = state.get_price_data_for_period();
1087
1088    if data.is_empty() {
1089        let empty = Paragraph::new("No price data").block(
1090            Block::default()
1091                .title(" Price (USD) ")
1092                .borders(Borders::ALL),
1093        );
1094        f.render_widget(empty, area);
1095        return;
1096    }
1097
1098    // Calculate price statistics
1099    let current_price = state.current_price;
1100    let first_price = data.first().map(|(_, p)| *p).unwrap_or(current_price);
1101    let price_change = current_price - first_price;
1102    let price_change_pct = if first_price > 0.0 {
1103        (price_change / first_price) * 100.0
1104    } else {
1105        0.0
1106    };
1107
1108    // Determine if price is up or down for coloring
1109    let is_price_up = price_change >= 0.0;
1110    let trend_color = if is_price_up {
1111        Color::Green
1112    } else {
1113        Color::Red
1114    };
1115    let trend_symbol = if is_price_up { "▲" } else { "▼" };
1116
1117    // Format current price based on magnitude
1118    let price_str = format_price_usd(current_price);
1119    let change_str = if price_change_pct.abs() < 0.01 {
1120        "0.00%".to_string()
1121    } else {
1122        format!(
1123            "{}{:.2}%",
1124            if is_price_up { "+" } else { "" },
1125            price_change_pct
1126        )
1127    };
1128
1129    // Build title with current price and change
1130    let chart_title = Line::from(vec![
1131        Span::raw(" ◆ "),
1132        Span::styled(
1133            format!("{} {} ", price_str, trend_symbol),
1134            Style::default()
1135                .fg(trend_color)
1136                .add_modifier(Modifier::BOLD),
1137        ),
1138        Span::styled(
1139            format!("({}) ", change_str),
1140            Style::default().fg(trend_color),
1141        ),
1142        Span::styled(
1143            format!("│{}│ ", state.time_period.label()),
1144            Style::default().fg(Color::Gray),
1145        ),
1146    ]);
1147
1148    // Calculate bounds
1149    let (min_price, max_price) = data
1150        .iter()
1151        .fold((f64::MAX, f64::MIN), |(min, max), (_, p)| {
1152            (min.min(*p), max.max(*p))
1153        });
1154
1155    // Handle case where all prices are the same (e.g., stablecoins)
1156    let price_range = max_price - min_price;
1157    let (y_min, y_max) = if price_range < 0.0001 {
1158        // Add ±0.1% padding when prices are flat
1159        let padding = min_price * 0.001;
1160        (min_price - padding, max_price + padding)
1161    } else {
1162        (min_price - price_range * 0.1, max_price + price_range * 0.1)
1163    };
1164
1165    let x_min = data.first().map(|(t, _)| *t).unwrap_or(0.0);
1166    let x_max = data.last().map(|(t, _)| *t).unwrap_or(1.0);
1167    // Ensure x range is non-zero for proper rendering
1168    let x_max = if (x_max - x_min).abs() < 0.001 {
1169        x_min + 1.0
1170    } else {
1171        x_max
1172    };
1173
1174    // Split data into synthetic and real datasets for visual differentiation
1175    let synthetic_data: Vec<(f64, f64)> = data
1176        .iter()
1177        .zip(&is_real)
1178        .filter(|(_, real)| !**real)
1179        .map(|(point, _)| *point)
1180        .collect();
1181
1182    let real_data: Vec<(f64, f64)> = data
1183        .iter()
1184        .zip(&is_real)
1185        .filter(|(_, real)| **real)
1186        .map(|(point, _)| *point)
1187        .collect();
1188
1189    // Create reference line at first price (horizontal line for comparison)
1190    let reference_line: Vec<(f64, f64)> = vec![(x_min, first_price), (x_max, first_price)];
1191
1192    let mut datasets = Vec::new();
1193
1194    // Reference line (starting price) - dashed gray
1195    datasets.push(
1196        Dataset::default()
1197            .name("━Start")
1198            .marker(symbols::Marker::Braille)
1199            .graph_type(GraphType::Line)
1200            .style(Style::default().fg(Color::DarkGray))
1201            .data(&reference_line),
1202    );
1203
1204    // Synthetic data shown with Dot marker and dimmed color
1205    if !synthetic_data.is_empty() {
1206        datasets.push(
1207            Dataset::default()
1208                .name("◇Est")
1209                .marker(symbols::Marker::Braille)
1210                .graph_type(GraphType::Line)
1211                .style(Style::default().fg(Color::Cyan))
1212                .data(&synthetic_data),
1213        );
1214    }
1215
1216    // Real data shown with Braille marker and trend color
1217    if !real_data.is_empty() {
1218        datasets.push(
1219            Dataset::default()
1220                .name("●Live")
1221                .marker(symbols::Marker::Braille)
1222                .graph_type(GraphType::Line)
1223                .style(Style::default().fg(trend_color))
1224                .data(&real_data),
1225        );
1226    }
1227
1228    // Create time labels based on period
1229    let time_label = format!("-{}", state.time_period.label());
1230
1231    // Calculate middle price for 3-point y-axis labels
1232    let mid_price = (y_min + y_max) / 2.0;
1233
1234    let chart = Chart::new(datasets)
1235        .block(
1236            Block::default()
1237                .title(chart_title)
1238                .borders(Borders::ALL)
1239                .border_style(Style::default().fg(trend_color)),
1240        )
1241        .x_axis(
1242            Axis::default()
1243                .title(Span::styled("Time", Style::default().fg(Color::Gray)))
1244                .style(Style::default().fg(Color::Gray))
1245                .bounds([x_min, x_max])
1246                .labels(vec![Span::raw(time_label), Span::raw("now")]),
1247        )
1248        .y_axis(
1249            Axis::default()
1250                .title(Span::styled("USD", Style::default().fg(Color::Gray)))
1251                .style(Style::default().fg(Color::Gray))
1252                .bounds([y_min, y_max])
1253                .labels(vec![
1254                    Span::raw(format_price_usd(y_min)),
1255                    Span::raw(format_price_usd(mid_price)),
1256                    Span::raw(format_price_usd(y_max)),
1257                ]),
1258        );
1259
1260    f.render_widget(chart, area);
1261}
1262
1263/// Checks if a price indicates a stablecoin (pegged around $1.00).
1264fn is_stablecoin_price(price: f64) -> bool {
1265    (0.95..=1.05).contains(&price)
1266}
1267
1268/// Formats a price in USD with appropriate precision.
1269/// Stablecoins get extra precision (6 decimals) to show micro-fluctuations.
1270fn format_price_usd(price: f64) -> String {
1271    if price >= 1000.0 {
1272        format!("${:.2}", price)
1273    } else if is_stablecoin_price(price) {
1274        // Stablecoins get 6 decimals to show micro-fluctuations
1275        format!("${:.6}", price)
1276    } else if price >= 1.0 {
1277        format!("${:.4}", price)
1278    } else if price >= 0.01 {
1279        format!("${:.6}", price)
1280    } else if price >= 0.0001 {
1281        format!("${:.8}", price)
1282    } else {
1283        format!("${:.10}", price)
1284    }
1285}
1286
1287/// Renders a candlestick chart using OHLC data.
1288fn render_candlestick_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
1289    let candles = state.get_ohlc_candles();
1290
1291    if candles.is_empty() {
1292        let empty = Paragraph::new("No candle data (waiting for more data points)").block(
1293            Block::default()
1294                .title(" Candlestick (USD) ")
1295                .borders(Borders::ALL),
1296        );
1297        f.render_widget(empty, area);
1298        return;
1299    }
1300
1301    // Calculate price statistics
1302    let current_price = state.current_price;
1303    let first_candle = candles.first().unwrap();
1304    let last_candle = candles.last().unwrap();
1305    let price_change = last_candle.close - first_candle.open;
1306    let price_change_pct = if first_candle.open > 0.0 {
1307        (price_change / first_candle.open) * 100.0
1308    } else {
1309        0.0
1310    };
1311
1312    let is_price_up = price_change >= 0.0;
1313    let trend_color = if is_price_up {
1314        Color::Green
1315    } else {
1316        Color::Red
1317    };
1318    let trend_symbol = if is_price_up { "▲" } else { "▼" };
1319
1320    let price_str = format_price_usd(current_price);
1321    let change_str = format!(
1322        "{}{:.2}%",
1323        if is_price_up { "+" } else { "" },
1324        price_change_pct
1325    );
1326
1327    // Calculate bounds from all candle high/low
1328    let (min_price, max_price) = candles.iter().fold((f64::MAX, f64::MIN), |(min, max), c| {
1329        (min.min(c.low), max.max(c.high))
1330    });
1331
1332    let price_range = max_price - min_price;
1333    let (y_min, y_max) = if price_range < 0.0001 {
1334        let padding = min_price * 0.001;
1335        (min_price - padding, max_price + padding)
1336    } else {
1337        (min_price - price_range * 0.1, max_price + price_range * 0.1)
1338    };
1339
1340    let x_min = candles.first().map(|c| c.timestamp).unwrap_or(0.0);
1341    let x_max = candles.last().map(|c| c.timestamp).unwrap_or(1.0);
1342    let x_range = x_max - x_min;
1343    let x_max = if x_range < 0.001 {
1344        x_min + 1.0
1345    } else {
1346        x_max + x_range * 0.05
1347    };
1348
1349    // Calculate candle width based on number of candles and area
1350    let candle_count = candles.len() as f64;
1351    let candle_spacing = x_range / candle_count.max(1.0);
1352    let candle_width = candle_spacing * 0.6; // 60% of spacing for body
1353
1354    let title = Line::from(vec![
1355        Span::raw(" ⬡ "),
1356        Span::styled(
1357            format!("{} {} ", price_str, trend_symbol),
1358            Style::default()
1359                .fg(trend_color)
1360                .add_modifier(Modifier::BOLD),
1361        ),
1362        Span::styled(
1363            format!("({}) ", change_str),
1364            Style::default().fg(trend_color),
1365        ),
1366        Span::styled(
1367            format!("│{}│ ", state.time_period.label()),
1368            Style::default().fg(Color::Gray),
1369        ),
1370        Span::styled("⊞Candles ", Style::default().fg(Color::Magenta)),
1371    ]);
1372
1373    // Clone candles for the closure
1374    let candles_clone = candles.clone();
1375
1376    let canvas = Canvas::default()
1377        .block(
1378            Block::default()
1379                .title(title)
1380                .borders(Borders::ALL)
1381                .border_style(Style::default().fg(trend_color)),
1382        )
1383        .x_bounds([x_min - candle_spacing, x_max])
1384        .y_bounds([y_min, y_max])
1385        .paint(move |ctx| {
1386            for candle in &candles_clone {
1387                let color = if candle.is_bullish {
1388                    Color::Green
1389                } else {
1390                    Color::Red
1391                };
1392
1393                // Draw the wick (high-low line)
1394                ctx.draw(&CanvasLine {
1395                    x1: candle.timestamp,
1396                    y1: candle.low,
1397                    x2: candle.timestamp,
1398                    y2: candle.high,
1399                    color,
1400                });
1401
1402                // Draw the body (open-close rectangle)
1403                let body_top = candle.open.max(candle.close);
1404                let body_bottom = candle.open.min(candle.close);
1405                let body_height = (body_top - body_bottom).max(price_range * 0.002); // Minimum visible height
1406
1407                ctx.draw(&Rectangle {
1408                    x: candle.timestamp - candle_width / 2.0,
1409                    y: body_bottom,
1410                    width: candle_width,
1411                    height: body_height,
1412                    color,
1413                });
1414            }
1415        });
1416
1417    f.render_widget(canvas, area);
1418}
1419
1420/// Renders the volume chart with visual differentiation between real and synthetic data.
1421fn render_volume_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
1422    // Get data filtered by selected time period
1423    let (data, is_real) = state.get_volume_data_for_period();
1424
1425    if data.is_empty() {
1426        let empty = Paragraph::new("No volume data")
1427            .block(Block::default().title(" 24h Volume ").borders(Borders::ALL));
1428        f.render_widget(empty, area);
1429        return;
1430    }
1431
1432    // Get current volume for display
1433    let current_volume = state.volume_24h;
1434    let volume_str = format_usd(current_volume);
1435
1436    // Count synthetic vs real points for the legend
1437    let has_synthetic = is_real.iter().any(|r| !r);
1438    let has_real = is_real.iter().any(|r| *r);
1439
1440    // Build title with current volume
1441    let data_indicator = if has_synthetic && has_real {
1442        "[◆ est │ ● live]"
1443    } else if has_synthetic {
1444        "[◆ estimated]"
1445    } else {
1446        "[● live]"
1447    };
1448
1449    let chart_title = Line::from(vec![
1450        Span::raw(" ▣ "),
1451        Span::styled(
1452            format!("24h Vol: {} ", volume_str),
1453            Style::default()
1454                .fg(Color::Blue)
1455                .add_modifier(Modifier::BOLD),
1456        ),
1457        Span::styled(
1458            format!("│{}│ ", state.time_period.label()),
1459            Style::default().fg(Color::Gray),
1460        ),
1461        Span::styled(data_indicator, Style::default().fg(Color::DarkGray)),
1462    ]);
1463
1464    // Calculate bounds
1465    let max_volume = data.iter().map(|(_, v)| *v).fold(0.0_f64, f64::max);
1466    let min_volume = data.iter().map(|(_, v)| *v).fold(f64::MAX, f64::min);
1467
1468    // Handle case where volumes are similar (cumulative 24h volume doesn't change much)
1469    let vol_range = max_volume - min_volume;
1470    let (y_min, y_max) = if vol_range < max_volume * 0.01 {
1471        // Less than 1% variation - center the data with ±5% padding
1472        let padding = max_volume * 0.05;
1473        (min_volume - padding, max_volume + padding)
1474    } else {
1475        // Normal variation - show from 0 to max
1476        (0.0, max_volume * 1.1)
1477    };
1478
1479    // Ensure y_min is not negative
1480    let y_min = y_min.max(0.0);
1481
1482    let x_min = data.first().map(|(t, _)| *t).unwrap_or(0.0);
1483    let x_max = data.last().map(|(t, _)| *t).unwrap_or(1.0);
1484    // Ensure x range is non-zero
1485    let x_max = if (x_max - x_min).abs() < 0.001 {
1486        x_min + 1.0
1487    } else {
1488        x_max
1489    };
1490
1491    // Split data into synthetic and real datasets for visual differentiation
1492    let synthetic_data: Vec<(f64, f64)> = data
1493        .iter()
1494        .zip(&is_real)
1495        .filter(|(_, real)| !**real)
1496        .map(|(point, _)| *point)
1497        .collect();
1498
1499    let real_data: Vec<(f64, f64)> = data
1500        .iter()
1501        .zip(&is_real)
1502        .filter(|(_, real)| **real)
1503        .map(|(point, _)| *point)
1504        .collect();
1505
1506    let mut datasets = Vec::new();
1507
1508    // Synthetic data shown with Dot marker and light blue color
1509    if !synthetic_data.is_empty() {
1510        datasets.push(
1511            Dataset::default()
1512                .name("◇Est")
1513                .marker(symbols::Marker::Braille)
1514                .graph_type(GraphType::Line)
1515                .style(Style::default().fg(Color::LightBlue))
1516                .data(&synthetic_data),
1517        );
1518    }
1519
1520    // Real data shown with Braille marker and blue color
1521    if !real_data.is_empty() {
1522        datasets.push(
1523            Dataset::default()
1524                .name("●Live")
1525                .marker(symbols::Marker::Braille)
1526                .graph_type(GraphType::Line)
1527                .style(Style::default().fg(Color::Blue))
1528                .data(&real_data),
1529        );
1530    }
1531
1532    // Create time labels based on period
1533    let time_label = format!("-{}", state.time_period.label());
1534
1535    let chart = Chart::new(datasets)
1536        .block(
1537            Block::default()
1538                .title(chart_title)
1539                .borders(Borders::ALL)
1540                .border_style(Style::default().fg(Color::Blue)),
1541        )
1542        .x_axis(
1543            Axis::default()
1544                .title("Time")
1545                .style(Style::default().fg(Color::Gray))
1546                .bounds([x_min, x_max])
1547                .labels(vec![Span::raw(time_label), Span::raw("now")]),
1548        )
1549        .y_axis(
1550            Axis::default()
1551                .title("USD")
1552                .style(Style::default().fg(Color::Gray))
1553                .bounds([y_min, y_max])
1554                .labels(vec![
1555                    Span::raw(format_number(y_min)),
1556                    Span::raw(format_number((y_min + y_max) / 2.0)),
1557                    Span::raw(format_number(y_max)),
1558                ]),
1559        );
1560
1561    f.render_widget(chart, area);
1562}
1563
1564/// Renders the buy/sell ratio gauge and recent activity.
1565fn render_buy_sell_gauge(f: &mut Frame, area: Rect, state: &MonitorState) {
1566    let chunks = Layout::default()
1567        .direction(Direction::Vertical)
1568        .constraints([Constraint::Length(3), Constraint::Min(0)])
1569        .split(area);
1570
1571    // Buy/Sell gauge
1572    let ratio = state.buy_ratio();
1573    let color = if ratio > 0.5 {
1574        Color::Green
1575    } else {
1576        Color::Red
1577    };
1578
1579    // Create a visual bar using Unicode block characters
1580    let buy_indicator = if ratio > 0.5 { "▶" } else { "▷" };
1581    let sell_indicator = if ratio < 0.5 { "◀" } else { "◁" };
1582
1583    let gauge = Gauge::default()
1584        .block(
1585            Block::default()
1586                .title(" ◐ Buy/Sell Ratio (24h) ")
1587                .borders(Borders::ALL)
1588                .border_style(Style::default().fg(color)),
1589        )
1590        .gauge_style(Style::default().fg(color))
1591        .ratio(ratio)
1592        .label(format!(
1593            "{}Buys: {} │ Sells: {}{} ({:.1}%)",
1594            buy_indicator,
1595            state.buys_24h,
1596            state.sells_24h,
1597            sell_indicator,
1598            ratio * 100.0
1599        ));
1600
1601    f.render_widget(gauge, chunks[0]);
1602
1603    // Activity log
1604    let items: Vec<ListItem> = state
1605        .log_messages
1606        .iter()
1607        .rev()
1608        .take(5)
1609        .map(|msg| ListItem::new(msg.as_str()).style(Style::default().fg(Color::Gray)))
1610        .collect();
1611
1612    let log_list = List::new(items).block(
1613        Block::default()
1614            .title(" ◷ Activity Log ")
1615            .borders(Borders::ALL)
1616            .border_style(Style::default().fg(Color::DarkGray)),
1617    );
1618
1619    f.render_widget(log_list, chunks[1]);
1620}
1621
1622/// Renders the key metrics panel.
1623fn render_metrics_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1624    // Format 5m change with appropriate color
1625    let change_5m_str = if state.price_change_5m.abs() < 0.0001 {
1626        "0.00%".to_string()
1627    } else {
1628        format!("{:+.4}%", state.price_change_5m)
1629    };
1630    let change_5m_color = if state.price_change_5m > 0.0 {
1631        Color::Green
1632    } else if state.price_change_5m < 0.0 {
1633        Color::Red
1634    } else {
1635        Color::Gray
1636    };
1637
1638    // Calculate time since last price change
1639    let now_ts = chrono::Utc::now().timestamp() as f64;
1640    let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
1641    let last_change_str = if secs_since_change < 60 {
1642        format!("{}s ago", secs_since_change)
1643    } else if secs_since_change < 3600 {
1644        format!("{}m ago", secs_since_change / 60)
1645    } else {
1646        format!("{}h ago", secs_since_change / 3600)
1647    };
1648
1649    // Build metrics as styled lines
1650    let text: Vec<Line> = vec![
1651        Line::from(vec![
1652            Span::raw("Price:      "),
1653            Span::styled(
1654                format_price_usd(state.current_price),
1655                Style::default().add_modifier(Modifier::BOLD),
1656            ),
1657        ]),
1658        Line::from(vec![
1659            Span::raw("5m Change:  "),
1660            Span::styled(change_5m_str, Style::default().fg(change_5m_color)),
1661        ]),
1662        Line::from(vec![
1663            Span::raw("Last Δ:     "),
1664            Span::styled(
1665                last_change_str,
1666                Style::default().fg(if secs_since_change < 60 {
1667                    Color::Green
1668                } else {
1669                    Color::Yellow
1670                }),
1671            ),
1672        ]),
1673        Line::from(format!(
1674            "24h Change: {}{:.2}%",
1675            if state.price_change_24h >= 0.0 {
1676                "+"
1677            } else {
1678                ""
1679            },
1680            state.price_change_24h
1681        )),
1682        Line::from(format!("Liquidity:  {}", format_usd(state.liquidity_usd))),
1683        Line::from(format!("24h Volume: {}", format_usd(state.volume_24h))),
1684        Line::from(format!(
1685            "Market Cap: {}",
1686            state
1687                .market_cap
1688                .map(format_usd)
1689                .unwrap_or_else(|| "N/A".to_string())
1690        )),
1691        Line::from(String::new()),
1692        Line::from(format!("24h Buys:   {}", state.buys_24h)),
1693        Line::from(format!("24h Sells:  {}", state.sells_24h)),
1694    ];
1695
1696    let panel = Paragraph::new(text).block(
1697        Block::default()
1698            .title(" ◉ Key Metrics ")
1699            .borders(Borders::ALL)
1700            .border_style(Style::default().fg(Color::Magenta)),
1701    );
1702
1703    f.render_widget(panel, area);
1704}
1705
1706/// Renders the footer with status and controls.
1707fn render_footer(f: &mut Frame, area: Rect, state: &MonitorState) {
1708    let elapsed = state.last_update.elapsed().as_secs();
1709
1710    // Calculate time since last price change
1711    let now_ts = chrono::Utc::now().timestamp() as f64;
1712    let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
1713    let price_change_str = if secs_since_change < 60 {
1714        format!("{}s", secs_since_change)
1715    } else if secs_since_change < 3600 {
1716        format!("{}m", secs_since_change / 60)
1717    } else {
1718        format!("{}h", secs_since_change / 3600)
1719    };
1720
1721    // Get data stats
1722    let (synthetic_count, real_count) = state.data_stats();
1723    let memory_bytes = state.memory_usage();
1724    let memory_str = if memory_bytes >= 1024 * 1024 {
1725        format!("{:.1}MB", memory_bytes as f64 / (1024.0 * 1024.0))
1726    } else if memory_bytes >= 1024 {
1727        format!("{:.1}KB", memory_bytes as f64 / 1024.0)
1728    } else {
1729        format!("{}B", memory_bytes)
1730    };
1731
1732    let status = if let Some(ref err) = state.error_message {
1733        Span::styled(format!("⚠ {}", err), Style::default().fg(Color::Red))
1734    } else if state.paused {
1735        Span::styled(
1736            "⏸ PAUSED",
1737            Style::default()
1738                .fg(Color::Yellow)
1739                .add_modifier(Modifier::BOLD),
1740        )
1741    } else {
1742        Span::styled(
1743            format!(
1744                "↻ {}s │ Δ {} │ {} pts │ {}",
1745                elapsed,
1746                price_change_str,
1747                synthetic_count + real_count,
1748                memory_str
1749            ),
1750            Style::default().fg(Color::Gray),
1751        )
1752    };
1753
1754    let spans = vec![
1755        status,
1756        Span::raw(" ║ "),
1757        Span::styled(
1758            "Q",
1759            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1760        ),
1761        Span::raw("uit "),
1762        Span::styled(
1763            "R",
1764            Style::default()
1765                .fg(Color::Green)
1766                .add_modifier(Modifier::BOLD),
1767        ),
1768        Span::raw("efresh "),
1769        Span::styled(
1770            "P",
1771            Style::default()
1772                .fg(Color::Yellow)
1773                .add_modifier(Modifier::BOLD),
1774        ),
1775        Span::raw("ause "),
1776        Span::styled(
1777            "1-4",
1778            Style::default()
1779                .fg(Color::Magenta)
1780                .add_modifier(Modifier::BOLD),
1781        ),
1782        Span::raw("/"),
1783        Span::styled(
1784            "T",
1785            Style::default()
1786                .fg(Color::Magenta)
1787                .add_modifier(Modifier::BOLD),
1788        ),
1789        Span::raw("ime "),
1790        Span::styled(
1791            "C",
1792            Style::default()
1793                .fg(Color::LightBlue)
1794                .add_modifier(Modifier::BOLD),
1795        ),
1796        Span::raw(format!("hart:{} ", state.chart_mode.label())),
1797        Span::styled(
1798            "±",
1799            Style::default()
1800                .fg(Color::Cyan)
1801                .add_modifier(Modifier::BOLD),
1802        ),
1803        Span::raw("Speed"),
1804    ];
1805
1806    let footer = Paragraph::new(Line::from(spans)).block(Block::default().borders(Borders::ALL));
1807
1808    f.render_widget(footer, area);
1809}
1810
1811/// Formats a number with K/M/B suffixes.
1812fn format_number(n: f64) -> String {
1813    if n >= 1_000_000_000.0 {
1814        format!("{:.2}B", n / 1_000_000_000.0)
1815    } else if n >= 1_000_000.0 {
1816        format!("{:.2}M", n / 1_000_000.0)
1817    } else if n >= 1_000.0 {
1818        format!("{:.2}K", n / 1_000.0)
1819    } else {
1820        format!("{:.2}", n)
1821    }
1822}
1823
1824/// Formats a USD amount with appropriate suffix.
1825fn format_usd(n: f64) -> String {
1826    if n >= 1_000_000_000.0 {
1827        format!("${:.2}B", n / 1_000_000_000.0)
1828    } else if n >= 1_000_000.0 {
1829        format!("${:.2}M", n / 1_000_000.0)
1830    } else if n >= 1_000.0 {
1831        format!("${:.2}K", n / 1_000.0)
1832    } else {
1833        format!("${:.2}", n)
1834    }
1835}
1836
1837/// Entry point for the monitor command from interactive mode.
1838pub async fn run(
1839    token: Option<String>,
1840    ctx: &SessionContext,
1841    config: &Config,
1842    clients: &dyn ChainClientFactory,
1843) -> Result<()> {
1844    let token_input = match token {
1845        Some(t) => t,
1846        None => {
1847            return Err(ScopeError::Chain(
1848                "Token address or symbol required. Usage: monitor <token>".to_string(),
1849            ));
1850        }
1851    };
1852
1853    println!("Starting live monitor for {}...", token_input);
1854    println!("Fetching initial data...");
1855
1856    // Resolve token address
1857    let dex_client = clients.create_dex_client();
1858    let token_address =
1859        resolve_token_address(&token_input, &ctx.chain, config, dex_client.as_ref()).await?;
1860
1861    // Fetch initial data
1862    let initial_data = dex_client
1863        .get_token_data(&ctx.chain, &token_address)
1864        .await?;
1865
1866    println!(
1867        "Monitoring {} ({}) on {}",
1868        initial_data.symbol, initial_data.name, ctx.chain
1869    );
1870    println!("Press Q to quit, R to refresh, P to pause...\n");
1871
1872    // Small delay to let user read the message
1873    tokio::time::sleep(Duration::from_millis(500)).await;
1874
1875    // Create and run the app
1876    let mut app = MonitorApp::new(initial_data, &ctx.chain)?;
1877    let result = app.run().await;
1878
1879    // Cleanup is handled by Drop, but we do it explicitly for error handling
1880    if let Err(e) = app.cleanup() {
1881        eprintln!("Warning: Failed to cleanup terminal: {}", e);
1882    }
1883
1884    result
1885}
1886
1887/// Resolves a token input (address or symbol) to an address.
1888async fn resolve_token_address(
1889    input: &str,
1890    chain: &str,
1891    _config: &Config,
1892    dex_client: &dyn DexDataSource,
1893) -> Result<String> {
1894    // Check if it's already an address
1895    if input.starts_with("0x") && input.len() == 42 {
1896        return Ok(input.to_string());
1897    }
1898
1899    // Check saved aliases
1900    let aliases = crate::tokens::TokenAliases::load();
1901    if let Some(alias) = aliases.get(input, Some(chain)) {
1902        return Ok(alias.address.clone());
1903    }
1904
1905    // Search by name/symbol
1906    let results = dex_client.search_tokens(input, Some(chain)).await?;
1907
1908    if results.is_empty() {
1909        return Err(ScopeError::NotFound(format!(
1910            "No token found matching '{}' on {}",
1911            input, chain
1912        )));
1913    }
1914
1915    // Use the first result (highest liquidity)
1916    let token = &results[0];
1917    println!(
1918        "Found: {} ({}) - ${:.6}",
1919        token.symbol,
1920        token.name,
1921        token.price_usd.unwrap_or(0.0)
1922    );
1923
1924    Ok(token.address.clone())
1925}
1926
1927// ============================================================================
1928// Unit Tests
1929// ============================================================================
1930
1931#[cfg(test)]
1932mod tests {
1933    use super::*;
1934
1935    fn create_test_token_data() -> DexTokenData {
1936        DexTokenData {
1937            address: "0x1234".to_string(),
1938            symbol: "TEST".to_string(),
1939            name: "Test Token".to_string(),
1940            price_usd: 1.0,
1941            price_change_24h: 5.0,
1942            price_change_6h: 2.0,
1943            price_change_1h: 0.5,
1944            price_change_5m: 0.1,
1945            volume_24h: 1_000_000.0,
1946            volume_6h: 250_000.0,
1947            volume_1h: 50_000.0,
1948            liquidity_usd: 500_000.0,
1949            market_cap: Some(10_000_000.0),
1950            fdv: Some(100_000_000.0),
1951            pairs: vec![],
1952            price_history: vec![],
1953            volume_history: vec![],
1954            total_buys_24h: 100,
1955            total_sells_24h: 50,
1956            total_buys_6h: 25,
1957            total_sells_6h: 12,
1958            total_buys_1h: 5,
1959            total_sells_1h: 3,
1960            earliest_pair_created_at: Some(1700000000000),
1961            image_url: None,
1962            websites: vec![],
1963            socials: vec![],
1964            dexscreener_url: None,
1965        }
1966    }
1967
1968    #[test]
1969    fn test_monitor_state_new() {
1970        let token_data = create_test_token_data();
1971        let state = MonitorState::new(&token_data, "ethereum");
1972
1973        assert_eq!(state.symbol, "TEST");
1974        assert_eq!(state.chain, "ethereum");
1975        assert_eq!(state.current_price, 1.0);
1976        assert_eq!(state.buys_24h, 100);
1977        assert_eq!(state.sells_24h, 50);
1978        assert!(!state.paused);
1979    }
1980
1981    #[test]
1982    fn test_monitor_state_buy_ratio() {
1983        let token_data = create_test_token_data();
1984        let state = MonitorState::new(&token_data, "ethereum");
1985
1986        let ratio = state.buy_ratio();
1987        assert!((ratio - 0.6666).abs() < 0.01); // 100 / 150 ≈ 0.667
1988    }
1989
1990    #[test]
1991    fn test_monitor_state_buy_ratio_zero() {
1992        let mut token_data = create_test_token_data();
1993        token_data.total_buys_24h = 0;
1994        token_data.total_sells_24h = 0;
1995        let state = MonitorState::new(&token_data, "ethereum");
1996
1997        assert_eq!(state.buy_ratio(), 0.5); // Default to 50/50 when no data
1998    }
1999
2000    #[test]
2001    fn test_monitor_state_toggle_pause() {
2002        let token_data = create_test_token_data();
2003        let mut state = MonitorState::new(&token_data, "ethereum");
2004
2005        assert!(!state.paused);
2006        state.toggle_pause();
2007        assert!(state.paused);
2008        state.toggle_pause();
2009        assert!(!state.paused);
2010    }
2011
2012    #[test]
2013    fn test_monitor_state_should_refresh() {
2014        let token_data = create_test_token_data();
2015        let mut state = MonitorState::new(&token_data, "ethereum");
2016        state.refresh_rate = Duration::from_secs(60);
2017
2018        // Just created, should not need refresh (60s refresh rate)
2019        assert!(!state.should_refresh());
2020
2021        // Simulate time passing well beyond refresh rate
2022        state.last_update = Instant::now() - Duration::from_secs(120);
2023        assert!(state.should_refresh());
2024
2025        // Pause should prevent refresh
2026        state.paused = true;
2027        assert!(!state.should_refresh());
2028    }
2029
2030    #[test]
2031    fn test_format_number() {
2032        assert_eq!(format_number(500.0), "500.00");
2033        assert_eq!(format_number(1_500.0), "1.50K");
2034        assert_eq!(format_number(1_500_000.0), "1.50M");
2035        assert_eq!(format_number(1_500_000_000.0), "1.50B");
2036    }
2037
2038    #[test]
2039    fn test_format_usd() {
2040        assert_eq!(format_usd(500.0), "$500.00");
2041        assert_eq!(format_usd(1_500.0), "$1.50K");
2042        assert_eq!(format_usd(1_500_000.0), "$1.50M");
2043        assert_eq!(format_usd(1_500_000_000.0), "$1.50B");
2044    }
2045
2046    #[test]
2047    fn test_monitor_state_update() {
2048        let token_data = create_test_token_data();
2049        let mut state = MonitorState::new(&token_data, "ethereum");
2050
2051        let initial_len = state.price_history.len();
2052
2053        let mut updated_data = token_data.clone();
2054        updated_data.price_usd = 1.5;
2055        updated_data.total_buys_24h = 150;
2056
2057        state.update(&updated_data);
2058
2059        assert_eq!(state.current_price, 1.5);
2060        assert_eq!(state.buys_24h, 150);
2061        // Should have one more point after update
2062        assert_eq!(state.price_history.len(), initial_len + 1);
2063    }
2064
2065    #[test]
2066    fn test_monitor_state_refresh_rate_adjustment() {
2067        let token_data = create_test_token_data();
2068        let mut state = MonitorState::new(&token_data, "ethereum");
2069
2070        // Default is 5 seconds
2071        assert_eq!(state.refresh_rate_secs(), 5);
2072
2073        // Slow down (+5s)
2074        state.slower_refresh();
2075        assert_eq!(state.refresh_rate_secs(), 10);
2076
2077        // Speed up (-5s)
2078        state.faster_refresh();
2079        assert_eq!(state.refresh_rate_secs(), 5);
2080
2081        // Speed up again (should hit minimum of 1s)
2082        state.faster_refresh();
2083        assert_eq!(state.refresh_rate_secs(), 1);
2084
2085        // Can't go below 1s
2086        state.faster_refresh();
2087        assert_eq!(state.refresh_rate_secs(), 1);
2088
2089        // Slow down to max (60s)
2090        for _ in 0..20 {
2091            state.slower_refresh();
2092        }
2093        assert_eq!(state.refresh_rate_secs(), 60);
2094    }
2095
2096    #[test]
2097    fn test_time_period() {
2098        assert_eq!(TimePeriod::Min15.label(), "15m");
2099        assert_eq!(TimePeriod::Hour1.label(), "1h");
2100        assert_eq!(TimePeriod::Hour6.label(), "6h");
2101        assert_eq!(TimePeriod::Hour24.label(), "24h");
2102
2103        assert_eq!(TimePeriod::Min15.duration_secs(), 15 * 60);
2104        assert_eq!(TimePeriod::Hour1.duration_secs(), 3600);
2105        assert_eq!(TimePeriod::Hour6.duration_secs(), 6 * 3600);
2106        assert_eq!(TimePeriod::Hour24.duration_secs(), 24 * 3600);
2107
2108        // Test cycling
2109        assert_eq!(TimePeriod::Min15.next(), TimePeriod::Hour1);
2110        assert_eq!(TimePeriod::Hour1.next(), TimePeriod::Hour6);
2111        assert_eq!(TimePeriod::Hour6.next(), TimePeriod::Hour24);
2112        assert_eq!(TimePeriod::Hour24.next(), TimePeriod::Min15);
2113    }
2114
2115    #[test]
2116    fn test_monitor_state_time_period() {
2117        let token_data = create_test_token_data();
2118        let mut state = MonitorState::new(&token_data, "ethereum");
2119
2120        // Default is 1 hour
2121        assert_eq!(state.time_period, TimePeriod::Hour1);
2122
2123        // Cycle through periods
2124        state.cycle_time_period();
2125        assert_eq!(state.time_period, TimePeriod::Hour6);
2126
2127        state.set_time_period(TimePeriod::Hour24);
2128        assert_eq!(state.time_period, TimePeriod::Hour24);
2129    }
2130
2131    #[test]
2132    fn test_synthetic_history_generation() {
2133        let token_data = create_test_token_data();
2134        let state = MonitorState::new(&token_data, "ethereum");
2135
2136        // Should have generated history (synthetic or cached real)
2137        assert!(state.price_history.len() > 1);
2138        assert!(state.volume_history.len() > 1);
2139
2140        // Price history should span some time range
2141        if let (Some(first), Some(last)) = (state.price_history.front(), state.price_history.back())
2142        {
2143            let span = last.timestamp - first.timestamp;
2144            assert!(span > 0.0); // History should span some time
2145        }
2146    }
2147
2148    #[test]
2149    fn test_real_data_marking() {
2150        let token_data = create_test_token_data();
2151        let mut state = MonitorState::new(&token_data, "ethereum");
2152
2153        // Initially all synthetic
2154        let (synthetic, real) = state.data_stats();
2155        assert!(synthetic > 0);
2156        assert_eq!(real, 0);
2157
2158        // After update, should have real data
2159        let mut updated_data = token_data.clone();
2160        updated_data.price_usd = 1.5;
2161        state.update(&updated_data);
2162
2163        let (synthetic2, real2) = state.data_stats();
2164        assert!(synthetic2 > 0);
2165        assert_eq!(real2, 1);
2166        assert_eq!(state.real_data_count, 1);
2167
2168        // The last point should be real
2169        assert!(
2170            state
2171                .price_history
2172                .back()
2173                .map(|p| p.is_real)
2174                .unwrap_or(false)
2175        );
2176    }
2177
2178    #[test]
2179    fn test_memory_usage() {
2180        let token_data = create_test_token_data();
2181        let state = MonitorState::new(&token_data, "ethereum");
2182
2183        let mem = state.memory_usage();
2184        // DataPoint is 24 bytes, should have some data points
2185        assert!(mem > 0);
2186
2187        // Each DataPoint is 24 bytes (f64 + f64 + bool + padding)
2188        let expected_point_size = std::mem::size_of::<DataPoint>();
2189        assert_eq!(expected_point_size, 24);
2190    }
2191
2192    #[test]
2193    fn test_get_data_for_period_returns_flags() {
2194        let token_data = create_test_token_data();
2195        let mut state = MonitorState::new(&token_data, "ethereum");
2196
2197        // Get initial data (may contain cached real data or synthetic)
2198        let (data, is_real) = state.get_price_data_for_period();
2199        assert_eq!(data.len(), is_real.len());
2200
2201        // Add real data point
2202        state.update(&token_data);
2203
2204        let (_data2, is_real2) = state.get_price_data_for_period();
2205        // Should have at least one real point now
2206        assert!(is_real2.iter().any(|r| *r));
2207    }
2208
2209    #[test]
2210    fn test_cache_path_generation() {
2211        let path =
2212            MonitorState::cache_path("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "ethereum");
2213        assert!(path.to_string_lossy().contains("bcc_monitor_"));
2214        assert!(path.to_string_lossy().contains("ethereum"));
2215        // Should be in temp directory
2216        let temp_dir = std::env::temp_dir();
2217        assert!(path.starts_with(temp_dir));
2218    }
2219
2220    #[test]
2221    fn test_cache_save_and_load() {
2222        let token_data = create_test_token_data();
2223        let mut state = MonitorState::new(&token_data, "test_chain");
2224
2225        // Add some real data
2226        state.update(&token_data);
2227        state.update(&token_data);
2228
2229        // Save cache
2230        state.save_cache();
2231
2232        // Verify cache file exists
2233        let path = MonitorState::cache_path(&state.token_address, &state.chain);
2234        assert!(path.exists(), "Cache file should exist after save");
2235
2236        // Load cache
2237        let loaded = MonitorState::load_cache(&state.token_address, &state.chain);
2238        assert!(loaded.is_some(), "Should be able to load saved cache");
2239
2240        let cached = loaded.unwrap();
2241        assert_eq!(cached.token_address, state.token_address);
2242        assert_eq!(cached.chain, state.chain);
2243        assert!(!cached.price_history.is_empty());
2244
2245        // Cleanup
2246        let _ = std::fs::remove_file(path);
2247    }
2248
2249    // ========================================================================
2250    // Price formatting tests
2251    // ========================================================================
2252
2253    #[test]
2254    fn test_format_price_usd_high() {
2255        let formatted = format_price_usd(2500.50);
2256        assert!(formatted.starts_with("$2500.50"));
2257    }
2258
2259    #[test]
2260    fn test_format_price_usd_stablecoin() {
2261        let formatted = format_price_usd(1.0001);
2262        assert!(formatted.contains("1.000100"));
2263        assert!(is_stablecoin_price(1.0001));
2264    }
2265
2266    #[test]
2267    fn test_format_price_usd_medium() {
2268        let formatted = format_price_usd(5.1234);
2269        assert!(formatted.starts_with("$5.1234"));
2270    }
2271
2272    #[test]
2273    fn test_format_price_usd_small() {
2274        let formatted = format_price_usd(0.05);
2275        assert!(formatted.starts_with("$0.0500"));
2276    }
2277
2278    #[test]
2279    fn test_format_price_usd_micro() {
2280        let formatted = format_price_usd(0.001);
2281        assert!(formatted.starts_with("$0.0010"));
2282    }
2283
2284    #[test]
2285    fn test_format_price_usd_nano() {
2286        let formatted = format_price_usd(0.00001);
2287        assert!(formatted.contains("0.0000100"));
2288    }
2289
2290    #[test]
2291    fn test_is_stablecoin_price() {
2292        assert!(is_stablecoin_price(1.0));
2293        assert!(is_stablecoin_price(0.999));
2294        assert!(is_stablecoin_price(1.001));
2295        assert!(is_stablecoin_price(0.95));
2296        assert!(is_stablecoin_price(1.05));
2297        assert!(!is_stablecoin_price(0.94));
2298        assert!(!is_stablecoin_price(1.06));
2299        assert!(!is_stablecoin_price(100.0));
2300    }
2301
2302    // ========================================================================
2303    // OHLC candle tests
2304    // ========================================================================
2305
2306    #[test]
2307    fn test_ohlc_candle_new() {
2308        let candle = OhlcCandle::new(1000.0, 50.0);
2309        assert_eq!(candle.open, 50.0);
2310        assert_eq!(candle.high, 50.0);
2311        assert_eq!(candle.low, 50.0);
2312        assert_eq!(candle.close, 50.0);
2313        assert!(candle.is_bullish);
2314    }
2315
2316    #[test]
2317    fn test_ohlc_candle_update() {
2318        let mut candle = OhlcCandle::new(1000.0, 50.0);
2319        candle.update(55.0);
2320        assert_eq!(candle.high, 55.0);
2321        assert_eq!(candle.close, 55.0);
2322        assert!(candle.is_bullish);
2323
2324        candle.update(45.0);
2325        assert_eq!(candle.low, 45.0);
2326        assert_eq!(candle.close, 45.0);
2327        assert!(!candle.is_bullish); // close < open
2328    }
2329
2330    #[test]
2331    fn test_get_ohlc_candles() {
2332        let token_data = create_test_token_data();
2333        let mut state = MonitorState::new(&token_data, "ethereum");
2334        // Add several data points
2335        for i in 0..20 {
2336            let mut data = token_data.clone();
2337            data.price_usd = 1.0 + (i as f64 * 0.01);
2338            state.update(&data);
2339        }
2340        let candles = state.get_ohlc_candles();
2341        // Should have some candles
2342        assert!(!candles.is_empty());
2343    }
2344
2345    // ========================================================================
2346    // ChartMode tests
2347    // ========================================================================
2348
2349    #[test]
2350    fn test_chart_mode_cycle() {
2351        let mode = ChartMode::Line;
2352        assert_eq!(mode.next(), ChartMode::Candlestick);
2353        assert_eq!(ChartMode::Candlestick.next(), ChartMode::Line);
2354    }
2355
2356    #[test]
2357    fn test_chart_mode_label() {
2358        assert_eq!(ChartMode::Line.label(), "Line");
2359        assert_eq!(ChartMode::Candlestick.label(), "Candle");
2360    }
2361
2362    // ========================================================================
2363    // TUI rendering tests (headless TestBackend)
2364    // ========================================================================
2365
2366    use ratatui::backend::TestBackend;
2367
2368    fn create_test_terminal() -> Terminal<TestBackend> {
2369        let backend = TestBackend::new(120, 40);
2370        Terminal::new(backend).unwrap()
2371    }
2372
2373    fn create_populated_state() -> MonitorState {
2374        let token_data = create_test_token_data();
2375        let mut state = MonitorState::new(&token_data, "ethereum");
2376        // Add real data points so charts have content
2377        for i in 0..30 {
2378            let mut data = token_data.clone();
2379            data.price_usd = 1.0 + (i as f64 * 0.01);
2380            data.volume_24h = 1_000_000.0 + (i as f64 * 10_000.0);
2381            state.update(&data);
2382        }
2383        state
2384    }
2385
2386    #[test]
2387    fn test_render_header_no_panic() {
2388        let mut terminal = create_test_terminal();
2389        let state = create_populated_state();
2390        terminal
2391            .draw(|f| render_header(f, f.area(), &state))
2392            .unwrap();
2393    }
2394
2395    #[test]
2396    fn test_render_price_chart_no_panic() {
2397        let mut terminal = create_test_terminal();
2398        let state = create_populated_state();
2399        terminal
2400            .draw(|f| render_price_chart(f, f.area(), &state))
2401            .unwrap();
2402    }
2403
2404    #[test]
2405    fn test_render_price_chart_line_mode() {
2406        let mut terminal = create_test_terminal();
2407        let mut state = create_populated_state();
2408        state.chart_mode = ChartMode::Line;
2409        terminal
2410            .draw(|f| render_price_chart(f, f.area(), &state))
2411            .unwrap();
2412    }
2413
2414    #[test]
2415    fn test_render_candlestick_chart_no_panic() {
2416        let mut terminal = create_test_terminal();
2417        let state = create_populated_state();
2418        terminal
2419            .draw(|f| render_candlestick_chart(f, f.area(), &state))
2420            .unwrap();
2421    }
2422
2423    #[test]
2424    fn test_render_candlestick_chart_empty() {
2425        let mut terminal = create_test_terminal();
2426        let token_data = create_test_token_data();
2427        let state = MonitorState::new(&token_data, "ethereum");
2428        terminal
2429            .draw(|f| render_candlestick_chart(f, f.area(), &state))
2430            .unwrap();
2431    }
2432
2433    #[test]
2434    fn test_render_volume_chart_no_panic() {
2435        let mut terminal = create_test_terminal();
2436        let state = create_populated_state();
2437        terminal
2438            .draw(|f| render_volume_chart(f, f.area(), &state))
2439            .unwrap();
2440    }
2441
2442    #[test]
2443    fn test_render_volume_chart_empty() {
2444        let mut terminal = create_test_terminal();
2445        let token_data = create_test_token_data();
2446        let state = MonitorState::new(&token_data, "ethereum");
2447        terminal
2448            .draw(|f| render_volume_chart(f, f.area(), &state))
2449            .unwrap();
2450    }
2451
2452    #[test]
2453    fn test_render_buy_sell_gauge_no_panic() {
2454        let mut terminal = create_test_terminal();
2455        let state = create_populated_state();
2456        terminal
2457            .draw(|f| render_buy_sell_gauge(f, f.area(), &state))
2458            .unwrap();
2459    }
2460
2461    #[test]
2462    fn test_render_buy_sell_gauge_balanced() {
2463        let mut terminal = create_test_terminal();
2464        let mut token_data = create_test_token_data();
2465        token_data.total_buys_24h = 100;
2466        token_data.total_sells_24h = 100;
2467        let state = MonitorState::new(&token_data, "ethereum");
2468        terminal
2469            .draw(|f| render_buy_sell_gauge(f, f.area(), &state))
2470            .unwrap();
2471    }
2472
2473    #[test]
2474    fn test_render_metrics_panel_no_panic() {
2475        let mut terminal = create_test_terminal();
2476        let state = create_populated_state();
2477        terminal
2478            .draw(|f| render_metrics_panel(f, f.area(), &state))
2479            .unwrap();
2480    }
2481
2482    #[test]
2483    fn test_render_metrics_panel_no_market_cap() {
2484        let mut terminal = create_test_terminal();
2485        let mut token_data = create_test_token_data();
2486        token_data.market_cap = None;
2487        token_data.fdv = None;
2488        let state = MonitorState::new(&token_data, "ethereum");
2489        terminal
2490            .draw(|f| render_metrics_panel(f, f.area(), &state))
2491            .unwrap();
2492    }
2493
2494    #[test]
2495    fn test_render_footer_no_panic() {
2496        let mut terminal = create_test_terminal();
2497        let state = create_populated_state();
2498        terminal
2499            .draw(|f| render_footer(f, f.area(), &state))
2500            .unwrap();
2501    }
2502
2503    #[test]
2504    fn test_render_footer_paused() {
2505        let mut terminal = create_test_terminal();
2506        let token_data = create_test_token_data();
2507        let mut state = MonitorState::new(&token_data, "ethereum");
2508        state.paused = true;
2509        terminal
2510            .draw(|f| render_footer(f, f.area(), &state))
2511            .unwrap();
2512    }
2513
2514    #[test]
2515    fn test_render_all_components() {
2516        // Exercise the full draw_ui layout path
2517        let mut terminal = create_test_terminal();
2518        let state = create_populated_state();
2519        terminal
2520            .draw(|f| {
2521                let area = f.area();
2522                let chunks = Layout::default()
2523                    .direction(Direction::Vertical)
2524                    .constraints([
2525                        Constraint::Length(3),
2526                        Constraint::Min(10),
2527                        Constraint::Length(5),
2528                        Constraint::Length(3),
2529                        Constraint::Length(3),
2530                    ])
2531                    .split(area);
2532                render_header(f, chunks[0], &state);
2533                render_price_chart(f, chunks[1], &state);
2534                render_volume_chart(f, chunks[2], &state);
2535                render_buy_sell_gauge(f, chunks[3], &state);
2536                render_footer(f, chunks[4], &state);
2537            })
2538            .unwrap();
2539    }
2540
2541    #[test]
2542    fn test_render_candlestick_mode() {
2543        let mut terminal = create_test_terminal();
2544        let mut state = create_populated_state();
2545        state.chart_mode = ChartMode::Candlestick;
2546        terminal
2547            .draw(|f| {
2548                let area = f.area();
2549                let chunks = Layout::default()
2550                    .direction(Direction::Vertical)
2551                    .constraints([Constraint::Length(3), Constraint::Min(10)])
2552                    .split(area);
2553                render_header(f, chunks[0], &state);
2554                render_candlestick_chart(f, chunks[1], &state);
2555            })
2556            .unwrap();
2557    }
2558
2559    #[test]
2560    fn test_render_with_different_time_periods() {
2561        let mut terminal = create_test_terminal();
2562        let mut state = create_populated_state();
2563
2564        for period in [
2565            TimePeriod::Min15,
2566            TimePeriod::Hour1,
2567            TimePeriod::Hour6,
2568            TimePeriod::Hour24,
2569        ] {
2570            state.time_period = period;
2571            terminal
2572                .draw(|f| render_price_chart(f, f.area(), &state))
2573                .unwrap();
2574        }
2575    }
2576
2577    #[test]
2578    fn test_render_metrics_with_stablecoin() {
2579        let mut terminal = create_test_terminal();
2580        let mut token_data = create_test_token_data();
2581        token_data.price_usd = 0.999;
2582        token_data.symbol = "USDC".to_string();
2583        let state = MonitorState::new(&token_data, "ethereum");
2584        terminal
2585            .draw(|f| render_metrics_panel(f, f.area(), &state))
2586            .unwrap();
2587    }
2588
2589    #[test]
2590    fn test_render_header_with_negative_change() {
2591        let mut terminal = create_test_terminal();
2592        let mut token_data = create_test_token_data();
2593        token_data.price_change_24h = -15.5;
2594        token_data.price_change_1h = -2.3;
2595        let state = MonitorState::new(&token_data, "ethereum");
2596        terminal
2597            .draw(|f| render_header(f, f.area(), &state))
2598            .unwrap();
2599    }
2600
2601    // ========================================================================
2602    // MonitorState method tests
2603    // ========================================================================
2604
2605    #[test]
2606    fn test_toggle_chart_mode_roundtrip() {
2607        let token_data = create_test_token_data();
2608        let mut state = MonitorState::new(&token_data, "ethereum");
2609        assert_eq!(state.chart_mode, ChartMode::Line);
2610        state.toggle_chart_mode();
2611        assert_eq!(state.chart_mode, ChartMode::Candlestick);
2612        state.toggle_chart_mode();
2613        assert_eq!(state.chart_mode, ChartMode::Line);
2614    }
2615
2616    #[test]
2617    fn test_cycle_all_time_periods() {
2618        let token_data = create_test_token_data();
2619        let mut state = MonitorState::new(&token_data, "ethereum");
2620        assert_eq!(state.time_period, TimePeriod::Hour1);
2621        state.cycle_time_period();
2622        assert_eq!(state.time_period, TimePeriod::Hour6);
2623        state.cycle_time_period();
2624        assert_eq!(state.time_period, TimePeriod::Hour24);
2625        state.cycle_time_period();
2626        assert_eq!(state.time_period, TimePeriod::Min15);
2627        state.cycle_time_period();
2628        assert_eq!(state.time_period, TimePeriod::Hour1);
2629    }
2630
2631    #[test]
2632    fn test_set_specific_time_period() {
2633        let token_data = create_test_token_data();
2634        let mut state = MonitorState::new(&token_data, "ethereum");
2635        state.set_time_period(TimePeriod::Hour24);
2636        assert_eq!(state.time_period, TimePeriod::Hour24);
2637    }
2638
2639    #[test]
2640    fn test_pause_resume_roundtrip() {
2641        let token_data = create_test_token_data();
2642        let mut state = MonitorState::new(&token_data, "ethereum");
2643        assert!(!state.paused);
2644        state.toggle_pause();
2645        assert!(state.paused);
2646        state.toggle_pause();
2647        assert!(!state.paused);
2648    }
2649
2650    #[test]
2651    fn test_force_refresh_unpauses() {
2652        let token_data = create_test_token_data();
2653        let mut state = MonitorState::new(&token_data, "ethereum");
2654        state.paused = true;
2655        state.force_refresh();
2656        assert!(!state.paused);
2657        assert!(state.should_refresh());
2658    }
2659
2660    #[test]
2661    fn test_refresh_rate_adjust() {
2662        let token_data = create_test_token_data();
2663        let mut state = MonitorState::new(&token_data, "ethereum");
2664        assert_eq!(state.refresh_rate_secs(), 5);
2665
2666        state.slower_refresh();
2667        assert_eq!(state.refresh_rate_secs(), 10);
2668
2669        state.faster_refresh();
2670        assert_eq!(state.refresh_rate_secs(), 5);
2671    }
2672
2673    #[test]
2674    fn test_faster_refresh_clamped_min() {
2675        let token_data = create_test_token_data();
2676        let mut state = MonitorState::new(&token_data, "ethereum");
2677        for _ in 0..10 {
2678            state.faster_refresh();
2679        }
2680        assert!(state.refresh_rate_secs() >= 1);
2681    }
2682
2683    #[test]
2684    fn test_slower_refresh_clamped_max() {
2685        let token_data = create_test_token_data();
2686        let mut state = MonitorState::new(&token_data, "ethereum");
2687        for _ in 0..20 {
2688            state.slower_refresh();
2689        }
2690        assert!(state.refresh_rate_secs() <= 60);
2691    }
2692
2693    #[test]
2694    fn test_buy_ratio_balanced() {
2695        let mut token_data = create_test_token_data();
2696        token_data.total_buys_24h = 100;
2697        token_data.total_sells_24h = 100;
2698        let state = MonitorState::new(&token_data, "ethereum");
2699        assert!((state.buy_ratio() - 0.5).abs() < 0.01);
2700    }
2701
2702    #[test]
2703    fn test_buy_ratio_no_trades() {
2704        let mut token_data = create_test_token_data();
2705        token_data.total_buys_24h = 0;
2706        token_data.total_sells_24h = 0;
2707        let state = MonitorState::new(&token_data, "ethereum");
2708        assert!((state.buy_ratio() - 0.5).abs() < 0.01);
2709    }
2710
2711    #[test]
2712    fn test_data_stats_initial() {
2713        let token_data = create_test_token_data();
2714        let state = MonitorState::new(&token_data, "ethereum");
2715        let (synthetic, real) = state.data_stats();
2716        assert!(synthetic > 0 || real == 0);
2717    }
2718
2719    #[test]
2720    fn test_memory_usage_nonzero() {
2721        let token_data = create_test_token_data();
2722        let state = MonitorState::new(&token_data, "ethereum");
2723        let usage = state.memory_usage();
2724        assert!(usage > 0);
2725    }
2726
2727    #[test]
2728    fn test_price_data_for_period() {
2729        let token_data = create_test_token_data();
2730        let state = MonitorState::new(&token_data, "ethereum");
2731        let (data, is_real) = state.get_price_data_for_period();
2732        assert_eq!(data.len(), is_real.len());
2733    }
2734
2735    #[test]
2736    fn test_volume_data_for_period() {
2737        let token_data = create_test_token_data();
2738        let state = MonitorState::new(&token_data, "ethereum");
2739        let (data, is_real) = state.get_volume_data_for_period();
2740        assert_eq!(data.len(), is_real.len());
2741    }
2742
2743    #[test]
2744    fn test_ohlc_candles_generation() {
2745        let token_data = create_test_token_data();
2746        let state = MonitorState::new(&token_data, "ethereum");
2747        let candles = state.get_ohlc_candles();
2748        for candle in &candles {
2749            assert!(candle.high >= candle.low);
2750        }
2751    }
2752
2753    #[test]
2754    fn test_state_update_with_new_data() {
2755        let token_data = create_test_token_data();
2756        let mut state = MonitorState::new(&token_data, "ethereum");
2757        let initial_count = state.real_data_count;
2758
2759        let mut updated_data = create_test_token_data();
2760        updated_data.price_usd = 2.0;
2761        updated_data.volume_24h = 2_000_000.0;
2762
2763        state.update(&updated_data);
2764        assert_eq!(state.current_price, 2.0);
2765        assert_eq!(state.real_data_count, initial_count + 1);
2766        assert!(state.error_message.is_none());
2767    }
2768
2769    #[test]
2770    fn test_cache_roundtrip_save_load() {
2771        let token_data = create_test_token_data();
2772        let state = MonitorState::new(&token_data, "ethereum");
2773
2774        state.save_cache();
2775
2776        let cache_path = MonitorState::cache_path(&token_data.address, "ethereum");
2777        assert!(cache_path.exists());
2778
2779        let cached = MonitorState::load_cache(&token_data.address, "ethereum");
2780        assert!(cached.is_some());
2781
2782        let _ = std::fs::remove_file(cache_path);
2783    }
2784
2785    #[test]
2786    fn test_should_refresh_when_paused() {
2787        let token_data = create_test_token_data();
2788        let mut state = MonitorState::new(&token_data, "ethereum");
2789        assert!(!state.should_refresh());
2790        state.paused = true;
2791        assert!(!state.should_refresh());
2792    }
2793
2794    #[test]
2795    fn test_ohlc_candle_lifecycle() {
2796        let mut candle = OhlcCandle::new(1700000000.0, 100.0);
2797        assert_eq!(candle.open, 100.0);
2798        assert!(candle.is_bullish);
2799        candle.update(110.0);
2800        assert_eq!(candle.high, 110.0);
2801        assert!(candle.is_bullish);
2802        candle.update(90.0);
2803        assert_eq!(candle.low, 90.0);
2804        assert!(!candle.is_bullish);
2805    }
2806
2807    #[test]
2808    fn test_time_period_display_impl() {
2809        assert_eq!(format!("{}", TimePeriod::Min15), "15m");
2810        assert_eq!(format!("{}", TimePeriod::Hour24), "24h");
2811    }
2812
2813    #[test]
2814    fn test_log_messages_accumulate() {
2815        let token_data = create_test_token_data();
2816        let mut state = MonitorState::new(&token_data, "ethereum");
2817        // Trigger actions that log
2818        state.toggle_pause();
2819        state.toggle_pause();
2820        state.cycle_time_period();
2821        state.toggle_chart_mode();
2822        assert!(!state.log_messages.is_empty());
2823    }
2824
2825    #[test]
2826    fn test_ui_function_full_render() {
2827        // Test the main ui() function which orchestrates all rendering
2828        let mut terminal = create_test_terminal();
2829        let state = create_populated_state();
2830        terminal.draw(|f| ui(f, &state)).unwrap();
2831    }
2832
2833    #[test]
2834    fn test_ui_function_candlestick_mode() {
2835        let mut terminal = create_test_terminal();
2836        let mut state = create_populated_state();
2837        state.chart_mode = ChartMode::Candlestick;
2838        terminal.draw(|f| ui(f, &state)).unwrap();
2839    }
2840
2841    #[test]
2842    fn test_ui_function_with_error_message() {
2843        let mut terminal = create_test_terminal();
2844        let mut state = create_populated_state();
2845        state.error_message = Some("Test error".to_string());
2846        terminal.draw(|f| ui(f, &state)).unwrap();
2847    }
2848
2849    #[test]
2850    fn test_render_header_with_small_positive_change() {
2851        let mut terminal = create_test_terminal();
2852        let mut state = create_populated_state();
2853        state.price_change_24h = 0.3; // Between 0 and 0.5 -> △
2854        terminal
2855            .draw(|f| render_header(f, f.area(), &state))
2856            .unwrap();
2857    }
2858
2859    #[test]
2860    fn test_render_header_with_small_negative_change() {
2861        let mut terminal = create_test_terminal();
2862        let mut state = create_populated_state();
2863        state.price_change_24h = -0.3; // Between -0.5 and 0 -> ▽
2864        terminal
2865            .draw(|f| render_header(f, f.area(), &state))
2866            .unwrap();
2867    }
2868
2869    #[test]
2870    fn test_render_buy_sell_gauge_high_buy_ratio() {
2871        let mut terminal = create_test_terminal();
2872        let token_data = create_test_token_data();
2873        let mut state = MonitorState::new(&token_data, "ethereum");
2874        state.buys_24h = 100;
2875        state.sells_24h = 10;
2876        terminal
2877            .draw(|f| render_buy_sell_gauge(f, f.area(), &state))
2878            .unwrap();
2879    }
2880
2881    #[test]
2882    fn test_render_buy_sell_gauge_zero_total() {
2883        let mut terminal = create_test_terminal();
2884        let token_data = create_test_token_data();
2885        let mut state = MonitorState::new(&token_data, "ethereum");
2886        state.buys_24h = 0;
2887        state.sells_24h = 0;
2888        terminal
2889            .draw(|f| render_buy_sell_gauge(f, f.area(), &state))
2890            .unwrap();
2891    }
2892
2893    #[test]
2894    fn test_render_metrics_with_market_cap() {
2895        let mut terminal = create_test_terminal();
2896        let token_data = create_test_token_data();
2897        let mut state = MonitorState::new(&token_data, "ethereum");
2898        state.market_cap = Some(1_000_000_000.0);
2899        state.fdv = Some(2_000_000_000.0);
2900        terminal
2901            .draw(|f| render_metrics_panel(f, f.area(), &state))
2902            .unwrap();
2903    }
2904
2905    #[test]
2906    fn test_render_footer_with_error() {
2907        let mut terminal = create_test_terminal();
2908        let mut state = create_populated_state();
2909        state.error_message = Some("Connection failed".to_string());
2910        terminal
2911            .draw(|f| render_footer(f, f.area(), &state))
2912            .unwrap();
2913    }
2914
2915    #[test]
2916    fn test_format_price_usd_various() {
2917        // Test format_price_usd with various magnitudes
2918        assert!(!format_price_usd(0.0000001).is_empty());
2919        assert!(!format_price_usd(0.001).is_empty());
2920        assert!(!format_price_usd(1.0).is_empty());
2921        assert!(!format_price_usd(100.0).is_empty());
2922        assert!(!format_price_usd(10000.0).is_empty());
2923        assert!(!format_price_usd(1000000.0).is_empty());
2924    }
2925
2926    #[test]
2927    fn test_format_usd_various() {
2928        assert!(!format_usd(0.0).is_empty());
2929        assert!(!format_usd(999.0).is_empty());
2930        assert!(!format_usd(1500.0).is_empty());
2931        assert!(!format_usd(1_500_000.0).is_empty());
2932        assert!(!format_usd(1_500_000_000.0).is_empty());
2933        assert!(!format_usd(1_500_000_000_000.0).is_empty());
2934    }
2935
2936    #[test]
2937    fn test_format_number_various() {
2938        assert!(!format_number(0.0).is_empty());
2939        assert!(!format_number(999.0).is_empty());
2940        assert!(!format_number(1500.0).is_empty());
2941        assert!(!format_number(1_500_000.0).is_empty());
2942        assert!(!format_number(1_500_000_000.0).is_empty());
2943    }
2944
2945    #[test]
2946    fn test_render_with_min15_period() {
2947        let mut terminal = create_test_terminal();
2948        let mut state = create_populated_state();
2949        state.set_time_period(TimePeriod::Min15);
2950        terminal.draw(|f| ui(f, &state)).unwrap();
2951    }
2952
2953    #[test]
2954    fn test_render_with_hour6_period() {
2955        let mut terminal = create_test_terminal();
2956        let mut state = create_populated_state();
2957        state.set_time_period(TimePeriod::Hour6);
2958        terminal.draw(|f| ui(f, &state)).unwrap();
2959    }
2960
2961    #[test]
2962    fn test_ui_with_fresh_state_no_real_data() {
2963        let mut terminal = create_test_terminal();
2964        let token_data = create_test_token_data();
2965        let state = MonitorState::new(&token_data, "ethereum");
2966        // Fresh state with only synthetic data
2967        terminal.draw(|f| ui(f, &state)).unwrap();
2968    }
2969
2970    #[test]
2971    fn test_ui_with_paused_state() {
2972        let mut terminal = create_test_terminal();
2973        let mut state = create_populated_state();
2974        state.toggle_pause();
2975        terminal.draw(|f| ui(f, &state)).unwrap();
2976    }
2977
2978    #[test]
2979    fn test_render_all_with_different_time_periods_and_modes() {
2980        let mut terminal = create_test_terminal();
2981        let mut state = create_populated_state();
2982
2983        // Test all combinations of time period + chart mode
2984        for period in &[
2985            TimePeriod::Min15,
2986            TimePeriod::Hour1,
2987            TimePeriod::Hour6,
2988            TimePeriod::Hour24,
2989        ] {
2990            for mode in &[ChartMode::Line, ChartMode::Candlestick] {
2991                state.set_time_period(*period);
2992                state.chart_mode = *mode;
2993                terminal.draw(|f| ui(f, &state)).unwrap();
2994            }
2995        }
2996    }
2997
2998    #[test]
2999    fn test_render_metrics_with_large_values() {
3000        let mut terminal = create_test_terminal();
3001        let mut state = create_populated_state();
3002        state.market_cap = Some(50_000_000_000.0); // 50B
3003        state.fdv = Some(100_000_000_000.0); // 100B
3004        state.volume_24h = 5_000_000_000.0; // 5B
3005        state.liquidity_usd = 500_000_000.0; // 500M
3006        terminal
3007            .draw(|f| render_metrics_panel(f, f.area(), &state))
3008            .unwrap();
3009    }
3010
3011    #[test]
3012    fn test_render_header_large_positive_change() {
3013        let mut terminal = create_test_terminal();
3014        let mut state = create_populated_state();
3015        state.price_change_24h = 50.0; // >0.5 -> ▲
3016        terminal
3017            .draw(|f| render_header(f, f.area(), &state))
3018            .unwrap();
3019    }
3020
3021    #[test]
3022    fn test_render_header_large_negative_change() {
3023        let mut terminal = create_test_terminal();
3024        let mut state = create_populated_state();
3025        state.price_change_24h = -50.0; // <-0.5 -> ▼
3026        terminal
3027            .draw(|f| render_header(f, f.area(), &state))
3028            .unwrap();
3029    }
3030
3031    #[test]
3032    fn test_render_price_chart_empty_data() {
3033        let mut terminal = create_test_terminal();
3034        let token_data = create_test_token_data();
3035        // Create state with no price history data
3036        let mut state = MonitorState::new(&token_data, "ethereum");
3037        state.price_history.clear();
3038        terminal
3039            .draw(|f| render_price_chart(f, f.area(), &state))
3040            .unwrap();
3041    }
3042
3043    #[test]
3044    fn test_render_price_chart_price_down() {
3045        let mut terminal = create_test_terminal();
3046        let mut state = create_populated_state();
3047        // Force price down scenario
3048        state.price_change_24h = -15.0;
3049        state.current_price = 0.5; // Below initial
3050        terminal
3051            .draw(|f| render_price_chart(f, f.area(), &state))
3052            .unwrap();
3053    }
3054
3055    #[test]
3056    fn test_render_price_chart_zero_first_price() {
3057        let mut terminal = create_test_terminal();
3058        let mut token_data = create_test_token_data();
3059        token_data.price_usd = 0.0;
3060        let state = MonitorState::new(&token_data, "ethereum");
3061        terminal
3062            .draw(|f| render_price_chart(f, f.area(), &state))
3063            .unwrap();
3064    }
3065
3066    #[test]
3067    fn test_render_metrics_panel_zero_5m_change() {
3068        let mut terminal = create_test_terminal();
3069        let mut state = create_populated_state();
3070        state.price_change_5m = 0.0; // Exactly zero
3071        terminal
3072            .draw(|f| render_metrics_panel(f, f.area(), &state))
3073            .unwrap();
3074    }
3075
3076    #[test]
3077    fn test_render_metrics_panel_positive_5m_change() {
3078        let mut terminal = create_test_terminal();
3079        let mut state = create_populated_state();
3080        state.price_change_5m = 5.0; // Positive
3081        terminal
3082            .draw(|f| render_metrics_panel(f, f.area(), &state))
3083            .unwrap();
3084    }
3085
3086    #[test]
3087    fn test_render_metrics_panel_negative_5m_change() {
3088        let mut terminal = create_test_terminal();
3089        let mut state = create_populated_state();
3090        state.price_change_5m = -3.0; // Negative
3091        terminal
3092            .draw(|f| render_metrics_panel(f, f.area(), &state))
3093            .unwrap();
3094    }
3095
3096    #[test]
3097    fn test_render_metrics_panel_negative_24h_change() {
3098        let mut terminal = create_test_terminal();
3099        let mut state = create_populated_state();
3100        state.price_change_24h = -10.0;
3101        terminal
3102            .draw(|f| render_metrics_panel(f, f.area(), &state))
3103            .unwrap();
3104    }
3105
3106    #[test]
3107    fn test_render_metrics_panel_old_last_change() {
3108        let mut terminal = create_test_terminal();
3109        let mut state = create_populated_state();
3110        // Set last_price_change_at to over an hour ago
3111        state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 7200.0; // 2h ago
3112        terminal
3113            .draw(|f| render_metrics_panel(f, f.area(), &state))
3114            .unwrap();
3115    }
3116
3117    #[test]
3118    fn test_render_metrics_panel_minutes_ago_change() {
3119        let mut terminal = create_test_terminal();
3120        let mut state = create_populated_state();
3121        // Set last_price_change_at to minutes ago
3122        state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 300.0; // 5 min ago
3123        terminal
3124            .draw(|f| render_metrics_panel(f, f.area(), &state))
3125            .unwrap();
3126    }
3127
3128    #[test]
3129    fn test_render_candlestick_empty_fresh_state() {
3130        let mut terminal = create_test_terminal();
3131        let token_data = create_test_token_data();
3132        let mut state = MonitorState::new(&token_data, "ethereum");
3133        state.price_history.clear();
3134        state.chart_mode = ChartMode::Candlestick;
3135        terminal
3136            .draw(|f| render_candlestick_chart(f, f.area(), &state))
3137            .unwrap();
3138    }
3139
3140    #[test]
3141    fn test_render_candlestick_price_down() {
3142        let mut terminal = create_test_terminal();
3143        let token_data = create_test_token_data();
3144        let mut state = MonitorState::new(&token_data, "ethereum");
3145        // Add data going down
3146        for i in 0..20 {
3147            let mut data = token_data.clone();
3148            data.price_usd = 2.0 - (i as f64 * 0.05);
3149            state.update(&data);
3150        }
3151        state.chart_mode = ChartMode::Candlestick;
3152        terminal
3153            .draw(|f| render_candlestick_chart(f, f.area(), &state))
3154            .unwrap();
3155    }
3156
3157    #[test]
3158    fn test_render_volume_chart_with_many_points() {
3159        let mut terminal = create_test_terminal();
3160        let token_data = create_test_token_data();
3161        let mut state = MonitorState::new(&token_data, "ethereum");
3162        // Add lots of data points
3163        for i in 0..100 {
3164            let mut data = token_data.clone();
3165            data.volume_24h = 1_000_000.0 + (i as f64 * 50_000.0);
3166            data.price_usd = 1.0 + (i as f64 * 0.001);
3167            state.update(&data);
3168        }
3169        terminal
3170            .draw(|f| render_volume_chart(f, f.area(), &state))
3171            .unwrap();
3172    }
3173
3174    // ========================================================================
3175    // Key event handler tests
3176    // ========================================================================
3177
3178    fn make_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
3179        crossterm::event::KeyEvent::new(code, KeyModifiers::NONE)
3180    }
3181
3182    fn make_ctrl_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
3183        crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)
3184    }
3185
3186    #[test]
3187    fn test_handle_key_quit_q() {
3188        let token_data = create_test_token_data();
3189        let mut state = MonitorState::new(&token_data, "ethereum");
3190        assert!(handle_key_event_on_state(
3191            make_key_event(KeyCode::Char('q')),
3192            &mut state
3193        ));
3194    }
3195
3196    #[test]
3197    fn test_handle_key_quit_esc() {
3198        let token_data = create_test_token_data();
3199        let mut state = MonitorState::new(&token_data, "ethereum");
3200        assert!(handle_key_event_on_state(
3201            make_key_event(KeyCode::Esc),
3202            &mut state
3203        ));
3204    }
3205
3206    #[test]
3207    fn test_handle_key_quit_ctrl_c() {
3208        let token_data = create_test_token_data();
3209        let mut state = MonitorState::new(&token_data, "ethereum");
3210        assert!(handle_key_event_on_state(
3211            make_ctrl_key_event(KeyCode::Char('c')),
3212            &mut state
3213        ));
3214    }
3215
3216    #[test]
3217    fn test_handle_key_refresh() {
3218        let token_data = create_test_token_data();
3219        let mut state = MonitorState::new(&token_data, "ethereum");
3220        state.refresh_rate = Duration::from_secs(60);
3221        // Set last_update in the past so should_refresh was false
3222        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('r')), &mut state);
3223        assert!(!exit);
3224        // force_refresh sets last_update to epoch, so should_refresh() should be true
3225        assert!(state.should_refresh());
3226    }
3227
3228    #[test]
3229    fn test_handle_key_pause_toggle() {
3230        let token_data = create_test_token_data();
3231        let mut state = MonitorState::new(&token_data, "ethereum");
3232        assert!(!state.paused);
3233
3234        handle_key_event_on_state(make_key_event(KeyCode::Char('p')), &mut state);
3235        assert!(state.paused);
3236
3237        handle_key_event_on_state(make_key_event(KeyCode::Char(' ')), &mut state);
3238        assert!(!state.paused);
3239    }
3240
3241    #[test]
3242    fn test_handle_key_slower_refresh() {
3243        let token_data = create_test_token_data();
3244        let mut state = MonitorState::new(&token_data, "ethereum");
3245        let initial = state.refresh_rate;
3246
3247        handle_key_event_on_state(make_key_event(KeyCode::Char('+')), &mut state);
3248        assert!(state.refresh_rate > initial);
3249
3250        state.refresh_rate = initial;
3251        handle_key_event_on_state(make_key_event(KeyCode::Char('=')), &mut state);
3252        assert!(state.refresh_rate > initial);
3253
3254        state.refresh_rate = initial;
3255        handle_key_event_on_state(make_key_event(KeyCode::Char(']')), &mut state);
3256        assert!(state.refresh_rate > initial);
3257    }
3258
3259    #[test]
3260    fn test_handle_key_faster_refresh() {
3261        let token_data = create_test_token_data();
3262        let mut state = MonitorState::new(&token_data, "ethereum");
3263        // First make it slower so there's room to go faster
3264        state.refresh_rate = Duration::from_secs(30);
3265        let initial = state.refresh_rate;
3266
3267        handle_key_event_on_state(make_key_event(KeyCode::Char('-')), &mut state);
3268        assert!(state.refresh_rate < initial);
3269
3270        state.refresh_rate = initial;
3271        handle_key_event_on_state(make_key_event(KeyCode::Char('_')), &mut state);
3272        assert!(state.refresh_rate < initial);
3273
3274        state.refresh_rate = initial;
3275        handle_key_event_on_state(make_key_event(KeyCode::Char('[')), &mut state);
3276        assert!(state.refresh_rate < initial);
3277    }
3278
3279    #[test]
3280    fn test_handle_key_time_periods() {
3281        let token_data = create_test_token_data();
3282        let mut state = MonitorState::new(&token_data, "ethereum");
3283
3284        handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
3285        assert!(matches!(state.time_period, TimePeriod::Min15));
3286
3287        handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
3288        assert!(matches!(state.time_period, TimePeriod::Hour1));
3289
3290        handle_key_event_on_state(make_key_event(KeyCode::Char('3')), &mut state);
3291        assert!(matches!(state.time_period, TimePeriod::Hour6));
3292
3293        handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
3294        assert!(matches!(state.time_period, TimePeriod::Hour24));
3295    }
3296
3297    #[test]
3298    fn test_handle_key_cycle_time_period() {
3299        let token_data = create_test_token_data();
3300        let mut state = MonitorState::new(&token_data, "ethereum");
3301
3302        handle_key_event_on_state(make_key_event(KeyCode::Char('t')), &mut state);
3303        // Should cycle from default
3304        let first = state.time_period;
3305
3306        handle_key_event_on_state(make_key_event(KeyCode::Tab), &mut state);
3307        // Should have cycled again
3308        // Verify it cycled (no panic is the main check)
3309        let _ = state.time_period;
3310        let _ = first;
3311    }
3312
3313    #[test]
3314    fn test_handle_key_toggle_chart_mode() {
3315        let token_data = create_test_token_data();
3316        let mut state = MonitorState::new(&token_data, "ethereum");
3317        let initial_mode = state.chart_mode;
3318
3319        handle_key_event_on_state(make_key_event(KeyCode::Char('c')), &mut state);
3320        assert!(state.chart_mode != initial_mode);
3321    }
3322
3323    #[test]
3324    fn test_handle_key_unknown_no_op() {
3325        let token_data = create_test_token_data();
3326        let mut state = MonitorState::new(&token_data, "ethereum");
3327        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
3328        assert!(!exit);
3329    }
3330
3331    // ========================================================================
3332    // Cache save/load tests
3333    // ========================================================================
3334
3335    #[test]
3336    fn test_save_and_load_cache() {
3337        let token_data = create_test_token_data();
3338        let mut state = MonitorState::new(&token_data, "ethereum");
3339        state.price_history.push_back(DataPoint {
3340            timestamp: 1.0,
3341            value: 100.0,
3342            is_real: true,
3343        });
3344        state.price_history.push_back(DataPoint {
3345            timestamp: 2.0,
3346            value: 101.0,
3347            is_real: true,
3348        });
3349        state.volume_history.push_back(DataPoint {
3350            timestamp: 1.0,
3351            value: 5000.0,
3352            is_real: true,
3353        });
3354
3355        // save_cache uses dirs::cache_dir() which we can't redirect easily
3356        // but we can test the load_cache path with a real write
3357        state.save_cache();
3358        let cached = MonitorState::load_cache(&state.token_address, &state.chain);
3359        // Cache may or may not exist depending on system - just verify no panic
3360        if let Some(c) = cached {
3361            assert_eq!(
3362                c.token_address.to_lowercase(),
3363                state.token_address.to_lowercase()
3364            );
3365        }
3366    }
3367
3368    #[test]
3369    fn test_load_cache_nonexistent_token() {
3370        let cached = MonitorState::load_cache("0xNONEXISTENT_TOKEN_ADDR", "nonexistent_chain");
3371        assert!(cached.is_none());
3372    }
3373}