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