Skip to main content

scope_cli/cli/
crawl.rs

1//! # Crawl Command
2//!
3//! This module implements the `crawl` command for retrieving comprehensive
4//! token analytics data including holder information, volume statistics,
5//! and price data.
6//!
7//! ## Usage
8//!
9//! ```bash
10//! # Basic crawl by address
11//! scope crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
12//!
13//! # Search by token name/symbol
14//! scope crawl USDC
15//! scope crawl "wrapped ether"
16//!
17//! # Specify chain and period
18//! scope crawl USDC --chain ethereum --period 7d
19//!
20//! # Generate markdown report
21//! scope crawl USDC --report report.md
22//!
23//! # Output as JSON
24//! scope crawl USDC --format json
25//! ```
26
27use crate::cli::output::Output;
28use crate::cli::progress::{self, Spinner};
29use crate::cli::resolve::{DexCandidates, ResolveContext, VenueKind, picker_for, resolve};
30use crate::outln;
31use clap::Args;
32use scope::chains::{
33    ChainClientFactory, DexClient, DexDataSource, DexPair, Token, TokenAnalytics, TokenHolder,
34    infer_chain_from_address,
35};
36use scope::config::{Config, OutputFormat};
37use scope::display::{charts, report};
38use scope::error::{Result, ScopeError};
39use scope::tokens::TokenAliases;
40use std::path::PathBuf;
41
42/// Time period for analytics data.
43#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
44pub enum Period {
45    /// 1 hour
46    #[value(name = "1h")]
47    Hour1,
48    /// 24 hours (default)
49    #[default]
50    #[value(name = "24h")]
51    Hour24,
52    /// 7 days
53    #[value(name = "7d")]
54    Day7,
55    /// 30 days
56    #[value(name = "30d")]
57    Day30,
58}
59
60impl Period {
61    /// Returns the period duration in seconds.
62    pub fn as_seconds(&self) -> i64 {
63        match self {
64            Period::Hour1 => 3600,
65            Period::Hour24 => 86400,
66            Period::Day7 => 604800,
67            Period::Day30 => 2592000,
68        }
69    }
70
71    /// Returns a human-readable label.
72    pub fn label(&self) -> &'static str {
73        match self {
74            Period::Hour1 => "1 Hour",
75            Period::Hour24 => "24 Hours",
76            Period::Day7 => "7 Days",
77            Period::Day30 => "30 Days",
78        }
79    }
80}
81
82/// Arguments for the crawl command.
83#[derive(Debug, Args)]
84#[command(
85    after_help = "\x1b[1mExamples:\x1b[0m
86  scope crawl USDC
87  scope crawl @usdc-token                                 \x1b[2m# address book shortcut\x1b[0m
88  scope crawl 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --chain ethereum
89  scope crawl USDC --period 7d --report usdc_report.md
90  scope crawl PEPE --format json --no-charts",
91    after_long_help = "\x1b[1mExamples:\x1b[0m
92
93  \x1b[1m$ scope crawl USDC\x1b[0m
94
95  Key Metrics
96  ==================================================
97  Price:           $0.999900
98  24h Change:      -0.01%
99  24h Volume:      $5.00M
100  Liquidity:       $100.00M
101  Market Cap:      $30.00B
102  FDV:             $30.00B
103
104  Top Trading Pairs
105  ==================================================
106  1. Uniswap V3 USDC/WETH - $5.00M ($50.00M liq)
107  2. Uniswap V2 USDC/USDT - $2.50M ($25.00M liq)
108  ...
109
110  \x1b[1m$ scope crawl PEPE --period 7d --no-charts\x1b[0m
111
112  Key Metrics
113  ==================================================
114  Price:           $0.000012
115  24h Change:      +8.50%
116  24h Volume:      $120.00M
117  Liquidity:       $45.00M
118  Market Cap:      $5.00B
119  ...
120
121  \x1b[1m$ scope crawl USDC --report usdc.md\x1b[0m
122
123  Key Metrics
124  ==================================================
125  Price:           $0.999900
126  ...
127  Report saved to usdc.md"
128)]
129pub struct CrawlArgs {
130    /// Token address or symbol to analyze.
131    ///
132    /// Can be a contract address (0x...) or a token symbol. A symbol that
133    /// names more than one token lists them so you can choose (only exact
134    /// symbols match).
135    /// Use @label to resolve from the address book (e.g., @usdc-token).
136    pub token: String,
137
138    /// Target blockchain network.
139    ///
140    /// If not specified, the chain will be inferred from the address format
141    /// or all chains will be searched for token names.
142    #[arg(short, long, default_value = scope::chains::DEFAULT_CHAIN)]
143    pub chain: String,
144
145    /// Time period for volume and price data.
146    #[arg(short, long, default_value = "24h")]
147    pub period: Period,
148
149    /// Maximum number of holders to display.
150    #[arg(long, default_value = "10")]
151    pub holders_limit: u32,
152
153    /// Output format for the results.
154    #[arg(short, long, default_value = "table")]
155    pub format: OutputFormat,
156
157    /// Disable ASCII chart output.
158    #[arg(long)]
159    pub no_charts: bool,
160
161    /// Generate and save a markdown report to the specified path.
162    #[arg(long, value_name = "PATH")]
163    pub report: Option<PathBuf>,
164
165    /// Skip the save question. An ambiguous symbol still stops: choose
166    /// with --token.
167    #[arg(long)]
168    pub yes: bool,
169
170    /// Save the chosen token so the symbol always means it.
171    #[arg(long)]
172    pub save: bool,
173
174    /// The token's contract: use it and skip the symbol lookup.
175    #[arg(long = "token", value_name = "CONTRACT")]
176    pub token_contract: Option<String>,
177}
178
179/// Token resolution result with optional alias info.
180#[derive(Debug, Clone)]
181struct ResolvedToken {
182    address: String,
183    chain: String,
184    /// If resolved from an alias, contains (symbol, name)
185    alias_info: Option<(String, String)>,
186}
187
188/// Resolves a token input (address or symbol) to a concrete address and
189/// chain: an address is used as is (with its chain inferred); anything
190/// else goes through [`crate::cli::resolve`].
191///
192/// Uses `dex_client` for search to enable dependency injection and testing.
193///
194/// When an optional `spinner` is provided, progress messages are routed through
195/// it instead of printing directly to stderr/stdout, keeping output on a single
196/// updating line.
197async fn resolve_token_input(
198    args: &CrawlArgs,
199    aliases: &mut TokenAliases,
200    dex_client: &dyn DexDataSource,
201    http: std::sync::Arc<dyn scope::http::HttpClient>,
202    spinner: Option<&Spinner>,
203    out: &Output,
204) -> Result<ResolvedToken> {
205    let input = args.token.trim();
206
207    // Check if it's a direct address
208    if TokenAliases::is_address(input) {
209        let chain = if args.chain == "ethereum" {
210            infer_chain_from_address(input)
211                .unwrap_or("ethereum")
212                .to_string()
213        } else {
214            args.chain.clone()
215        };
216        return Ok(ResolvedToken {
217            address: input.to_string(),
218            chain,
219            alias_info: None,
220        });
221    }
222
223    let ctx = ResolveContext {
224        chain: (args.chain != "ethereum").then(|| args.chain.clone()),
225        venue: VenueKind::Dex,
226        token_flag: args.token_contract.clone(),
227        yes: args.yes,
228        save: args.save,
229    };
230    let source = DexCandidates {
231        dex: dex_client,
232        out,
233        fallback: Some(http),
234    };
235    progress::status(spinner, out, &format!("Searching for '{}'...", input));
236    let picker = picker_for(out, matches!(args.format, OutputFormat::Json));
237    let r = resolve(input, &ctx, &source, picker.as_ref(), aliases, out).await?;
238    Ok(ResolvedToken {
239        address: r.address.unwrap_or_default(),
240        chain: if r.chain.is_empty() {
241            args.chain.clone()
242        } else {
243            r.chain
244        },
245        alias_info: Some((r.symbol, r.name)),
246    })
247}
248
249/// Resolves token input (address or symbol) and returns full analytics.
250///
251/// When an optional `spinner` is provided, progress messages (searching,
252/// selecting) are routed through it for single-line in-place updates.
253pub async fn fetch_analytics_for_input(
254    token_input: &str,
255    chain: &str,
256    period: Period,
257    holders_limit: u32,
258    clients: &dyn ChainClientFactory,
259    spinner: Option<&crate::cli::progress::Spinner>,
260    out: &Output,
261) -> Result<TokenAnalytics> {
262    // `yes: false`: an ambiguous symbol asks, or stops without a terminal.
263    // It was `true`, which took the first search match silently.
264    let args = CrawlArgs {
265        token: token_input.to_string(),
266        chain: chain.to_string(),
267        period,
268        holders_limit,
269        format: OutputFormat::Table,
270        no_charts: true,
271        report: None,
272        yes: false,
273        save: false,
274        token_contract: None,
275    };
276    let mut aliases = TokenAliases::load();
277    let dex_client = clients.create_dex_client();
278    let resolved = resolve_token_input(
279        &args,
280        &mut aliases,
281        dex_client.as_ref(),
282        clients.http(),
283        spinner,
284        out,
285    )
286    .await?;
287    let fetching = format!(
288        "Fetching analytics for {} on {}...",
289        resolved.address, resolved.chain
290    );
291    progress::status(spinner, out, &fetching);
292    let mut analytics =
293        fetch_token_analytics(&resolved.address, &resolved.chain, &args, clients).await?;
294    if let Some((symbol, name)) = &resolved.alias_info
295        && (analytics.token.symbol == "UNKNOWN" || analytics.token.name == "Unknown Token")
296    {
297        analytics.token.symbol = symbol.clone();
298        analytics.token.name = name.clone();
299    }
300    Ok(analytics)
301}
302
303/// Runs the crawl command.
304///
305/// Fetches comprehensive token analytics and displays them with ASCII charts
306/// or generates a markdown report.
307pub async fn run(
308    mut args: CrawlArgs,
309    config: &Config,
310    clients: &dyn ChainClientFactory,
311    out: &Output,
312) -> Result<()> {
313    // Resolve address book label → address + chain before token resolution
314    if let Some((address, chain)) =
315        crate::cli::address_book::resolve_address_book_input(&args.token, config)?
316    {
317        args.token = address;
318        if args.chain == "ethereum" {
319            args.chain = chain;
320        }
321    }
322
323    // Load token aliases
324    let mut aliases = TokenAliases::load();
325
326    // Start spinner early so resolution messages route through it
327    let sp = crate::cli::progress::Spinner::new(
328        &format!("Crawling token {} on {}...", args.token, args.chain),
329        out,
330    );
331
332    // Resolve the token input to an address (uses factory's dex client for search)
333    let dex_client = clients.create_dex_client();
334    let resolved = resolve_token_input(
335        &args,
336        &mut aliases,
337        dex_client.as_ref(),
338        clients.http(),
339        Some(&sp),
340        out,
341    )
342    .await?;
343
344    tracing::info!(
345        token = %resolved.address,
346        chain = %resolved.chain,
347        period = ?args.period,
348        "Starting token crawl"
349    );
350
351    sp.set_message(format!(
352        "Fetching analytics for {} on {}...",
353        resolved.address, resolved.chain
354    ));
355
356    // Fetch token analytics from multiple sources
357    let mut analytics =
358        fetch_token_analytics(&resolved.address, &resolved.chain, &args, clients).await?;
359
360    sp.finish("Token data loaded.");
361
362    // If we have alias info and the fetched token info is unknown, use alias info
363    if (analytics.token.symbol == "UNKNOWN" || analytics.token.name == "Unknown Token")
364        && let Some((symbol, name)) = &resolved.alias_info
365    {
366        analytics.token.symbol = symbol.clone();
367        analytics.token.name = name.clone();
368    }
369
370    // Output based on format
371    match args.format {
372        OutputFormat::Json => {
373            let json = serde_json::to_string_pretty(&analytics)?;
374            outln!(out, "{}", json)?;
375        }
376        OutputFormat::Csv => {
377            output_csv(&analytics, out)?;
378        }
379        OutputFormat::Table => {
380            output_table(&analytics, &args, out)?;
381        }
382        OutputFormat::Markdown => {
383            let md = report::generate_report(&analytics);
384            outln!(out, "{}", md)?;
385        }
386    }
387
388    // Generate report if requested
389    if let Some(ref report_path) = args.report {
390        let markdown_report = report::generate_report(&analytics);
391        report::save_report(&markdown_report, report_path)?;
392        out.note_saved("Report", report_path.display());
393    }
394
395    Ok(())
396}
397
398/// Fetches comprehensive token analytics from multiple data sources.
399async fn fetch_token_analytics(
400    token_address: &str,
401    chain: &str,
402    args: &CrawlArgs,
403    clients: &dyn ChainClientFactory,
404) -> Result<TokenAnalytics> {
405    // Initialize DEX client via factory
406    let dex_client = clients.create_dex_client();
407
408    // Try to fetch DEX data (price, volume, liquidity)
409    let dex_result = dex_client.get_token_data(chain, token_address).await;
410
411    // Handle DEX data - either use it or fall back to block explorer only
412    match dex_result {
413        Ok(dex_data) => {
414            // We have DEX data - proceed with full analytics
415            fetch_analytics_with_dex(token_address, chain, args, clients, dex_data).await
416        }
417        Err(ScopeError::NotFound(_)) => {
418            // No DEX data - fall back to block explorer only
419            tracing::debug!("No DEX data, falling back to block explorer");
420            fetch_analytics_from_explorer(token_address, chain, args, clients).await
421        }
422        Err(e) => Err(e),
423    }
424}
425
426/// Fetches analytics when DEX data is available.
427async fn fetch_analytics_with_dex(
428    token_address: &str,
429    chain: &str,
430    args: &CrawlArgs,
431    clients: &dyn ChainClientFactory,
432    dex_data: scope::chains::dex::DexTokenData,
433) -> Result<TokenAnalytics> {
434    // Fetch holder data from block explorer (if available)
435    let holders = fetch_holders(token_address, chain, args.holders_limit, clients).await?;
436
437    // Get token info
438    let token = Token {
439        contract_address: token_address.to_string(),
440        symbol: dex_data.symbol.clone(),
441        name: dex_data.name.clone(),
442        decimals: 18, // Default, could be fetched from contract
443    };
444
445    // Calculate concentration metrics
446    let top_10_pct: f64 = holders.iter().take(10).map(|h| h.percentage).sum();
447    let top_50_pct: f64 = holders.iter().take(50).map(|h| h.percentage).sum();
448    let top_100_pct: f64 = holders.iter().take(100).map(|h| h.percentage).sum();
449
450    // Convert DEX pairs
451    let dex_pairs: Vec<DexPair> = dex_data.pairs;
452
453    // Calculate 7d volume estimate
454    let volume_7d = DexClient::estimate_7d_volume(dex_data.volume_24h);
455
456    // Get current timestamp
457    let fetched_at = chrono::Utc::now().timestamp();
458
459    // Calculate token age in hours from earliest pair creation
460    // DexScreener returns pairCreatedAt in milliseconds, so convert to seconds
461    let token_age_hours = dex_data.earliest_pair_created_at.map(|created_at| {
462        let now = chrono::Utc::now().timestamp();
463        // If timestamp is in milliseconds (> year 3000 in seconds), convert to seconds
464        let created_at_secs = if created_at > 32503680000 {
465            created_at / 1000
466        } else {
467            created_at
468        };
469        let age_secs = now - created_at_secs;
470        if age_secs > 0 {
471            (age_secs as f64) / 3600.0
472        } else {
473            0.0 // Fallback for invalid timestamps
474        }
475    });
476
477    // Map social links to the TokenSocial type used in TokenAnalytics
478    let socials: Vec<scope::chains::TokenSocial> = dex_data
479        .socials
480        .iter()
481        .map(|s| scope::chains::TokenSocial {
482            platform: s.platform.clone(),
483            url: s.url.clone(),
484        })
485        .collect();
486
487    Ok(TokenAnalytics {
488        token,
489        chain: chain.to_string(),
490        holders,
491        total_holders: 0, // Would need a separate API call
492        volume_24h: dex_data.volume_24h,
493        volume_7d,
494        price_usd: dex_data.price_usd,
495        price_change_24h: dex_data.price_change_24h,
496        price_change_7d: 0.0, // Not available from DexScreener directly
497        liquidity_usd: dex_data.liquidity_usd,
498        market_cap: dex_data.market_cap,
499        fdv: dex_data.fdv,
500        total_supply: None,
501        circulating_supply: None,
502        price_history: dex_data.price_history,
503        volume_history: dex_data.volume_history,
504        holder_history: Vec::new(), // Would need historical data
505        dex_pairs,
506        fetched_at,
507        top_10_concentration: if top_10_pct > 0.0 {
508            Some(top_10_pct)
509        } else {
510            None
511        },
512        top_50_concentration: if top_50_pct > 0.0 {
513            Some(top_50_pct)
514        } else {
515            None
516        },
517        top_100_concentration: if top_100_pct > 0.0 {
518            Some(top_100_pct)
519        } else {
520            None
521        },
522        price_change_6h: dex_data.price_change_6h,
523        price_change_1h: dex_data.price_change_1h,
524        total_buys_24h: dex_data.total_buys_24h,
525        total_sells_24h: dex_data.total_sells_24h,
526        total_buys_6h: dex_data.total_buys_6h,
527        total_sells_6h: dex_data.total_sells_6h,
528        total_buys_1h: dex_data.total_buys_1h,
529        total_sells_1h: dex_data.total_sells_1h,
530        token_age_hours,
531        image_url: dex_data.image_url.clone(),
532        websites: dex_data.websites.clone(),
533        socials,
534        dexscreener_url: dex_data.dexscreener_url.clone(),
535    })
536}
537
538/// Fetches basic token analytics from block explorer when DEX data is unavailable.
539async fn fetch_analytics_from_explorer(
540    token_address: &str,
541    chain: &str,
542    args: &CrawlArgs,
543    clients: &dyn ChainClientFactory,
544) -> Result<TokenAnalytics> {
545    // EVM chains, Solana (token info via RPC), and Tron support block explorer data
546    let has_explorer = scope::chains::registry::has_explorer(chain);
547
548    if !has_explorer {
549        return Err(ScopeError::NotFound(format!(
550            "No DEX data found for token {} on {} and block explorer fallback not supported for this chain",
551            token_address, chain
552        )));
553    }
554
555    // Create chain client via factory
556    let client = clients.create_chain_client(chain)?;
557
558    // Fetch token info
559    let token = match client.get_token_info(token_address).await {
560        Ok(t) => t,
561        Err(e) => {
562            tracing::debug!("Failed to fetch token info: {}", e);
563            // Use placeholder token info
564            Token {
565                contract_address: token_address.to_string(),
566                symbol: "UNKNOWN".to_string(),
567                name: "Unknown Token".to_string(),
568                decimals: 18,
569            }
570        }
571    };
572
573    // Fetch holder data
574    let holders = fetch_holders(token_address, chain, args.holders_limit, clients).await?;
575
576    // Fetch holder count
577    let total_holders = match client.get_token_holder_count(token_address).await {
578        Ok(count) => count,
579        Err(e) => {
580            tracing::debug!("Failed to fetch holder count: {}", e);
581            0
582        }
583    };
584
585    // Calculate concentration metrics
586    let top_10_pct: f64 = holders.iter().take(10).map(|h| h.percentage).sum();
587    let top_50_pct: f64 = holders.iter().take(50).map(|h| h.percentage).sum();
588    let top_100_pct: f64 = holders.iter().take(100).map(|h| h.percentage).sum();
589
590    // Get current timestamp
591    let fetched_at = chrono::Utc::now().timestamp();
592
593    Ok(TokenAnalytics {
594        token,
595        chain: chain.to_string(),
596        holders,
597        total_holders,
598        volume_24h: 0.0,
599        volume_7d: 0.0,
600        price_usd: 0.0,
601        price_change_24h: 0.0,
602        price_change_7d: 0.0,
603        liquidity_usd: 0.0,
604        market_cap: None,
605        fdv: None,
606        total_supply: None,
607        circulating_supply: None,
608        price_history: Vec::new(),
609        volume_history: Vec::new(),
610        holder_history: Vec::new(),
611        dex_pairs: Vec::new(),
612        fetched_at,
613        top_10_concentration: if top_10_pct > 0.0 {
614            Some(top_10_pct)
615        } else {
616            None
617        },
618        top_50_concentration: if top_50_pct > 0.0 {
619            Some(top_50_pct)
620        } else {
621            None
622        },
623        top_100_concentration: if top_100_pct > 0.0 {
624            Some(top_100_pct)
625        } else {
626            None
627        },
628        price_change_6h: 0.0,
629        price_change_1h: 0.0,
630        total_buys_24h: 0,
631        total_sells_24h: 0,
632        total_buys_6h: 0,
633        total_sells_6h: 0,
634        total_buys_1h: 0,
635        total_sells_1h: 0,
636        token_age_hours: None,
637        image_url: None,
638        websites: Vec::new(),
639        socials: Vec::new(),
640        dexscreener_url: None,
641    })
642}
643
644/// Fetches token holder data from block explorer APIs.
645async fn fetch_holders(
646    token_address: &str,
647    chain: &str,
648    limit: u32,
649    clients: &dyn ChainClientFactory,
650) -> Result<Vec<TokenHolder>> {
651    // EVM chains and Tron support holder data; Solana uses default (empty) until Solscan Pro
652    match scope::chains::registry::has_explorer(chain) {
653        true => {
654            let client = clients.create_chain_client(chain)?;
655            match client.get_token_holders(token_address, limit).await {
656                Ok(holders) => Ok(holders),
657                Err(e) => {
658                    tracing::debug!("Failed to fetch holders: {}", e);
659                    Ok(Vec::new())
660                }
661            }
662        }
663        false => {
664            tracing::info!("Holder data not available for chain: {}", chain);
665            Ok(Vec::new())
666        }
667    }
668}
669
670/// Outputs analytics in table format with optional charts.
671fn output_table(analytics: &TokenAnalytics, args: &CrawlArgs, out: &Output) -> Result<()> {
672    outln!(out)?;
673
674    // Check if we have DEX data (price > 0 indicates DEX data)
675    let has_dex_data = analytics.price_usd > 0.0;
676
677    if has_dex_data {
678        // Full output with DEX data
679        output_table_with_dex(analytics, args, out)
680    } else {
681        // Explorer-only output
682        output_table_explorer_only(analytics, out)
683    }
684}
685
686/// Outputs full analytics table with DEX data.
687fn output_table_with_dex(analytics: &TokenAnalytics, args: &CrawlArgs, out: &Output) -> Result<()> {
688    use scope::display::terminal as t;
689
690    // Display charts if not disabled
691    if !args.no_charts {
692        let dashboard = charts::render_analytics_dashboard(
693            &analytics.price_history,
694            &analytics.volume_history,
695            &analytics.holders,
696            &analytics.token.symbol,
697            &analytics.chain,
698        );
699        outln!(out, "{}", dashboard)?;
700    } else {
701        // Display text-only summary
702        outln!(
703            out,
704            "{}",
705            t::section_header(&format!(
706                "{} ({})",
707                analytics.token.name, analytics.token.symbol
708            ))
709        )?;
710        outln!(out, "{}", t::kv_row("Chain", &analytics.chain))?;
711        outln!(
712            out,
713            "{}",
714            t::kv_row("Contract", &analytics.token.contract_address)
715        )?;
716        outln!(out, "{}", t::blank_row())?;
717    }
718
719    // Key metrics
720    outln!(out, "{}", t::subsection_header("Key Metrics"))?;
721    outln!(
722        out,
723        "{}",
724        t::kv_row(
725            "Price",
726            &scope::display::format::format_price_usd(analytics.price_usd)
727        )
728    )?;
729    outln!(
730        out,
731        "{}",
732        t::kv_row_delta(
733            "24h Change",
734            analytics.price_change_24h,
735            &format!("{:+.2}%", analytics.price_change_24h)
736        )
737    )?;
738    outln!(
739        out,
740        "{}",
741        t::kv_row(
742            "24h Volume",
743            &scope::display::format_usd(analytics.volume_24h)
744        )
745    )?;
746    outln!(
747        out,
748        "{}",
749        t::kv_row(
750            "Liquidity",
751            &scope::display::format_usd(analytics.liquidity_usd)
752        )
753    )?;
754
755    if let Some(mc) = analytics.market_cap {
756        outln!(
757            out,
758            "{}",
759            t::kv_row("Market Cap", &scope::display::format_usd(mc))
760        )?;
761    }
762
763    if let Some(fdv) = analytics.fdv {
764        outln!(
765            out,
766            "{}",
767            t::kv_row("FDV", &scope::display::format_usd(fdv))
768        )?;
769    }
770
771    // Trading pairs
772    if !analytics.dex_pairs.is_empty() {
773        outln!(out, "{}", t::blank_row())?;
774        outln!(out, "{}", t::subsection_header("Top Trading Pairs"))?;
775
776        for (i, pair) in analytics.dex_pairs.iter().take(5).enumerate() {
777            let pair_str = format!(
778                "{} {}/{} - {} ({} liq)",
779                pair.dex_name,
780                pair.base_token,
781                pair.quote_token,
782                scope::display::format_usd(pair.volume_24h),
783                scope::display::format_usd(pair.liquidity_usd)
784            );
785            outln!(out, "{}", t::numbered_row(i + 1, &pair_str))?;
786        }
787    }
788
789    // Concentration summary
790    if let Some(top_10) = analytics.top_10_concentration {
791        outln!(out, "{}", t::blank_row())?;
792        outln!(out, "{}", t::subsection_header("Holder Concentration"))?;
793        outln!(
794            out,
795            "{}",
796            t::kv_row("Top 10 holders", &format!("{:.1}% of supply", top_10))
797        )?;
798
799        if let Some(top_50) = analytics.top_50_concentration {
800            outln!(
801                out,
802                "{}",
803                t::kv_row("Top 50 holders", &format!("{:.1}% of supply", top_50))
804            )?;
805        }
806    }
807
808    if args.no_charts {
809        outln!(out, "{}", t::section_footer())?;
810    }
811
812    Ok(())
813}
814
815/// Outputs basic token info from block explorer (no DEX data).
816fn output_table_explorer_only(analytics: &TokenAnalytics, out: &Output) -> Result<()> {
817    use scope::display::terminal as t;
818
819    outln!(out, "{}", t::section_header("Token Info (Block Explorer)"))?;
820
821    // Basic token info
822    outln!(out, "{}", t::kv_row("Name", &analytics.token.name))?;
823    outln!(out, "{}", t::kv_row("Symbol", &analytics.token.symbol))?;
824    outln!(
825        out,
826        "{}",
827        t::kv_row("Contract", &analytics.token.contract_address)
828    )?;
829    outln!(out, "{}", t::kv_row("Chain", &analytics.chain))?;
830    outln!(
831        out,
832        "{}",
833        t::kv_row("Decimals", &analytics.token.decimals.to_string())
834    )?;
835
836    if analytics.total_holders > 0 {
837        outln!(
838            out,
839            "{}",
840            t::kv_row("Total Holders", &analytics.total_holders.to_string())
841        )?;
842    }
843
844    if let Some(supply) = &analytics.total_supply {
845        outln!(out, "{}", t::kv_row("Total Supply", supply))?;
846    }
847
848    // Note about missing DEX data
849    outln!(out, "{}", t::blank_row())?;
850    outln!(
851        out,
852        "{}",
853        t::info_row(
854            "No DEX trading data available for this token. Price, volume, and liquidity data require active DEX pairs."
855        )
856    )?;
857
858    // Top holders if available
859    if !analytics.holders.is_empty() {
860        outln!(out, "{}", t::blank_row())?;
861        outln!(out, "{}", t::subsection_header("Top Holders"))?;
862
863        let cols = [
864            t::Col {
865                label: "Rank",
866                width: 4,
867                align: '>',
868            },
869            t::Col {
870                label: "Percent",
871                width: 10,
872                align: '>',
873            },
874            t::Col {
875                label: "Balance",
876                width: 20,
877                align: '>',
878            },
879            t::Col {
880                label: "Address",
881                width: 42,
882                align: '<',
883            },
884        ];
885        outln!(out, "{}", t::table_header(&cols))?;
886
887        for holder in analytics.holders.iter().take(10) {
888            let addr_display = scope::display::text::abbreviate(&holder.address);
889
890            let rank_str = holder.rank.to_string();
891            let percent_str = format!("{:.2}%", holder.percentage);
892            let values = [
893                rank_str.as_str(),
894                percent_str.as_str(),
895                holder.formatted_balance.as_str(),
896                addr_display.as_str(),
897            ];
898            outln!(out, "{}", t::table_row(&cols, &values))?;
899        }
900    }
901
902    // Concentration summary
903    if let Some(top_10) = analytics.top_10_concentration {
904        outln!(out, "{}", t::blank_row())?;
905        outln!(out, "{}", t::subsection_header("Holder Concentration"))?;
906        outln!(
907            out,
908            "{}",
909            t::kv_row("Top 10 holders", &format!("{:.1}% of supply", top_10))
910        )?;
911
912        if let Some(top_50) = analytics.top_50_concentration {
913            outln!(
914                out,
915                "{}",
916                t::kv_row("Top 50 holders", &format!("{:.1}% of supply", top_50))
917            )?;
918        }
919    }
920
921    outln!(out, "{}", t::section_footer())?;
922
923    Ok(())
924}
925
926/// Outputs analytics in CSV format.
927fn output_csv(analytics: &TokenAnalytics, out: &Output) -> Result<()> {
928    // Header
929    outln!(out, "metric,value")?;
930
931    // Basic info
932    outln!(out, "symbol,{}", analytics.token.symbol)?;
933    outln!(out, "name,{}", analytics.token.name)?;
934    outln!(out, "chain,{}", analytics.chain)?;
935    outln!(out, "contract,{}", analytics.token.contract_address)?;
936
937    // Metrics
938    outln!(out, "price_usd,{}", analytics.price_usd)?;
939    outln!(out, "price_change_24h,{}", analytics.price_change_24h)?;
940    outln!(out, "volume_24h,{}", analytics.volume_24h)?;
941    outln!(out, "volume_7d,{}", analytics.volume_7d)?;
942    outln!(out, "liquidity_usd,{}", analytics.liquidity_usd)?;
943
944    if let Some(mc) = analytics.market_cap {
945        outln!(out, "market_cap,{}", mc)?;
946    }
947
948    if let Some(fdv) = analytics.fdv {
949        outln!(out, "fdv,{}", fdv)?;
950    }
951
952    outln!(out, "total_holders,{}", analytics.total_holders)?;
953
954    if let Some(top_10) = analytics.top_10_concentration {
955        outln!(out, "top_10_concentration,{}", top_10)?;
956    }
957
958    // Holders section
959    if !analytics.holders.is_empty() {
960        outln!(out)?;
961        outln!(out, "rank,address,balance,percentage")?;
962        for holder in &analytics.holders {
963            outln!(
964                out,
965                "{},{},{},{}",
966                holder.rank,
967                holder.address,
968                holder.balance,
969                holder.percentage
970            )?;
971        }
972    }
973
974    Ok(())
975}
976
977// ============================================================================
978// Unit Tests
979// ============================================================================
980
981#[cfg(test)]
982mod tests {
983    use super::*;
984
985    fn quiet() -> crate::cli::output::Output {
986        crate::cli::output::Output::capture().0
987    }
988
989    #[test]
990    fn test_period_as_seconds() {
991        assert_eq!(Period::Hour1.as_seconds(), 3600);
992        assert_eq!(Period::Hour24.as_seconds(), 86400);
993        assert_eq!(Period::Day7.as_seconds(), 604800);
994        assert_eq!(Period::Day30.as_seconds(), 2592000);
995    }
996
997    #[test]
998    fn test_period_label() {
999        assert_eq!(Period::Hour1.label(), "1 Hour");
1000        assert_eq!(Period::Hour24.label(), "24 Hours");
1001        assert_eq!(Period::Day7.label(), "7 Days");
1002        assert_eq!(Period::Day30.label(), "30 Days");
1003    }
1004
1005    #[test]
1006    fn test_format_large_number() {
1007        assert_eq!(scope::display::format_large_number(500.0), "500.00");
1008        assert_eq!(scope::display::format_large_number(1500.0), "1.50K");
1009        assert_eq!(scope::display::format_large_number(1500000.0), "1.50M");
1010        assert_eq!(scope::display::format_large_number(1500000000.0), "1.50B");
1011    }
1012
1013    #[test]
1014    fn test_period_default() {
1015        let period = Period::default();
1016        assert!(matches!(period, Period::Hour24));
1017    }
1018
1019    #[test]
1020    fn test_crawl_args_defaults() {
1021        use clap::Parser;
1022
1023        #[derive(Parser)]
1024        struct TestCli {
1025            #[command(flatten)]
1026            crawl: CrawlArgs,
1027        }
1028
1029        let cli = TestCli::try_parse_from(["test", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"])
1030            .unwrap();
1031
1032        assert_eq!(cli.crawl.chain, "ethereum");
1033        assert!(matches!(cli.crawl.period, Period::Hour24));
1034        assert_eq!(cli.crawl.holders_limit, 10);
1035        assert!(!cli.crawl.no_charts);
1036        assert!(cli.crawl.report.is_none());
1037    }
1038
1039    // ========================================================================
1040    // format_large_number edge cases
1041    // ========================================================================
1042
1043    #[test]
1044    fn test_format_large_number_zero() {
1045        assert_eq!(scope::display::format_large_number(0.0), "0.00");
1046    }
1047
1048    #[test]
1049    fn test_format_large_number_small() {
1050        assert_eq!(scope::display::format_large_number(0.12), "0.12");
1051    }
1052
1053    #[test]
1054    fn test_format_large_number_boundary_k() {
1055        assert_eq!(scope::display::format_large_number(999.99), "999.99");
1056        assert_eq!(scope::display::format_large_number(1000.0), "1.00K");
1057    }
1058
1059    #[test]
1060    fn test_format_large_number_boundary_m() {
1061        assert_eq!(scope::display::format_large_number(999_999.0), "1000.00K");
1062        assert_eq!(scope::display::format_large_number(1_000_000.0), "1.00M");
1063    }
1064
1065    #[test]
1066    fn test_format_large_number_boundary_b() {
1067        assert_eq!(
1068            scope::display::format_large_number(999_999_999.0),
1069            "1000.00M"
1070        );
1071        assert_eq!(
1072            scope::display::format_large_number(1_000_000_000.0),
1073            "1.00B"
1074        );
1075    }
1076
1077    #[test]
1078    fn test_format_large_number_very_large() {
1079        let result = scope::display::format_large_number(1_500_000_000_000.0);
1080        assert!(result.contains("B"));
1081    }
1082
1083    // ========================================================================
1084    // Period tests
1085    // ========================================================================
1086
1087    #[test]
1088    fn test_period_seconds_all() {
1089        assert_eq!(Period::Hour1.as_seconds(), 3600);
1090        assert_eq!(Period::Hour24.as_seconds(), 86400);
1091        assert_eq!(Period::Day7.as_seconds(), 604800);
1092        assert_eq!(Period::Day30.as_seconds(), 2592000);
1093    }
1094
1095    #[test]
1096    fn test_period_labels_all() {
1097        assert_eq!(Period::Hour1.label(), "1 Hour");
1098        assert_eq!(Period::Hour24.label(), "24 Hours");
1099        assert_eq!(Period::Day7.label(), "7 Days");
1100        assert_eq!(Period::Day30.label(), "30 Days");
1101    }
1102
1103    // ========================================================================
1104    // Output formatting tests
1105    // ========================================================================
1106
1107    use scope::chains::{
1108        DexPair, PricePoint, Token, TokenAnalytics, TokenHolder, TokenSearchResult, TokenSocial,
1109    };
1110
1111    fn make_test_analytics(with_dex: bool) -> TokenAnalytics {
1112        TokenAnalytics {
1113            token: Token {
1114                contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1115                symbol: "USDC".to_string(),
1116                name: "USD Coin".to_string(),
1117                decimals: 6,
1118            },
1119            chain: "ethereum".to_string(),
1120            holders: vec![
1121                TokenHolder {
1122                    address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
1123                    balance: "1000000000000".to_string(),
1124                    formatted_balance: "1,000,000".to_string(),
1125                    percentage: 12.5,
1126                    rank: 1,
1127                },
1128                TokenHolder {
1129                    address: "0xabcdef1234567890abcdef1234567890abcdef12".to_string(),
1130                    balance: "500000000000".to_string(),
1131                    formatted_balance: "500,000".to_string(),
1132                    percentage: 6.25,
1133                    rank: 2,
1134                },
1135            ],
1136            total_holders: 150_000,
1137            volume_24h: if with_dex { 5_000_000.0 } else { 0.0 },
1138            volume_7d: if with_dex { 25_000_000.0 } else { 0.0 },
1139            price_usd: if with_dex { 0.9999 } else { 0.0 },
1140            price_change_24h: if with_dex { -0.01 } else { 0.0 },
1141            price_change_7d: if with_dex { 0.02 } else { 0.0 },
1142            liquidity_usd: if with_dex { 100_000_000.0 } else { 0.0 },
1143            market_cap: if with_dex {
1144                Some(30_000_000_000.0)
1145            } else {
1146                None
1147            },
1148            fdv: if with_dex {
1149                Some(30_000_000_000.0)
1150            } else {
1151                None
1152            },
1153            total_supply: Some("30000000000".to_string()),
1154            circulating_supply: Some("28000000000".to_string()),
1155            price_history: vec![
1156                PricePoint {
1157                    timestamp: 1700000000,
1158                    price: 0.9998,
1159                },
1160                PricePoint {
1161                    timestamp: 1700003600,
1162                    price: 0.9999,
1163                },
1164            ],
1165            volume_history: vec![],
1166            holder_history: vec![],
1167            dex_pairs: if with_dex {
1168                vec![DexPair {
1169                    dex_name: "Uniswap V3".to_string(),
1170                    pair_address: "0xpair".to_string(),
1171                    base_token: "USDC".to_string(),
1172                    quote_token: "WETH".to_string(),
1173                    price_usd: 0.9999,
1174                    volume_24h: 5_000_000.0,
1175                    liquidity_usd: 50_000_000.0,
1176                    liquidity_base: None,
1177                    price_change_24h: -0.01,
1178                    buys_24h: 1000,
1179                    sells_24h: 900,
1180                    buys_6h: 300,
1181                    sells_6h: 250,
1182                    buys_1h: 50,
1183                    sells_1h: 45,
1184                    pair_created_at: Some(1600000000),
1185                    url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
1186                }]
1187            } else {
1188                vec![]
1189            },
1190            fetched_at: 1700003600,
1191            top_10_concentration: Some(35.5),
1192            top_50_concentration: Some(55.0),
1193            top_100_concentration: Some(65.0),
1194            price_change_6h: 0.01,
1195            price_change_1h: -0.005,
1196            total_buys_24h: 1000,
1197            total_sells_24h: 900,
1198            total_buys_6h: 300,
1199            total_sells_6h: 250,
1200            total_buys_1h: 50,
1201            total_sells_1h: 45,
1202            token_age_hours: Some(25000.0),
1203            image_url: None,
1204            websites: vec!["https://www.centre.io/usdc".to_string()],
1205            socials: vec![TokenSocial {
1206                platform: "twitter".to_string(),
1207                url: "https://twitter.com/circle".to_string(),
1208            }],
1209            dexscreener_url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
1210        }
1211    }
1212
1213    /// A search that finds two tokens with the exact symbol USDN.
1214    struct TwoUsdn;
1215
1216    #[async_trait::async_trait]
1217    impl scope::chains::DexDataSource for TwoUsdn {
1218        async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
1219            None
1220        }
1221        async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
1222            None
1223        }
1224        async fn get_token_data(
1225            &self,
1226            _chain: &str,
1227            _address: &str,
1228        ) -> Result<scope::chains::dex::DexTokenData> {
1229            Err(ScopeError::NotFound("not in this fake".into()))
1230        }
1231        async fn search_tokens(
1232            &self,
1233            _query: &str,
1234            _chain: Option<&str>,
1235        ) -> Result<Vec<scope::chains::dex::TokenSearchResult>> {
1236            let usdn = |chain: &str, address: &str| scope::chains::dex::TokenSearchResult {
1237                address: address.into(),
1238                symbol: "USDN".into(),
1239                name: "USDN".into(),
1240                chain: chain.into(),
1241                price_usd: Some(1.0),
1242                volume_24h: 0.0,
1243                liquidity_usd: 1.0,
1244                market_cap: None,
1245            };
1246            Ok(vec![usdn("ethereum", "0xA"), usdn("bsc", "0xB")])
1247        }
1248    }
1249
1250    #[tokio::test]
1251    async fn test_crawl_yes_with_two_matches_stops_with_the_candidates() {
1252        // Jon, 2026-10-15: --yes no longer picks the first match.
1253        let mut aliases = TokenAliases::default();
1254        let mut args = make_test_crawl_args();
1255        args.token = "USDN".into();
1256        args.yes = true;
1257        let err = resolve_token_input(
1258            &args,
1259            &mut aliases,
1260            &TwoUsdn,
1261            scope::http::test_transport(),
1262            None,
1263            &quiet(),
1264        )
1265        .await
1266        .unwrap_err();
1267        assert!(matches!(err, ScopeError::AmbiguousToken { .. }), "{err}");
1268        assert!(err.to_string().contains("bsc 0xB"), "{err}");
1269    }
1270
1271    fn make_test_crawl_args() -> CrawlArgs {
1272        CrawlArgs {
1273            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1274            chain: "ethereum".to_string(),
1275            period: Period::Hour24,
1276            holders_limit: 10,
1277            format: OutputFormat::Table,
1278            no_charts: true,
1279            report: None,
1280            yes: false,
1281            save: false,
1282            token_contract: None,
1283        }
1284    }
1285
1286    #[test]
1287    fn test_output_table_with_dex_data() {
1288        let analytics = make_test_analytics(true);
1289        let args = make_test_crawl_args();
1290        let result = output_table(&analytics, &args, &quiet());
1291        assert!(result.is_ok());
1292    }
1293
1294    #[test]
1295    fn test_output_table_explorer_only() {
1296        let analytics = make_test_analytics(false);
1297        let args = make_test_crawl_args();
1298        let result = output_table(&analytics, &args, &quiet());
1299        assert!(result.is_ok());
1300    }
1301
1302    #[test]
1303    fn test_output_table_no_holders() {
1304        let mut analytics = make_test_analytics(false);
1305        analytics.holders = vec![];
1306        analytics.total_holders = 0;
1307        analytics.top_10_concentration = None;
1308        analytics.top_50_concentration = None;
1309        let args = make_test_crawl_args();
1310        let result = output_table(&analytics, &args, &quiet());
1311        assert!(result.is_ok());
1312    }
1313
1314    #[test]
1315    fn test_output_csv() {
1316        let analytics = make_test_analytics(true);
1317        let result = output_csv(&analytics, &quiet());
1318        assert!(result.is_ok());
1319    }
1320
1321    #[test]
1322    fn test_output_csv_no_market_cap() {
1323        let mut analytics = make_test_analytics(true);
1324        analytics.market_cap = None;
1325        analytics.fdv = None;
1326        analytics.top_10_concentration = None;
1327        let result = output_csv(&analytics, &quiet());
1328        assert!(result.is_ok());
1329    }
1330
1331    #[test]
1332    fn test_output_csv_no_holders() {
1333        let mut analytics = make_test_analytics(true);
1334        analytics.holders = vec![];
1335        let result = output_csv(&analytics, &quiet());
1336        assert!(result.is_ok());
1337    }
1338
1339    #[test]
1340    fn test_output_table_with_dex_no_charts() {
1341        let analytics = make_test_analytics(true);
1342        let mut args = make_test_crawl_args();
1343        args.no_charts = true;
1344        let result = output_table_with_dex(&analytics, &args, &quiet());
1345        assert!(result.is_ok());
1346    }
1347
1348    #[test]
1349    fn test_output_table_with_dex_no_market_cap() {
1350        let mut analytics = make_test_analytics(true);
1351        analytics.market_cap = None;
1352        analytics.fdv = None;
1353        analytics.top_10_concentration = None;
1354        let args = make_test_crawl_args();
1355        let result = output_table_with_dex(&analytics, &args, &quiet());
1356        assert!(result.is_ok());
1357    }
1358
1359    #[test]
1360    fn test_output_table_explorer_with_concentration() {
1361        let mut analytics = make_test_analytics(false);
1362        analytics.top_10_concentration = Some(40.0);
1363        analytics.top_50_concentration = Some(60.0);
1364        let result = output_table_explorer_only(&analytics, &quiet());
1365        assert!(result.is_ok());
1366    }
1367
1368    #[test]
1369    fn test_output_table_explorer_no_supply() {
1370        let mut analytics = make_test_analytics(false);
1371        analytics.total_supply = None;
1372        let result = output_table_explorer_only(&analytics, &quiet());
1373        assert!(result.is_ok());
1374    }
1375
1376    #[test]
1377    fn test_output_table_explorer_with_supply_and_holders() {
1378        let mut analytics = make_test_analytics(false);
1379        analytics.total_supply = Some("1000000000".to_string());
1380        analytics.total_holders = 50_000;
1381        let result = output_table_explorer_only(&analytics, &quiet());
1382        assert!(result.is_ok());
1383    }
1384
1385    #[test]
1386    fn test_output_table_with_dex_multiple_pairs() {
1387        let mut analytics = make_test_analytics(true);
1388        for i in 0..8 {
1389            analytics.dex_pairs.push(DexPair {
1390                dex_name: format!("DEX {}", i),
1391                pair_address: format!("0xpair{}", i),
1392                base_token: "USDC".to_string(),
1393                quote_token: "WETH".to_string(),
1394                price_usd: 0.9999,
1395                volume_24h: 1_000_000.0 - (i as f64 * 100_000.0),
1396                liquidity_usd: 10_000_000.0 - (i as f64 * 1_000_000.0),
1397                liquidity_base: None,
1398                price_change_24h: 0.0,
1399                buys_24h: 100,
1400                sells_24h: 90,
1401                buys_6h: 30,
1402                sells_6h: 25,
1403                buys_1h: 5,
1404                sells_1h: 4,
1405                pair_created_at: None,
1406                url: None,
1407            });
1408        }
1409        let args = make_test_crawl_args();
1410        // Should only show top 5
1411        let result = output_table_with_dex(&analytics, &args, &quiet());
1412        assert!(result.is_ok());
1413    }
1414
1415    // ========================================================================
1416    // CrawlArgs CLI parsing tests
1417    // ========================================================================
1418
1419    #[test]
1420    fn test_crawl_args_with_report() {
1421        use clap::Parser;
1422
1423        #[derive(Parser)]
1424        struct TestCli {
1425            #[command(flatten)]
1426            crawl: CrawlArgs,
1427        }
1428
1429        let cli = TestCli::try_parse_from([
1430            "test",
1431            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1432            "--report",
1433            "output.md",
1434        ])
1435        .unwrap();
1436
1437        assert_eq!(
1438            cli.crawl.report,
1439            Some(std::path::PathBuf::from("output.md"))
1440        );
1441    }
1442
1443    #[test]
1444    fn test_crawl_args_with_chain_and_period() {
1445        use clap::Parser;
1446
1447        #[derive(Parser)]
1448        struct TestCli {
1449            #[command(flatten)]
1450            crawl: CrawlArgs,
1451        }
1452
1453        let cli = TestCli::try_parse_from([
1454            "test",
1455            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1456            "--chain",
1457            "polygon",
1458            "--period",
1459            "7d",
1460            "--no-charts",
1461            "--yes",
1462            "--save",
1463        ])
1464        .unwrap();
1465
1466        assert_eq!(cli.crawl.chain, "polygon");
1467        assert!(matches!(cli.crawl.period, Period::Day7));
1468        assert!(cli.crawl.no_charts);
1469        assert!(cli.crawl.yes);
1470        assert!(cli.crawl.save);
1471    }
1472
1473    #[test]
1474    fn test_crawl_args_all_periods() {
1475        use clap::Parser;
1476
1477        #[derive(Parser)]
1478        struct TestCli {
1479            #[command(flatten)]
1480            crawl: CrawlArgs,
1481        }
1482
1483        for (period_str, expected) in [
1484            ("1h", Period::Hour1),
1485            ("24h", Period::Hour24),
1486            ("7d", Period::Day7),
1487            ("30d", Period::Day30),
1488        ] {
1489            let cli = TestCli::try_parse_from(["test", "token", "--period", period_str]).unwrap();
1490            assert_eq!(cli.crawl.period.as_seconds(), expected.as_seconds());
1491        }
1492    }
1493
1494    // ========================================================================
1495    // JSON serialization test for TokenAnalytics
1496    // ========================================================================
1497
1498    #[test]
1499    fn test_analytics_json_serialization() {
1500        let analytics = make_test_analytics(true);
1501        let json = serde_json::to_string(&analytics).unwrap();
1502        assert!(json.contains("USDC"));
1503        assert!(json.contains("USD Coin"));
1504        assert!(json.contains("ethereum"));
1505        assert!(json.contains("0.9999"));
1506    }
1507
1508    #[test]
1509    fn test_analytics_json_no_optional_fields() {
1510        let mut analytics = make_test_analytics(false);
1511        analytics.market_cap = None;
1512        analytics.fdv = None;
1513        analytics.total_supply = None;
1514        analytics.top_10_concentration = None;
1515        analytics.top_50_concentration = None;
1516        analytics.top_100_concentration = None;
1517        analytics.token_age_hours = None;
1518        analytics.dexscreener_url = None;
1519        let json = serde_json::to_string(&analytics).unwrap();
1520        assert!(!json.contains("market_cap"));
1521        assert!(!json.contains("fdv"));
1522    }
1523
1524    // ========================================================================
1525    // End-to-end tests using MockClientFactory
1526    // ========================================================================
1527
1528    use scope::chains::mocks::{MockClientFactory, MockDexSource};
1529
1530    fn mock_factory_for_crawl() -> MockClientFactory {
1531        let mut factory = MockClientFactory::new();
1532        // Provide complete DexTokenData so crawl succeeds
1533        factory.mock_dex = MockDexSource::new();
1534        factory
1535    }
1536
1537    #[tokio::test]
1538    async fn test_run_crawl_json_output() {
1539        let config = Config::default();
1540        let factory = mock_factory_for_crawl();
1541        let args = CrawlArgs {
1542            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1543            chain: "ethereum".to_string(),
1544            period: Period::Hour24,
1545            holders_limit: 5,
1546            format: OutputFormat::Json,
1547            no_charts: true,
1548            report: None,
1549            yes: true,
1550            save: false,
1551            token_contract: None,
1552        };
1553        let result = super::run(args, &config, &factory, &quiet()).await;
1554        assert!(result.is_ok());
1555    }
1556
1557    #[tokio::test]
1558    async fn test_run_crawl_table_output() {
1559        let config = Config::default();
1560        let factory = mock_factory_for_crawl();
1561        let args = CrawlArgs {
1562            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1563            chain: "ethereum".to_string(),
1564            period: Period::Hour24,
1565            holders_limit: 5,
1566            format: OutputFormat::Table,
1567            no_charts: true,
1568            report: None,
1569            yes: true,
1570            save: false,
1571            token_contract: None,
1572        };
1573        let result = super::run(args, &config, &factory, &quiet()).await;
1574        assert!(result.is_ok());
1575    }
1576
1577    #[tokio::test]
1578    async fn test_run_crawl_csv_output() {
1579        let config = Config::default();
1580        let factory = mock_factory_for_crawl();
1581        let args = CrawlArgs {
1582            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1583            chain: "ethereum".to_string(),
1584            period: Period::Hour24,
1585            holders_limit: 5,
1586            format: OutputFormat::Csv,
1587            no_charts: true,
1588            report: None,
1589            yes: true,
1590            save: false,
1591            token_contract: None,
1592        };
1593        let result = super::run(args, &config, &factory, &quiet()).await;
1594        assert!(result.is_ok());
1595    }
1596
1597    #[tokio::test]
1598    async fn test_json_with_a_report_file_keeps_stdout_pure_json() {
1599        // `scope crawl X --format json --report r.md | jq`: the "saved to"
1600        // line must not land in the JSON.
1601        let dir = tempfile::tempdir().unwrap();
1602        let report_path = dir.path().join("r.md");
1603        let factory = MockClientFactory::new();
1604        let args = CrawlArgs {
1605            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1606            chain: "ethereum".to_string(),
1607            period: Period::Hour24,
1608            holders_limit: 5,
1609            format: OutputFormat::Json,
1610            no_charts: true,
1611            report: Some(report_path.clone()),
1612            yes: true,
1613            save: false,
1614            token_contract: None,
1615        };
1616        let (out, cap) = crate::cli::output::Output::capture();
1617        super::run(args, &Config::default(), &factory, &out)
1618            .await
1619            .unwrap();
1620        let json: serde_json::Value = serde_json::from_str(&cap.out())
1621            .unwrap_or_else(|e| panic!("stdout is not JSON ({e}): {}", cap.out()));
1622        assert!(json.is_object());
1623        assert!(report_path.exists());
1624        assert!(cap.err().contains("Report saved to: "), "{}", cap.err());
1625    }
1626
1627    #[tokio::test]
1628    async fn test_run_crawl_symbol_resolution_via_factory_dex() {
1629        // Verifies resolve_token_input uses DexDataSource from factory (not DexClient::new)
1630        let mut factory = MockClientFactory::new();
1631        factory.mock_dex.search_results = vec![TokenSearchResult {
1632            address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1633            symbol: "MOCK".to_string(),
1634            name: "Mock Token".to_string(),
1635            chain: "ethereum".to_string(),
1636            price_usd: Some(1.0),
1637            volume_24h: 1_000_000.0,
1638            liquidity_usd: 5_000_000.0,
1639            market_cap: Some(100_000_000.0),
1640        }];
1641        // Make token_data use the same address so fetch succeeds
1642        if let Some(ref mut td) = factory.mock_dex.token_data {
1643            td.address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string();
1644        }
1645
1646        let config = Config::default();
1647        let args = CrawlArgs {
1648            token: "MOCK".to_string(),
1649            chain: "ethereum".to_string(),
1650            period: Period::Hour24,
1651            holders_limit: 5,
1652            format: OutputFormat::Json,
1653            no_charts: true,
1654            report: None,
1655            yes: true,
1656            save: false,
1657            token_contract: None,
1658        };
1659        let result = super::run(args, &config, &factory, &quiet()).await;
1660        assert!(result.is_ok());
1661    }
1662
1663    #[tokio::test]
1664    async fn test_run_crawl_no_dex_data_evm() {
1665        let config = Config::default();
1666        let mut factory = MockClientFactory::new();
1667        factory.mock_dex.token_data = None; // No DEX data → falls back to explorer
1668        factory.mock_client.token_info = Some(Token {
1669            contract_address: "0xtoken".to_string(),
1670            symbol: "TEST".to_string(),
1671            name: "Test Token".to_string(),
1672            decimals: 18,
1673        });
1674        let args = CrawlArgs {
1675            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1676            chain: "ethereum".to_string(),
1677            period: Period::Hour24,
1678            holders_limit: 5,
1679            format: OutputFormat::Json,
1680            no_charts: true,
1681            report: None,
1682            yes: true,
1683            save: false,
1684            token_contract: None,
1685        };
1686        let result = super::run(args, &config, &factory, &quiet()).await;
1687        assert!(result.is_ok());
1688    }
1689
1690    #[tokio::test]
1691    async fn test_run_crawl_table_no_charts() {
1692        let config = Config::default();
1693        let factory = mock_factory_for_crawl();
1694        let args = CrawlArgs {
1695            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1696            chain: "ethereum".to_string(),
1697            period: Period::Hour24,
1698            holders_limit: 5,
1699            format: OutputFormat::Table,
1700            no_charts: true,
1701            report: None,
1702            yes: true,
1703            save: false,
1704            token_contract: None,
1705        };
1706        let result = super::run(args, &config, &factory, &quiet()).await;
1707        assert!(result.is_ok());
1708    }
1709
1710    #[tokio::test]
1711    async fn test_run_crawl_with_charts() {
1712        let config = Config::default();
1713        let factory = mock_factory_for_crawl();
1714        let args = CrawlArgs {
1715            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1716            chain: "ethereum".to_string(),
1717            period: Period::Hour1,
1718            holders_limit: 5,
1719            format: OutputFormat::Table,
1720            no_charts: false, // Charts enabled
1721            report: None,
1722            yes: true,
1723            save: false,
1724            token_contract: None,
1725        };
1726        let result = super::run(args, &config, &factory, &quiet()).await;
1727        assert!(result.is_ok());
1728    }
1729
1730    #[tokio::test]
1731    async fn test_run_crawl_day7_period() {
1732        let config = Config::default();
1733        let factory = mock_factory_for_crawl();
1734        let args = CrawlArgs {
1735            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1736            chain: "ethereum".to_string(),
1737            period: Period::Day7,
1738            holders_limit: 5,
1739            format: OutputFormat::Table,
1740            no_charts: true,
1741            report: None,
1742            yes: true,
1743            save: false,
1744            token_contract: None,
1745        };
1746        let result = super::run(args, &config, &factory, &quiet()).await;
1747        assert!(result.is_ok());
1748    }
1749
1750    #[tokio::test]
1751    async fn test_run_crawl_markdown_output() {
1752        let config = Config::default();
1753        let factory = mock_factory_for_crawl();
1754        let args = CrawlArgs {
1755            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1756            chain: "ethereum".to_string(),
1757            period: Period::Hour24,
1758            holders_limit: 5,
1759            format: OutputFormat::Markdown,
1760            no_charts: true,
1761            report: None,
1762            yes: true,
1763            save: false,
1764            token_contract: None,
1765        };
1766        let result = super::run(args, &config, &factory, &quiet()).await;
1767        assert!(result.is_ok());
1768    }
1769
1770    #[tokio::test]
1771    async fn test_run_crawl_unsupported_chain_no_dex() {
1772        // No DEX data + chain without explorer support → NotFound
1773        let mut factory = MockClientFactory::new();
1774        factory.mock_dex.token_data = None;
1775        factory.mock_dex.search_results = vec![];
1776
1777        let config = Config::default();
1778        let args = CrawlArgs {
1779            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1780            chain: "avalanche".to_string(), // Not in has_explorer list
1781            period: Period::Hour24,
1782            holders_limit: 5,
1783            format: OutputFormat::Json,
1784            no_charts: true,
1785            report: None,
1786            yes: true,
1787            save: false,
1788            token_contract: None,
1789        };
1790        let result = super::run(args, &config, &factory, &quiet()).await;
1791        assert!(result.is_err());
1792        let err_str = result.unwrap_err().to_string();
1793        assert!(
1794            err_str.contains("avalanche")
1795                || err_str.contains("block explorer")
1796                || err_str.contains("No DEX"),
1797            "Expected error about unsupported chain, got: {}",
1798            err_str
1799        );
1800    }
1801
1802    #[tokio::test]
1803    async fn test_run_crawl_day30_period() {
1804        let config = Config::default();
1805        let factory = mock_factory_for_crawl();
1806        let args = CrawlArgs {
1807            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1808            chain: "ethereum".to_string(),
1809            period: Period::Day30,
1810            holders_limit: 5,
1811            format: OutputFormat::Table,
1812            no_charts: true,
1813            report: None,
1814            yes: true,
1815            save: false,
1816            token_contract: None,
1817        };
1818        let result = super::run(args, &config, &factory, &quiet()).await;
1819        assert!(result.is_ok());
1820    }
1821
1822    #[test]
1823    fn test_output_table_with_dex_with_charts() {
1824        let analytics = make_test_analytics(true);
1825        let mut args = make_test_crawl_args();
1826        args.no_charts = false; // Enable charts
1827        let result = output_table_with_dex(&analytics, &args, &quiet());
1828        assert!(result.is_ok());
1829    }
1830
1831    #[test]
1832    fn test_output_table_explorer_short_addresses() {
1833        let mut analytics = make_test_analytics(false);
1834        analytics.holders = vec![TokenHolder {
1835            address: "0xshort".to_string(), // Short address
1836            balance: "100".to_string(),
1837            formatted_balance: "100".to_string(),
1838            percentage: 1.0,
1839            rank: 1,
1840        }];
1841        let result = output_table_explorer_only(&analytics, &quiet());
1842        assert!(result.is_ok());
1843    }
1844
1845    #[test]
1846    fn test_output_csv_with_all_fields() {
1847        let analytics = make_test_analytics(true);
1848        let result = output_csv(&analytics, &quiet());
1849        assert!(result.is_ok());
1850    }
1851
1852    #[tokio::test]
1853    async fn test_fetch_analytics_for_input() {
1854        let factory = mock_factory_for_crawl();
1855        let result = super::fetch_analytics_for_input(
1856            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1857            "ethereum",
1858            Period::Hour24,
1859            5,
1860            &factory,
1861            None,
1862            &quiet(),
1863        )
1864        .await;
1865        assert!(result.is_ok());
1866        let analytics = result.unwrap();
1867        assert_eq!(analytics.chain, "ethereum");
1868        assert!(!analytics.token.contract_address.is_empty());
1869    }
1870
1871    #[tokio::test]
1872    async fn test_run_crawl_chain_without_holder_support() {
1873        // Chain not in fetch_holders list (e.g. fantom) → Ok(Vec::new()) for holders
1874        let mut factory = mock_factory_for_crawl();
1875        if let Some(ref mut td) = factory.mock_dex.token_data {
1876            td.address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string();
1877        }
1878        let config = Config::default();
1879        let args = CrawlArgs {
1880            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1881            chain: "fantom".to_string(),
1882            period: Period::Hour24,
1883            holders_limit: 5,
1884            format: OutputFormat::Json,
1885            no_charts: true,
1886            report: None,
1887            yes: true,
1888            save: false,
1889            token_contract: None,
1890        };
1891        let result = super::run(args, &config, &factory, &quiet()).await;
1892        assert!(result.is_ok());
1893    }
1894
1895    #[tokio::test]
1896    async fn test_run_crawl_with_report() {
1897        let config = Config::default();
1898        let factory = mock_factory_for_crawl();
1899        let tmp = tempfile::NamedTempFile::new().unwrap();
1900        let args = CrawlArgs {
1901            token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
1902            chain: "ethereum".to_string(),
1903            period: Period::Hour24,
1904            holders_limit: 5,
1905            format: OutputFormat::Table,
1906            no_charts: true,
1907            report: Some(tmp.path().to_path_buf()),
1908            yes: true,
1909            save: false,
1910            token_contract: None,
1911        };
1912        let result = super::run(args, &config, &factory, &quiet()).await;
1913        assert!(result.is_ok());
1914        // Report file should exist and contain markdown
1915        let content = std::fs::read_to_string(tmp.path()).unwrap();
1916        assert!(content.contains("Token Analysis Report"));
1917    }
1918
1919    // ========================================================================
1920    // Additional output formatting coverage
1921    // ========================================================================
1922
1923    #[test]
1924    fn test_output_table_explorer_long_address_truncation() {
1925        let mut analytics = make_test_analytics(false);
1926        analytics.holders = vec![TokenHolder {
1927            address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
1928            balance: "1000000".to_string(),
1929            formatted_balance: "1,000,000".to_string(),
1930            percentage: 50.0,
1931            rank: 1,
1932        }];
1933        let result = output_table_explorer_only(&analytics, &quiet());
1934        assert!(result.is_ok());
1935    }
1936
1937    #[test]
1938    fn test_output_table_with_dex_empty_pairs() {
1939        let mut analytics = make_test_analytics(true);
1940        analytics.dex_pairs = vec![];
1941        let args = make_test_crawl_args();
1942        let result = output_table_with_dex(&analytics, &args, &quiet());
1943        assert!(result.is_ok());
1944    }
1945
1946    #[test]
1947    fn test_output_table_explorer_no_concentration() {
1948        let mut analytics = make_test_analytics(false);
1949        analytics.top_10_concentration = None;
1950        analytics.top_50_concentration = None;
1951        analytics.top_100_concentration = None;
1952        let result = output_table_explorer_only(&analytics, &quiet());
1953        assert!(result.is_ok());
1954    }
1955
1956    #[test]
1957    fn test_output_table_with_dex_top_10_only() {
1958        let mut analytics = make_test_analytics(true);
1959        analytics.top_10_concentration = Some(25.0);
1960        analytics.top_50_concentration = None;
1961        analytics.top_100_concentration = None;
1962        let args = make_test_crawl_args();
1963        let result = output_table_with_dex(&analytics, &args, &quiet());
1964        assert!(result.is_ok());
1965    }
1966
1967    #[test]
1968    fn test_output_table_with_dex_top_100_concentration() {
1969        let mut analytics = make_test_analytics(true);
1970        analytics.top_10_concentration = Some(20.0);
1971        analytics.top_50_concentration = Some(45.0);
1972        analytics.top_100_concentration = Some(65.0);
1973        let args = make_test_crawl_args();
1974        let result = output_table_with_dex(&analytics, &args, &quiet());
1975        assert!(result.is_ok());
1976    }
1977
1978    #[test]
1979    fn test_output_csv_with_market_cap_and_fdv() {
1980        let mut analytics = make_test_analytics(true);
1981        analytics.market_cap = Some(1_000_000_000.0);
1982        analytics.fdv = Some(1_500_000_000.0);
1983        let result = output_csv(&analytics, &quiet());
1984        assert!(result.is_ok());
1985    }
1986
1987    #[test]
1988    fn test_output_table_routing_has_dex_data() {
1989        let analytics = make_test_analytics(true);
1990        assert!(analytics.price_usd > 0.0);
1991        let args = make_test_crawl_args();
1992        let result = output_table(&analytics, &args, &quiet());
1993        assert!(result.is_ok());
1994    }
1995
1996    #[test]
1997    fn test_output_table_routing_no_dex_data() {
1998        let analytics = make_test_analytics(false);
1999        assert_eq!(analytics.price_usd, 0.0);
2000        let args = make_test_crawl_args();
2001        let result = output_table(&analytics, &args, &quiet());
2002        assert!(result.is_ok());
2003    }
2004
2005    #[test]
2006    fn test_format_large_number_negative() {
2007        let result = scope::display::format_large_number(-1_000_000.0);
2008        assert!(result.contains("M") || result.contains("-"));
2009    }
2010
2011    #[test]
2012    fn test_output_table_with_dex_with_holders() {
2013        let mut analytics = make_test_analytics(true);
2014        analytics.holders = vec![
2015            TokenHolder {
2016                address: "0xholder1".to_string(),
2017                balance: "1000000".to_string(),
2018                formatted_balance: "1,000,000".to_string(),
2019                percentage: 30.0,
2020                rank: 1,
2021            },
2022            TokenHolder {
2023                address: "0xholder2".to_string(),
2024                balance: "500000".to_string(),
2025                formatted_balance: "500,000".to_string(),
2026                percentage: 15.0,
2027                rank: 2,
2028            },
2029        ];
2030        let args = make_test_crawl_args();
2031        let result = output_table_with_dex(&analytics, &args, &quiet());
2032        assert!(result.is_ok());
2033    }
2034
2035    #[test]
2036    fn test_output_json() {
2037        let analytics = make_test_analytics(true);
2038        let result = serde_json::to_string_pretty(&analytics);
2039        assert!(result.is_ok());
2040    }
2041
2042    // ========================================================================
2043    // output_table and output_csv tests
2044    // ========================================================================
2045
2046    #[test]
2047    fn test_output_csv_no_panic() {
2048        let analytics = create_test_analytics_minimal();
2049        let result = output_csv(&analytics, &quiet());
2050        assert!(result.is_ok());
2051    }
2052
2053    #[test]
2054    fn test_output_table_no_dex_data() {
2055        // analytics with price_usd=0 → explorer-only output
2056        let analytics = create_test_analytics_minimal();
2057        let args = CrawlArgs {
2058            token: "0xtest".to_string(),
2059            chain: "ethereum".to_string(),
2060            period: Period::Hour24,
2061            holders_limit: 10,
2062            format: OutputFormat::Table,
2063            no_charts: true,
2064            report: None,
2065            yes: false,
2066            save: false,
2067            token_contract: None,
2068        };
2069        let result = output_table(&analytics, &args, &quiet());
2070        assert!(result.is_ok());
2071    }
2072
2073    #[test]
2074    fn test_output_table_with_dex_data_no_charts() {
2075        let mut analytics = create_test_analytics_minimal();
2076        analytics.price_usd = 1.0;
2077        analytics.volume_24h = 1_000_000.0;
2078        analytics.liquidity_usd = 500_000.0;
2079        analytics.market_cap = Some(1_000_000_000.0);
2080        analytics.fdv = Some(2_000_000_000.0);
2081
2082        let args = CrawlArgs {
2083            token: "0xtest".to_string(),
2084            chain: "ethereum".to_string(),
2085            period: Period::Hour24,
2086            holders_limit: 10,
2087            format: OutputFormat::Table,
2088            no_charts: true,
2089            report: None,
2090            yes: false,
2091            save: false,
2092            token_contract: None,
2093        };
2094        let result = output_table(&analytics, &args, &quiet());
2095        assert!(result.is_ok());
2096    }
2097
2098    #[test]
2099    fn test_output_table_with_dex_data_and_charts() {
2100        let mut analytics = create_test_analytics_minimal();
2101        analytics.price_usd = 1.0;
2102        analytics.volume_24h = 1_000_000.0;
2103        analytics.liquidity_usd = 500_000.0;
2104        analytics.price_history = vec![
2105            scope::chains::PricePoint {
2106                timestamp: 1,
2107                price: 0.99,
2108            },
2109            scope::chains::PricePoint {
2110                timestamp: 2,
2111                price: 1.01,
2112            },
2113        ];
2114        analytics.volume_history = vec![
2115            scope::chains::VolumePoint {
2116                timestamp: 1,
2117                volume: 50000.0,
2118            },
2119            scope::chains::VolumePoint {
2120                timestamp: 2,
2121                volume: 60000.0,
2122            },
2123        ];
2124
2125        let args = CrawlArgs {
2126            token: "0xtest".to_string(),
2127            chain: "ethereum".to_string(),
2128            period: Period::Hour24,
2129            holders_limit: 10,
2130            format: OutputFormat::Table,
2131            no_charts: false,
2132            report: None,
2133            yes: false,
2134            save: false,
2135            token_contract: None,
2136        };
2137        let result = output_table(&analytics, &args, &quiet());
2138        assert!(result.is_ok());
2139    }
2140
2141    fn create_test_analytics_minimal() -> TokenAnalytics {
2142        TokenAnalytics {
2143            token: Token {
2144                contract_address: "0xtest".to_string(),
2145                symbol: "TEST".to_string(),
2146                name: "Test Token".to_string(),
2147                decimals: 18,
2148            },
2149            chain: "ethereum".to_string(),
2150            holders: Vec::new(),
2151            total_holders: 0,
2152            volume_24h: 0.0,
2153            volume_7d: 0.0,
2154            price_usd: 0.0,
2155            price_change_24h: 0.0,
2156            price_change_7d: 0.0,
2157            liquidity_usd: 0.0,
2158            market_cap: None,
2159            fdv: None,
2160            total_supply: None,
2161            circulating_supply: None,
2162            price_history: Vec::new(),
2163            volume_history: Vec::new(),
2164            holder_history: Vec::new(),
2165            dex_pairs: Vec::new(),
2166            fetched_at: 0,
2167            top_10_concentration: None,
2168            top_50_concentration: None,
2169            top_100_concentration: None,
2170            price_change_6h: 0.0,
2171            price_change_1h: 0.0,
2172            total_buys_24h: 0,
2173            total_sells_24h: 0,
2174            total_buys_6h: 0,
2175            total_sells_6h: 0,
2176            total_buys_1h: 0,
2177            total_sells_1h: 0,
2178            token_age_hours: None,
2179            image_url: None,
2180            websites: Vec::new(),
2181            socials: Vec::new(),
2182            dexscreener_url: None,
2183        }
2184    }
2185}