Skip to main content

scope/cli/
token_health.rs

1//! # Token Health Command
2//!
3//! Composite command combining DEX analytics (crawl) with optional market/order book
4//! summary for stablecoins. Produces a unified health report: liquidity, volume,
5//! peg deviation, and order book depth.
6
7use 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/// Arguments for the token-health command.
18#[derive(Debug, Args)]
19pub struct TokenHealthArgs {
20    /// Token symbol or contract address (e.g., USDC, 0xA0b86991...).
21    pub token: String,
22
23    /// Target blockchain network.
24    #[arg(short, long, default_value = "ethereum")]
25    pub chain: String,
26
27    /// Include order book / market summary (for stablecoins).
28    #[arg(long)]
29    pub with_market: bool,
30
31    /// Market venue for order book data: binance, biconomy, eth, solana.
32    /// CEX: binance=USDCUSDT, biconomy=USDC_USDT. DEX (eth/solana): uses chain liquidity.
33    #[arg(long, default_value = "binance")]
34    pub market_venue: MarketVenue,
35
36    /// Output format.
37    #[arg(short, long, default_value = "table")]
38    pub format: crate::config::OutputFormat,
39}
40
41/// Runs the token-health command.
42pub async fn run(
43    args: TokenHealthArgs,
44    config: &Config,
45    clients: &dyn ChainClientFactory,
46) -> Result<()> {
47    // --ai sets config.output.format to Markdown; respect that override
48    let format = if config.output.format == crate::config::OutputFormat::Markdown {
49        config.output.format
50    } else {
51        args.format
52    };
53    // 1. Fetch DEX analytics (crawl)
54    let analytics =
55        crawl::fetch_analytics_for_input(&args.token, &args.chain, Period::Hour24, 10, clients)
56            .await?;
57
58    // 2. Optionally fetch market summary for stablecoin
59    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            // DEX venues: synthesize from analytics (only when chain matches venue)
103            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(); // safe: we checked !is_empty
119                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    // 3. Output combined report
151    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    // Use crawl's full report as base
176    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    // DEX section
273    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    /// Test DEX venue with dex_pairs: synthesizes order book from analytics.
633    #[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}