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::{
13 BinanceClient, HealthThresholds, MarketSummary, MarketVenue, order_book_from_analytics,
14};
15use clap::Args;
16
17#[derive(Debug, Args)]
19pub struct TokenHealthArgs {
20 pub token: String,
22
23 #[arg(short, long, default_value = "ethereum")]
25 pub chain: String,
26
27 #[arg(long)]
29 pub with_market: bool,
30
31 #[arg(long, default_value = "binance")]
34 pub market_venue: MarketVenue,
35
36 #[arg(short, long, default_value = "table")]
38 pub format: crate::config::OutputFormat,
39}
40
41pub async fn run(
43 args: TokenHealthArgs,
44 config: &Config,
45 clients: &dyn ChainClientFactory,
46) -> Result<()> {
47 let format = if config.output.format == crate::config::OutputFormat::Markdown {
49 config.output.format
50 } else {
51 args.format
52 };
53 let analytics =
55 crawl::fetch_analytics_for_input(&args.token, &args.chain, Period::Hour24, 10, clients)
56 .await?;
57
58 let market_summary = if args.with_market {
60 let thresholds = HealthThresholds {
61 peg_target: 1.0,
62 peg_range: 0.001,
63 min_levels: 6,
64 min_depth: 3000.0,
65 min_bid_ask_ratio: 0.2,
66 max_bid_ask_ratio: 5.0,
67 };
68 if args.market_venue.is_cex() {
69 let pair = args.market_venue.format_pair(&analytics.token.symbol);
70 if let Some(client) = args.market_venue.create_client() {
71 match client.fetch_order_book(&pair).await {
72 Ok(book) => {
73 let volume_24h = match args.market_venue {
74 MarketVenue::Binance => BinanceClient::default_url()
75 .fetch_24h_volume(&pair)
76 .await
77 .ok()
78 .flatten(),
79 _ => None,
80 };
81 Some(MarketSummary::from_order_book(
82 &book,
83 1.0,
84 &thresholds,
85 volume_24h,
86 ))
87 }
88 Err(e) => {
89 tracing::warn!(
90 "Market data unavailable for {} on {:?}: {}",
91 pair,
92 args.market_venue,
93 e
94 );
95 None
96 }
97 }
98 } else {
99 None
100 }
101 } else {
102 let venue_chain = match args.market_venue {
104 MarketVenue::Ethereum => "ethereum",
105 MarketVenue::Solana => "solana",
106 _ => &analytics.chain,
107 };
108 if analytics.chain.eq_ignore_ascii_case(venue_chain) && !analytics.dex_pairs.is_empty()
109 {
110 let best_pair = analytics
111 .dex_pairs
112 .iter()
113 .max_by(|a, b| {
114 a.liquidity_usd
115 .partial_cmp(&b.liquidity_usd)
116 .unwrap_or(std::cmp::Ordering::Equal)
117 })
118 .unwrap(); let book =
120 order_book_from_analytics(&analytics.chain, best_pair, &analytics.token.symbol);
121 let volume_24h = Some(best_pair.volume_24h);
122 Some(MarketSummary::from_order_book(
123 &book,
124 1.0,
125 &thresholds,
126 volume_24h,
127 ))
128 } else {
129 if analytics.chain.ne(venue_chain) {
130 tracing::warn!(
131 "DEX venue {:?} requires --chain {}; got {}. Use matching chain for DEX depth.",
132 args.market_venue,
133 venue_chain,
134 analytics.chain
135 );
136 } else if analytics.dex_pairs.is_empty() {
137 tracing::warn!(
138 "No DEX pairs found for {} on {}",
139 analytics.token.symbol,
140 analytics.chain
141 );
142 }
143 None
144 }
145 }
146 } else {
147 None
148 };
149
150 match format {
152 crate::config::OutputFormat::Markdown => {
153 let venue_label = args.with_market.then_some(args.market_venue);
154 let md = token_health_to_markdown(&analytics, market_summary.as_ref(), venue_label);
155 println!("{}", md);
156 }
157 crate::config::OutputFormat::Json => {
158 let json = token_health_to_json(&analytics, market_summary.as_ref())?;
159 println!("{}", json);
160 }
161 crate::config::OutputFormat::Table | crate::config::OutputFormat::Csv => {
162 let venue_label = args.with_market.then_some(args.market_venue);
163 output_token_health_table(&analytics, market_summary.as_ref(), venue_label)?;
164 }
165 }
166
167 Ok(())
168}
169
170fn token_health_to_markdown(
171 analytics: &TokenAnalytics,
172 market: Option<&MarketSummary>,
173 venue: Option<MarketVenue>,
174) -> String {
175 let mut md = report::generate_report(analytics);
177
178 if let Some(summary) = market {
179 md.push_str("\n---\n\n");
180 md.push_str("## Market / Order Book\n\n");
181 if let Some(v) = venue {
182 md.push_str(&format!("**Venue:** {} \n\n", format_venue(v)));
183 }
184 md.push_str(&format!(
185 "| Metric | Value |\n|--------|-------|\n\
186 | Peg Target | {:.4} |\n\
187 | Best Bid | {} |\n\
188 | Best Ask | {} |\n\
189 | Mid Price | {} |\n\
190 | Spread | {} |\n\
191 | Bid Depth | {:.0} |\n\
192 | Ask Depth | {:.0} |\n\
193 | Healthy | {} |\n",
194 summary.peg_target,
195 summary
196 .best_bid
197 .map(|b| format!("{:.4}", b))
198 .unwrap_or_else(|| "-".to_string()),
199 summary
200 .best_ask
201 .map(|a| format!("{:.4}", a))
202 .unwrap_or_else(|| "-".to_string()),
203 summary
204 .mid_price
205 .map(|m| format!("{:.4}", m))
206 .unwrap_or_else(|| "-".to_string()),
207 summary
208 .spread
209 .map(|s| format!("{:.4}", s))
210 .unwrap_or_else(|| "-".to_string()),
211 summary.bid_depth,
212 summary.ask_depth,
213 if summary.healthy { "Yes" } else { "No" }
214 ));
215 if !summary.checks.is_empty() {
216 md.push_str("\n**Health Checks:**\n");
217 for check in &summary.checks {
218 let (icon, msg) = match check {
219 crate::market::HealthCheck::Pass(m) => ("✓", m.as_str()),
220 crate::market::HealthCheck::Fail(m) => ("✗", m.as_str()),
221 };
222 md.push_str(&format!("- {} {}\n", icon, msg));
223 }
224 }
225 }
226
227 md.push_str(&report::report_footer());
228 md
229}
230
231fn token_health_to_json(
232 analytics: &TokenAnalytics,
233 market: Option<&MarketSummary>,
234) -> Result<String> {
235 let market_json = market.map(|m| {
236 serde_json::json!({
237 "peg_target": m.peg_target,
238 "best_bid": m.best_bid,
239 "best_ask": m.best_ask,
240 "mid_price": m.mid_price,
241 "spread": m.spread,
242 "bid_depth": m.bid_depth,
243 "ask_depth": m.ask_depth,
244 "healthy": m.healthy,
245 "checks": m.checks.iter().map(|c| match c {
246 crate::market::HealthCheck::Pass(msg) => serde_json::json!({"status": "pass", "message": msg}),
247 crate::market::HealthCheck::Fail(msg) => serde_json::json!({"status": "fail", "message": msg}),
248 }).collect::<Vec<_>>()
249 })
250 });
251 let json = serde_json::json!({
252 "analytics": analytics,
253 "market": market_json
254 });
255 serde_json::to_string_pretty(&json).map_err(|e| ScopeError::Other(e.to_string()))
256}
257
258fn format_venue(venue: MarketVenue) -> &'static str {
259 match venue {
260 MarketVenue::Binance => "Binance Spot",
261 MarketVenue::Biconomy => "Biconomy",
262 MarketVenue::Ethereum => "Ethereum DEX",
263 MarketVenue::Solana => "Solana DEX",
264 }
265}
266
267fn output_token_health_table(
268 analytics: &TokenAnalytics,
269 market: Option<&MarketSummary>,
270 venue: Option<MarketVenue>,
271) -> Result<()> {
272 println!(
274 "\n# Token Health: {} ({})\n",
275 analytics.token.symbol, analytics.token.name
276 );
277 println!("## DEX Analytics");
278 println!("{}", "=".repeat(50));
279 println!("Price: ${:.6}", analytics.price_usd);
280 println!("24h Change: {:+.2}%", analytics.price_change_24h);
281 println!(
282 "24h Volume: ${}",
283 format_large_number(analytics.volume_24h)
284 );
285 println!(
286 "Liquidity: ${}",
287 format_large_number(analytics.liquidity_usd)
288 );
289 if let Some(mc) = analytics.market_cap {
290 println!("Market Cap: ${}", format_large_number(mc));
291 }
292 if let Some(top10) = analytics.top_10_concentration {
293 println!("Top 10 Holders: {:.1}%", top10);
294 }
295
296 if let Some(summary) = market {
297 println!();
298 println!("## Market / Order Book");
299 println!("{}", "=".repeat(50));
300 if let Some(v) = venue {
301 println!("Venue: {}", format_venue(v));
302 }
303 println!("Peg Target: {:.4}", summary.peg_target);
304 if let Some(b) = summary.best_bid {
305 println!("Best Bid: {:.4}", b);
306 }
307 if let Some(a) = summary.best_ask {
308 println!("Best Ask: {:.4}", a);
309 }
310 if let Some(m) = summary.mid_price {
311 println!("Mid Price: {:.4}", m);
312 }
313 println!("Bid Depth: {:.0}", summary.bid_depth);
314 println!("Ask Depth: {:.0}", summary.ask_depth);
315 println!(
316 "Healthy: {}",
317 if summary.healthy { "Yes" } else { "No" }
318 );
319 for check in &summary.checks {
320 let (icon, msg) = match check {
321 crate::market::HealthCheck::Pass(m) => ("✓", m.as_str()),
322 crate::market::HealthCheck::Fail(m) => ("✗", m.as_str()),
323 };
324 println!(" {} {}", icon, msg);
325 }
326 }
327
328 println!();
329 Ok(())
330}
331
332fn format_large_number(value: f64) -> String {
333 if value >= 1_000_000_000.0 {
334 format!("{:.2}B", value / 1_000_000_000.0)
335 } else if value >= 1_000_000.0 {
336 format!("{:.2}M", value / 1_000_000.0)
337 } else if value >= 1_000.0 {
338 format!("{:.2}K", value / 1_000.0)
339 } else {
340 format!("{:.2}", value)
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use crate::chains::dex::DexTokenData;
348 use crate::chains::mocks::MockClientFactory;
349 use crate::chains::{DexPair, Token, TokenAnalytics, TokenHolder, TokenSocial};
350 use crate::config::OutputFormat;
351 use crate::market::{HealthCheck, MarketSummary};
352
353 fn make_test_analytics(with_dex_pairs: bool) -> TokenAnalytics {
354 TokenAnalytics {
355 token: Token {
356 contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
357 symbol: "USDC".to_string(),
358 name: "USD Coin".to_string(),
359 decimals: 6,
360 },
361 chain: "ethereum".to_string(),
362 holders: vec![TokenHolder {
363 address: "0x1234".to_string(),
364 balance: "1000000".to_string(),
365 formatted_balance: "1.0".to_string(),
366 percentage: 10.0,
367 rank: 1,
368 }],
369 total_holders: 1000,
370 volume_24h: 5_000_000.0,
371 volume_7d: 25_000_000.0,
372 price_usd: 0.9999,
373 price_change_24h: -0.01,
374 price_change_7d: 0.02,
375 liquidity_usd: 100_000_000.0,
376 market_cap: Some(30_000_000_000.0),
377 fdv: None,
378 total_supply: None,
379 circulating_supply: None,
380 price_history: vec![],
381 volume_history: vec![],
382 holder_history: vec![],
383 dex_pairs: if with_dex_pairs {
384 vec![DexPair {
385 dex_name: "Uniswap V3".to_string(),
386 pair_address: "0xpair".to_string(),
387 base_token: "USDC".to_string(),
388 quote_token: "WETH".to_string(),
389 price_usd: 0.9999,
390 volume_24h: 5_000_000.0,
391 liquidity_usd: 50_000_000.0,
392 price_change_24h: -0.01,
393 buys_24h: 1000,
394 sells_24h: 900,
395 buys_6h: 300,
396 sells_6h: 250,
397 buys_1h: 50,
398 sells_1h: 45,
399 pair_created_at: Some(1600000000),
400 url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
401 }]
402 } else {
403 vec![]
404 },
405 fetched_at: 1700003600,
406 top_10_concentration: Some(35.5),
407 top_50_concentration: Some(55.0),
408 top_100_concentration: Some(65.0),
409 price_change_6h: 0.01,
410 price_change_1h: -0.005,
411 total_buys_24h: 1000,
412 total_sells_24h: 900,
413 total_buys_6h: 300,
414 total_sells_6h: 250,
415 total_buys_1h: 50,
416 total_sells_1h: 45,
417 token_age_hours: Some(25000.0),
418 image_url: None,
419 websites: vec!["https://centre.io".to_string()],
420 socials: vec![TokenSocial {
421 platform: "twitter".to_string(),
422 url: "https://twitter.com/circle".to_string(),
423 }],
424 dexscreener_url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
425 }
426 }
427
428 fn make_test_market_summary() -> MarketSummary {
429 use crate::market::{ExecutionEstimate, ExecutionSide};
430 MarketSummary {
431 pair: "USDC/USDT".to_string(),
432 peg_target: 1.0,
433 best_bid: Some(0.9999),
434 best_ask: Some(1.0001),
435 mid_price: Some(1.0),
436 spread: Some(0.0002),
437 volume_24h: Some(1_000_000.0),
438 execution_10k_buy: Some(ExecutionEstimate {
439 notional_usdt: 10_000.0,
440 side: ExecutionSide::Buy,
441 vwap: 1.0001,
442 slippage_bps: 1.0,
443 fillable: true,
444 }),
445 execution_10k_sell: Some(ExecutionEstimate {
446 notional_usdt: 10_000.0,
447 side: ExecutionSide::Sell,
448 vwap: 0.9999,
449 slippage_bps: 1.0,
450 fillable: true,
451 }),
452 asks: vec![],
453 bids: vec![],
454 ask_outliers: 0,
455 bid_outliers: 0,
456 ask_depth: 5000.0,
457 bid_depth: 6000.0,
458 checks: vec![
459 HealthCheck::Pass("No sells below peg".to_string()),
460 HealthCheck::Pass("Bid/Ask ratio: 1.20x".to_string()),
461 ],
462 healthy: true,
463 }
464 }
465
466 #[test]
467 fn test_format_venue() {
468 assert_eq!(format_venue(MarketVenue::Binance), "Binance Spot");
469 assert_eq!(format_venue(MarketVenue::Biconomy), "Biconomy");
470 assert_eq!(format_venue(MarketVenue::Ethereum), "Ethereum DEX");
471 assert_eq!(format_venue(MarketVenue::Solana), "Solana DEX");
472 }
473
474 #[test]
475 fn test_format_large_number() {
476 assert_eq!(format_large_number(1_500_000_000.0), "1.50B");
477 assert_eq!(format_large_number(2_500_000.0), "2.50M");
478 assert_eq!(format_large_number(3_500.0), "3.50K");
479 assert_eq!(format_large_number(99.99), "99.99");
480 }
481
482 #[test]
483 fn test_token_health_to_markdown_without_market() {
484 let analytics = make_test_analytics(false);
485 let md = token_health_to_markdown(&analytics, None, None);
486 assert!(md.contains("USDC"));
487 assert!(md.contains("USD Coin"));
488 assert!(!md.contains("Market / Order Book"));
489 }
490
491 #[test]
492 fn test_token_health_to_markdown_with_market() {
493 let analytics = make_test_analytics(false);
494 let market = make_test_market_summary();
495 let md = token_health_to_markdown(&analytics, Some(&market), Some(MarketVenue::Binance));
496 assert!(md.contains("Market / Order Book"));
497 assert!(md.contains("Binance Spot"));
498 assert!(md.contains("0.9999"));
499 assert!(md.contains("Yes"));
500 assert!(md.contains("Health Checks"));
501 }
502
503 #[test]
504 fn test_token_health_to_json_without_market() {
505 let analytics = make_test_analytics(false);
506 let json = token_health_to_json(&analytics, None).unwrap();
507 assert!(json.contains("\"analytics\""));
508 assert!(json.contains("\"market\": null"));
509 assert!(json.contains("USDC"));
510 }
511
512 #[test]
513 fn test_token_health_to_json_with_market() {
514 let analytics = make_test_analytics(false);
515 let market = make_test_market_summary();
516 let json = token_health_to_json(&analytics, Some(&market)).unwrap();
517 assert!(json.contains("\"market\""));
518 assert!(json.contains("\"peg_target\": 1.0"));
519 assert!(json.contains("\"healthy\": true"));
520 }
521
522 #[test]
523 fn test_output_token_health_table_without_market() {
524 let analytics = make_test_analytics(false);
525 let result = output_token_health_table(&analytics, None, None);
526 assert!(result.is_ok());
527 }
528
529 #[test]
530 fn test_output_token_health_table_with_market() {
531 let analytics = make_test_analytics(false);
532 let market = make_test_market_summary();
533 let result =
534 output_token_health_table(&analytics, Some(&market), Some(MarketVenue::Biconomy));
535 assert!(result.is_ok());
536 }
537
538 fn make_test_dex_token_data(pairs: Vec<DexPair>) -> DexTokenData {
539 DexTokenData {
540 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
541 symbol: "USDC".to_string(),
542 name: "USD Coin".to_string(),
543 price_usd: 0.9999,
544 price_change_24h: -0.01,
545 price_change_6h: 0.01,
546 price_change_1h: -0.005,
547 price_change_5m: 0.0,
548 volume_24h: 5_000_000.0,
549 volume_6h: 1_250_000.0,
550 volume_1h: 250_000.0,
551 liquidity_usd: 100_000_000.0,
552 market_cap: Some(30_000_000_000.0),
553 fdv: Some(30_000_000_000.0),
554 pairs,
555 price_history: vec![],
556 volume_history: vec![],
557 total_buys_24h: 1000,
558 total_sells_24h: 900,
559 total_buys_6h: 300,
560 total_sells_6h: 250,
561 total_buys_1h: 50,
562 total_sells_1h: 45,
563 earliest_pair_created_at: Some(1600000000),
564 image_url: None,
565 websites: vec![],
566 socials: vec![crate::chains::dex::TokenSocial {
567 platform: "twitter".to_string(),
568 url: "https://twitter.com/circle".to_string(),
569 }],
570 dexscreener_url: None,
571 }
572 }
573
574 #[tokio::test]
575 async fn test_run_token_health_table() {
576 let mut factory = MockClientFactory::new();
577 factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
578
579 let config = Config::default();
580 let args = TokenHealthArgs {
581 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
582 chain: "ethereum".to_string(),
583 with_market: false,
584 market_venue: MarketVenue::Binance,
585 format: OutputFormat::Table,
586 };
587
588 let result = run(args, &config, &factory).await;
589 assert!(result.is_ok());
590 }
591
592 #[tokio::test]
593 async fn test_run_token_health_json() {
594 let mut factory = MockClientFactory::new();
595 let mut data = make_test_dex_token_data(vec![]);
596 data.price_usd = 1.0;
597 data.volume_24h = 1_000_000.0;
598 data.liquidity_usd = 5_000_000.0;
599 factory.mock_dex.token_data = Some(data);
600
601 let config = Config::default();
602 let args = TokenHealthArgs {
603 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
604 chain: "ethereum".to_string(),
605 with_market: false,
606 market_venue: MarketVenue::Binance,
607 format: OutputFormat::Json,
608 };
609
610 let result = run(args, &config, &factory).await;
611 assert!(result.is_ok());
612 }
613
614 #[tokio::test]
615 async fn test_run_token_health_markdown() {
616 let mut factory = MockClientFactory::new();
617 factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
618
619 let config = Config::default();
620 let args = TokenHealthArgs {
621 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
622 chain: "ethereum".to_string(),
623 with_market: false,
624 market_venue: MarketVenue::Binance,
625 format: OutputFormat::Markdown,
626 };
627
628 let result = run(args, &config, &factory).await;
629 assert!(result.is_ok());
630 }
631
632 #[tokio::test]
634 async fn test_run_token_health_dex_market() {
635 let mut factory = MockClientFactory::new();
636 let pair = DexPair {
637 dex_name: "Uniswap V3".to_string(),
638 pair_address: "0xpair".to_string(),
639 base_token: "USDC".to_string(),
640 quote_token: "WETH".to_string(),
641 price_usd: 0.9999,
642 volume_24h: 5_000_000.0,
643 liquidity_usd: 50_000_000.0,
644 price_change_24h: -0.01,
645 buys_24h: 1000,
646 sells_24h: 900,
647 buys_6h: 300,
648 sells_6h: 250,
649 buys_1h: 50,
650 sells_1h: 45,
651 pair_created_at: Some(1600000000),
652 url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
653 };
654 factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![pair]));
655
656 let config = Config::default();
657 let args = TokenHealthArgs {
658 token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
659 chain: "ethereum".to_string(),
660 with_market: true,
661 market_venue: MarketVenue::Ethereum,
662 format: OutputFormat::Table,
663 };
664
665 let result = run(args, &config, &factory).await;
666 assert!(result.is_ok());
667 }
668}