1use crate::chains::{ChainClientFactory, TokenAnalytics};
8use crate::cli::crawl::{self, Period};
9use crate::config::Config;
10use crate::display::report;
11use crate::error::{Result, ScopeError};
12use crate::market::{HealthThresholds, MarketSummary, VenueRegistry, order_book_from_analytics};
13use clap::Args;
14
15#[derive(Debug, Args)]
17pub struct TokenHealthArgs {
18 pub token: String,
20
21 #[arg(short, long, default_value = "ethereum")]
23 pub chain: String,
24
25 #[arg(long)]
27 pub with_market: bool,
28
29 #[arg(long, default_value = "binance")]
33 pub venue: String,
34
35 #[arg(short, long, default_value = "table")]
37 pub format: crate::config::OutputFormat,
38}
39
40pub async fn run(
42 mut args: TokenHealthArgs,
43 config: &Config,
44 clients: &dyn ChainClientFactory,
45) -> Result<()> {
46 if let Some((address, chain)) =
48 crate::cli::address_book::resolve_address_book_input(&args.token, config)?
49 {
50 args.token = address;
51 if args.chain == "ethereum" {
52 args.chain = chain;
53 }
54 }
55
56 let format = if config.output.format == crate::config::OutputFormat::Markdown {
58 config.output.format
59 } else {
60 args.format
61 };
62 let sp = crate::cli::progress::Spinner::new("Fetching token health data...");
64 let analytics = crawl::fetch_analytics_for_input(
65 &args.token,
66 &args.chain,
67 Period::Hour24,
68 10,
69 clients,
70 Some(&sp),
71 )
72 .await?;
73
74 let market_summary = if args.with_market {
76 sp.set_message("Fetching market data...");
77 let thresholds = HealthThresholds {
78 peg_target: 1.0,
79 peg_range: 0.001,
80 min_levels: 6,
81 min_depth: 3000.0,
82 min_bid_ask_ratio: 0.2,
83 max_bid_ask_ratio: 5.0,
84 };
85 if is_dex_venue(&args.venue) {
86 let venue_chain = dex_venue_to_chain(&args.venue);
88 if analytics.chain.eq_ignore_ascii_case(venue_chain) && !analytics.dex_pairs.is_empty()
89 {
90 let best_pair = analytics
91 .dex_pairs
92 .iter()
93 .max_by(|a, b| {
94 a.liquidity_usd
95 .partial_cmp(&b.liquidity_usd)
96 .unwrap_or(std::cmp::Ordering::Equal)
97 })
98 .unwrap();
99 let book =
100 order_book_from_analytics(&analytics.chain, best_pair, &analytics.token.symbol);
101 let volume_24h = Some(best_pair.volume_24h);
102 Some(MarketSummary::from_order_book(
103 &book,
104 1.0,
105 &thresholds,
106 volume_24h,
107 ))
108 } else {
109 if analytics.chain.ne(venue_chain) {
110 sp.println(&format!(
111 " Warning: DEX venue '{}' requires --chain {} (got {})",
112 args.venue, venue_chain, analytics.chain
113 ));
114 } else if analytics.dex_pairs.is_empty() {
115 sp.println(&format!(
116 " Warning: No DEX pairs found for {} on {}",
117 analytics.token.symbol, analytics.chain
118 ));
119 }
120 None
121 }
122 } else {
123 match VenueRegistry::load().and_then(|r| r.create_exchange_client(&args.venue)) {
125 Ok(exchange) => {
126 let pair = exchange.format_pair(&analytics.token.symbol);
127 match exchange.fetch_order_book(&pair).await {
128 Ok(book) => {
129 let volume_24h = if exchange.has_ticker() {
130 exchange
131 .fetch_ticker(&pair)
132 .await
133 .ok()
134 .and_then(|t| t.quote_volume_24h.or(t.volume_24h))
135 } else {
136 None
137 };
138 Some(MarketSummary::from_order_book(
139 &book,
140 1.0,
141 &thresholds,
142 volume_24h,
143 ))
144 }
145 Err(e) => {
146 sp.println(&format!(
147 " Warning: Market data unavailable for {} on {}",
148 analytics.token.symbol, args.venue
149 ));
150 tracing::debug!("Market data error: {}", e);
151 None
152 }
153 }
154 }
155 Err(e) => {
156 sp.println(&format!(" Warning: {}", e));
157 None
158 }
159 }
160 }
161 } else {
162 None
163 };
164
165 sp.finish("Token health data loaded.");
166
167 let venue_label = if args.with_market {
169 Some(args.venue.as_str())
170 } else {
171 None
172 };
173 match format {
174 crate::config::OutputFormat::Markdown => {
175 let md = token_health_to_markdown(&analytics, market_summary.as_ref(), venue_label);
176 println!("{}", md);
177 }
178 crate::config::OutputFormat::Json => {
179 let json = token_health_to_json(&analytics, market_summary.as_ref())?;
180 println!("{}", json);
181 }
182 crate::config::OutputFormat::Table | crate::config::OutputFormat::Csv => {
183 output_token_health_table(&analytics, market_summary.as_ref(), venue_label)?;
184 }
185 }
186
187 Ok(())
188}
189
190fn is_dex_venue(venue: &str) -> bool {
192 matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
193}
194
195fn dex_venue_to_chain(venue: &str) -> &str {
197 match venue.to_lowercase().as_str() {
198 "ethereum" | "eth" => "ethereum",
199 "solana" => "solana",
200 _ => "ethereum",
201 }
202}
203
204fn token_health_to_markdown(
205 analytics: &TokenAnalytics,
206 market: Option<&MarketSummary>,
207 venue: Option<&str>,
208) -> String {
209 let mut md = report::generate_report(analytics);
211
212 if let Some(summary) = market {
213 md.push_str("\n---\n\n");
214 md.push_str("## Market / Order Book\n\n");
215 if let Some(v) = venue {
216 md.push_str(&format!("**Venue:** {} \n\n", v));
217 }
218 md.push_str(&format!(
219 "| Metric | Value |\n|--------|-------|\n\
220 | Peg Target | {:.4} |\n\
221 | Best Bid | {} |\n\
222 | Best Ask | {} |\n\
223 | Mid Price | {} |\n\
224 | Spread | {} |\n\
225 | Bid Depth | {:.0} |\n\
226 | Ask Depth | {:.0} |\n\
227 | Healthy | {} |\n",
228 summary.peg_target,
229 summary
230 .best_bid
231 .map(|b| format!("{:.4}", b))
232 .unwrap_or_else(|| "-".to_string()),
233 summary
234 .best_ask
235 .map(|a| format!("{:.4}", a))
236 .unwrap_or_else(|| "-".to_string()),
237 summary
238 .mid_price
239 .map(|m| format!("{:.4}", m))
240 .unwrap_or_else(|| "-".to_string()),
241 summary
242 .spread
243 .map(|s| format!("{:.4}", s))
244 .unwrap_or_else(|| "-".to_string()),
245 summary.bid_depth,
246 summary.ask_depth,
247 if summary.healthy { "Yes" } else { "No" }
248 ));
249 if !summary.checks.is_empty() {
250 md.push_str("\n**Health Checks:**\n");
251 for check in &summary.checks {
252 let (icon, msg) = match check {
253 crate::market::HealthCheck::Pass(m) => ("✓", m.as_str()),
254 crate::market::HealthCheck::Fail(m) => ("✗", m.as_str()),
255 };
256 md.push_str(&format!("- {} {}\n", icon, msg));
257 }
258 }
259 }
260
261 md.push_str(&report::report_footer());
262 md
263}
264
265fn token_health_to_json(
266 analytics: &TokenAnalytics,
267 market: Option<&MarketSummary>,
268) -> Result<String> {
269 let market_json = market.map(|m| {
270 serde_json::json!({
271 "peg_target": m.peg_target,
272 "best_bid": m.best_bid,
273 "best_ask": m.best_ask,
274 "mid_price": m.mid_price,
275 "spread": m.spread,
276 "bid_depth": m.bid_depth,
277 "ask_depth": m.ask_depth,
278 "healthy": m.healthy,
279 "checks": m.checks.iter().map(|c| match c {
280 crate::market::HealthCheck::Pass(msg) => serde_json::json!({"status": "pass", "message": msg}),
281 crate::market::HealthCheck::Fail(msg) => serde_json::json!({"status": "fail", "message": msg}),
282 }).collect::<Vec<_>>()
283 })
284 });
285 let json = serde_json::json!({
286 "analytics": analytics,
287 "market": market_json
288 });
289 serde_json::to_string_pretty(&json).map_err(|e| ScopeError::Other(e.to_string()))
290}
291
292fn output_token_health_table(
293 analytics: &TokenAnalytics,
294 market: Option<&MarketSummary>,
295 venue: Option<&str>,
296) -> Result<()> {
297 use crate::display::terminal as t;
298
299 let title = format!("{} ({})", analytics.token.symbol, analytics.token.name);
300 println!("{}", t::section_header(&title));
301
302 println!("{}", t::subsection_header("DEX Analytics"));
304 println!(
305 "{}",
306 t::kv_row("Price", &format!("${:.6}", analytics.price_usd))
307 );
308 println!(
309 "{}",
310 t::kv_row_delta(
311 "24h Change",
312 analytics.price_change_24h,
313 &format!("{:+.2}%", analytics.price_change_24h)
314 )
315 );
316 println!(
317 "{}",
318 t::kv_row(
319 "24h Volume",
320 &format!(
321 "${}",
322 crate::display::format_large_number(analytics.volume_24h)
323 )
324 )
325 );
326 println!(
327 "{}",
328 t::kv_row(
329 "Liquidity",
330 &format!(
331 "${}",
332 crate::display::format_large_number(analytics.liquidity_usd)
333 )
334 )
335 );
336 if let Some(mc) = analytics.market_cap {
337 println!(
338 "{}",
339 t::kv_row(
340 "Market Cap",
341 &format!("${}", crate::display::format_large_number(mc))
342 )
343 );
344 }
345 if let Some(top10) = analytics.top_10_concentration {
346 println!("{}", t::kv_row("Top 10 Holders", &format!("{:.1}%", top10)));
347 }
348
349 if let Some(summary) = market {
351 println!("{}", t::subsection_header("Market / Order Book"));
352 if let Some(v) = venue {
353 println!("{}", t::kv_row("Venue", v));
354 }
355 println!(
356 "{}",
357 t::kv_row("Peg Target", &format!("{:.4}", summary.peg_target))
358 );
359 if let Some(b) = summary.best_bid {
360 println!(
361 "{}",
362 t::kv_row("Best Bid", &t::format_price_peg(b, summary.peg_target))
363 );
364 }
365 if let Some(a) = summary.best_ask {
366 println!(
367 "{}",
368 t::kv_row("Best Ask", &t::format_price_peg(a, summary.peg_target))
369 );
370 }
371 if let Some(m) = summary.mid_price {
372 println!(
373 "{}",
374 t::kv_row("Mid Price", &t::format_price_peg(m, summary.peg_target))
375 );
376 }
377 println!(
378 "{}",
379 t::kv_row("Bid Depth", &format!("{:.0} USDT", summary.bid_depth))
380 );
381 println!(
382 "{}",
383 t::kv_row("Ask Depth", &format!("{:.0} USDT", summary.ask_depth))
384 );
385 println!("{}", t::blank_row());
386
387 for check in &summary.checks {
389 match check {
390 crate::market::HealthCheck::Pass(m) => println!("{}", t::check_pass(m)),
391 crate::market::HealthCheck::Fail(m) => println!("{}", t::check_fail(m)),
392 }
393 }
394 println!("{}", t::blank_row());
395 println!("{}", t::status_line(summary.healthy));
396 }
397
398 println!("{}", t::section_footer());
399 Ok(())
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use crate::chains::dex::DexTokenData;
406 use crate::chains::mocks::MockClientFactory;
407 use crate::chains::{DexPair, Token, TokenAnalytics, TokenHolder, TokenSocial};
408 use crate::config::OutputFormat;
409 use crate::market::{HealthCheck, MarketSummary};
410
411 fn make_test_analytics(with_dex_pairs: bool) -> TokenAnalytics {
412 TokenAnalytics {
413 token: Token {
414 contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
415 symbol: "USDC".to_string(),
416 name: "USD Coin".to_string(),
417 decimals: 6,
418 },
419 chain: "ethereum".to_string(),
420 holders: vec![TokenHolder {
421 address: "0x1234".to_string(),
422 balance: "1000000".to_string(),
423 formatted_balance: "1.0".to_string(),
424 percentage: 10.0,
425 rank: 1,
426 }],
427 total_holders: 1000,
428 volume_24h: 5_000_000.0,
429 volume_7d: 25_000_000.0,
430 price_usd: 0.9999,
431 price_change_24h: -0.01,
432 price_change_7d: 0.02,
433 liquidity_usd: 100_000_000.0,
434 market_cap: Some(30_000_000_000.0),
435 fdv: None,
436 total_supply: None,
437 circulating_supply: None,
438 price_history: vec![],
439 volume_history: vec![],
440 holder_history: vec![],
441 dex_pairs: if with_dex_pairs {
442 vec![DexPair {
443 dex_name: "Uniswap V3".to_string(),
444 pair_address: "0xpair".to_string(),
445 base_token: "USDC".to_string(),
446 quote_token: "WETH".to_string(),
447 price_usd: 0.9999,
448 volume_24h: 5_000_000.0,
449 liquidity_usd: 50_000_000.0,
450 price_change_24h: -0.01,
451 buys_24h: 1000,
452 sells_24h: 900,
453 buys_6h: 300,
454 sells_6h: 250,
455 buys_1h: 50,
456 sells_1h: 45,
457 pair_created_at: Some(1600000000),
458 url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
459 }]
460 } else {
461 vec![]
462 },
463 fetched_at: 1700003600,
464 top_10_concentration: Some(35.5),
465 top_50_concentration: Some(55.0),
466 top_100_concentration: Some(65.0),
467 price_change_6h: 0.01,
468 price_change_1h: -0.005,
469 total_buys_24h: 1000,
470 total_sells_24h: 900,
471 total_buys_6h: 300,
472 total_sells_6h: 250,
473 total_buys_1h: 50,
474 total_sells_1h: 45,
475 token_age_hours: Some(25000.0),
476 image_url: None,
477 websites: vec!["https://centre.io".to_string()],
478 socials: vec![TokenSocial {
479 platform: "twitter".to_string(),
480 url: "https://twitter.com/circle".to_string(),
481 }],
482 dexscreener_url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
483 }
484 }
485
486 fn make_test_market_summary() -> MarketSummary {
487 use crate::market::{ExecutionEstimate, ExecutionSide};
488 MarketSummary {
489 pair: "USDC/USDT".to_string(),
490 peg_target: 1.0,
491 best_bid: Some(0.9999),
492 best_ask: Some(1.0001),
493 mid_price: Some(1.0),
494 spread: Some(0.0002),
495 volume_24h: Some(1_000_000.0),
496 execution_10k_buy: Some(ExecutionEstimate {
497 notional_usdt: 10_000.0,
498 side: ExecutionSide::Buy,
499 vwap: 1.0001,
500 slippage_bps: 1.0,
501 fillable: true,
502 }),
503 execution_10k_sell: Some(ExecutionEstimate {
504 notional_usdt: 10_000.0,
505 side: ExecutionSide::Sell,
506 vwap: 0.9999,
507 slippage_bps: 1.0,
508 fillable: true,
509 }),
510 asks: vec![],
511 bids: vec![],
512 ask_outliers: 0,
513 bid_outliers: 0,
514 ask_depth: 5000.0,
515 bid_depth: 6000.0,
516 checks: vec![
517 HealthCheck::Pass("No sells below peg".to_string()),
518 HealthCheck::Pass("Bid/Ask ratio: 1.20x".to_string()),
519 ],
520 healthy: true,
521 }
522 }
523
524 #[test]
525 fn test_is_dex_venue() {
526 assert!(is_dex_venue("eth"));
527 assert!(is_dex_venue("ethereum"));
528 assert!(is_dex_venue("solana"));
529 assert!(!is_dex_venue("binance"));
530 assert!(!is_dex_venue("okx"));
531 }
532
533 #[test]
534 fn test_is_dex_venue_values() {
535 assert!(is_dex_venue("eth"));
536 assert!(is_dex_venue("ethereum"));
537 assert!(is_dex_venue("solana"));
538 assert!(!is_dex_venue("binance"));
539 assert!(!is_dex_venue("mexc"));
540 }
541
542 #[test]
543 fn test_dex_venue_to_chain_values() {
544 assert_eq!(dex_venue_to_chain("eth"), "ethereum");
545 assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
546 assert_eq!(dex_venue_to_chain("solana"), "solana");
547 assert_eq!(dex_venue_to_chain("unknown"), "ethereum");
548 }
549
550 #[test]
551 fn test_token_health_args_debug() {
552 let args = TokenHealthArgs {
553 token: "USDC".to_string(),
554 chain: "ethereum".to_string(),
555 with_market: false,
556 venue: "binance".to_string(),
557 format: crate::config::OutputFormat::Table,
558 };
559 let debug = format!("{:?}", args);
560 assert!(debug.contains("TokenHealthArgs"));
561 }
562
563 #[test]
564 fn test_format_large_number() {
565 assert_eq!(
566 crate::display::format_large_number(1_500_000_000.0),
567 "1.50B"
568 );
569 assert_eq!(crate::display::format_large_number(2_500_000.0), "2.50M");
570 assert_eq!(crate::display::format_large_number(3_500.0), "3.50K");
571 assert_eq!(crate::display::format_large_number(99.99), "99.99");
572 }
573
574 #[test]
575 fn test_token_health_to_markdown_without_market() {
576 let analytics = make_test_analytics(false);
577 let md = token_health_to_markdown(&analytics, None, None);
578 assert!(md.contains("USDC"));
579 assert!(md.contains("USD Coin"));
580 assert!(!md.contains("Market / Order Book"));
581 }
582
583 #[test]
584 fn test_token_health_to_markdown_with_market() {
585 let analytics = make_test_analytics(false);
586 let market = make_test_market_summary();
587 let md = token_health_to_markdown(&analytics, Some(&market), Some("binance"));
588 assert!(md.contains("Market / Order Book"));
589 assert!(md.contains("binance"));
590 assert!(md.contains("0.9999"));
591 assert!(md.contains("Yes"));
592 assert!(md.contains("Health Checks"));
593 }
594
595 #[test]
596 fn test_token_health_to_markdown_without_venue() {
597 let analytics = make_test_analytics(false);
598 let market = make_test_market_summary();
599 let md = token_health_to_markdown(&analytics, Some(&market), None);
600 assert!(md.contains("Market / Order Book"));
601 assert!(!md.contains("Venue:")); assert!(md.contains("0.9999"));
603 assert!(md.contains("Yes"));
604 }
605
606 #[test]
607 fn test_token_health_to_markdown_unhealthy_market() {
608 let analytics = make_test_analytics(false);
609 let mut market = make_test_market_summary();
610 market.healthy = false;
611 market.checks = vec![
612 HealthCheck::Pass("Some check passed".to_string()),
613 HealthCheck::Fail("Peg deviation too high".to_string()),
614 HealthCheck::Fail("Insufficient bid depth".to_string()),
615 ];
616 let md = token_health_to_markdown(&analytics, Some(&market), Some("binance"));
617 assert!(md.contains("Market / Order Book"));
618 assert!(md.contains("No")); assert!(md.contains("Health Checks"));
620 assert!(md.contains("✗")); assert!(md.contains("Peg deviation too high"));
622 assert!(md.contains("Insufficient bid depth"));
623 }
624
625 #[test]
626 fn test_token_health_to_json_without_market() {
627 let analytics = make_test_analytics(false);
628 let json = token_health_to_json(&analytics, None).unwrap();
629 assert!(json.contains("\"analytics\""));
630 assert!(json.contains("\"market\": null"));
631 assert!(json.contains("USDC"));
632 }
633
634 #[test]
635 fn test_token_health_to_json_with_market() {
636 let analytics = make_test_analytics(false);
637 let market = make_test_market_summary();
638 let json = token_health_to_json(&analytics, Some(&market)).unwrap();
639 assert!(json.contains("\"market\""));
640 assert!(json.contains("\"peg_target\": 1.0"));
641 assert!(json.contains("\"healthy\": true"));
642 }
643
644 #[test]
645 fn test_token_health_to_json_with_fail_checks() {
646 let analytics = make_test_analytics(false);
647 let mut market = make_test_market_summary();
648 market.healthy = false;
649 market.checks = vec![
650 HealthCheck::Pass("Bid/Ask ratio OK".to_string()),
651 HealthCheck::Fail("Peg deviation exceeds threshold".to_string()),
652 HealthCheck::Fail("Ask depth below minimum".to_string()),
653 ];
654 let json = token_health_to_json(&analytics, Some(&market)).unwrap();
655 assert!(json.contains("\"market\""));
656 assert!(json.contains("\"healthy\": false"));
657 assert!(json.contains("\"status\": \"pass\""));
658 assert!(json.contains("\"status\": \"fail\""));
659 assert!(json.contains("Peg deviation exceeds threshold"));
660 assert!(json.contains("Ask depth below minimum"));
661 }
662
663 #[test]
664 fn test_output_token_health_table_without_market() {
665 let analytics = make_test_analytics(false);
666 let result = output_token_health_table(&analytics, None, None);
667 assert!(result.is_ok());
668 }
669
670 #[test]
671 fn test_output_token_health_table_with_market() {
672 let analytics = make_test_analytics(false);
673 let market = make_test_market_summary();
674 let result = output_token_health_table(&analytics, Some(&market), Some("biconomy"));
675 assert!(result.is_ok());
676 }
677
678 #[test]
679 fn test_output_token_health_table_no_market_cap() {
680 let mut analytics = make_test_analytics(false);
681 analytics.market_cap = None;
682 analytics.top_10_concentration = None;
683 let result = output_token_health_table(&analytics, None, None);
684 assert!(result.is_ok());
685 }
687
688 fn make_test_dex_token_data(pairs: Vec<DexPair>) -> DexTokenData {
689 DexTokenData {
690 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
691 symbol: "USDC".to_string(),
692 name: "USD Coin".to_string(),
693 price_usd: 0.9999,
694 price_change_24h: -0.01,
695 price_change_6h: 0.01,
696 price_change_1h: -0.005,
697 price_change_5m: 0.0,
698 volume_24h: 5_000_000.0,
699 volume_6h: 1_250_000.0,
700 volume_1h: 250_000.0,
701 liquidity_usd: 100_000_000.0,
702 market_cap: Some(30_000_000_000.0),
703 fdv: Some(30_000_000_000.0),
704 pairs,
705 price_history: vec![],
706 volume_history: vec![],
707 total_buys_24h: 1000,
708 total_sells_24h: 900,
709 total_buys_6h: 300,
710 total_sells_6h: 250,
711 total_buys_1h: 50,
712 total_sells_1h: 45,
713 earliest_pair_created_at: Some(1600000000),
714 image_url: None,
715 websites: vec![],
716 socials: vec![crate::chains::dex::TokenSocial {
717 platform: "twitter".to_string(),
718 url: "https://twitter.com/circle".to_string(),
719 }],
720 dexscreener_url: None,
721 }
722 }
723
724 #[tokio::test]
725 async fn test_run_token_health_table() {
726 let mut factory = MockClientFactory::new();
727 factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
728
729 let config = Config::default();
730 let args = TokenHealthArgs {
731 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
732 chain: "ethereum".to_string(),
733 with_market: false,
734 venue: "binance".to_string(),
735 format: OutputFormat::Table,
736 };
737
738 let result = run(args, &config, &factory).await;
739 assert!(result.is_ok());
740 }
741
742 #[tokio::test]
743 async fn test_run_token_health_json() {
744 let mut factory = MockClientFactory::new();
745 let mut data = make_test_dex_token_data(vec![]);
746 data.price_usd = 1.0;
747 data.volume_24h = 1_000_000.0;
748 data.liquidity_usd = 5_000_000.0;
749 factory.mock_dex.token_data = Some(data);
750
751 let config = Config::default();
752 let args = TokenHealthArgs {
753 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
754 chain: "ethereum".to_string(),
755 with_market: false,
756 venue: "binance".to_string(),
757 format: OutputFormat::Json,
758 };
759
760 let result = run(args, &config, &factory).await;
761 assert!(result.is_ok());
762 }
763
764 #[tokio::test]
765 async fn test_run_token_health_markdown() {
766 let mut factory = MockClientFactory::new();
767 factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
768
769 let config = Config::default();
770 let args = TokenHealthArgs {
771 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
772 chain: "ethereum".to_string(),
773 with_market: false,
774 venue: "binance".to_string(),
775 format: OutputFormat::Markdown,
776 };
777
778 let result = run(args, &config, &factory).await;
779 assert!(result.is_ok());
780 }
781
782 #[tokio::test]
784 async fn test_run_token_health_dex_market() {
785 let mut factory = MockClientFactory::new();
786 let pair = DexPair {
787 dex_name: "Uniswap V3".to_string(),
788 pair_address: "0xpair".to_string(),
789 base_token: "USDC".to_string(),
790 quote_token: "WETH".to_string(),
791 price_usd: 0.9999,
792 volume_24h: 5_000_000.0,
793 liquidity_usd: 50_000_000.0,
794 price_change_24h: -0.01,
795 buys_24h: 1000,
796 sells_24h: 900,
797 buys_6h: 300,
798 sells_6h: 250,
799 buys_1h: 50,
800 sells_1h: 45,
801 pair_created_at: Some(1600000000),
802 url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
803 };
804 factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![pair]));
805
806 let config = Config::default();
807 let args = TokenHealthArgs {
808 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
809 chain: "ethereum".to_string(),
810 with_market: true,
811 venue: "eth".to_string(),
812 format: OutputFormat::Table,
813 };
814
815 let result = run(args, &config, &factory).await;
816 assert!(result.is_ok());
817 }
818}