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