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 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/// Arguments for the token-health command.
20#[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    /// Token symbol or contract address (e.g., USDC, 0xA0b86991...).
67    /// Use @label to resolve from the address book (e.g., @dai-token).
68    pub token: String,
69
70    /// Target blockchain network.
71    #[arg(short, long, default_value = scope::chains::DEFAULT_CHAIN)]
72    pub chain: String,
73
74    /// Include order book / market summary (for stablecoins).
75    #[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    /// Output format.
82    #[arg(short, long, default_value = "table")]
83    pub format: scope::config::OutputFormat,
84
85    /// Health threshold overrides for --with-market.
86    #[command(flatten)]
87    pub health: HealthArgs,
88}
89
90/// Runs the token-health command.
91pub async fn run(
92    mut args: TokenHealthArgs,
93    config: &Config,
94    clients: &dyn ChainClientFactory,
95    out: &Output,
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 == scope::chains::DEFAULT_CHAIN {
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...", 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    // 2. Optionally fetch market summary for stablecoin
127    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    // 3. Output combined report
145    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    // Use crawl's full report as base
173    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    // DEX Analytics subsection
209    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    // Market / Order Book subsection
259    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        // Health checks
310        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        // An unknown name is not a DEX venue; it used to map to ethereum.
486        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:")); // Should not include venue when None
542        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 | ✗ |")); // Should show unhealthy
559        assert!(md.contains("Health Checks"));
560        assert!(md.contains("✗")); // Should show fail checks
561        assert!(md.contains("Peg deviation too high"));
562        assert!(md.contains("Insufficient bid depth"));
563    }
564
565    /// token-health and market share one chain rule: a DEX venue never builds
566    /// a book from another chain's pools, and `sol` names the Solana DEX.
567    #[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        // An unknown name is the one unknown-venue error (a warning here).
581        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        // Should not panic when market_cap and top_10_concentration are None
649    }
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    /// Test DEX venue with dex_pairs: synthesizes order book from analytics.
749    #[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    /// Several pools: the market section picks the deepest one.
821    #[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    /// Missing market data only warns: the token report must still succeed.
840    #[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        // Token is on ethereum; the solana venue cannot price it.
846        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    /// No DEX pools only warns: the token report must still succeed.
857    #[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("-")); // Dash for missing prices in table
905    }
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:")); // Empty checks = no section
915    }
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, // Args say Table, config overrides to Markdown
1008            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")); // None serializes as null
1026    }
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}