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