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