Skip to main content

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