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