1use 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
14const DEFAULT_EXPORT_DIR: &str = "./scope-exports";
16
17const 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
29pub(crate) const MAX_RECENT_TRADES: usize = 200;
31
32pub struct MonitorState {
34 pub token_address: String,
36
37 pub symbol: String,
39
40 pub name: String,
42
43 pub chain: String,
45
46 pub price_history: VecDeque<DataPoint>,
48
49 pub volume_history: VecDeque<DataPoint>,
51
52 pub real_data_count: usize,
54
55 pub current_price: f64,
57
58 pub price_change_24h: f64,
60
61 pub price_change_6h: f64,
63
64 pub price_change_1h: f64,
66
67 pub price_change_5m: f64,
69
70 pub last_price_change_at: f64,
72
73 pub previous_price: f64,
75
76 pub buys_24h: u64,
78
79 pub sells_24h: u64,
81
82 pub liquidity_usd: f64,
84
85 pub volume_24h: f64,
87
88 pub market_cap: Option<f64>,
90
91 pub fdv: Option<f64>,
93
94 pub last_update: Instant,
96
97 pub refresh_rate: Duration,
99
100 pub show_keys: bool,
102
103 pub paused: bool,
105
106 pub log_messages: VecDeque<String>,
108
109 pub log_list_state: ListState,
111
112 pub error_message: Option<String>,
114
115 pub time_period: TimePeriod,
117
118 pub chart_mode: ChartMode,
120
121 pub scale_mode: ScaleMode,
123
124 pub color_scheme: ColorScheme,
126
127 pub holder_count: Option<u64>,
129
130 pub liquidity_pairs: Vec<(String, f64)>,
132
133 pub order_book: Option<OrderBook>,
135
136 pub recent_trades: VecDeque<Trade>,
138
139 pub thresholds: HealthThresholds,
142
143 pub book_venue: Option<String>,
146
147 pub trades_venue: Option<String>,
150
151 pub dex_pairs: Vec<DexPair>,
153
154 pub websites: Vec<String>,
156
157 pub socials: Vec<(String, String)>,
159
160 pub earliest_pair_created_at: Option<i64>,
162
163 pub dexscreener_url: Option<String>,
165
166 pub holder_fetch_counter: u32,
168
169 pub start_timestamp: i64,
171
172 pub layout: LayoutPreset,
174
175 pub widgets: WidgetVisibility,
177
178 pub auto_layout: bool,
180
181 pub widget_toggle_mode: bool,
183
184 pub alerts: AlertConfig,
187
188 pub active_alerts: Vec<ActiveAlert>,
190
191 pub alert_flash_until: Option<Instant>,
193
194 pub export_active: bool,
197
198 pub export_path: Option<PathBuf>,
200
201 pub export_dir: PathBuf,
203
204 pub volume_avg: f64,
206
207 pub auto_pause_on_input: bool,
210
211 pub last_input_at: Instant,
213
214 pub auto_pause_timeout: Duration,
216
217 pub exchange_ohlc: Vec<OhlcCandle>,
221
222 pub venue_pair: Option<String>,
224
225 pub venue: Option<String>,
227
228 pub venue_only: bool,
231
232 pub change_24h_known: bool,
235
236 pub volume_known: bool,
239}
240
241pub(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
250fn 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 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 let (price_history, volume_history, real_data_count) =
278 if let Some(cached) = Self::load_cache(&token_data.address, chain) {
279 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 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, 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, chart_mode: ChartMode::Line, scale_mode: ScaleMode::Linear, color_scheme: ColorScheme::GreenRed, 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 alerts: AlertConfig::default(),
367 active_alerts: Vec::new(),
368 alert_flash_until: None,
369 export_active: false,
371 export_path: None,
372 export_dir: PathBuf::from(DEFAULT_EXPORT_DIR),
373 volume_avg: token_data.volume_24h,
374 auto_pause_on_input: false,
376 last_input_at: now,
377 auto_pause_timeout: Duration::from_secs(3),
378 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 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 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 self.volume_history.clear();
413 }
414 }
415
416 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 pub fn change_24h(&self) -> Option<f64> {
429 self.change_24h_known.then_some(self.price_change_24h)
430 }
431
432 pub fn short_changes_known(&self) -> bool {
434 !self.venue_only
435 }
436
437 pub fn volume_usd(&self) -> Option<f64> {
439 self.volume_known.then_some(self.volume_24h)
440 }
441
442 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 pub(crate) fn cache_path(token_address: &str, chain: &str) -> PathBuf {
455 let mut path = cache_dir();
456 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 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 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 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 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 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 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 for i in 0..points.len() - 1 {
539 let (t1, p1) = points[i];
540 let (t2, p2) = points[i + 1];
541 let steps = 4; 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, });
552 }
553 }
554 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 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 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 let volume = if hours_ago <= 1 {
583 volume_1h
584 } else if hours_ago <= 6 {
585 volume_6h / 6.0
586 } else {
587 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, });
596 }
597
598 history
599 }
600
601 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 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 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 pub fn update(&mut self, token_data: &DexTokenData) {
628 let now_ts = chrono::Utc::now().timestamp() as f64;
629
630 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 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 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 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 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 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 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 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 let qty = if token_data.volume_24h > 0.0 && token_data.price_usd > 0.0 {
717 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 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 self.check_alerts(token_data);
752
753 if self.export_active {
755 self.write_export_row();
756 }
757
758 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 if self.real_data_count.is_multiple_of(60) {
771 self.save_cache();
772 }
773 }
774
775 pub(crate) fn check_alerts(&mut self, token_data: &DexTokenData) {
777 self.active_alerts.clear();
778
779 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 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 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 if let Some(whale_min) = self.alerts.whale_min_usd {
823 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 if !self.active_alerts.is_empty() {
840 self.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
841 }
842 }
843
844 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 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 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 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 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 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 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 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 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 pub fn memory_usage(&self) -> usize {
999 let point_size = std::mem::size_of::<DataPoint>();
1001 (self.price_history.len() + self.volume_history.len()) * point_size
1002 }
1003
1004 pub fn get_ohlc_candles(&self) -> Vec<OhlcCandle> {
1014 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 let candle_duration_secs = match self.time_period {
1027 TimePeriod::Min1 => 5.0, TimePeriod::Min5 => 15.0, TimePeriod::Min15 => 60.0, TimePeriod::Hour1 => 300.0, TimePeriod::Hour4 => 900.0, TimePeriod::Day1 => 3600.0, };
1034
1035 let mut candles: Vec<OhlcCandle> = Vec::new();
1036
1037 for (timestamp, price) in data {
1038 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 last_candle.update(price);
1045 } else {
1046 candles.push(OhlcCandle::new(candle_start, price));
1048 }
1049 } else {
1050 candles.push(OhlcCandle::new(candle_start, price));
1052 }
1053 }
1054
1055 candles
1056 }
1057
1058 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 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 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 pub fn should_refresh(&self) -> bool {
1083 if self.paused {
1084 return false;
1085 }
1086 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 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 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 pub fn force_refresh(&mut self) {
1110 self.paused = false;
1111 self.last_update = Instant::now() - self.refresh_rate;
1112 }
1113
1114 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 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 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 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 pub fn refresh_rate_secs(&self) -> u64 {
1154 self.refresh_rate.as_secs()
1155 }
1156
1157 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}