Skip to main content

scope_cli/cli/monitor/
state.rs

1//! Application state management for the monitor TUI.
2
3use ratatui::widgets::ListState;
4use scope::chains::DexPair;
5use scope::chains::dex::DexTokenData;
6use scope::market::{
7    HealthThresholds, OrderBook, Trade, TradeSide, most_liquid, order_book_from_analytics,
8};
9use std::collections::VecDeque;
10use std::fs;
11use std::io::{BufWriter, Write as _};
12use std::path::{Path, PathBuf};
13
14/// Default directory for CSV exports when config `export.path` is not set.
15const DEFAULT_EXPORT_DIR: &str = "./scope-exports";
16
17/// The CSV header of a monitor export.
18const EXPORT_HEADER: &str =
19    "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap\n";
20use std::time::{Duration, Instant};
21
22use super::config::{
23    ActiveAlert, AlertConfig, CACHE_FILE_PREFIX, CachedMonitorData, ChartMode, ColorPalette,
24    ColorScheme, ColorSchemeExt, DEFAULT_REFRESH_SECS, DataPoint, LayoutPreset, MAX_DATA_AGE_SECS,
25    MAX_REFRESH_SECS, MIN_REFRESH_SECS, MonitorConfig, OhlcCandle, ScaleMode, TimePeriod,
26    WidgetVisibility,
27};
28
29/// Recent trades the monitor keeps, synthetic or from the venue.
30pub(crate) const MAX_RECENT_TRADES: usize = 200;
31
32/// State for the live token monitor.
33pub struct MonitorState {
34    /// Token contract address.
35    pub token_address: String,
36
37    /// Token symbol.
38    pub symbol: String,
39
40    /// Token name.
41    pub name: String,
42
43    /// Blockchain network.
44    pub chain: String,
45
46    /// Historical price data points with real/synthetic indicator.
47    pub price_history: VecDeque<DataPoint>,
48
49    /// Historical volume data points with real/synthetic indicator.
50    pub volume_history: VecDeque<DataPoint>,
51
52    /// Count of real (non-synthetic) data points.
53    pub real_data_count: usize,
54
55    /// Current price in USD.
56    pub current_price: f64,
57
58    /// 24-hour price change percentage.
59    pub price_change_24h: f64,
60
61    /// 6-hour price change percentage.
62    pub price_change_6h: f64,
63
64    /// 1-hour price change percentage.
65    pub price_change_1h: f64,
66
67    /// 5-minute price change percentage.
68    pub price_change_5m: f64,
69
70    /// Timestamp when the price last changed (Unix timestamp).
71    pub last_price_change_at: f64,
72
73    /// Previous price for change detection.
74    pub previous_price: f64,
75
76    /// Total buy transactions in 24 hours.
77    pub buys_24h: u64,
78
79    /// Total sell transactions in 24 hours.
80    pub sells_24h: u64,
81
82    /// Total liquidity in USD.
83    pub liquidity_usd: f64,
84
85    /// 24-hour volume in USD.
86    pub volume_24h: f64,
87
88    /// Market capitalization.
89    pub market_cap: Option<f64>,
90
91    /// Fully diluted valuation.
92    pub fdv: Option<f64>,
93
94    /// Last update timestamp.
95    pub last_update: Instant,
96
97    /// Refresh rate.
98    pub refresh_rate: Duration,
99
100    /// The key overlay (`?`) is open (#68).
101    pub show_keys: bool,
102
103    /// Whether monitoring is paused.
104    pub paused: bool,
105
106    /// Recent log messages.
107    pub log_messages: VecDeque<String>,
108
109    /// Scroll state for the activity log list widget.
110    pub log_list_state: ListState,
111
112    /// Error message to display (if any).
113    pub error_message: Option<String>,
114
115    /// Selected time period for chart display.
116    pub time_period: TimePeriod,
117
118    /// Chart display mode (line or candlestick).
119    pub chart_mode: ChartMode,
120
121    /// Y-axis scale mode for price charts (Linear or Log).
122    pub scale_mode: ScaleMode,
123
124    /// Active color scheme.
125    pub color_scheme: ColorScheme,
126
127    /// Holder count (fetched from chain client, if available).
128    pub holder_count: Option<u64>,
129
130    /// Per-pair liquidity data: (pair_name, liquidity_usd).
131    pub liquidity_pairs: Vec<(String, f64)>,
132
133    /// Synthetic order book generated from DEX pair data.
134    pub order_book: Option<OrderBook>,
135
136    /// Recent trades (synthetic from DEX pair data or real from exchange API).
137    pub recent_trades: VecDeque<Trade>,
138
139    /// Health thresholds for the book built from DEX pairs: config
140    /// `market.health`, as `market summary` uses.
141    pub thresholds: HealthThresholds,
142
143    /// The venue whose real order book `order_book` holds (#78). `None`
144    /// while the book is built from DEX pairs.
145    pub book_venue: Option<String>,
146
147    /// The venue whose real trades `recent_trades` holds. `None` while the
148    /// trades are synthetic.
149    pub trades_venue: Option<String>,
150
151    /// Raw DEX pair data for the exchange view.
152    pub dex_pairs: Vec<DexPair>,
153
154    /// Token metadata: website URLs.
155    pub websites: Vec<String>,
156
157    /// Token metadata: social links (name, url).
158    pub socials: Vec<(String, String)>,
159
160    /// Earliest pair creation timestamp (for "listed since").
161    pub earliest_pair_created_at: Option<i64>,
162
163    /// DexScreener URL for this token.
164    pub dexscreener_url: Option<String>,
165
166    /// Counter to throttle holder count fetches.
167    pub holder_fetch_counter: u32,
168
169    /// Unix timestamp when monitoring started.
170    pub start_timestamp: i64,
171
172    /// Current layout preset.
173    pub layout: LayoutPreset,
174
175    /// Widget visibility toggles.
176    pub widgets: WidgetVisibility,
177
178    /// Whether responsive auto-layout is active (disabled by manual layout switch).
179    pub auto_layout: bool,
180
181    /// Whether the widget-toggle input mode is active (waiting for digit 1-5).
182    pub widget_toggle_mode: bool,
183
184    // ── Phase 7: Alert System ──
185    /// Alert configuration thresholds.
186    pub alerts: AlertConfig,
187
188    /// Currently firing alerts.
189    pub active_alerts: Vec<ActiveAlert>,
190
191    /// Visual flash timer for alert overlay.
192    pub alert_flash_until: Option<Instant>,
193
194    // ── Phase 8: CSV Export ──
195    /// Whether CSV export is currently active.
196    pub export_active: bool,
197
198    /// Path to the current export file.
199    pub export_path: Option<PathBuf>,
200
201    /// Directory where the E key starts new exports (config `export.path`).
202    pub export_dir: PathBuf,
203
204    /// Rolling volume average for spike detection (simple moving average).
205    pub volume_avg: f64,
206
207    // ── Phase 9: Auto-Pause ──
208    /// Whether auto-pause on user input is enabled.
209    pub auto_pause_on_input: bool,
210
211    /// Timestamp of the last user key input.
212    pub last_input_at: Instant,
213
214    /// Duration after last input before auto-pause lifts (default 3s).
215    pub auto_pause_timeout: Duration,
216
217    // ── Exchange OHLC ──
218    /// Real OHLC candles fetched from an exchange venue.
219    /// When present, `get_ohlc_candles` uses these instead of synthetic candles.
220    pub exchange_ohlc: Vec<OhlcCandle>,
221
222    /// Venue-formatted pair symbol for OHLC queries (e.g., "BTCUSDT").
223    pub venue_pair: Option<String>,
224
225    /// Venue id from `--venue` (e.g., "binance"), when a venue is set.
226    pub venue: Option<String>,
227
228    /// The token is priced from the venue ticker alone (`--pair`, or the
229    /// venue fallback). DexScreener is never called in this mode.
230    pub venue_only: bool,
231
232    /// False when the venue ticker gives no 24h change: `price_change_24h`
233    /// is then a placeholder, shown as n/a.
234    pub change_24h_known: bool,
235
236    /// False when the 24h volume is not known in USD: `volume_24h` is then
237    /// a placeholder. It is not charted, exported or fed to alerts.
238    pub volume_known: bool,
239}
240
241/// Where the data comes from: the chain, or `<VENUE> <pair>` in venue-only
242/// mode.
243pub(crate) fn source_label(chain: &str, venue: Option<(&str, &str)>) -> String {
244    match venue {
245        Some((id, pair)) => format!("{} {}", id.to_uppercase(), pair),
246        None => chain.to_uppercase(),
247    }
248}
249
250/// Where the monitor keeps its price cache: the system temp directory. In
251/// tests, a directory for each process and test thread: tests run in
252/// parallel and must not save, read or remove each other's cache file (a
253/// shared file failed `test_cache_roundtrip_save_load` on CI).
254fn cache_dir() -> PathBuf {
255    let dir = std::env::temp_dir();
256    #[cfg(test)]
257    let dir = {
258        let own = dir.join(format!(
259            "scope-monitor-test-{}-{:?}",
260            std::process::id(),
261            std::thread::current().id()
262        ));
263        let _ = fs::create_dir_all(&own);
264        own
265    };
266    dir
267}
268
269impl MonitorState {
270    /// Creates a new monitor state from initial token data.
271    /// Attempts to load cached data from disk first.
272    pub fn new(token_data: &DexTokenData, chain: &str) -> Self {
273        let now = Instant::now();
274        let now_ts = chrono::Utc::now().timestamp() as f64;
275
276        // Try to load cached data first
277        let (price_history, volume_history, real_data_count) =
278            if let Some(cached) = Self::load_cache(&token_data.address, chain) {
279                // Filter out data older than 24 hours
280                let cutoff = now_ts - MAX_DATA_AGE_SECS;
281                let price_hist: VecDeque<DataPoint> = cached
282                    .price_history
283                    .into_iter()
284                    .filter(|p| p.timestamp >= cutoff)
285                    .collect();
286                let vol_hist: VecDeque<DataPoint> = cached
287                    .volume_history
288                    .into_iter()
289                    .filter(|p| p.timestamp >= cutoff)
290                    .collect();
291                let real_count = price_hist.iter().filter(|p| p.is_real).count();
292                (price_hist, vol_hist, real_count)
293            } else {
294                // Generate synthetic historical data from price change percentages
295                let price_hist = Self::generate_synthetic_price_history(
296                    token_data.price_usd,
297                    token_data.price_change_1h,
298                    token_data.price_change_6h,
299                    token_data.price_change_24h,
300                    now_ts,
301                );
302                let vol_hist = Self::generate_synthetic_volume_history(
303                    token_data.volume_24h,
304                    token_data.volume_6h,
305                    token_data.volume_1h,
306                    now_ts,
307                );
308                (price_hist, vol_hist, 0)
309            };
310
311        Self {
312            token_address: token_data.address.clone(),
313            symbol: token_data.symbol.clone(),
314            name: token_data.name.clone(),
315            chain: chain.to_string(),
316            price_history,
317            volume_history,
318            real_data_count,
319            current_price: token_data.price_usd,
320            price_change_24h: token_data.price_change_24h,
321            price_change_6h: token_data.price_change_6h,
322            price_change_1h: token_data.price_change_1h,
323            price_change_5m: token_data.price_change_5m,
324            last_price_change_at: now_ts, // Initialize to current time
325            previous_price: token_data.price_usd,
326            buys_24h: token_data.total_buys_24h,
327            sells_24h: token_data.total_sells_24h,
328            liquidity_usd: token_data.liquidity_usd,
329            volume_24h: token_data.volume_24h,
330            market_cap: token_data.market_cap,
331            fdv: token_data.fdv,
332            last_update: now,
333            refresh_rate: Duration::from_secs(DEFAULT_REFRESH_SECS),
334            show_keys: false,
335            paused: false,
336            log_messages: VecDeque::with_capacity(10),
337            log_list_state: ListState::default(),
338            error_message: None,
339            time_period: TimePeriod::Hour1, // Default to 1 hour view
340            chart_mode: ChartMode::Line,    // Default to line chart
341            scale_mode: ScaleMode::Linear,  // Default to linear scale
342            color_scheme: ColorScheme::GreenRed, // Default color scheme
343            holder_count: None,
344            liquidity_pairs: Vec::new(),
345            order_book: None,
346            recent_trades: VecDeque::new(),
347            thresholds: HealthThresholds::default(),
348            book_venue: None,
349            trades_venue: None,
350            dex_pairs: token_data.pairs.clone(),
351            websites: token_data.websites.clone(),
352            socials: token_data
353                .socials
354                .iter()
355                .map(|s| (s.platform.clone(), s.url.clone()))
356                .collect(),
357            earliest_pair_created_at: token_data.earliest_pair_created_at,
358            dexscreener_url: token_data.dexscreener_url.clone(),
359            holder_fetch_counter: 0,
360            start_timestamp: now_ts as i64,
361            layout: LayoutPreset::Dashboard,
362            widgets: WidgetVisibility::default(),
363            auto_layout: true,
364            widget_toggle_mode: false,
365            // Phase 7: Alerts
366            alerts: AlertConfig::default(),
367            active_alerts: Vec::new(),
368            alert_flash_until: None,
369            // Phase 8: Export
370            export_active: false,
371            export_path: None,
372            export_dir: PathBuf::from(DEFAULT_EXPORT_DIR),
373            volume_avg: token_data.volume_24h,
374            // Phase 9: Auto-Pause
375            auto_pause_on_input: false,
376            last_input_at: now,
377            auto_pause_timeout: Duration::from_secs(3),
378            // Exchange OHLC
379            exchange_ohlc: Vec::new(),
380            venue_pair: None,
381            venue: None,
382            venue_only: false,
383            change_24h_known: true,
384            volume_known: true,
385        }
386    }
387
388    /// Applies monitor config settings to this state.
389    pub fn apply_config(&mut self, config: &MonitorConfig) {
390        self.layout = config.layout;
391        self.widgets = config.widgets.clone();
392        self.refresh_rate = Duration::from_secs(config.refresh_seconds);
393        self.scale_mode = config.scale;
394        self.color_scheme = config.color_scheme;
395        self.alerts = config.alerts.clone();
396        self.auto_pause_on_input = config.auto_pause_on_input;
397        self.export_dir = config
398            .export
399            .path
400            .as_deref()
401            .map_or_else(|| PathBuf::from(DEFAULT_EXPORT_DIR), PathBuf::from);
402    }
403
404    /// Switches to venue-only mode and records which stats `ticker` gives.
405    /// Call it before `update` with data built from the same ticker.
406    pub fn set_venue_ticker(&mut self, ticker: &scope::market::Ticker) {
407        self.venue_only = true;
408        self.change_24h_known = ticker.price_change_pct_24h.is_some();
409        self.volume_known = ticker.usd_volume().is_some();
410        if !self.volume_known {
411            // Synthetic volume built from a placeholder is not data.
412            self.volume_history.clear();
413        }
414    }
415
416    /// The source of the data, for the header and the startup line.
417    pub fn source_label(&self) -> String {
418        let venue = self.venue_only.then(|| {
419            (
420                self.venue.as_deref().unwrap_or("venue"),
421                self.venue_pair.as_deref().unwrap_or(&self.symbol),
422            )
423        });
424        source_label(&self.chain, venue)
425    }
426
427    /// The 24h change, or `None` when the source does not give it.
428    pub fn change_24h(&self) -> Option<f64> {
429        self.change_24h_known.then_some(self.price_change_24h)
430    }
431
432    /// True when the 5m/1h/6h changes are data. A venue ticker has none.
433    pub fn short_changes_known(&self) -> bool {
434        !self.venue_only
435    }
436
437    /// The 24h volume in USD, or `None` when it is not known in USD.
438    pub fn volume_usd(&self) -> Option<f64> {
439        self.volume_known.then_some(self.volume_24h)
440    }
441
442    /// Toggles between line and candlestick chart modes.
443    /// Returns the current color palette based on the active color scheme.
444    pub fn palette(&self) -> ColorPalette {
445        self.color_scheme.palette()
446    }
447
448    pub fn toggle_chart_mode(&mut self) {
449        self.chart_mode = self.chart_mode.next();
450        self.log(format!("Chart mode: {}", self.chart_mode.label()));
451    }
452
453    /// Returns the path to the cache file for a token.
454    pub(crate) fn cache_path(token_address: &str, chain: &str) -> PathBuf {
455        let mut path = cache_dir();
456        // Create a safe filename from address (first 16 chars) and chain
457        let safe_addr = token_address
458            .chars()
459            .filter(|c| c.is_alphanumeric())
460            .take(16)
461            .collect::<String>()
462            .to_lowercase();
463        path.push(format!("{}{}_{}.json", CACHE_FILE_PREFIX, chain, safe_addr));
464        path
465    }
466
467    /// Loads cached monitor data from disk.
468    pub(crate) fn load_cache(token_address: &str, chain: &str) -> Option<CachedMonitorData> {
469        let path = Self::cache_path(token_address, chain);
470        if !path.exists() {
471            return None;
472        }
473
474        match fs::read_to_string(&path) {
475            Ok(contents) => {
476                match serde_json::from_str::<CachedMonitorData>(&contents) {
477                    Ok(cached) => {
478                        // Verify this is for the same token
479                        if cached.token_address.to_lowercase() == token_address.to_lowercase()
480                            && cached.chain.to_lowercase() == chain.to_lowercase()
481                        {
482                            Some(cached)
483                        } else {
484                            None
485                        }
486                    }
487                    Err(_) => None,
488                }
489            }
490            Err(_) => None,
491        }
492    }
493
494    /// Saves monitor data to cache file.
495    pub fn save_cache(&self) {
496        let cached = CachedMonitorData {
497            token_address: self.token_address.clone(),
498            chain: self.chain.clone(),
499            price_history: self.price_history.iter().copied().collect(),
500            volume_history: self.volume_history.iter().copied().collect(),
501            saved_at: chrono::Utc::now().timestamp() as f64,
502        };
503
504        let path = Self::cache_path(&self.token_address, &self.chain);
505        if let Ok(json) = serde_json::to_string(&cached) {
506            let _ = fs::write(&path, json);
507        }
508    }
509
510    /// Generates synthetic price history from percentage changes.
511    /// All generated points are marked as synthetic (is_real = false).
512    pub(crate) fn generate_synthetic_price_history(
513        current_price: f64,
514        change_1h: f64,
515        change_6h: f64,
516        change_24h: f64,
517        now_ts: f64,
518    ) -> VecDeque<DataPoint> {
519        let mut history = VecDeque::with_capacity(50);
520
521        // Calculate prices at known points (working backwards from current)
522        let price_1h_ago = current_price / (1.0 + change_1h / 100.0);
523        let price_6h_ago = current_price / (1.0 + change_6h / 100.0);
524        let price_24h_ago = current_price / (1.0 + change_24h / 100.0);
525
526        // Generate points: 24h ago, 12h ago, 6h ago, 3h ago, 1h ago, 30m ago, now
527        let points = [
528            (now_ts - 24.0 * 3600.0, price_24h_ago),
529            (now_ts - 12.0 * 3600.0, (price_24h_ago + price_6h_ago) / 2.0),
530            (now_ts - 6.0 * 3600.0, price_6h_ago),
531            (now_ts - 3.0 * 3600.0, (price_6h_ago + price_1h_ago) / 2.0),
532            (now_ts - 1.0 * 3600.0, price_1h_ago),
533            (now_ts - 0.5 * 3600.0, (price_1h_ago + current_price) / 2.0),
534            (now_ts, current_price),
535        ];
536
537        // Interpolate to create more points for smoother charts
538        for i in 0..points.len() - 1 {
539            let (t1, p1) = points[i];
540            let (t2, p2) = points[i + 1];
541            let steps = 4; // Number of interpolated points between each pair
542
543            for j in 0..steps {
544                let frac = j as f64 / steps as f64;
545                let t = t1 + (t2 - t1) * frac;
546                let p = p1 + (p2 - p1) * frac;
547                history.push_back(DataPoint {
548                    timestamp: t,
549                    value: p,
550                    is_real: false, // Synthetic data
551                });
552            }
553        }
554        // Add the final point (also synthetic since it's estimated)
555        history.push_back(DataPoint {
556            timestamp: points[points.len() - 1].0,
557            value: points[points.len() - 1].1,
558            is_real: false,
559        });
560
561        history
562    }
563
564    /// Generates synthetic volume history from known data points.
565    /// All generated points are marked as synthetic (is_real = false).
566    pub(crate) fn generate_synthetic_volume_history(
567        volume_24h: f64,
568        volume_6h: f64,
569        volume_1h: f64,
570        now_ts: f64,
571    ) -> VecDeque<DataPoint> {
572        let mut history = VecDeque::with_capacity(24);
573
574        // Create hourly volume estimates
575        let hourly_avg = volume_24h / 24.0;
576
577        for i in 0..24 {
578            let hours_ago = 24 - i;
579            let ts = now_ts - (hours_ago as f64) * 3600.0;
580
581            // Use more accurate data for recent hours
582            let volume = if hours_ago <= 1 {
583                volume_1h
584            } else if hours_ago <= 6 {
585                volume_6h / 6.0
586            } else {
587                // Estimate with some variation
588                hourly_avg * (0.8 + (i as f64 / 24.0) * 0.4)
589            };
590
591            history.push_back(DataPoint {
592                timestamp: ts,
593                value: volume,
594                is_real: false, // Synthetic data
595            });
596        }
597
598        history
599    }
600
601    /// The book built from DEX pairs: the core AMM book of the most liquid
602    /// pool, with the source it reports (H1). `None` without a priced pool.
603    pub(crate) fn dex_book(&self, pairs: &[DexPair], symbol: &str) -> Option<OrderBook> {
604        most_liquid(pairs)
605            .filter(|p| p.price_usd > 0.0)
606            .map(|p| order_book_from_analytics(&self.chain, p, symbol, &self.thresholds))
607    }
608
609    /// Shows the venue's real order book (#78). DEX refreshes no longer
610    /// replace it.
611    pub fn set_venue_book(&mut self, venue: &str, book: OrderBook) {
612        self.book_venue = Some(venue.to_string());
613        self.order_book = Some(book);
614    }
615
616    /// Shows the venue's real recent trades, oldest first, in place of the
617    /// synthetic ones.
618    pub fn set_venue_trades(&mut self, venue: &str, mut trades: Vec<Trade>) {
619        trades.sort_by_key(|t| t.timestamp_ms);
620        let keep = trades.len().saturating_sub(MAX_RECENT_TRADES);
621        self.trades_venue = Some(venue.to_string());
622        self.recent_trades = trades.into_iter().skip(keep).collect();
623    }
624
625    /// Updates the state with new token data.
626    /// New data points are marked as real (is_real = true).
627    pub fn update(&mut self, token_data: &DexTokenData) {
628        let now_ts = chrono::Utc::now().timestamp() as f64;
629
630        // Add new REAL data points
631        self.price_history.push_back(DataPoint {
632            timestamp: now_ts,
633            value: token_data.price_usd,
634            is_real: true,
635        });
636        if self.volume_known {
637            self.volume_history.push_back(DataPoint {
638                timestamp: now_ts,
639                value: token_data.volume_24h,
640                is_real: true,
641            });
642        }
643        self.real_data_count += 1;
644
645        // Trim data points older than 24 hours
646        let cutoff = now_ts - MAX_DATA_AGE_SECS;
647
648        while let Some(point) = self.price_history.front() {
649            if point.timestamp < cutoff {
650                self.price_history.pop_front();
651            } else {
652                break;
653            }
654        }
655        while let Some(point) = self.volume_history.front() {
656            if point.timestamp < cutoff {
657                self.volume_history.pop_front();
658            } else {
659                break;
660            }
661        }
662
663        // Track when price actually changes (using 8 decimal precision for stablecoins)
664        let price_changed = (self.previous_price - token_data.price_usd).abs() > 0.00000001;
665        if price_changed {
666            self.last_price_change_at = now_ts;
667            self.previous_price = token_data.price_usd;
668        }
669
670        // Update current values
671        self.current_price = token_data.price_usd;
672        self.price_change_24h = token_data.price_change_24h;
673        self.price_change_6h = token_data.price_change_6h;
674        self.price_change_1h = token_data.price_change_1h;
675        self.price_change_5m = token_data.price_change_5m;
676        self.buys_24h = token_data.total_buys_24h;
677        self.sells_24h = token_data.total_sells_24h;
678        self.liquidity_usd = token_data.liquidity_usd;
679        self.volume_24h = token_data.volume_24h;
680        self.market_cap = token_data.market_cap;
681        self.fdv = token_data.fdv;
682
683        // Extract per-pair liquidity data
684        self.liquidity_pairs = token_data
685            .pairs
686            .iter()
687            .map(|p| {
688                let label = format!("{}/{} ({})", p.base_token, p.quote_token, p.dex_name);
689                (label, p.liquidity_usd)
690            })
691            .collect();
692
693        // Update DEX pairs and generate synthetic order book
694        self.dex_pairs = token_data.pairs.clone();
695        if self.book_venue.is_none() {
696            self.order_book = self.dex_book(&token_data.pairs, &token_data.symbol);
697        }
698
699        // Generate a synthetic trade from price movement, unless the venue's
700        // real trades are shown.
701        if self.trades_venue.is_none() && token_data.price_usd > 0.0 {
702            let side = if price_changed && token_data.price_usd > self.current_price {
703                TradeSide::Buy
704            } else if price_changed {
705                TradeSide::Sell
706            } else {
707                // No change — alternate based on buy/sell ratio
708                if token_data.total_buys_24h >= token_data.total_sells_24h {
709                    TradeSide::Buy
710                } else {
711                    TradeSide::Sell
712                }
713            };
714            let ts_ms = (now_ts * 1000.0) as u64;
715            // Synthetic quantity based on recent volume
716            let qty = if token_data.volume_24h > 0.0 && token_data.price_usd > 0.0 {
717                // Rough: daily volume / 86400 updates * refresh_rate gives per-update volume
718                let per_update_vol =
719                    token_data.volume_24h / 86400.0 * self.refresh_rate.as_secs_f64();
720                per_update_vol / token_data.price_usd
721            } else {
722                1.0
723            };
724            self.recent_trades.push_back(Trade {
725                price: token_data.price_usd,
726                quantity: qty,
727                quote_quantity: Some(qty * token_data.price_usd),
728                timestamp_ms: ts_ms,
729                side,
730                id: None,
731            });
732            while self.recent_trades.len() > MAX_RECENT_TRADES {
733                self.recent_trades.pop_front();
734            }
735        }
736
737        // Update metadata
738        self.websites = token_data.websites.clone();
739        self.socials = token_data
740            .socials
741            .iter()
742            .map(|s| (s.platform.clone(), s.url.clone()))
743            .collect();
744        self.earliest_pair_created_at = token_data.earliest_pair_created_at;
745        self.dexscreener_url = token_data.dexscreener_url.clone();
746
747        self.last_update = Instant::now();
748        self.error_message = None;
749
750        // ── Alert checks ──
751        self.check_alerts(token_data);
752
753        // ── CSV export ──
754        if self.export_active {
755            self.write_export_row();
756        }
757
758        // ── Volume average for spike detection ──
759        // Exponential moving average: 10% weight on new value
760        if self.volume_known {
761            self.volume_avg = self.volume_avg * 0.9 + token_data.volume_24h * 0.1;
762        }
763
764        self.log(format!(
765            "Updated: {}",
766            scope::display::format::format_price_usd(token_data.price_usd)
767        ));
768
769        // Periodically save to cache (every 60 updates, ~5 minutes at 5s refresh)
770        if self.real_data_count.is_multiple_of(60) {
771            self.save_cache();
772        }
773    }
774
775    /// Checks alert thresholds and updates active_alerts.
776    pub(crate) fn check_alerts(&mut self, token_data: &DexTokenData) {
777        self.active_alerts.clear();
778
779        // Price min alert
780        if let Some(min) = self.alerts.price_min
781            && self.current_price < min
782        {
783            self.active_alerts.push(ActiveAlert {
784                message: format!(
785                    "⚠ Price {} below min {}",
786                    scope::display::format::format_price_usd(self.current_price),
787                    scope::display::format::format_price_usd(min)
788                ),
789                triggered_at: Instant::now(),
790            });
791        }
792
793        // Price max alert
794        if let Some(max) = self.alerts.price_max
795            && self.current_price > max
796        {
797            self.active_alerts.push(ActiveAlert {
798                message: format!(
799                    "⚠ Price {} above max {}",
800                    scope::display::format::format_price_usd(self.current_price),
801                    scope::display::format::format_price_usd(max)
802                ),
803                triggered_at: Instant::now(),
804            });
805        }
806
807        // Volume spike alert
808        if let Some(threshold_pct) = self.alerts.volume_spike_threshold_pct
809            && self.volume_known
810            && self.volume_avg > 0.0
811        {
812            let spike_pct = ((token_data.volume_24h - self.volume_avg) / self.volume_avg) * 100.0;
813            if spike_pct > threshold_pct {
814                self.active_alerts.push(ActiveAlert {
815                    message: format!("⚠ Volume spike: +{:.1}% vs avg", spike_pct),
816                    triggered_at: Instant::now(),
817                });
818            }
819        }
820
821        // Whale detection — estimate from buy/sell changes
822        if let Some(whale_min) = self.alerts.whale_min_usd {
823            // Approximate single-transaction size from volume/tx count
824            let total_txs = (token_data.total_buys_24h + token_data.total_sells_24h) as f64;
825            if total_txs > 0.0 && token_data.volume_24h / total_txs >= whale_min {
826                let avg_tx_size = token_data.volume_24h / total_txs;
827                self.active_alerts.push(ActiveAlert {
828                    message: format!(
829                        "🐋 Avg tx size {} ≥ whale threshold {}",
830                        scope::display::format_usd(avg_tx_size),
831                        scope::display::format_usd(whale_min)
832                    ),
833                    triggered_at: Instant::now(),
834                });
835            }
836        }
837
838        // Set flash timer if any alerts are active
839        if !self.active_alerts.is_empty() {
840            self.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
841        }
842    }
843
844    /// Writes a single CSV row to the export file.
845    ///
846    /// A failed write stops the export and logs the reason, so the REC
847    /// indicator never claims rows that were not written.
848    pub(crate) fn write_export_row(&mut self) {
849        let Some(path) = self.export_path.clone() else {
850            return;
851        };
852        if let Err(e) = self.append_export_row(&path) {
853            self.log(format!("Export failed: {}: {}", path.display(), e));
854            self.export_active = false;
855            self.export_path = None;
856        }
857    }
858
859    fn append_export_row(&self, path: &Path) -> std::io::Result<()> {
860        let file = fs::OpenOptions::new().append(true).open(path)?;
861        let mut writer = BufWriter::new(file);
862        let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
863        let market_cap_str = self
864            .market_cap
865            .map(|mc| format!("{:.2}", mc))
866            .unwrap_or_default();
867        // Unknown values are empty fields, not zeros.
868        let volume_str = self
869            .volume_usd()
870            .map(|v| format!("{:.2}", v))
871            .unwrap_or_default();
872        let (liquidity_str, buys_str, sells_str) = if self.venue_only {
873            Default::default()
874        } else {
875            (
876                format!("{:.2}", self.liquidity_usd),
877                self.buys_24h.to_string(),
878                self.sells_24h.to_string(),
879            )
880        };
881        let row = format!(
882            "{},{:.8},{},{},{},{},{}\n",
883            timestamp,
884            self.current_price,
885            volume_str,
886            liquidity_str,
887            buys_str,
888            sells_str,
889            market_cap_str,
890        );
891        writer.write_all(row.as_bytes())?;
892        writer.flush()
893    }
894
895    /// Starts CSV export in the export directory, with a timestamped name.
896    /// A failure is logged and leaves the export off.
897    pub fn start_export(&mut self) {
898        let date_str = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
899        let path = self
900            .export_dir
901            .join(format!("{}_{}.csv", self.symbol, date_str));
902        if let Err(e) = self.start_export_to(&path) {
903            self.log(format!("Export failed: {}: {}", path.display(), e));
904        }
905    }
906
907    /// Starts CSV export at `path`: creates its directory and the file, and
908    /// writes the header.
909    ///
910    /// # Errors
911    ///
912    /// Returns the file system error. The export stays off.
913    pub fn start_export_to(&mut self, path: &Path) -> std::io::Result<()> {
914        if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
915            fs::create_dir_all(parent)?;
916        }
917        fs::File::create(path)?.write_all(EXPORT_HEADER.as_bytes())?;
918        self.export_path = Some(path.to_path_buf());
919        self.export_active = true;
920        self.log(format!("Export started: {}", path.display()));
921        Ok(())
922    }
923
924    /// Stops CSV export and closes the file.
925    pub fn stop_export(&mut self) {
926        if let Some(ref path) = self.export_path {
927            self.log(format!("Export saved: {}", path.display()));
928        }
929        self.export_active = false;
930        self.export_path = None;
931    }
932
933    /// Toggles CSV export on/off.
934    pub fn toggle_export(&mut self) {
935        if self.export_active {
936            self.stop_export();
937        } else {
938            self.start_export();
939        }
940    }
941
942    /// Returns data points filtered by the current time period.
943    /// Returns tuples for chart compatibility, plus a separate vector of is_real flags.
944    pub fn get_price_data_for_period(&self) -> (Vec<(f64, f64)>, Vec<bool>) {
945        let now_ts = chrono::Utc::now().timestamp() as f64;
946        let cutoff = now_ts - self.time_period.duration_secs() as f64;
947
948        let filtered: Vec<&DataPoint> = self
949            .price_history
950            .iter()
951            .filter(|p| p.timestamp >= cutoff)
952            .collect();
953
954        let data: Vec<(f64, f64)> = filtered.iter().map(|p| (p.timestamp, p.value)).collect();
955        let is_real: Vec<bool> = filtered.iter().map(|p| p.is_real).collect();
956
957        (data, is_real)
958    }
959
960    /// Returns volume data filtered by the current time period.
961    /// Returns tuples for chart compatibility, plus a separate vector of is_real flags.
962    pub fn get_volume_data_for_period(&self) -> (Vec<(f64, f64)>, Vec<bool>) {
963        let now_ts = chrono::Utc::now().timestamp() as f64;
964        let cutoff = now_ts - self.time_period.duration_secs() as f64;
965
966        let filtered: Vec<&DataPoint> = self
967            .volume_history
968            .iter()
969            .filter(|p| p.timestamp >= cutoff)
970            .collect();
971
972        let data: Vec<(f64, f64)> = filtered.iter().map(|p| (p.timestamp, p.value)).collect();
973        let is_real: Vec<bool> = filtered.iter().map(|p| p.is_real).collect();
974
975        (data, is_real)
976    }
977
978    /// Returns count of synthetic vs real data points in the current view.
979    pub fn data_stats(&self) -> (usize, usize) {
980        let now_ts = chrono::Utc::now().timestamp() as f64;
981        let cutoff = now_ts - self.time_period.duration_secs() as f64;
982
983        let (synthetic, real) = self
984            .price_history
985            .iter()
986            .filter(|p| p.timestamp >= cutoff)
987            .fold(
988                (0, 0),
989                |(s, r), p| {
990                    if p.is_real { (s, r + 1) } else { (s + 1, r) }
991                },
992            );
993
994        (synthetic, real)
995    }
996
997    /// Estimates memory usage of stored data in bytes.
998    pub fn memory_usage(&self) -> usize {
999        // DataPoint is 24 bytes (f64 + f64 + bool + padding)
1000        let point_size = std::mem::size_of::<DataPoint>();
1001        (self.price_history.len() + self.volume_history.len()) * point_size
1002    }
1003
1004    /// Generates OHLC candles from price history for the current time period.
1005    ///
1006    /// The candle duration is automatically determined based on the selected time period:
1007    /// - 1m view: 5-second candles
1008    /// - 5m view: 15-second candles
1009    /// - 15m view: 1-minute candles
1010    /// - 1h view: 5-minute candles
1011    /// - 4h view: 15-minute candles
1012    /// - 1d view: 1-hour candles
1013    pub fn get_ohlc_candles(&self) -> Vec<OhlcCandle> {
1014        // Prefer real exchange OHLC candles when available
1015        if !self.exchange_ohlc.is_empty() {
1016            return self.exchange_ohlc.clone();
1017        }
1018
1019        let (data, _) = self.get_price_data_for_period();
1020
1021        if data.is_empty() {
1022            return vec![];
1023        }
1024
1025        // Determine candle duration based on time period
1026        let candle_duration_secs = match self.time_period {
1027            TimePeriod::Min1 => 5.0,    // 5-second candles
1028            TimePeriod::Min5 => 15.0,   // 15-second candles
1029            TimePeriod::Min15 => 60.0,  // 1-minute candles
1030            TimePeriod::Hour1 => 300.0, // 5-minute candles
1031            TimePeriod::Hour4 => 900.0, // 15-minute candles
1032            TimePeriod::Day1 => 3600.0, // 1-hour candles
1033        };
1034
1035        let mut candles: Vec<OhlcCandle> = Vec::new();
1036
1037        for (timestamp, price) in data {
1038            // Determine which candle this point belongs to
1039            let candle_start = (timestamp / candle_duration_secs).floor() * candle_duration_secs;
1040
1041            if let Some(last_candle) = candles.last_mut() {
1042                if (last_candle.timestamp - candle_start).abs() < 0.001 {
1043                    // Same candle, update it
1044                    last_candle.update(price);
1045                } else {
1046                    // New candle
1047                    candles.push(OhlcCandle::new(candle_start, price));
1048                }
1049            } else {
1050                // First candle
1051                candles.push(OhlcCandle::new(candle_start, price));
1052            }
1053        }
1054
1055        candles
1056    }
1057
1058    /// Cycles to the next time period.
1059    pub fn cycle_time_period(&mut self) {
1060        self.time_period = self.time_period.next();
1061        self.log(format!("Time period: {}", self.time_period.label()));
1062    }
1063
1064    /// Sets a specific time period.
1065    pub fn set_time_period(&mut self, period: TimePeriod) {
1066        self.time_period = period;
1067        self.log(format!("Time period: {}", period.label()));
1068    }
1069
1070    /// Logs a message to the log panel.
1071    pub(crate) fn log(&mut self, message: String) {
1072        let timestamp = chrono::Local::now().format("%H:%M:%S").to_string();
1073        self.log_messages
1074            .push_back(format!("[{}] {}", timestamp, message));
1075        while self.log_messages.len() > 10 {
1076            self.log_messages.pop_front();
1077        }
1078    }
1079
1080    /// Returns whether a refresh is needed.
1081    /// Respects manual pause, auto-pause on input, and the refresh interval.
1082    pub fn should_refresh(&self) -> bool {
1083        if self.paused {
1084            return false;
1085        }
1086        // Auto-pause: if the user is actively interacting, defer refresh
1087        if self.auto_pause_on_input && self.last_input_at.elapsed() < self.auto_pause_timeout {
1088            return false;
1089        }
1090        self.last_update.elapsed() >= self.refresh_rate
1091    }
1092
1093    /// Returns true if auto-pause is currently suppressing refreshes.
1094    pub fn is_auto_paused(&self) -> bool {
1095        self.auto_pause_on_input && self.last_input_at.elapsed() < self.auto_pause_timeout
1096    }
1097
1098    /// Toggles pause state.
1099    pub fn toggle_pause(&mut self) {
1100        self.paused = !self.paused;
1101        self.log(if self.paused {
1102            "Paused".to_string()
1103        } else {
1104            "Resumed".to_string()
1105        });
1106    }
1107
1108    /// Forces an immediate refresh.
1109    pub fn force_refresh(&mut self) {
1110        self.paused = false;
1111        self.last_update = Instant::now() - self.refresh_rate;
1112    }
1113
1114    /// Increases refresh interval (slower updates).
1115    pub fn slower_refresh(&mut self) {
1116        let current_secs = self.refresh_rate.as_secs();
1117        let new_secs = (current_secs + 5).min(MAX_REFRESH_SECS);
1118        self.refresh_rate = Duration::from_secs(new_secs);
1119        self.log(format!("Refresh rate: {}s", new_secs));
1120    }
1121
1122    /// Decreases refresh interval (faster updates).
1123    pub fn faster_refresh(&mut self) {
1124        let current_secs = self.refresh_rate.as_secs();
1125        let new_secs = current_secs.saturating_sub(5).max(MIN_REFRESH_SECS);
1126        self.refresh_rate = Duration::from_secs(new_secs);
1127        self.log(format!("Refresh rate: {}s", new_secs));
1128    }
1129
1130    /// Scrolls the activity log down (newer messages).
1131    pub fn scroll_log_down(&mut self) {
1132        let len = self.log_messages.len();
1133        if len == 0 {
1134            return;
1135        }
1136        let i = self
1137            .log_list_state
1138            .selected()
1139            .map_or(0, |i| if i + 1 < len { i + 1 } else { i });
1140        self.log_list_state.select(Some(i));
1141    }
1142
1143    /// Scrolls the activity log up (older messages).
1144    pub fn scroll_log_up(&mut self) {
1145        let i = self
1146            .log_list_state
1147            .selected()
1148            .map_or(0, |i| i.saturating_sub(1));
1149        self.log_list_state.select(Some(i));
1150    }
1151
1152    /// Returns the current refresh rate in seconds.
1153    pub fn refresh_rate_secs(&self) -> u64 {
1154        self.refresh_rate.as_secs()
1155    }
1156
1157    /// Returns the buy/sell ratio as a percentage (0.0 to 1.0).
1158    pub fn buy_ratio(&self) -> f64 {
1159        let total = self.buys_24h + self.sells_24h;
1160        if total == 0 {
1161            0.5
1162        } else {
1163            self.buys_24h as f64 / total as f64
1164        }
1165    }
1166}