1use 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
20pub struct MonitorState {
22 pub token_address: String,
24
25 pub symbol: String,
27
28 pub name: String,
30
31 pub chain: String,
33
34 pub price_history: VecDeque<DataPoint>,
36
37 pub volume_history: VecDeque<DataPoint>,
39
40 pub real_data_count: usize,
42
43 pub current_price: f64,
45
46 pub price_change_24h: f64,
48
49 pub price_change_6h: f64,
51
52 pub price_change_1h: f64,
54
55 pub price_change_5m: f64,
57
58 pub last_price_change_at: f64,
60
61 pub previous_price: f64,
63
64 pub buys_24h: u64,
66
67 pub sells_24h: u64,
69
70 pub liquidity_usd: f64,
72
73 pub volume_24h: f64,
75
76 pub market_cap: Option<f64>,
78
79 pub fdv: Option<f64>,
81
82 pub last_update: Instant,
84
85 pub refresh_rate: Duration,
87
88 pub paused: bool,
90
91 pub log_messages: VecDeque<String>,
93
94 pub log_list_state: ListState,
96
97 pub error_message: Option<String>,
99
100 pub time_period: TimePeriod,
102
103 pub chart_mode: ChartMode,
105
106 pub scale_mode: ScaleMode,
108
109 pub color_scheme: ColorScheme,
111
112 pub holder_count: Option<u64>,
114
115 pub liquidity_pairs: Vec<(String, f64)>,
117
118 pub order_book: Option<OrderBook>,
120
121 pub recent_trades: VecDeque<Trade>,
123
124 pub dex_pairs: Vec<DexPair>,
126
127 pub websites: Vec<String>,
129
130 pub socials: Vec<(String, String)>,
132
133 pub earliest_pair_created_at: Option<i64>,
135
136 pub dexscreener_url: Option<String>,
138
139 pub holder_fetch_counter: u32,
141
142 pub start_timestamp: i64,
144
145 pub layout: LayoutPreset,
147
148 pub widgets: WidgetVisibility,
150
151 pub auto_layout: bool,
153
154 pub widget_toggle_mode: bool,
156
157 pub alerts: AlertConfig,
160
161 pub active_alerts: Vec<ActiveAlert>,
163
164 pub alert_flash_until: Option<Instant>,
166
167 pub export_active: bool,
170
171 pub export_path: Option<PathBuf>,
173
174 pub volume_avg: f64,
176
177 pub auto_pause_on_input: bool,
180
181 pub last_input_at: Instant,
183
184 pub auto_pause_timeout: Duration,
186
187 pub exchange_ohlc: Vec<OhlcCandle>,
191
192 pub venue_pair: Option<String>,
194}
195
196impl MonitorState {
197 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 let (price_history, volume_history, real_data_count) =
205 if let Some(cached) = Self::load_cache(&token_data.address, chain) {
206 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 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, 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, chart_mode: ChartMode::Line, scale_mode: ScaleMode::Linear, color_scheme: ColorScheme::GreenRed, 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 alerts: AlertConfig::default(),
290 active_alerts: Vec::new(),
291 alert_flash_until: None,
292 export_active: false,
294 export_path: None,
295 volume_avg: token_data.volume_24h,
296 auto_pause_on_input: false,
298 last_input_at: now,
299 auto_pause_timeout: Duration::from_secs(3),
300 exchange_ohlc: Vec::new(),
302 venue_pair: None,
303 }
304 }
305
306 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 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 pub(crate) fn cache_path(token_address: &str, chain: &str) -> PathBuf {
330 let mut path = std::env::temp_dir();
331 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 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 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 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 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 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 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 for i in 0..points.len() - 1 {
414 let (t1, p1) = points[i];
415 let (t2, p2) = points[i + 1];
416 let steps = 4; 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, });
427 }
428 }
429 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 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 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 let volume = if hours_ago <= 1 {
458 volume_1h
459 } else if hours_ago <= 6 {
460 volume_6h / 6.0
461 } else {
462 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, });
471 }
472
473 history
474 }
475
476 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 let base_spread_bps = if total_liquidity > 1_000_000.0 {
493 5.0 } else if total_liquidity > 100_000.0 {
495 15.0 } else {
497 50.0 };
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 let mut asks = Vec::with_capacity(num_levels);
506 for i in 0..num_levels {
507 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 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 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 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 pub fn update(&mut self, token_data: &DexTokenData) {
557 let now_ts = chrono::Utc::now().timestamp() as f64;
558
559 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 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 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 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 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 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 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 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 let qty = if token_data.volume_24h > 0.0 && token_data.price_usd > 0.0 {
646 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 while self.recent_trades.len() > 200 {
663 self.recent_trades.pop_front();
664 }
665 }
666
667 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 self.check_alerts(token_data);
682
683 if self.export_active {
685 self.write_export_row();
686 }
687
688 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 if self.real_data_count.is_multiple_of(60) {
696 self.save_cache();
697 }
698 }
699
700 pub(crate) fn check_alerts(&mut self, token_data: &DexTokenData) {
702 self.active_alerts.clear();
703
704 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 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 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 if let Some(whale_min) = self.alerts.whale_min_usd {
739 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 if !self.active_alerts.is_empty() {
756 self.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
757 }
758 }
759
760 pub(crate) fn write_export_row(&mut self) {
762 if let Some(ref path) = self.export_path {
763 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 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 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 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 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 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 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 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 pub fn memory_usage(&self) -> usize {
881 let point_size = std::mem::size_of::<DataPoint>();
883 (self.price_history.len() + self.volume_history.len()) * point_size
884 }
885
886 pub fn get_ohlc_candles(&self) -> Vec<OhlcCandle> {
896 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 let candle_duration_secs = match self.time_period {
909 TimePeriod::Min1 => 5.0, TimePeriod::Min5 => 15.0, TimePeriod::Min15 => 60.0, TimePeriod::Hour1 => 300.0, TimePeriod::Hour4 => 900.0, TimePeriod::Day1 => 3600.0, };
916
917 let mut candles: Vec<OhlcCandle> = Vec::new();
918
919 for (timestamp, price) in data {
920 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 last_candle.update(price);
927 } else {
928 candles.push(OhlcCandle::new(candle_start, price));
930 }
931 } else {
932 candles.push(OhlcCandle::new(candle_start, price));
934 }
935 }
936
937 candles
938 }
939
940 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 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 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 pub fn should_refresh(&self) -> bool {
965 if self.paused {
966 return false;
967 }
968 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 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 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 pub fn force_refresh(&mut self) {
992 self.paused = false;
993 self.last_update = Instant::now() - self.refresh_rate;
994 }
995
996 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 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 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 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 pub fn refresh_rate_secs(&self) -> u64 {
1036 self.refresh_rate.as_secs()
1037 }
1038
1039 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}