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