1use crate::cli::crawl::{self, Period};
8use clap::Args;
9use scope::chains::{ChainClientFactory, TokenAnalytics};
10use scope::config::Config;
11
12use super::health_args::HealthArgs;
13use scope::display::report;
14use scope::error::{Result, ScopeError};
15use scope::market::{MarketSummary, VenueRegistry, order_book_from_analytics};
16
17#[derive(Debug, Args)]
19#[command(
20 after_help = "\x1b[1mExamples:\x1b[0m
21 scope token-health USDC
22 scope token-health @dai-token --with-market \x1b[2m# address book shortcut\x1b[0m
23 scope token-health DAI --with-market --venue binance
24 scope token-health 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --format json",
25 after_long_help = "\x1b[1mExamples:\x1b[0m
26
27 \x1b[1m$ scope token-health USDC\x1b[0m
28
29 +-- USDC (USD Coin) ---------------------------------+
30 | |
31 |-- DEX Analytics |
32 | Price $0.9999 |
33 | 24h Change -0.01% |
34 | 24h Volume $5.00M |
35 | Liquidity $100.00M |
36 | Market Cap $30.00B |
37 | Top 10 Holders 35.5% |
38 +-----------------------------------------------------+
39
40 \x1b[1m$ scope token-health DAI --with-market --venue binance\x1b[0m
41
42 +-- DAI (Dai Stablecoin) -----------------------------+
43 | |
44 |-- DEX Analytics |
45 | Price $0.9999 |
46 | 24h Change +0.02% |
47 | 24h Volume $50.00K |
48 | Liquidity $250.00K |
49 | |
50 |-- Market / Order Book |
51 | Venue binance |
52 | Best Bid 0.9999 |
53 | Best Ask 1.0001 |
54 | Bid Depth 6000 USDT |
55 | Ask Depth 5000 USDT |
56 | |
57 | + No sells below peg |
58 | + Bid/Ask ratio: 1.20x |
59 | |
60 | HEALTHY |
61 +-----------------------------------------------------+"
62)]
63pub struct TokenHealthArgs {
64 pub token: String,
67
68 #[arg(short, long, default_value = "ethereum")]
70 pub chain: String,
71
72 #[arg(long)]
74 pub with_market: bool,
75
76 #[arg(long, default_value = "binance")]
80 pub venue: String,
81
82 #[arg(short, long, default_value = "table")]
84 pub format: scope::config::OutputFormat,
85
86 #[command(flatten)]
88 pub health: HealthArgs,
89}
90
91pub async fn run(
93 mut args: TokenHealthArgs,
94 config: &Config,
95 clients: &dyn ChainClientFactory,
96) -> Result<()> {
97 if let Some((address, chain)) =
99 crate::cli::address_book::resolve_address_book_input(&args.token, config)?
100 {
101 args.token = address;
102 if args.chain == "ethereum" {
103 args.chain = chain;
104 }
105 }
106
107 let format = if config.output.format == scope::config::OutputFormat::Markdown {
109 config.output.format
110 } else {
111 args.format
112 };
113 let sp = crate::cli::progress::Spinner::new("Fetching token health data...");
115 let analytics = crawl::fetch_analytics_for_input(
116 &args.token,
117 &args.chain,
118 Period::Hour24,
119 10,
120 clients,
121 Some(&sp),
122 )
123 .await?;
124
125 let market_summary = if args.with_market {
127 sp.set_message("Fetching market data...");
128 let thresholds = args.health.resolve(config);
129 if is_dex_venue(&args.venue) {
130 let venue_chain = dex_venue_to_chain(&args.venue);
132 if analytics.chain.eq_ignore_ascii_case(venue_chain) && !analytics.dex_pairs.is_empty()
133 {
134 let best_pair = analytics
135 .dex_pairs
136 .iter()
137 .max_by(|a, b| {
138 a.liquidity_usd
139 .partial_cmp(&b.liquidity_usd)
140 .unwrap_or(std::cmp::Ordering::Equal)
141 })
142 .unwrap();
143 let book =
144 order_book_from_analytics(&analytics.chain, best_pair, &analytics.token.symbol);
145 let volume_24h = Some(best_pair.volume_24h);
146 Some(MarketSummary::from_order_book(
147 &book,
148 &thresholds,
149 volume_24h,
150 ))
151 } else {
152 if analytics.chain.ne(venue_chain) {
153 sp.println(&format!(
154 " Warning: DEX venue '{}' requires --chain {} (got {})",
155 args.venue, venue_chain, analytics.chain
156 ));
157 } else if analytics.dex_pairs.is_empty() {
158 sp.println(&format!(
159 " Warning: No DEX pairs found for {} on {}",
160 analytics.token.symbol, analytics.chain
161 ));
162 }
163 None
164 }
165 } else {
166 match VenueRegistry::load().and_then(|r| r.create_exchange_client(&args.venue)) {
168 Ok(exchange) => {
169 let pair = exchange.format_pair(&analytics.token.symbol);
170 match exchange.fetch_order_book(&pair).await {
171 Ok(book) => {
172 let volume_24h = if exchange.has_ticker() {
173 exchange
174 .fetch_ticker(&pair)
175 .await
176 .ok()
177 .and_then(|t| t.quote_volume_24h.or(t.volume_24h))
178 } else {
179 None
180 };
181 Some(MarketSummary::from_order_book(
182 &book,
183 &thresholds,
184 volume_24h,
185 ))
186 }
187 Err(e) => {
188 sp.println(&format!(
189 " Warning: Market data unavailable for {} on {}",
190 analytics.token.symbol, args.venue
191 ));
192 tracing::debug!("Market data error: {}", e);
193 None
194 }
195 }
196 }
197 Err(e) => {
198 sp.println(&format!(" Warning: {}", e));
199 None
200 }
201 }
202 }
203 } else {
204 None
205 };
206
207 sp.finish("Token health data loaded.");
208
209 let venue_label = if args.with_market {
211 Some(args.venue.as_str())
212 } else {
213 None
214 };
215 match format {
216 scope::config::OutputFormat::Markdown => {
217 let md = token_health_to_markdown(&analytics, market_summary.as_ref(), venue_label);
218 println!("{}", md);
219 }
220 scope::config::OutputFormat::Json => {
221 let json = token_health_to_json(&analytics, market_summary.as_ref())?;
222 println!("{}", json);
223 }
224 scope::config::OutputFormat::Table | scope::config::OutputFormat::Csv => {
225 output_token_health_table(&analytics, market_summary.as_ref(), venue_label)?;
226 }
227 }
228
229 Ok(())
230}
231
232fn is_dex_venue(venue: &str) -> bool {
234 matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
235}
236
237fn dex_venue_to_chain(venue: &str) -> &str {
239 match venue.to_lowercase().as_str() {
240 "ethereum" | "eth" => "ethereum",
241 "solana" => "solana",
242 _ => "ethereum",
243 }
244}
245
246fn token_health_to_markdown(
247 analytics: &TokenAnalytics,
248 market: Option<&MarketSummary>,
249 venue: Option<&str>,
250) -> String {
251 let mut md = report::generate_report(analytics);
253
254 if let Some(summary) = market {
255 md.push_str("\n---\n\n");
256 md.push_str("## Market / Order Book\n\n");
257 if let Some(v) = venue {
258 md.push_str(&format!("**Venue:** {} \n\n", v));
259 }
260 md.push_str(&format!(
261 "| Metric | Value |\n|--------|-------|\n\
262 | Peg Target | {:.4} |\n\
263 | Best Bid | {} |\n\
264 | Best Ask | {} |\n\
265 | Mid Price | {} |\n\
266 | Spread | {} |\n\
267 | Bid Depth | {:.0} |\n\
268 | Ask Depth | {:.0} |\n\
269 | Healthy | {} |\n",
270 summary.peg_target,
271 summary
272 .best_bid
273 .map(|b| format!("{:.4}", b))
274 .unwrap_or_else(|| "-".to_string()),
275 summary
276 .best_ask
277 .map(|a| format!("{:.4}", a))
278 .unwrap_or_else(|| "-".to_string()),
279 summary
280 .mid_price
281 .map(|m| format!("{:.4}", m))
282 .unwrap_or_else(|| "-".to_string()),
283 summary
284 .spread
285 .map(|s| format!("{:.4}", s))
286 .unwrap_or_else(|| "-".to_string()),
287 summary.bid_depth,
288 summary.ask_depth,
289 if summary.healthy { "Yes" } else { "No" }
290 ));
291 if !summary.checks.is_empty() {
292 md.push_str("\n**Health Checks:**\n");
293 for check in &summary.checks {
294 let (icon, msg) = match check {
295 scope::market::HealthCheck::Pass(m) => ("✓", m.as_str()),
296 scope::market::HealthCheck::Fail(m) => ("✗", m.as_str()),
297 };
298 md.push_str(&format!("- {} {}\n", icon, msg));
299 }
300 }
301 }
302
303 md.push_str(&report::report_footer());
304 md
305}
306
307fn token_health_to_json(
308 analytics: &TokenAnalytics,
309 market: Option<&MarketSummary>,
310) -> Result<String> {
311 let market_json = market.map(|m| {
312 serde_json::json!({
313 "peg_target": m.peg_target,
314 "best_bid": m.best_bid,
315 "best_ask": m.best_ask,
316 "mid_price": m.mid_price,
317 "spread": m.spread,
318 "bid_depth": m.bid_depth,
319 "ask_depth": m.ask_depth,
320 "healthy": m.healthy,
321 "checks": m.checks.iter().map(|c| match c {
322 scope::market::HealthCheck::Pass(msg) => serde_json::json!({"status": "pass", "message": msg}),
323 scope::market::HealthCheck::Fail(msg) => serde_json::json!({"status": "fail", "message": msg}),
324 }).collect::<Vec<_>>()
325 })
326 });
327 let json = serde_json::json!({
328 "analytics": analytics,
329 "market": market_json
330 });
331 serde_json::to_string_pretty(&json).map_err(ScopeError::Json)
332}
333
334fn output_token_health_table(
335 analytics: &TokenAnalytics,
336 market: Option<&MarketSummary>,
337 venue: Option<&str>,
338) -> Result<()> {
339 use scope::display::terminal as t;
340
341 let title = format!("{} ({})", analytics.token.symbol, analytics.token.name);
342 println!("{}", t::section_header(&title));
343
344 println!("{}", t::subsection_header("DEX Analytics"));
346 println!(
347 "{}",
348 t::kv_row("Price", &format!("${:.6}", analytics.price_usd))
349 );
350 println!(
351 "{}",
352 t::kv_row_delta(
353 "24h Change",
354 analytics.price_change_24h,
355 &format!("{:+.2}%", analytics.price_change_24h)
356 )
357 );
358 println!(
359 "{}",
360 t::kv_row(
361 "24h Volume",
362 &format!(
363 "${}",
364 scope::display::format_large_number(analytics.volume_24h)
365 )
366 )
367 );
368 println!(
369 "{}",
370 t::kv_row(
371 "Liquidity",
372 &format!(
373 "${}",
374 scope::display::format_large_number(analytics.liquidity_usd)
375 )
376 )
377 );
378 if let Some(mc) = analytics.market_cap {
379 println!(
380 "{}",
381 t::kv_row(
382 "Market Cap",
383 &format!("${}", scope::display::format_large_number(mc))
384 )
385 );
386 }
387 if let Some(top10) = analytics.top_10_concentration {
388 println!("{}", t::kv_row("Top 10 Holders", &format!("{:.1}%", top10)));
389 }
390
391 if let Some(summary) = market {
393 println!("{}", t::subsection_header("Market / Order Book"));
394 if let Some(v) = venue {
395 println!("{}", t::kv_row("Venue", v));
396 }
397 println!(
398 "{}",
399 t::kv_row("Peg Target", &format!("{:.4}", summary.peg_target))
400 );
401 if let Some(b) = summary.best_bid {
402 println!(
403 "{}",
404 t::kv_row("Best Bid", &t::format_price_peg(b, summary.peg_target))
405 );
406 }
407 if let Some(a) = summary.best_ask {
408 println!(
409 "{}",
410 t::kv_row("Best Ask", &t::format_price_peg(a, summary.peg_target))
411 );
412 }
413 if let Some(m) = summary.mid_price {
414 println!(
415 "{}",
416 t::kv_row("Mid Price", &t::format_price_peg(m, summary.peg_target))
417 );
418 }
419 println!(
420 "{}",
421 t::kv_row("Bid Depth", &format!("{:.0} USDT", summary.bid_depth))
422 );
423 println!(
424 "{}",
425 t::kv_row("Ask Depth", &format!("{:.0} USDT", summary.ask_depth))
426 );
427 println!("{}", t::blank_row());
428
429 for check in &summary.checks {
431 match check {
432 scope::market::HealthCheck::Pass(m) => println!("{}", t::check_pass(m)),
433 scope::market::HealthCheck::Fail(m) => println!("{}", t::check_fail(m)),
434 }
435 }
436 println!("{}", t::blank_row());
437 println!("{}", t::status_line(summary.healthy));
438 }
439
440 println!("{}", t::section_footer());
441 Ok(())
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use scope::chains::dex::DexTokenData;
448 use scope::chains::mocks::MockClientFactory;
449 use scope::chains::{DexPair, Token, TokenAnalytics, TokenHolder, TokenSocial};
450 use scope::config::OutputFormat;
451 use scope::market::{HealthCheck, MarketSummary};
452
453 fn make_test_analytics(with_dex_pairs: bool) -> TokenAnalytics {
454 TokenAnalytics {
455 token: Token {
456 contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
457 symbol: "USDC".to_string(),
458 name: "USD Coin".to_string(),
459 decimals: 6,
460 },
461 chain: "ethereum".to_string(),
462 holders: vec![TokenHolder {
463 address: "0x1234".to_string(),
464 balance: "1000000".to_string(),
465 formatted_balance: "1.0".to_string(),
466 percentage: 10.0,
467 rank: 1,
468 }],
469 total_holders: 1000,
470 volume_24h: 5_000_000.0,
471 volume_7d: 25_000_000.0,
472 price_usd: 0.9999,
473 price_change_24h: -0.01,
474 price_change_7d: 0.02,
475 liquidity_usd: 100_000_000.0,
476 market_cap: Some(30_000_000_000.0),
477 fdv: None,
478 total_supply: None,
479 circulating_supply: None,
480 price_history: vec![],
481 volume_history: vec![],
482 holder_history: vec![],
483 dex_pairs: if with_dex_pairs {
484 vec![DexPair {
485 dex_name: "Uniswap V3".to_string(),
486 pair_address: "0xpair".to_string(),
487 base_token: "USDC".to_string(),
488 quote_token: "WETH".to_string(),
489 price_usd: 0.9999,
490 volume_24h: 5_000_000.0,
491 liquidity_usd: 50_000_000.0,
492 price_change_24h: -0.01,
493 buys_24h: 1000,
494 sells_24h: 900,
495 buys_6h: 300,
496 sells_6h: 250,
497 buys_1h: 50,
498 sells_1h: 45,
499 pair_created_at: Some(1600000000),
500 url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
501 }]
502 } else {
503 vec![]
504 },
505 fetched_at: 1700003600,
506 top_10_concentration: Some(35.5),
507 top_50_concentration: Some(55.0),
508 top_100_concentration: Some(65.0),
509 price_change_6h: 0.01,
510 price_change_1h: -0.005,
511 total_buys_24h: 1000,
512 total_sells_24h: 900,
513 total_buys_6h: 300,
514 total_sells_6h: 250,
515 total_buys_1h: 50,
516 total_sells_1h: 45,
517 token_age_hours: Some(25000.0),
518 image_url: None,
519 websites: vec!["https://centre.io".to_string()],
520 socials: vec![TokenSocial {
521 platform: "twitter".to_string(),
522 url: "https://twitter.com/circle".to_string(),
523 }],
524 dexscreener_url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
525 }
526 }
527
528 fn make_test_market_summary() -> MarketSummary {
529 use scope::market::{ExecutionEstimate, ExecutionSide};
530 MarketSummary {
531 pair: "USDC/USDT".to_string(),
532 peg_target: 1.0,
533 best_bid: Some(0.9999),
534 best_ask: Some(1.0001),
535 mid_price: Some(1.0),
536 spread: Some(0.0002),
537 volume_24h: Some(1_000_000.0),
538 execution_10k_buy: Some(ExecutionEstimate {
539 notional_usdt: 10_000.0,
540 side: ExecutionSide::Buy,
541 vwap: 1.0001,
542 slippage_bps: 1.0,
543 fillable: true,
544 }),
545 execution_10k_sell: Some(ExecutionEstimate {
546 notional_usdt: 10_000.0,
547 side: ExecutionSide::Sell,
548 vwap: 0.9999,
549 slippage_bps: 1.0,
550 fillable: true,
551 }),
552 asks: vec![],
553 bids: vec![],
554 ask_outliers: 0,
555 bid_outliers: 0,
556 ask_depth: 5000.0,
557 bid_depth: 6000.0,
558 checks: vec![
559 HealthCheck::Pass("No sells below peg".to_string()),
560 HealthCheck::Pass("Bid/Ask ratio: 1.20x".to_string()),
561 ],
562 healthy: true,
563 }
564 }
565
566 #[test]
567 fn test_is_dex_venue() {
568 assert!(is_dex_venue("eth"));
569 assert!(is_dex_venue("ethereum"));
570 assert!(is_dex_venue("solana"));
571 assert!(!is_dex_venue("binance"));
572 assert!(!is_dex_venue("okx"));
573 }
574
575 #[test]
576 fn test_is_dex_venue_values() {
577 assert!(is_dex_venue("eth"));
578 assert!(is_dex_venue("ethereum"));
579 assert!(is_dex_venue("solana"));
580 assert!(!is_dex_venue("binance"));
581 assert!(!is_dex_venue("mexc"));
582 }
583
584 #[test]
585 fn test_dex_venue_to_chain_values() {
586 assert_eq!(dex_venue_to_chain("eth"), "ethereum");
587 assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
588 assert_eq!(dex_venue_to_chain("solana"), "solana");
589 assert_eq!(dex_venue_to_chain("unknown"), "ethereum");
590 }
591
592 #[test]
593 fn test_token_health_args_debug() {
594 let args = TokenHealthArgs {
595 token: "USDC".to_string(),
596 chain: "ethereum".to_string(),
597 with_market: false,
598 venue: "binance".to_string(),
599 format: scope::config::OutputFormat::Table,
600 health: Default::default(),
601 };
602 let debug = format!("{:?}", args);
603 assert!(debug.contains("TokenHealthArgs"));
604 }
605
606 #[test]
607 fn test_format_large_number() {
608 assert_eq!(
609 scope::display::format_large_number(1_500_000_000.0),
610 "1.50B"
611 );
612 assert_eq!(scope::display::format_large_number(2_500_000.0), "2.50M");
613 assert_eq!(scope::display::format_large_number(3_500.0), "3.50K");
614 assert_eq!(scope::display::format_large_number(99.99), "99.99");
615 }
616
617 #[test]
618 fn test_token_health_to_markdown_without_market() {
619 let analytics = make_test_analytics(false);
620 let md = token_health_to_markdown(&analytics, None, None);
621 assert!(md.contains("USDC"));
622 assert!(md.contains("USD Coin"));
623 assert!(!md.contains("Market / Order Book"));
624 }
625
626 #[test]
627 fn test_token_health_to_markdown_with_market() {
628 let analytics = make_test_analytics(false);
629 let market = make_test_market_summary();
630 let md = token_health_to_markdown(&analytics, Some(&market), Some("binance"));
631 assert!(md.contains("Market / Order Book"));
632 assert!(md.contains("binance"));
633 assert!(md.contains("0.9999"));
634 assert!(md.contains("Yes"));
635 assert!(md.contains("Health Checks"));
636 }
637
638 #[test]
639 fn test_token_health_to_markdown_without_venue() {
640 let analytics = make_test_analytics(false);
641 let market = make_test_market_summary();
642 let md = token_health_to_markdown(&analytics, Some(&market), None);
643 assert!(md.contains("Market / Order Book"));
644 assert!(!md.contains("Venue:")); assert!(md.contains("0.9999"));
646 assert!(md.contains("Yes"));
647 }
648
649 #[test]
650 fn test_token_health_to_markdown_unhealthy_market() {
651 let analytics = make_test_analytics(false);
652 let mut market = make_test_market_summary();
653 market.healthy = false;
654 market.checks = vec![
655 HealthCheck::Pass("Some check passed".to_string()),
656 HealthCheck::Fail("Peg deviation too high".to_string()),
657 HealthCheck::Fail("Insufficient bid depth".to_string()),
658 ];
659 let md = token_health_to_markdown(&analytics, Some(&market), Some("binance"));
660 assert!(md.contains("Market / Order Book"));
661 assert!(md.contains("No")); assert!(md.contains("Health Checks"));
663 assert!(md.contains("✗")); assert!(md.contains("Peg deviation too high"));
665 assert!(md.contains("Insufficient bid depth"));
666 }
667
668 #[test]
669 fn test_token_health_to_json_without_market() {
670 let analytics = make_test_analytics(false);
671 let json = token_health_to_json(&analytics, None).unwrap();
672 assert!(json.contains("\"analytics\""));
673 assert!(json.contains("\"market\": null"));
674 assert!(json.contains("USDC"));
675 }
676
677 #[test]
678 fn test_token_health_to_json_with_market() {
679 let analytics = make_test_analytics(false);
680 let market = make_test_market_summary();
681 let json = token_health_to_json(&analytics, Some(&market)).unwrap();
682 assert!(json.contains("\"market\""));
683 assert!(json.contains("\"peg_target\": 1.0"));
684 assert!(json.contains("\"healthy\": true"));
685 }
686
687 #[test]
688 fn test_token_health_to_json_with_fail_checks() {
689 let analytics = make_test_analytics(false);
690 let mut market = make_test_market_summary();
691 market.healthy = false;
692 market.checks = vec![
693 HealthCheck::Pass("Bid/Ask ratio OK".to_string()),
694 HealthCheck::Fail("Peg deviation exceeds threshold".to_string()),
695 HealthCheck::Fail("Ask depth below minimum".to_string()),
696 ];
697 let json = token_health_to_json(&analytics, Some(&market)).unwrap();
698 assert!(json.contains("\"market\""));
699 assert!(json.contains("\"healthy\": false"));
700 assert!(json.contains("\"status\": \"pass\""));
701 assert!(json.contains("\"status\": \"fail\""));
702 assert!(json.contains("Peg deviation exceeds threshold"));
703 assert!(json.contains("Ask depth below minimum"));
704 }
705
706 #[test]
707 fn test_output_token_health_table_without_market() {
708 let analytics = make_test_analytics(false);
709 let result = output_token_health_table(&analytics, None, None);
710 assert!(result.is_ok());
711 }
712
713 #[test]
714 fn test_output_token_health_table_with_market() {
715 let analytics = make_test_analytics(false);
716 let market = make_test_market_summary();
717 let result = output_token_health_table(&analytics, Some(&market), Some("biconomy"));
718 assert!(result.is_ok());
719 }
720
721 #[test]
722 fn test_output_token_health_table_no_market_cap() {
723 let mut analytics = make_test_analytics(false);
724 analytics.market_cap = None;
725 analytics.top_10_concentration = None;
726 let result = output_token_health_table(&analytics, None, None);
727 assert!(result.is_ok());
728 }
730
731 fn make_test_dex_token_data(pairs: Vec<DexPair>) -> DexTokenData {
732 DexTokenData {
733 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
734 symbol: "USDC".to_string(),
735 name: "USD Coin".to_string(),
736 price_usd: 0.9999,
737 price_change_24h: -0.01,
738 price_change_6h: 0.01,
739 price_change_1h: -0.005,
740 price_change_5m: 0.0,
741 volume_24h: 5_000_000.0,
742 volume_6h: 1_250_000.0,
743 volume_1h: 250_000.0,
744 liquidity_usd: 100_000_000.0,
745 market_cap: Some(30_000_000_000.0),
746 fdv: Some(30_000_000_000.0),
747 pairs,
748 price_history: vec![],
749 volume_history: vec![],
750 total_buys_24h: 1000,
751 total_sells_24h: 900,
752 total_buys_6h: 300,
753 total_sells_6h: 250,
754 total_buys_1h: 50,
755 total_sells_1h: 45,
756 earliest_pair_created_at: Some(1600000000),
757 image_url: None,
758 websites: vec![],
759 socials: vec![scope::chains::dex::TokenSocial {
760 platform: "twitter".to_string(),
761 url: "https://twitter.com/circle".to_string(),
762 }],
763 dexscreener_url: None,
764 }
765 }
766
767 #[tokio::test]
768 async fn test_run_token_health_table() {
769 let mut factory = MockClientFactory::new();
770 factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
771
772 let config = Config::default();
773 let args = TokenHealthArgs {
774 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
775 chain: "ethereum".to_string(),
776 with_market: false,
777 venue: "binance".to_string(),
778 format: OutputFormat::Table,
779 health: Default::default(),
780 };
781
782 let result = run(args, &config, &factory).await;
783 assert!(result.is_ok());
784 }
785
786 #[tokio::test]
787 async fn test_run_token_health_json() {
788 let mut factory = MockClientFactory::new();
789 let mut data = make_test_dex_token_data(vec![]);
790 data.price_usd = 1.0;
791 data.volume_24h = 1_000_000.0;
792 data.liquidity_usd = 5_000_000.0;
793 factory.mock_dex.token_data = Some(data);
794
795 let config = Config::default();
796 let args = TokenHealthArgs {
797 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
798 chain: "ethereum".to_string(),
799 with_market: false,
800 venue: "binance".to_string(),
801 format: OutputFormat::Json,
802 health: Default::default(),
803 };
804
805 let result = run(args, &config, &factory).await;
806 assert!(result.is_ok());
807 }
808
809 #[tokio::test]
810 async fn test_run_token_health_markdown() {
811 let mut factory = MockClientFactory::new();
812 factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
813
814 let config = Config::default();
815 let args = TokenHealthArgs {
816 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
817 chain: "ethereum".to_string(),
818 with_market: false,
819 venue: "binance".to_string(),
820 format: OutputFormat::Markdown,
821 health: Default::default(),
822 };
823
824 let result = run(args, &config, &factory).await;
825 assert!(result.is_ok());
826 }
827
828 #[tokio::test]
830 async fn test_run_token_health_dex_market() {
831 let mut factory = MockClientFactory::new();
832 let pair = DexPair {
833 dex_name: "Uniswap V3".to_string(),
834 pair_address: "0xpair".to_string(),
835 base_token: "USDC".to_string(),
836 quote_token: "WETH".to_string(),
837 price_usd: 0.9999,
838 volume_24h: 5_000_000.0,
839 liquidity_usd: 50_000_000.0,
840 price_change_24h: -0.01,
841 buys_24h: 1000,
842 sells_24h: 900,
843 buys_6h: 300,
844 sells_6h: 250,
845 buys_1h: 50,
846 sells_1h: 45,
847 pair_created_at: Some(1600000000),
848 url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
849 };
850 factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![pair]));
851
852 let config = Config::default();
853 let args = TokenHealthArgs {
854 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
855 chain: "ethereum".to_string(),
856 with_market: true,
857 venue: "eth".to_string(),
858 format: OutputFormat::Table,
859 health: Default::default(),
860 };
861
862 let result = run(args, &config, &factory).await;
863 assert!(result.is_ok());
864 }
865
866 fn dex_market_args(venue: &str) -> TokenHealthArgs {
867 TokenHealthArgs {
868 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
869 chain: "ethereum".to_string(),
870 with_market: true,
871 venue: venue.to_string(),
872 format: OutputFormat::Json,
873 health: Default::default(),
874 }
875 }
876
877 fn pair_with_liquidity(liquidity_usd: f64) -> DexPair {
878 DexPair {
879 dex_name: "Uniswap V3".to_string(),
880 pair_address: format!("0xpair{liquidity_usd}"),
881 base_token: "USDC".to_string(),
882 quote_token: "USDT".to_string(),
883 price_usd: 1.0,
884 volume_24h: 1_000.0,
885 liquidity_usd,
886 price_change_24h: 0.0,
887 buys_24h: 0,
888 sells_24h: 0,
889 buys_6h: 0,
890 sells_6h: 0,
891 buys_1h: 0,
892 sells_1h: 0,
893 pair_created_at: None,
894 url: None,
895 }
896 }
897
898 #[tokio::test]
900 async fn test_run_token_health_dex_market_multiple_pairs() {
901 let mut factory = MockClientFactory::new();
902 factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![
903 pair_with_liquidity(1_000.0),
904 pair_with_liquidity(90_000.0),
905 pair_with_liquidity(5_000.0),
906 ]));
907 let result = run(dex_market_args("eth"), &Config::default(), &factory).await;
908 assert!(result.is_ok());
909 }
910
911 #[tokio::test]
913 async fn test_run_token_health_dex_market_chain_mismatch_still_reports() {
914 let mut factory = MockClientFactory::new();
915 factory.mock_dex.token_data =
916 Some(make_test_dex_token_data(vec![pair_with_liquidity(1_000.0)]));
917 let result = run(dex_market_args("solana"), &Config::default(), &factory).await;
919 assert!(result.is_ok());
920 }
921
922 #[tokio::test]
924 async fn test_run_token_health_dex_market_no_pairs_still_reports() {
925 let mut factory = MockClientFactory::new();
926 factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
927 let result = run(dex_market_args("eth"), &Config::default(), &factory).await;
928 assert!(result.is_ok());
929 }
930
931 #[test]
932 fn test_dex_venue_to_chain_case_insensitive() {
933 assert_eq!(dex_venue_to_chain("ETH"), "ethereum");
934 assert_eq!(dex_venue_to_chain("Ethereum"), "ethereum");
935 assert_eq!(dex_venue_to_chain("SOLANA"), "solana");
936 }
937
938 #[test]
939 fn test_is_dex_venue_uppercase() {
940 assert!(is_dex_venue("ETH"));
941 assert!(is_dex_venue("SOLANA"));
942 assert!(!is_dex_venue("BINANCE"));
943 }
944
945 #[test]
946 fn test_token_health_to_markdown_market_missing_prices() {
947 let analytics = make_test_analytics(false);
948 let mut market = make_test_market_summary();
949 market.best_bid = None;
950 market.best_ask = None;
951 market.mid_price = None;
952 market.spread = None;
953 let md = token_health_to_markdown(&analytics, Some(&market), Some("binance"));
954 assert!(md.contains("Market / Order Book"));
955 assert!(md.contains("-")); }
957
958 #[test]
959 fn test_token_health_to_markdown_market_empty_checks() {
960 let analytics = make_test_analytics(false);
961 let mut market = make_test_market_summary();
962 market.checks = vec![];
963 let md = token_health_to_markdown(&analytics, Some(&market), Some("binance"));
964 assert!(md.contains("Market / Order Book"));
965 assert!(!md.contains("Health Checks:")); }
967
968 #[test]
969 fn test_token_health_to_json_empty_checks() {
970 let analytics = make_test_analytics(false);
971 let mut market = make_test_market_summary();
972 market.checks = vec![];
973 let json = token_health_to_json(&analytics, Some(&market)).unwrap();
974 assert!(json.contains("\"market\""));
975 assert!(json.contains("\"checks\": []"));
976 }
977
978 #[test]
979 fn test_output_token_health_table_market_missing_prices() {
980 let analytics = make_test_analytics(false);
981 let mut market = make_test_market_summary();
982 market.best_bid = None;
983 market.best_ask = None;
984 market.mid_price = None;
985 let result = output_token_health_table(&analytics, Some(&market), Some("binance"));
986 assert!(result.is_ok());
987 }
988
989 #[test]
990 fn test_output_token_health_table_market_empty_checks() {
991 let analytics = make_test_analytics(false);
992 let mut market = make_test_market_summary();
993 market.checks = vec![];
994 let result = output_token_health_table(&analytics, Some(&market), None);
995 assert!(result.is_ok());
996 }
997
998 #[test]
999 fn test_output_token_health_table_market_without_venue() {
1000 let analytics = make_test_analytics(false);
1001 let market = make_test_market_summary();
1002 let result = output_token_health_table(&analytics, Some(&market), None);
1003 assert!(result.is_ok());
1004 }
1005
1006 #[test]
1007 fn test_token_health_args_all_formats() {
1008 for format in [
1009 OutputFormat::Table,
1010 OutputFormat::Json,
1011 OutputFormat::Csv,
1012 OutputFormat::Markdown,
1013 ] {
1014 let args = TokenHealthArgs {
1015 token: "USDC".to_string(),
1016 chain: "ethereum".to_string(),
1017 with_market: false,
1018 venue: "binance".to_string(),
1019 format,
1020 health: Default::default(),
1021 };
1022 assert_eq!(args.format, format);
1023 }
1024 }
1025
1026 #[tokio::test]
1027 async fn test_run_token_health_csv() {
1028 let mut factory = MockClientFactory::new();
1029 factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
1030
1031 let config = Config::default();
1032 let args = TokenHealthArgs {
1033 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1034 chain: "ethereum".to_string(),
1035 with_market: false,
1036 venue: "binance".to_string(),
1037 format: OutputFormat::Csv,
1038 health: Default::default(),
1039 };
1040
1041 let result = run(args, &config, &factory).await;
1042 assert!(result.is_ok());
1043 }
1044
1045 #[tokio::test]
1046 async fn test_run_token_health_config_markdown_override() {
1047 let mut factory = MockClientFactory::new();
1048 factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
1049
1050 let mut config = Config::default();
1051 config.output.format = OutputFormat::Markdown;
1052 let args = TokenHealthArgs {
1053 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1054 chain: "ethereum".to_string(),
1055 with_market: false,
1056 venue: "binance".to_string(),
1057 format: OutputFormat::Table, health: Default::default(),
1059 };
1060
1061 let result = run(args, &config, &factory).await;
1062 assert!(result.is_ok());
1063 }
1064
1065 #[test]
1066 fn test_token_health_to_json_market_none_values() {
1067 let analytics = make_test_analytics(false);
1068 let mut market = make_test_market_summary();
1069 market.best_bid = None;
1070 market.best_ask = None;
1071 market.mid_price = None;
1072 market.spread = None;
1073 let json = token_health_to_json(&analytics, Some(&market)).unwrap();
1074 assert!(json.contains("\"market\""));
1075 assert!(json.contains("null")); }
1077
1078 #[test]
1079 fn test_token_health_to_markdown_report_footer() {
1080 let analytics = make_test_analytics(false);
1081 let md = token_health_to_markdown(&analytics, None, None);
1082 assert!(!md.is_empty());
1083 assert!(md.contains("USDC"));
1084 assert!(md.contains("USD Coin"));
1085 }
1086
1087 #[test]
1088 fn test_output_token_health_table_with_dex_pairs_analytics() {
1089 let analytics = make_test_analytics(true);
1090 let result = output_token_health_table(&analytics, None, None);
1091 assert!(result.is_ok());
1092 }
1093
1094 #[test]
1095 fn test_token_health_to_markdown_market_with_venue_none() {
1096 let analytics = make_test_analytics(false);
1097 let market = make_test_market_summary();
1098 let md = token_health_to_markdown(&analytics, Some(&market), None);
1099 assert!(md.contains("Market / Order Book"));
1100 assert!(!md.contains("**Venue:**"));
1101 }
1102
1103 #[test]
1104 fn test_output_token_health_table_market_no_bid_ask() {
1105 let analytics = make_test_analytics(false);
1106 let mut market = make_test_market_summary();
1107 market.best_bid = None;
1108 market.best_ask = None;
1109 market.mid_price = None;
1110 let result = output_token_health_table(&analytics, Some(&market), Some("binance"));
1111 assert!(result.is_ok());
1112 }
1113}