Skip to main content

scope/cli/
token_health.rs

1//! # Token Health Command
2//!
3//! Composite command combining DEX analytics (crawl) with optional market/order book
4//! summary for stablecoins. Produces a unified health report: liquidity, volume,
5//! peg deviation, and order book depth.
6
7use crate::chains::{ChainClientFactory, TokenAnalytics};
8use crate::cli::crawl::{self, Period};
9use crate::config::Config;
10use crate::display::report;
11use crate::error::{Result, ScopeError};
12use crate::market::{HealthThresholds, MarketSummary, VenueRegistry, order_book_from_analytics};
13use clap::Args;
14
15/// Arguments for the token-health command.
16#[derive(Debug, Args)]
17pub struct TokenHealthArgs {
18    /// Token symbol or contract address (e.g., USDC, 0xA0b86991...).
19    pub token: String,
20
21    /// Target blockchain network.
22    #[arg(short, long, default_value = "ethereum")]
23    pub chain: String,
24
25    /// Include order book / market summary (for stablecoins).
26    #[arg(long)]
27    pub with_market: bool,
28
29    /// Market venue for order book data (e.g., binance, mexc, okx, eth, solana).
30    /// CEX venues use the venue registry; "eth"/"solana" use DEX liquidity.
31    /// Run `scope venues list` to see available venues.
32    #[arg(long, default_value = "binance")]
33    pub venue: String,
34
35    /// Output format.
36    #[arg(short, long, default_value = "table")]
37    pub format: crate::config::OutputFormat,
38}
39
40/// Runs the token-health command.
41pub async fn run(
42    mut args: TokenHealthArgs,
43    config: &Config,
44    clients: &dyn ChainClientFactory,
45) -> Result<()> {
46    // Resolve address book label → address + chain
47    if let Some((address, chain)) =
48        crate::cli::address_book::resolve_address_book_input(&args.token, config)?
49    {
50        args.token = address;
51        if args.chain == "ethereum" {
52            args.chain = chain;
53        }
54    }
55
56    // --ai sets config.output.format to Markdown; respect that override
57    let format = if config.output.format == crate::config::OutputFormat::Markdown {
58        config.output.format
59    } else {
60        args.format
61    };
62    // 1. Fetch DEX analytics (crawl)
63    let sp = crate::cli::progress::Spinner::new("Fetching token health data...");
64    let analytics =
65        crawl::fetch_analytics_for_input(&args.token, &args.chain, Period::Hour24, 10, clients)
66            .await?;
67
68    // 2. Optionally fetch market summary for stablecoin
69    let market_summary = if args.with_market {
70        sp.set_message("Fetching market data...");
71        let thresholds = HealthThresholds {
72            peg_target: 1.0,
73            peg_range: 0.001,
74            min_levels: 6,
75            min_depth: 3000.0,
76            min_bid_ask_ratio: 0.2,
77            max_bid_ask_ratio: 5.0,
78        };
79        if is_dex_venue(&args.venue) {
80            // DEX venues: synthesize from analytics (only when chain matches venue)
81            let venue_chain = dex_venue_to_chain(&args.venue);
82            if analytics.chain.eq_ignore_ascii_case(venue_chain) && !analytics.dex_pairs.is_empty()
83            {
84                let best_pair = analytics
85                    .dex_pairs
86                    .iter()
87                    .max_by(|a, b| {
88                        a.liquidity_usd
89                            .partial_cmp(&b.liquidity_usd)
90                            .unwrap_or(std::cmp::Ordering::Equal)
91                    })
92                    .unwrap();
93                let book =
94                    order_book_from_analytics(&analytics.chain, best_pair, &analytics.token.symbol);
95                let volume_24h = Some(best_pair.volume_24h);
96                Some(MarketSummary::from_order_book(
97                    &book,
98                    1.0,
99                    &thresholds,
100                    volume_24h,
101                ))
102            } else {
103                if analytics.chain.ne(venue_chain) {
104                    eprintln!(
105                        "  Warning: DEX venue '{}' requires --chain {} (got {})",
106                        args.venue, venue_chain, analytics.chain
107                    );
108                } else if analytics.dex_pairs.is_empty() {
109                    eprintln!(
110                        "  Warning: No DEX pairs found for {} on {}",
111                        analytics.token.symbol, analytics.chain
112                    );
113                }
114                None
115            }
116        } else {
117            // CEX venues: use VenueRegistry + ExchangeClient
118            match VenueRegistry::load().and_then(|r| r.create_exchange_client(&args.venue)) {
119                Ok(exchange) => {
120                    let pair = exchange.format_pair(&analytics.token.symbol);
121                    match exchange.fetch_order_book(&pair).await {
122                        Ok(book) => {
123                            let volume_24h = if exchange.has_ticker() {
124                                exchange
125                                    .fetch_ticker(&pair)
126                                    .await
127                                    .ok()
128                                    .and_then(|t| t.quote_volume_24h.or(t.volume_24h))
129                            } else {
130                                None
131                            };
132                            Some(MarketSummary::from_order_book(
133                                &book,
134                                1.0,
135                                &thresholds,
136                                volume_24h,
137                            ))
138                        }
139                        Err(e) => {
140                            eprintln!(
141                                "  Warning: Market data unavailable for {} on {}",
142                                analytics.token.symbol, args.venue
143                            );
144                            tracing::debug!("Market data error: {}", e);
145                            None
146                        }
147                    }
148                }
149                Err(e) => {
150                    eprintln!("  Warning: {}", e);
151                    None
152                }
153            }
154        }
155    } else {
156        None
157    };
158
159    sp.finish("Token health data loaded.");
160
161    // 3. Output combined report
162    let venue_label = if args.with_market {
163        Some(args.venue.as_str())
164    } else {
165        None
166    };
167    match format {
168        crate::config::OutputFormat::Markdown => {
169            let md = token_health_to_markdown(&analytics, market_summary.as_ref(), venue_label);
170            println!("{}", md);
171        }
172        crate::config::OutputFormat::Json => {
173            let json = token_health_to_json(&analytics, market_summary.as_ref())?;
174            println!("{}", json);
175        }
176        crate::config::OutputFormat::Table | crate::config::OutputFormat::Csv => {
177            output_token_health_table(&analytics, market_summary.as_ref(), venue_label)?;
178        }
179    }
180
181    Ok(())
182}
183
184/// Whether the venue string refers to a DEX venue.
185fn is_dex_venue(venue: &str) -> bool {
186    matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
187}
188
189/// Resolve DEX venue name to a canonical chain name.
190fn dex_venue_to_chain(venue: &str) -> &str {
191    match venue.to_lowercase().as_str() {
192        "ethereum" | "eth" => "ethereum",
193        "solana" => "solana",
194        _ => "ethereum",
195    }
196}
197
198fn token_health_to_markdown(
199    analytics: &TokenAnalytics,
200    market: Option<&MarketSummary>,
201    venue: Option<&str>,
202) -> String {
203    // Use crawl's full report as base
204    let mut md = report::generate_report(analytics);
205
206    if let Some(summary) = market {
207        md.push_str("\n---\n\n");
208        md.push_str("## Market / Order Book\n\n");
209        if let Some(v) = venue {
210            md.push_str(&format!("**Venue:** {}  \n\n", v));
211        }
212        md.push_str(&format!(
213            "| Metric | Value |\n|--------|-------|\n\
214             | Peg Target | {:.4} |\n\
215             | Best Bid | {} |\n\
216             | Best Ask | {} |\n\
217             | Mid Price | {} |\n\
218             | Spread | {} |\n\
219             | Bid Depth | {:.0} |\n\
220             | Ask Depth | {:.0} |\n\
221             | Healthy | {} |\n",
222            summary.peg_target,
223            summary
224                .best_bid
225                .map(|b| format!("{:.4}", b))
226                .unwrap_or_else(|| "-".to_string()),
227            summary
228                .best_ask
229                .map(|a| format!("{:.4}", a))
230                .unwrap_or_else(|| "-".to_string()),
231            summary
232                .mid_price
233                .map(|m| format!("{:.4}", m))
234                .unwrap_or_else(|| "-".to_string()),
235            summary
236                .spread
237                .map(|s| format!("{:.4}", s))
238                .unwrap_or_else(|| "-".to_string()),
239            summary.bid_depth,
240            summary.ask_depth,
241            if summary.healthy { "Yes" } else { "No" }
242        ));
243        if !summary.checks.is_empty() {
244            md.push_str("\n**Health Checks:**\n");
245            for check in &summary.checks {
246                let (icon, msg) = match check {
247                    crate::market::HealthCheck::Pass(m) => ("✓", m.as_str()),
248                    crate::market::HealthCheck::Fail(m) => ("✗", m.as_str()),
249                };
250                md.push_str(&format!("- {} {}\n", icon, msg));
251            }
252        }
253    }
254
255    md.push_str(&report::report_footer());
256    md
257}
258
259fn token_health_to_json(
260    analytics: &TokenAnalytics,
261    market: Option<&MarketSummary>,
262) -> Result<String> {
263    let market_json = market.map(|m| {
264        serde_json::json!({
265            "peg_target": m.peg_target,
266            "best_bid": m.best_bid,
267            "best_ask": m.best_ask,
268            "mid_price": m.mid_price,
269            "spread": m.spread,
270            "bid_depth": m.bid_depth,
271            "ask_depth": m.ask_depth,
272            "healthy": m.healthy,
273            "checks": m.checks.iter().map(|c| match c {
274                crate::market::HealthCheck::Pass(msg) => serde_json::json!({"status": "pass", "message": msg}),
275                crate::market::HealthCheck::Fail(msg) => serde_json::json!({"status": "fail", "message": msg}),
276            }).collect::<Vec<_>>()
277        })
278    });
279    let json = serde_json::json!({
280        "analytics": analytics,
281        "market": market_json
282    });
283    serde_json::to_string_pretty(&json).map_err(|e| ScopeError::Other(e.to_string()))
284}
285
286fn output_token_health_table(
287    analytics: &TokenAnalytics,
288    market: Option<&MarketSummary>,
289    venue: Option<&str>,
290) -> Result<()> {
291    use crate::display::terminal as t;
292
293    let title = format!("{} ({})", analytics.token.symbol, analytics.token.name);
294    println!("{}", t::section_header(&title));
295
296    // DEX Analytics subsection
297    println!("{}", t::subsection_header("DEX Analytics"));
298    println!(
299        "{}",
300        t::kv_row("Price", &format!("${:.6}", analytics.price_usd))
301    );
302    println!(
303        "{}",
304        t::kv_row_delta(
305            "24h Change",
306            analytics.price_change_24h,
307            &format!("{:+.2}%", analytics.price_change_24h)
308        )
309    );
310    println!(
311        "{}",
312        t::kv_row(
313            "24h Volume",
314            &format!(
315                "${}",
316                crate::display::format_large_number(analytics.volume_24h)
317            )
318        )
319    );
320    println!(
321        "{}",
322        t::kv_row(
323            "Liquidity",
324            &format!(
325                "${}",
326                crate::display::format_large_number(analytics.liquidity_usd)
327            )
328        )
329    );
330    if let Some(mc) = analytics.market_cap {
331        println!(
332            "{}",
333            t::kv_row(
334                "Market Cap",
335                &format!("${}", crate::display::format_large_number(mc))
336            )
337        );
338    }
339    if let Some(top10) = analytics.top_10_concentration {
340        println!("{}", t::kv_row("Top 10 Holders", &format!("{:.1}%", top10)));
341    }
342
343    // Market / Order Book subsection
344    if let Some(summary) = market {
345        println!("{}", t::subsection_header("Market / Order Book"));
346        if let Some(v) = venue {
347            println!("{}", t::kv_row("Venue", v));
348        }
349        println!(
350            "{}",
351            t::kv_row("Peg Target", &format!("{:.4}", summary.peg_target))
352        );
353        if let Some(b) = summary.best_bid {
354            println!(
355                "{}",
356                t::kv_row("Best Bid", &t::format_price_peg(b, summary.peg_target))
357            );
358        }
359        if let Some(a) = summary.best_ask {
360            println!(
361                "{}",
362                t::kv_row("Best Ask", &t::format_price_peg(a, summary.peg_target))
363            );
364        }
365        if let Some(m) = summary.mid_price {
366            println!(
367                "{}",
368                t::kv_row("Mid Price", &t::format_price_peg(m, summary.peg_target))
369            );
370        }
371        println!(
372            "{}",
373            t::kv_row("Bid Depth", &format!("{:.0} USDT", summary.bid_depth))
374        );
375        println!(
376            "{}",
377            t::kv_row("Ask Depth", &format!("{:.0} USDT", summary.ask_depth))
378        );
379        println!("{}", t::blank_row());
380
381        // Health checks
382        for check in &summary.checks {
383            match check {
384                crate::market::HealthCheck::Pass(m) => println!("{}", t::check_pass(m)),
385                crate::market::HealthCheck::Fail(m) => println!("{}", t::check_fail(m)),
386            }
387        }
388        println!("{}", t::blank_row());
389        println!("{}", t::status_line(summary.healthy));
390    }
391
392    println!("{}", t::section_footer());
393    Ok(())
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::chains::dex::DexTokenData;
400    use crate::chains::mocks::MockClientFactory;
401    use crate::chains::{DexPair, Token, TokenAnalytics, TokenHolder, TokenSocial};
402    use crate::config::OutputFormat;
403    use crate::market::{HealthCheck, MarketSummary};
404
405    fn make_test_analytics(with_dex_pairs: bool) -> TokenAnalytics {
406        TokenAnalytics {
407            token: Token {
408                contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
409                symbol: "USDC".to_string(),
410                name: "USD Coin".to_string(),
411                decimals: 6,
412            },
413            chain: "ethereum".to_string(),
414            holders: vec![TokenHolder {
415                address: "0x1234".to_string(),
416                balance: "1000000".to_string(),
417                formatted_balance: "1.0".to_string(),
418                percentage: 10.0,
419                rank: 1,
420            }],
421            total_holders: 1000,
422            volume_24h: 5_000_000.0,
423            volume_7d: 25_000_000.0,
424            price_usd: 0.9999,
425            price_change_24h: -0.01,
426            price_change_7d: 0.02,
427            liquidity_usd: 100_000_000.0,
428            market_cap: Some(30_000_000_000.0),
429            fdv: None,
430            total_supply: None,
431            circulating_supply: None,
432            price_history: vec![],
433            volume_history: vec![],
434            holder_history: vec![],
435            dex_pairs: if with_dex_pairs {
436                vec![DexPair {
437                    dex_name: "Uniswap V3".to_string(),
438                    pair_address: "0xpair".to_string(),
439                    base_token: "USDC".to_string(),
440                    quote_token: "WETH".to_string(),
441                    price_usd: 0.9999,
442                    volume_24h: 5_000_000.0,
443                    liquidity_usd: 50_000_000.0,
444                    price_change_24h: -0.01,
445                    buys_24h: 1000,
446                    sells_24h: 900,
447                    buys_6h: 300,
448                    sells_6h: 250,
449                    buys_1h: 50,
450                    sells_1h: 45,
451                    pair_created_at: Some(1600000000),
452                    url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
453                }]
454            } else {
455                vec![]
456            },
457            fetched_at: 1700003600,
458            top_10_concentration: Some(35.5),
459            top_50_concentration: Some(55.0),
460            top_100_concentration: Some(65.0),
461            price_change_6h: 0.01,
462            price_change_1h: -0.005,
463            total_buys_24h: 1000,
464            total_sells_24h: 900,
465            total_buys_6h: 300,
466            total_sells_6h: 250,
467            total_buys_1h: 50,
468            total_sells_1h: 45,
469            token_age_hours: Some(25000.0),
470            image_url: None,
471            websites: vec!["https://centre.io".to_string()],
472            socials: vec![TokenSocial {
473                platform: "twitter".to_string(),
474                url: "https://twitter.com/circle".to_string(),
475            }],
476            dexscreener_url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
477        }
478    }
479
480    fn make_test_market_summary() -> MarketSummary {
481        use crate::market::{ExecutionEstimate, ExecutionSide};
482        MarketSummary {
483            pair: "USDC/USDT".to_string(),
484            peg_target: 1.0,
485            best_bid: Some(0.9999),
486            best_ask: Some(1.0001),
487            mid_price: Some(1.0),
488            spread: Some(0.0002),
489            volume_24h: Some(1_000_000.0),
490            execution_10k_buy: Some(ExecutionEstimate {
491                notional_usdt: 10_000.0,
492                side: ExecutionSide::Buy,
493                vwap: 1.0001,
494                slippage_bps: 1.0,
495                fillable: true,
496            }),
497            execution_10k_sell: Some(ExecutionEstimate {
498                notional_usdt: 10_000.0,
499                side: ExecutionSide::Sell,
500                vwap: 0.9999,
501                slippage_bps: 1.0,
502                fillable: true,
503            }),
504            asks: vec![],
505            bids: vec![],
506            ask_outliers: 0,
507            bid_outliers: 0,
508            ask_depth: 5000.0,
509            bid_depth: 6000.0,
510            checks: vec![
511                HealthCheck::Pass("No sells below peg".to_string()),
512                HealthCheck::Pass("Bid/Ask ratio: 1.20x".to_string()),
513            ],
514            healthy: true,
515        }
516    }
517
518    #[test]
519    fn test_is_dex_venue() {
520        assert!(is_dex_venue("eth"));
521        assert!(is_dex_venue("ethereum"));
522        assert!(is_dex_venue("solana"));
523        assert!(!is_dex_venue("binance"));
524        assert!(!is_dex_venue("okx"));
525    }
526
527    #[test]
528    fn test_format_large_number() {
529        assert_eq!(
530            crate::display::format_large_number(1_500_000_000.0),
531            "1.50B"
532        );
533        assert_eq!(crate::display::format_large_number(2_500_000.0), "2.50M");
534        assert_eq!(crate::display::format_large_number(3_500.0), "3.50K");
535        assert_eq!(crate::display::format_large_number(99.99), "99.99");
536    }
537
538    #[test]
539    fn test_token_health_to_markdown_without_market() {
540        let analytics = make_test_analytics(false);
541        let md = token_health_to_markdown(&analytics, None, None);
542        assert!(md.contains("USDC"));
543        assert!(md.contains("USD Coin"));
544        assert!(!md.contains("Market / Order Book"));
545    }
546
547    #[test]
548    fn test_token_health_to_markdown_with_market() {
549        let analytics = make_test_analytics(false);
550        let market = make_test_market_summary();
551        let md = token_health_to_markdown(&analytics, Some(&market), Some("binance"));
552        assert!(md.contains("Market / Order Book"));
553        assert!(md.contains("binance"));
554        assert!(md.contains("0.9999"));
555        assert!(md.contains("Yes"));
556        assert!(md.contains("Health Checks"));
557    }
558
559    #[test]
560    fn test_token_health_to_markdown_without_venue() {
561        let analytics = make_test_analytics(false);
562        let market = make_test_market_summary();
563        let md = token_health_to_markdown(&analytics, Some(&market), None);
564        assert!(md.contains("Market / Order Book"));
565        assert!(!md.contains("Venue:")); // Should not include venue when None
566        assert!(md.contains("0.9999"));
567        assert!(md.contains("Yes"));
568    }
569
570    #[test]
571    fn test_token_health_to_markdown_unhealthy_market() {
572        let analytics = make_test_analytics(false);
573        let mut market = make_test_market_summary();
574        market.healthy = false;
575        market.checks = vec![
576            HealthCheck::Pass("Some check passed".to_string()),
577            HealthCheck::Fail("Peg deviation too high".to_string()),
578            HealthCheck::Fail("Insufficient bid depth".to_string()),
579        ];
580        let md = token_health_to_markdown(&analytics, Some(&market), Some("binance"));
581        assert!(md.contains("Market / Order Book"));
582        assert!(md.contains("No")); // Should show unhealthy
583        assert!(md.contains("Health Checks"));
584        assert!(md.contains("✗")); // Should show fail checks
585        assert!(md.contains("Peg deviation too high"));
586        assert!(md.contains("Insufficient bid depth"));
587    }
588
589    #[test]
590    fn test_token_health_to_json_without_market() {
591        let analytics = make_test_analytics(false);
592        let json = token_health_to_json(&analytics, None).unwrap();
593        assert!(json.contains("\"analytics\""));
594        assert!(json.contains("\"market\": null"));
595        assert!(json.contains("USDC"));
596    }
597
598    #[test]
599    fn test_token_health_to_json_with_market() {
600        let analytics = make_test_analytics(false);
601        let market = make_test_market_summary();
602        let json = token_health_to_json(&analytics, Some(&market)).unwrap();
603        assert!(json.contains("\"market\""));
604        assert!(json.contains("\"peg_target\": 1.0"));
605        assert!(json.contains("\"healthy\": true"));
606    }
607
608    #[test]
609    fn test_token_health_to_json_with_fail_checks() {
610        let analytics = make_test_analytics(false);
611        let mut market = make_test_market_summary();
612        market.healthy = false;
613        market.checks = vec![
614            HealthCheck::Pass("Bid/Ask ratio OK".to_string()),
615            HealthCheck::Fail("Peg deviation exceeds threshold".to_string()),
616            HealthCheck::Fail("Ask depth below minimum".to_string()),
617        ];
618        let json = token_health_to_json(&analytics, Some(&market)).unwrap();
619        assert!(json.contains("\"market\""));
620        assert!(json.contains("\"healthy\": false"));
621        assert!(json.contains("\"status\": \"pass\""));
622        assert!(json.contains("\"status\": \"fail\""));
623        assert!(json.contains("Peg deviation exceeds threshold"));
624        assert!(json.contains("Ask depth below minimum"));
625    }
626
627    #[test]
628    fn test_output_token_health_table_without_market() {
629        let analytics = make_test_analytics(false);
630        let result = output_token_health_table(&analytics, None, None);
631        assert!(result.is_ok());
632    }
633
634    #[test]
635    fn test_output_token_health_table_with_market() {
636        let analytics = make_test_analytics(false);
637        let market = make_test_market_summary();
638        let result = output_token_health_table(&analytics, Some(&market), Some("biconomy"));
639        assert!(result.is_ok());
640    }
641
642    #[test]
643    fn test_output_token_health_table_no_market_cap() {
644        let mut analytics = make_test_analytics(false);
645        analytics.market_cap = None;
646        analytics.top_10_concentration = None;
647        let result = output_token_health_table(&analytics, None, None);
648        assert!(result.is_ok());
649        // Should not panic when market_cap and top_10_concentration are None
650    }
651
652    fn make_test_dex_token_data(pairs: Vec<DexPair>) -> DexTokenData {
653        DexTokenData {
654            address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
655            symbol: "USDC".to_string(),
656            name: "USD Coin".to_string(),
657            price_usd: 0.9999,
658            price_change_24h: -0.01,
659            price_change_6h: 0.01,
660            price_change_1h: -0.005,
661            price_change_5m: 0.0,
662            volume_24h: 5_000_000.0,
663            volume_6h: 1_250_000.0,
664            volume_1h: 250_000.0,
665            liquidity_usd: 100_000_000.0,
666            market_cap: Some(30_000_000_000.0),
667            fdv: Some(30_000_000_000.0),
668            pairs,
669            price_history: vec![],
670            volume_history: vec![],
671            total_buys_24h: 1000,
672            total_sells_24h: 900,
673            total_buys_6h: 300,
674            total_sells_6h: 250,
675            total_buys_1h: 50,
676            total_sells_1h: 45,
677            earliest_pair_created_at: Some(1600000000),
678            image_url: None,
679            websites: vec![],
680            socials: vec![crate::chains::dex::TokenSocial {
681                platform: "twitter".to_string(),
682                url: "https://twitter.com/circle".to_string(),
683            }],
684            dexscreener_url: None,
685        }
686    }
687
688    #[tokio::test]
689    async fn test_run_token_health_table() {
690        let mut factory = MockClientFactory::new();
691        factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
692
693        let config = Config::default();
694        let args = TokenHealthArgs {
695            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
696            chain: "ethereum".to_string(),
697            with_market: false,
698            venue: "binance".to_string(),
699            format: OutputFormat::Table,
700        };
701
702        let result = run(args, &config, &factory).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        };
723
724        let result = run(args, &config, &factory).await;
725        assert!(result.is_ok());
726    }
727
728    #[tokio::test]
729    async fn test_run_token_health_markdown() {
730        let mut factory = MockClientFactory::new();
731        factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
732
733        let config = Config::default();
734        let args = TokenHealthArgs {
735            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
736            chain: "ethereum".to_string(),
737            with_market: false,
738            venue: "binance".to_string(),
739            format: OutputFormat::Markdown,
740        };
741
742        let result = run(args, &config, &factory).await;
743        assert!(result.is_ok());
744    }
745
746    /// Test DEX venue with dex_pairs: synthesizes order book from analytics.
747    #[tokio::test]
748    async fn test_run_token_health_dex_market() {
749        let mut factory = MockClientFactory::new();
750        let pair = DexPair {
751            dex_name: "Uniswap V3".to_string(),
752            pair_address: "0xpair".to_string(),
753            base_token: "USDC".to_string(),
754            quote_token: "WETH".to_string(),
755            price_usd: 0.9999,
756            volume_24h: 5_000_000.0,
757            liquidity_usd: 50_000_000.0,
758            price_change_24h: -0.01,
759            buys_24h: 1000,
760            sells_24h: 900,
761            buys_6h: 300,
762            sells_6h: 250,
763            buys_1h: 50,
764            sells_1h: 45,
765            pair_created_at: Some(1600000000),
766            url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
767        };
768        factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![pair]));
769
770        let config = Config::default();
771        let args = TokenHealthArgs {
772            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
773            chain: "ethereum".to_string(),
774            with_market: true,
775            venue: "eth".to_string(),
776            format: OutputFormat::Table,
777        };
778
779        let result = run(args, &config, &factory).await;
780        assert!(result.is_ok());
781    }
782}