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