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