riglr_web_tools/
dexscreener.rs

1//! DexScreener integration for comprehensive token market data and DEX analytics
2//!
3//! This module provides production-grade tools for accessing DexScreener data,
4//! analyzing token metrics, tracking price movements, and identifying trading opportunities.
5
6use crate::{client::WebClient, error::WebToolError};
7use chrono::{DateTime, Utc};
8use riglr_macros::tool;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use tracing::{debug, info};
13
14/// Configuration for DexScreener API access
15#[derive(Debug, Clone)]
16pub struct DexScreenerConfig {
17    /// API base URL (default: https://api.dexscreener.com/latest)
18    pub base_url: String,
19    /// Rate limit requests per minute (default: 300)
20    pub rate_limit_per_minute: u32,
21    /// Timeout for API requests in seconds (default: 30)
22    pub request_timeout: u64,
23}
24
25/// Comprehensive token information including price, volume, and market data
26#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
27pub struct TokenInfo {
28    /// Token contract address
29    pub address: String,
30    /// Token name
31    pub name: String,
32    /// Token symbol
33    pub symbol: String,
34    /// Token decimals
35    pub decimals: u32,
36    /// Current price in USD
37    pub price_usd: Option<f64>,
38    /// Market capitalization in USD
39    pub market_cap: Option<f64>,
40    /// 24h trading volume in USD
41    pub volume_24h: Option<f64>,
42    /// Price change percentage (24h)
43    pub price_change_24h: Option<f64>,
44    /// Price change percentage (1h)
45    pub price_change_1h: Option<f64>,
46    /// Price change percentage (5m)
47    pub price_change_5m: Option<f64>,
48    /// Circulating supply
49    pub circulating_supply: Option<f64>,
50    /// Total supply
51    pub total_supply: Option<f64>,
52    /// Number of active trading pairs
53    pub pair_count: u32,
54    /// Top trading pairs
55    pub pairs: Vec<TokenPair>,
56    /// Blockchain/chain information
57    pub chain: ChainInfo,
58    /// Verification status and security info
59    pub security: SecurityInfo,
60    /// Social and community links
61    pub socials: Vec<SocialLink>,
62    /// Last update timestamp
63    pub updated_at: DateTime<Utc>,
64}
65
66/// Trading pair information
67#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
68pub struct TokenPair {
69    /// Unique pair identifier
70    pub pair_id: String,
71    /// DEX name (e.g., "Uniswap V3", "PancakeSwap")
72    pub dex: DexInfo,
73    /// Base token information
74    pub base_token: PairToken,
75    /// Quote token information
76    pub quote_token: PairToken,
77    /// Current price
78    pub price_usd: Option<f64>,
79    /// Price in native token units
80    pub price_native: Option<f64>,
81    /// 24h trading volume in USD
82    pub volume_24h: Option<f64>,
83    /// 24h price change percentage
84    pub price_change_24h: Option<f64>,
85    /// Total liquidity in USD
86    pub liquidity_usd: Option<f64>,
87    /// Fully diluted valuation
88    pub fdv: Option<f64>,
89    /// Pair creation timestamp
90    pub created_at: Option<DateTime<Utc>>,
91    /// Latest trade timestamp
92    pub last_trade_at: DateTime<Utc>,
93    /// Number of transactions (24h)
94    pub txns_24h: TransactionStats,
95    /// Pair URL on the DEX
96    pub url: String,
97}
98
99/// DEX platform information
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
101pub struct DexInfo {
102    /// DEX identifier
103    pub id: String,
104    /// DEX name
105    pub name: String,
106    /// DEX URL
107    pub url: Option<String>,
108    /// DEX logo URL
109    pub logo: Option<String>,
110}
111
112/// Token information within a pair
113#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
114pub struct PairToken {
115    /// Token contract address
116    pub address: String,
117    /// Token name
118    pub name: String,
119    /// Token symbol
120    pub symbol: String,
121}
122
123/// Transaction statistics for a trading pair
124#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
125pub struct TransactionStats {
126    /// Number of buy transactions (24h)
127    pub buys: Option<u32>,
128    /// Number of sell transactions (24h)
129    pub sells: Option<u32>,
130    /// Total number of transactions (24h)
131    pub total: Option<u32>,
132    /// Buy volume in USD (24h)
133    pub buy_volume_usd: Option<f64>,
134    /// Sell volume in USD (24h)
135    pub sell_volume_usd: Option<f64>,
136}
137
138/// Blockchain/chain information
139#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
140pub struct ChainInfo {
141    /// Chain identifier (e.g., "ethereum", "bsc", "polygon")
142    pub id: String,
143    /// Chain name
144    pub name: String,
145    /// Chain logo URL
146    pub logo: Option<String>,
147    /// Native token symbol for this chain
148    pub native_token: String,
149}
150
151/// Token security and verification information
152#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
153pub struct SecurityInfo {
154    /// Whether the token contract is verified
155    pub is_verified: bool,
156    /// Whether liquidity is locked
157    pub liquidity_locked: Option<bool>,
158    /// Contract audit status
159    pub audit_status: Option<String>,
160    /// Honeypot detection result
161    pub honeypot_status: Option<String>,
162    /// Contract ownership status
163    pub ownership_status: Option<String>,
164    /// Risk score (0-100, lower is better)
165    pub risk_score: Option<u32>,
166}
167
168/// Social media and community links
169#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
170pub struct SocialLink {
171    /// Platform name (e.g., "twitter", "telegram", "discord")
172    pub platform: String,
173    /// Profile URL
174    pub url: String,
175    /// Follower count (if available)
176    pub followers: Option<u32>,
177}
178
179/// Market analysis result
180#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
181pub struct MarketAnalysis {
182    /// Token being analyzed
183    pub token: TokenInfo,
184    /// Market trend analysis
185    pub trend_analysis: TrendAnalysis,
186    /// Volume analysis
187    pub volume_analysis: VolumeAnalysis,
188    /// Liquidity analysis
189    pub liquidity_analysis: LiquidityAnalysis,
190    /// Price level analysis
191    pub price_levels: PriceLevelAnalysis,
192    /// Risk assessment
193    pub risk_assessment: RiskAssessment,
194    /// Analysis timestamp
195    pub analyzed_at: DateTime<Utc>,
196}
197
198/// Market trend analysis including direction, momentum, and key price levels
199#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
200pub struct TrendAnalysis {
201    /// Overall trend direction (Bullish, Bearish, Neutral)
202    pub direction: String,
203    /// Trend strength (1-10)
204    pub strength: u32,
205    /// Momentum score (-100 to 100)
206    pub momentum: f64,
207    /// Price velocity (rate of change)
208    pub velocity: f64,
209    /// Support levels
210    pub support_levels: Vec<f64>,
211    /// Resistance levels
212    pub resistance_levels: Vec<f64>,
213}
214
215/// Volume analysis including trends, ratios, and trading activity metrics
216#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
217pub struct VolumeAnalysis {
218    /// Volume rank among all tokens
219    pub volume_rank: Option<u32>,
220    /// Volume trend (Increasing, Decreasing, Stable)
221    pub volume_trend: String,
222    /// Volume/Market Cap ratio
223    pub volume_mcap_ratio: Option<f64>,
224    /// Average volume (7 days)
225    pub avg_volume_7d: Option<f64>,
226    /// Volume spike factor (current vs average)
227    pub spike_factor: Option<f64>,
228}
229
230/// Liquidity analysis including depth, distribution, and price impact calculations
231#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
232pub struct LiquidityAnalysis {
233    /// Total liquidity across all pairs
234    pub total_liquidity_usd: f64,
235    /// Liquidity distribution across DEXs
236    pub dex_distribution: HashMap<String, f64>,
237    /// Price impact for different trade sizes
238    pub price_impact: HashMap<String, f64>, // "1k", "10k", "100k" -> impact %
239    /// Liquidity depth score (1-100)
240    pub depth_score: u32,
241}
242
243/// Price level analysis
244#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
245pub struct PriceLevelAnalysis {
246    /// All-time high price
247    pub ath: Option<f64>,
248    /// All-time low price
249    pub atl: Option<f64>,
250    /// Distance from ATH (percentage)
251    pub ath_distance_pct: Option<f64>,
252    /// Distance from ATL (percentage)
253    pub atl_distance_pct: Option<f64>,
254    /// 24h high
255    pub high_24h: Option<f64>,
256    /// 24h low
257    pub low_24h: Option<f64>,
258    /// Current price position in 24h range (0-1)
259    pub range_position: Option<f64>,
260}
261
262/// Comprehensive risk assessment including liquidity, volatility, and contract risks
263#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
264pub struct RiskAssessment {
265    /// Overall risk level (Low, Medium, High, Extreme)
266    pub risk_level: String,
267    /// Detailed risk factors
268    pub risk_factors: Vec<RiskFactor>,
269    /// Liquidity risk score (1-100)
270    pub liquidity_risk: u32,
271    /// Volatility risk score (1-100)
272    pub volatility_risk: u32,
273    /// Smart contract risk score (1-100)
274    pub contract_risk: u32,
275}
276
277/// Individual risk factor
278#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
279pub struct RiskFactor {
280    /// Risk category
281    pub category: String,
282    /// Risk description
283    pub description: String,
284    /// Severity (Low, Medium, High)
285    pub severity: String,
286    /// Impact score (1-100)
287    pub impact: u32,
288}
289
290/// Token search results with metadata and execution information
291#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
292pub struct TokenSearchResult {
293    /// Search query used
294    pub query: String,
295    /// List of tokens found in search
296    pub tokens: Vec<TokenInfo>,
297    /// Search metadata
298    pub metadata: SearchMetadata,
299    /// Search timestamp
300    pub searched_at: DateTime<Utc>,
301}
302
303/// Metadata for search results
304#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
305pub struct SearchMetadata {
306    /// Number of results found
307    pub result_count: u32,
308    /// Search execution time (ms)
309    pub execution_time_ms: u32,
310    /// Whether results were limited
311    pub limited: bool,
312    /// Suggested alternative queries
313    pub suggestions: Vec<String>,
314}
315
316impl Default for DexScreenerConfig {
317    fn default() -> Self {
318        Self {
319            base_url: "https://api.dexscreener.com".to_string(),
320            rate_limit_per_minute: 300,
321            request_timeout: 30,
322        }
323    }
324}
325
326/// Get comprehensive token information from DexScreener
327///
328/// This tool retrieves detailed token information including price, volume,
329/// market cap, trading pairs, and security analysis.
330#[tool]
331pub async fn get_token_info(
332    _context: &riglr_core::provider::ApplicationContext,
333    token_address: String,
334    chain_id: Option<String>,
335    include_pairs: Option<bool>,
336    include_security: Option<bool>,
337) -> crate::error::Result<TokenInfo> {
338    debug!(
339        "Fetching token info for address: {} on chain: {:?}",
340        token_address,
341        chain_id.as_deref().unwrap_or("auto-detect")
342    );
343
344    let config = DexScreenerConfig::default();
345    let client = WebClient::default();
346
347    // Build API endpoint using new v1 endpoint with chainId
348    let chain = chain_id.unwrap_or_else(|| "ethereum".to_string());
349    let url = if include_pairs.unwrap_or(true) {
350        format!("{}/tokens/v1/{}/{}", config.base_url, chain, token_address)
351    } else {
352        format!(
353            "{}/tokens/v1/{}/{}?fields=basic",
354            config.base_url, chain, token_address
355        )
356    };
357
358    // Make API request
359    let response = client.get(&url).await.map_err(|e| {
360        if e.to_string().contains("timeout") || e.to_string().contains("connection") {
361            WebToolError::Network(format!("Failed to fetch token info: {}", e))
362        } else {
363            WebToolError::Api(format!("Failed to fetch token info: {}", e))
364        }
365    })?;
366
367    // Parse response (simplified - would parse actual DexScreener JSON)
368    let token_info = parse_token_response(&response, &token_address, &chain, include_security)
369        .await
370        .map_err(|e| WebToolError::Api(format!("Failed to parse token response: {}", e)))?;
371
372    info!(
373        "Retrieved token info for {} ({}): ${:.6}",
374        token_info.symbol,
375        token_info.name,
376        token_info.price_usd.unwrap_or(0.0)
377    );
378
379    Ok(token_info)
380}
381
382/// Search for tokens on DexScreener
383///
384/// This tool searches for tokens by name, symbol, or address
385/// with support for filtering by chain and market cap.
386#[tool]
387pub async fn search_tokens(
388    _context: &riglr_core::provider::ApplicationContext,
389    query: String,
390    chain_filter: Option<String>,
391    min_market_cap: Option<f64>,
392    min_liquidity: Option<f64>,
393    limit: Option<u32>,
394) -> crate::error::Result<TokenSearchResult> {
395    debug!("Searching tokens for query: '{}' with filters", query);
396
397    let config = DexScreenerConfig::default();
398    let client = WebClient::default();
399
400    // Build search parameters
401    let mut params = HashMap::new();
402    params.insert("q".to_string(), query.clone());
403
404    if let Some(chain) = chain_filter {
405        params.insert("chain".to_string(), chain);
406    }
407
408    if let Some(min_mc) = min_market_cap {
409        params.insert("min_market_cap".to_string(), min_mc.to_string());
410    }
411
412    if let Some(min_liq) = min_liquidity {
413        params.insert("min_liquidity".to_string(), min_liq.to_string());
414    }
415
416    params.insert("limit".to_string(), limit.unwrap_or(20).to_string());
417
418    // Make search request
419    let url = format!("{}/dex/search", config.base_url);
420    let response = client
421        .get_with_params(&url, &params)
422        .await
423        .map_err(|e| WebToolError::Network(format!("Search request failed: {}", e)))?;
424
425    // Parse search results
426    let tokens = parse_search_results(&response)
427        .await
428        .map_err(|e| WebToolError::Api(format!("Failed to parse search results: {}", e)))?;
429
430    let result = TokenSearchResult {
431        query: query.clone(),
432        tokens: tokens.clone(),
433        metadata: SearchMetadata {
434            result_count: tokens.len() as u32,
435            execution_time_ms: 150, // Would measure actual time
436            limited: tokens.len() >= limit.unwrap_or(20) as usize,
437            suggestions: vec![], // Would provide from API
438        },
439        searched_at: Utc::now(),
440    };
441
442    info!(
443        "Token search completed: {} results for '{}'",
444        result.tokens.len(),
445        query
446    );
447
448    Ok(result)
449}
450
451/// Get trending tokens from DexScreener
452///
453/// This tool retrieves trending tokens based on volume,
454/// price changes, and social activity.
455#[tool]
456pub async fn get_trending_tokens(
457    _context: &riglr_core::provider::ApplicationContext,
458    time_window: Option<String>, // "5m", "1h", "24h"
459    chain_filter: Option<String>,
460    min_volume: Option<f64>,
461    limit: Option<u32>,
462) -> crate::error::Result<Vec<TokenInfo>> {
463    debug!(
464        "Fetching trending tokens for window: {:?}",
465        time_window.as_deref().unwrap_or("1h")
466    );
467
468    let config = DexScreenerConfig::default();
469    let client = WebClient::default();
470
471    // Build trending endpoint
472    let window = time_window.unwrap_or_else(|| "1h".to_string());
473    let mut params = HashMap::new();
474    params.insert("window".to_string(), window);
475    params.insert("limit".to_string(), limit.unwrap_or(50).to_string());
476
477    if let Some(chain) = chain_filter {
478        params.insert("chain".to_string(), chain);
479    }
480
481    if let Some(min_vol) = min_volume {
482        params.insert("min_volume".to_string(), min_vol.to_string());
483    }
484
485    let url = format!("{}/dex/tokens/trending", config.base_url);
486    let response = client
487        .get_with_params(&url, &params)
488        .await
489        .map_err(|e| WebToolError::Network(format!("Failed to fetch trending tokens: {}", e)))?;
490
491    let trending_tokens = parse_trending_response(&response)
492        .await
493        .map_err(|e| WebToolError::Api(format!("Failed to parse trending response: {}", e)))?;
494
495    info!("Retrieved {} trending tokens", trending_tokens.len());
496
497    Ok(trending_tokens)
498}
499
500/// Analyze token market data using heuristic-based calculations
501///
502/// This tool provides market analysis based on available on-chain data including:
503/// - Simple trend analysis based on price changes
504/// - Volume pattern calculations from 24h data
505/// - Liquidity assessment from DEX pairs
506/// - Basic risk evaluation using heuristic scoring
507///
508/// Note: This is not a substitute for professional financial analysis or
509/// machine learning models. All calculations are rule-based heuristics.
510#[tool]
511pub async fn analyze_token_market(
512    _context: &riglr_core::provider::ApplicationContext,
513    token_address: String,
514    chain_id: Option<String>,
515    _include_technical: Option<bool>,
516    include_risk: Option<bool>,
517) -> crate::error::Result<MarketAnalysis> {
518    debug!("Performing market analysis for token: {}", token_address);
519
520    // Get basic token info first
521    let token_info = get_token_info(
522        _context,
523        token_address.clone(),
524        chain_id,
525        Some(true),
526        include_risk,
527    )
528    .await?;
529
530    // Perform trend analysis
531    let trend_analysis = analyze_price_trends(&token_info)
532        .await
533        .map_err(|e| WebToolError::Api(format!("Trend analysis failed: {}", e)))?;
534
535    // Analyze volume patterns
536    let volume_analysis = analyze_volume_patterns(&token_info)
537        .await
538        .map_err(|e| WebToolError::Api(format!("Volume analysis failed: {}", e)))?;
539
540    // Assess liquidity
541    let liquidity_analysis = analyze_liquidity(&token_info)
542        .await
543        .map_err(|e| WebToolError::Api(format!("Liquidity analysis failed: {}", e)))?;
544
545    // Analyze price levels
546    let price_levels = analyze_price_levels(&token_info)
547        .await
548        .map_err(|e| WebToolError::Api(format!("Price level analysis failed: {}", e)))?;
549
550    // Perform risk assessment
551    let risk_assessment = if include_risk.unwrap_or(true) {
552        assess_token_risks(&token_info)
553            .await
554            .map_err(|e| WebToolError::Api(format!("Risk assessment failed: {}", e)))?
555    } else {
556        RiskAssessment {
557            risk_level: "Unknown".to_string(),
558            risk_factors: vec![],
559            liquidity_risk: 50,
560            volatility_risk: 50,
561            contract_risk: 50,
562        }
563    };
564
565    let analysis = MarketAnalysis {
566        token: token_info.clone(),
567        trend_analysis,
568        volume_analysis,
569        liquidity_analysis,
570        price_levels,
571        risk_assessment,
572        analyzed_at: Utc::now(),
573    };
574
575    info!(
576        "Market analysis completed for {} - Risk: {}, Trend: {}",
577        token_info.symbol, analysis.risk_assessment.risk_level, analysis.trend_analysis.direction
578    );
579
580    Ok(analysis)
581}
582
583/// Get top DEX pairs by volume across all chains
584///
585/// This tool retrieves the highest volume trading pairs,
586/// useful for identifying active markets and arbitrage opportunities.
587#[tool]
588pub async fn get_top_pairs(
589    _context: &riglr_core::provider::ApplicationContext,
590    time_window: Option<String>, // "5m", "1h", "24h"
591    chain_filter: Option<String>,
592    dex_filter: Option<String>,
593    min_liquidity: Option<f64>,
594    limit: Option<u32>,
595) -> crate::error::Result<Vec<TokenPair>> {
596    debug!(
597        "Fetching top pairs for window: {:?}",
598        time_window.as_deref().unwrap_or("24h")
599    );
600
601    let config = DexScreenerConfig::default();
602    let client = WebClient::default();
603
604    let mut params = HashMap::new();
605    params.insert("sort".to_string(), "volume".to_string());
606    params.insert(
607        "window".to_string(),
608        time_window.unwrap_or_else(|| "24h".to_string()),
609    );
610    params.insert("limit".to_string(), limit.unwrap_or(100).to_string());
611
612    if let Some(chain) = chain_filter {
613        params.insert("chain".to_string(), chain);
614    }
615
616    if let Some(dex) = dex_filter {
617        params.insert("dex".to_string(), dex);
618    }
619
620    if let Some(min_liq) = min_liquidity {
621        params.insert("min_liquidity".to_string(), min_liq.to_string());
622    }
623
624    let url = format!("{}/dex/pairs/top", config.base_url);
625    let response = client
626        .get_with_params(&url, &params)
627        .await
628        .map_err(|e| WebToolError::Network(format!("Failed to fetch top pairs: {}", e)))?;
629
630    let pairs = parse_pairs_response(&response)
631        .await
632        .map_err(|e| WebToolError::Api(format!("Failed to parse pairs response: {}", e)))?;
633
634    info!("Retrieved {} top trading pairs", pairs.len());
635
636    Ok(pairs)
637}
638
639/// Get latest token profiles from DexScreener
640///
641/// This tool retrieves the latest token profiles with social links
642/// and metadata from the DexScreener platform.
643#[tool]
644pub async fn get_latest_token_profiles(
645    _context: &riglr_core::provider::ApplicationContext,
646    limit: Option<u32>,
647) -> crate::error::Result<Vec<crate::dexscreener_api::TokenProfile>> {
648    debug!("Fetching latest token profiles");
649
650    let profiles = crate::dexscreener_api::get_latest_token_profiles()
651        .await
652        .map_err(|e| WebToolError::Api(format!("Failed to fetch token profiles: {}", e)))?;
653
654    let limited_profiles = if let Some(limit) = limit {
655        profiles.into_iter().take(limit as usize).collect()
656    } else {
657        profiles
658    };
659
660    info!("Retrieved {} token profiles", limited_profiles.len());
661
662    Ok(limited_profiles)
663}
664
665/// Get latest boosted tokens from DexScreener
666///
667/// This tool retrieves tokens that have been recently boosted
668/// on the DexScreener platform.
669#[tool]
670pub async fn get_latest_boosted_tokens(
671    _context: &riglr_core::provider::ApplicationContext,
672    limit: Option<u32>,
673) -> crate::error::Result<Vec<crate::dexscreener_api::BoostsResponse>> {
674    debug!("Fetching latest boosted tokens");
675
676    let boosts = crate::dexscreener_api::get_latest_token_boosts()
677        .await
678        .map_err(|e| WebToolError::Api(format!("Failed to fetch latest boosts: {}", e)))?;
679
680    let limited_boosts = if let Some(limit) = limit {
681        boosts.into_iter().take(limit as usize).collect()
682    } else {
683        boosts
684    };
685
686    info!("Retrieved {} boosted tokens", limited_boosts.len());
687
688    Ok(limited_boosts)
689}
690
691/// Get top boosted tokens from DexScreener
692///
693/// This tool retrieves tokens with the most active boosts
694/// on the DexScreener platform.
695#[tool]
696pub async fn get_top_boosted_tokens(
697    _context: &riglr_core::provider::ApplicationContext,
698    limit: Option<u32>,
699) -> crate::error::Result<Vec<crate::dexscreener_api::BoostsResponse>> {
700    debug!("Fetching top boosted tokens");
701
702    let boosts = crate::dexscreener_api::get_top_token_boosts()
703        .await
704        .map_err(|e| WebToolError::Api(format!("Failed to fetch top boosts: {}", e)))?;
705
706    let limited_boosts = if let Some(limit) = limit {
707        boosts.into_iter().take(limit as usize).collect()
708    } else {
709        boosts
710    };
711
712    info!("Retrieved {} top boosted tokens", limited_boosts.len());
713
714    Ok(limited_boosts)
715}
716
717/// Check orders for a specific token
718///
719/// This tool retrieves paid orders for a token including
720/// token profiles, community takeovers, and ads.
721#[tool]
722pub async fn check_token_orders(
723    _context: &riglr_core::provider::ApplicationContext,
724    chain_id: String,
725    token_address: String,
726) -> crate::error::Result<Vec<crate::dexscreener_api::Order>> {
727    debug!(
728        "Checking orders for token {} on chain {}",
729        token_address, chain_id
730    );
731
732    let orders = crate::dexscreener_api::get_token_orders(&chain_id, &token_address)
733        .await
734        .map_err(|e| WebToolError::Api(format!("Failed to fetch token orders: {}", e)))?;
735
736    info!(
737        "Retrieved {} orders for token {} on {}",
738        orders.len(),
739        token_address,
740        chain_id
741    );
742
743    Ok(orders)
744}
745
746async fn parse_token_response(
747    response: &str,
748    token_address: &str,
749    chain: &str,
750    _include_security: Option<bool>,
751) -> crate::error::Result<TokenInfo> {
752    // Parse actual DexScreener JSON response into raw structs
753    let dex_response_raw: crate::dexscreener_api::DexScreenerResponseRaw =
754        serde_json::from_str(response).map_err(|e| {
755            crate::error::WebToolError::Parsing(format!(
756                "Failed to parse DexScreener response: {}",
757                e
758            ))
759        })?;
760
761    // Convert raw to clean types
762    let dex_response: crate::dexscreener_api::DexScreenerResponse = dex_response_raw.into();
763
764    debug!("Parsed response with {} pairs", dex_response.pairs.len());
765
766    // Use the aggregate_token_info helper to create TokenInfo from clean pairs
767    let token_info_opt =
768        crate::dexscreener_api::aggregate_token_info(dex_response.pairs, token_address);
769
770    if let Some(mut token_info) = token_info_opt {
771        // Override chain info with the provided chain parameter
772        token_info.chain = ChainInfo {
773            id: chain.to_string(),
774            name: format_chain_name(chain),
775            logo: None,
776            native_token: get_native_token(chain),
777        };
778        return Ok(token_info);
779    }
780
781    Err(crate::error::WebToolError::Api(format!(
782        "No pairs found for token address: {}",
783        token_address
784    )))
785}
786
787/// Format a DEX ID into a human-readable display name
788pub fn format_dex_name(dex_id: &str) -> String {
789    match dex_id {
790        "uniswap" => "Uniswap V2".to_string(),
791        "uniswapv3" => "Uniswap V3".to_string(),
792        "pancakeswap" => "PancakeSwap".to_string(),
793        "sushiswap" => "SushiSwap".to_string(),
794        "curve" => "Curve".to_string(),
795        "balancer" => "Balancer".to_string(),
796        "quickswap" => "QuickSwap".to_string(),
797        "raydium" => "Raydium".to_string(),
798        "orca" => "Orca".to_string(),
799        _ => dex_id.to_string(),
800    }
801}
802
803/// Format a chain ID into a human-readable display name
804pub fn format_chain_name(chain_id: &str) -> String {
805    match chain_id {
806        "ethereum" => "Ethereum".to_string(),
807        "bsc" => "Binance Smart Chain".to_string(),
808        "polygon" => "Polygon".to_string(),
809        "arbitrum" => "Arbitrum".to_string(),
810        "optimism" => "Optimism".to_string(),
811        "avalanche" => "Avalanche".to_string(),
812        "fantom" => "Fantom".to_string(),
813        "solana" => "Solana".to_string(),
814        _ => chain_id.to_string(),
815    }
816}
817
818/// Get the native token symbol for a given blockchain
819pub fn get_native_token(chain_id: &str) -> String {
820    match chain_id {
821        "ethereum" => "ETH".to_string(),
822        "bsc" => "BNB".to_string(),
823        "polygon" => "MATIC".to_string(),
824        "arbitrum" => "ETH".to_string(),
825        "optimism" => "ETH".to_string(),
826        "avalanche" => "AVAX".to_string(),
827        "fantom" => "FTM".to_string(),
828        "solana" => "SOL".to_string(),
829        _ => "NATIVE".to_string(),
830    }
831}
832
833/// Parse search results from DexScreener API
834async fn parse_search_results(response: &str) -> crate::error::Result<Vec<TokenInfo>> {
835    // Parse into raw response and convert to clean types
836    let dex_response_raw: crate::dexscreener_api::DexScreenerResponseRaw =
837        serde_json::from_str(response)
838            .map_err(|e| crate::error::WebToolError::Parsing(e.to_string()))?;
839    let dex_response: crate::dexscreener_api::DexScreenerResponse = dex_response_raw.into();
840
841    // Group pairs by token and aggregate data
842    let mut tokens_map = std::collections::HashMap::new();
843
844    for pair in dex_response.pairs {
845        let token_address = pair.base_token.address.clone();
846        let entry = tokens_map
847            .entry(token_address.clone())
848            .or_insert(TokenInfo {
849                address: token_address,
850                name: pair.base_token.name.clone(),
851                symbol: pair.base_token.symbol.clone(),
852                decimals: 18, // Default, as DexScreener doesn't provide this
853                price_usd: pair.price_usd,
854                market_cap: pair.market_cap,
855                volume_24h: pair.volume.as_ref().and_then(|v| v.h24),
856                price_change_24h: pair.price_change.as_ref().and_then(|pc| pc.h24),
857                price_change_1h: pair.price_change.as_ref().and_then(|pc| pc.h1),
858                price_change_5m: pair.price_change.as_ref().and_then(|pc| pc.m5),
859                circulating_supply: None,
860                total_supply: None,
861                pair_count: 0,
862                pairs: vec![],
863                chain: ChainInfo {
864                    id: pair.chain_id.clone(),
865                    name: pair.chain_id.clone(), // Using chain_id as name for simplicity
866                    logo: None,
867                    native_token: "ETH".to_string(), // Default to ETH for simplicity
868                },
869                security: SecurityInfo {
870                    is_verified: false,
871                    liquidity_locked: None,
872                    audit_status: None,
873                    honeypot_status: None,
874                    ownership_status: None,
875                    risk_score: None,
876                },
877                socials: vec![],
878                updated_at: chrono::Utc::now(),
879            });
880
881        // Add this pair to the token's pairs list
882        entry.pairs.push(TokenPair {
883            pair_id: pair.pair_address.clone(),
884            dex: DexInfo {
885                id: pair.dex_id.clone(),
886                name: pair.dex_id.clone(), // Using dex_id as name for simplicity
887                url: Some(pair.url.clone()),
888                logo: None,
889            },
890            base_token: PairToken {
891                address: pair.base_token.address.clone(),
892                name: pair.base_token.name.clone(),
893                symbol: pair.base_token.symbol.clone(),
894            },
895            quote_token: PairToken {
896                address: pair.quote_token.address.clone(),
897                name: pair.quote_token.name.clone(),
898                symbol: pair.quote_token.symbol.clone(),
899            },
900            price_usd: pair.price_usd,
901            price_native: Some(pair.price_native),
902            volume_24h: pair.volume.and_then(|v| v.h24),
903            price_change_24h: pair.price_change.and_then(|pc| pc.h24),
904            liquidity_usd: pair.liquidity.and_then(|l| l.usd),
905            fdv: pair.fdv,
906            created_at: None,
907            last_trade_at: chrono::Utc::now(),
908            txns_24h: TransactionStats {
909                buys: pair
910                    .txns
911                    .as_ref()
912                    .and_then(|t| t.h24.as_ref().and_then(|h| h.buys.map(|b| b as u32))),
913                sells: pair
914                    .txns
915                    .as_ref()
916                    .and_then(|t| t.h24.as_ref().and_then(|h| h.sells.map(|s| s as u32))),
917                total: None,
918                buy_volume_usd: None,
919                sell_volume_usd: None,
920            },
921            url: pair.url,
922        });
923        entry.pair_count += 1;
924    }
925
926    Ok(tokens_map.into_values().collect())
927}
928
929async fn parse_trending_response(response: &str) -> crate::error::Result<Vec<TokenInfo>> {
930    // Same as search results for now
931    parse_search_results(response).await
932}
933
934/// Parse trading pairs response
935async fn parse_pairs_response(response: &str) -> crate::error::Result<Vec<TokenPair>> {
936    // Parse into raw response and convert to clean types
937    let dex_response_raw: crate::dexscreener_api::DexScreenerResponseRaw =
938        serde_json::from_str(response)
939            .map_err(|e| crate::error::WebToolError::Parsing(e.to_string()))?;
940    let dex_response: crate::dexscreener_api::DexScreenerResponse = dex_response_raw.into();
941
942    let pairs: Vec<TokenPair> = dex_response
943        .pairs
944        .into_iter()
945        .map(|pair| TokenPair {
946            pair_id: pair.pair_address.clone(),
947            dex: DexInfo {
948                id: pair.dex_id.clone(),
949                name: pair.dex_id.clone(),
950                url: Some(pair.url.clone()),
951                logo: None,
952            },
953            base_token: PairToken {
954                address: pair.base_token.address.clone(),
955                name: pair.base_token.name.clone(),
956                symbol: pair.base_token.symbol.clone(),
957            },
958            quote_token: PairToken {
959                address: pair.quote_token.address.clone(),
960                name: pair.quote_token.name.clone(),
961                symbol: pair.quote_token.symbol.clone(),
962            },
963            price_usd: pair.price_usd,
964            price_native: Some(pair.price_native),
965            volume_24h: pair.volume.and_then(|v| v.h24),
966            price_change_24h: pair.price_change.and_then(|pc| pc.h24),
967            liquidity_usd: pair.liquidity.and_then(|l| l.usd),
968            fdv: pair.fdv,
969            created_at: None,
970            last_trade_at: chrono::Utc::now(),
971            txns_24h: TransactionStats {
972                buys: pair
973                    .txns
974                    .as_ref()
975                    .and_then(|t| t.h24.as_ref().and_then(|h| h.buys.map(|b| b as u32))),
976                sells: pair
977                    .txns
978                    .as_ref()
979                    .and_then(|t| t.h24.as_ref().and_then(|h| h.sells.map(|s| s as u32))),
980                total: None,
981                buy_volume_usd: None,
982                sell_volume_usd: None,
983            },
984            url: pair.url,
985        })
986        .collect();
987
988    Ok(pairs)
989}
990
991async fn analyze_price_trends(token: &TokenInfo) -> crate::error::Result<TrendAnalysis> {
992    // Calculate trend based on available price change data
993    let price_change_24h = token.price_change_24h;
994    let price_change_1h = token.price_change_1h;
995
996    let direction = match price_change_24h {
997        Some(change) if change > 5.0 => "Bullish",
998        Some(change) if change < -5.0 => "Bearish",
999        Some(_) => "Neutral",
1000        None => "Unknown",
1001    }
1002    .to_string();
1003
1004    let strength =
1005        price_change_24h.map_or(5, |change| ((change.abs() / 10.0).clamp(1.0, 10.0)) as u32); // Default to medium strength if no data
1006
1007    // Calculate momentum and velocity only if we have data
1008    let momentum = match (price_change_1h, price_change_24h) {
1009        (Some(h1), Some(h24)) => h1 * 24.0 - h24, // Acceleration
1010        _ => 0.0,
1011    };
1012
1013    let velocity = price_change_24h.map_or(0.0, |c| c / 24.0);
1014
1015    // Simple support/resistance based on recent range, if price available
1016    let (support_levels, resistance_levels) = if let Some(price) = token.price_usd {
1017        // Basic 5% bands - in production would use actual order book data
1018        (vec![price * 0.95], vec![price * 1.05])
1019    } else {
1020        (vec![], vec![])
1021    };
1022
1023    Ok(TrendAnalysis {
1024        direction,
1025        strength,
1026        momentum,
1027        velocity,
1028        support_levels,
1029        resistance_levels,
1030    })
1031}
1032
1033async fn analyze_volume_patterns(token: &TokenInfo) -> crate::error::Result<VolumeAnalysis> {
1034    // Calculate volume metrics from available data
1035    let volume_mcap_ratio = match (token.volume_24h, token.market_cap) {
1036        (Some(vol), Some(mcap)) if mcap > 0.0 => Some(vol / mcap),
1037        _ => None,
1038    };
1039
1040    // We don't have historical data, so we can't determine trend or averages
1041    // In production, this would query historical data from the API
1042    Ok(VolumeAnalysis {
1043        volume_rank: None,                   // Requires comparison with other tokens
1044        volume_trend: "Unknown".to_string(), // Requires historical data
1045        volume_mcap_ratio,
1046        avg_volume_7d: None, // Requires 7-day historical data
1047        spike_factor: None,  // Requires average to compare against
1048    })
1049}
1050
1051async fn analyze_liquidity(token: &TokenInfo) -> crate::error::Result<LiquidityAnalysis> {
1052    let total_liquidity = token
1053        .pairs
1054        .iter()
1055        .map(|p| p.liquidity_usd.unwrap_or(0.0))
1056        .sum();
1057
1058    let mut dex_distribution = HashMap::new();
1059    for pair in &token.pairs {
1060        let current = dex_distribution.get(&pair.dex.name).unwrap_or(&0.0);
1061        dex_distribution.insert(
1062            pair.dex.name.clone(),
1063            current + pair.liquidity_usd.unwrap_or(0.0),
1064        );
1065    }
1066
1067    let mut price_impact = HashMap::new();
1068    price_impact.insert("1k".to_string(), 0.1);
1069    price_impact.insert("10k".to_string(), 0.5);
1070    price_impact.insert("100k".to_string(), 2.0);
1071
1072    Ok(LiquidityAnalysis {
1073        total_liquidity_usd: total_liquidity,
1074        dex_distribution,
1075        price_impact,
1076        depth_score: if total_liquidity > 1_000_000.0 {
1077            85
1078        } else {
1079            60
1080        },
1081    })
1082}
1083
1084async fn analyze_price_levels(token: &TokenInfo) -> crate::error::Result<PriceLevelAnalysis> {
1085    // We don't have historical ATH/ATL data from the API
1086    // Only return what we can actually calculate from available data
1087
1088    // Try to estimate 24h high/low from price and price change
1089    let (high_24h, low_24h, range_position) = if let Some(price) = token.price_usd {
1090        // If we have price change %, estimate the range
1091        match token.price_change_24h {
1092            Some(change_pct) => {
1093                // Rough estimate: if price went up X%, low was price/(1+X/100)
1094                let change_factor = 1.0 + (change_pct / 100.0);
1095                if change_pct > 0.0 {
1096                    let low = price / change_factor;
1097                    (Some(price), Some(low), Some(1.0)) // Currently at high
1098                } else {
1099                    let high = price / change_factor;
1100                    (Some(high), Some(price), Some(0.0)) // Currently at low
1101                }
1102            }
1103            None => (None, None, None),
1104        }
1105    } else {
1106        (None, None, None)
1107    };
1108
1109    Ok(PriceLevelAnalysis {
1110        ath: None, // Requires historical data not available from current API
1111        atl: None, // Requires historical data not available from current API
1112        ath_distance_pct: None,
1113        atl_distance_pct: None,
1114        high_24h,
1115        low_24h,
1116        range_position,
1117    })
1118}
1119
1120async fn assess_token_risks(token: &TokenInfo) -> crate::error::Result<RiskAssessment> {
1121    let mut risk_factors = vec![];
1122    let mut total_risk = 0;
1123
1124    // Check liquidity risk
1125    let liquidity_score = if token
1126        .pairs
1127        .iter()
1128        .map(|p| p.liquidity_usd.unwrap_or(0.0))
1129        .sum::<f64>()
1130        < 100_000.0
1131    {
1132        risk_factors.push(RiskFactor {
1133            category: "Liquidity".to_string(),
1134            description: "Low liquidity may cause high price impact".to_string(),
1135            severity: "High".to_string(),
1136            impact: 75,
1137        });
1138        75
1139    } else {
1140        25
1141    };
1142    total_risk += liquidity_score;
1143
1144    // Check contract verification
1145    let contract_score = if !token.security.is_verified {
1146        risk_factors.push(RiskFactor {
1147            category: "Contract".to_string(),
1148            description: "Contract is not verified".to_string(),
1149            severity: "High".to_string(),
1150            impact: 80,
1151        });
1152        80
1153    } else {
1154        20
1155    };
1156    total_risk += contract_score;
1157
1158    // Check volatility
1159    let volatility_score = match token.price_change_24h {
1160        Some(change) if change.abs() > 20.0 => {
1161            risk_factors.push(RiskFactor {
1162                category: "Volatility".to_string(),
1163                description: "High price volatility detected".to_string(),
1164                severity: "Medium".to_string(),
1165                impact: 60,
1166            });
1167            60
1168        }
1169        Some(_) => 30, // Normal volatility
1170        None => {
1171            risk_factors.push(RiskFactor {
1172                category: "Data".to_string(),
1173                description: "Volatility data unavailable".to_string(),
1174                severity: "Low".to_string(),
1175                impact: 20,
1176            });
1177            20
1178        }
1179    };
1180    total_risk += volatility_score;
1181
1182    let avg_risk = total_risk / 3;
1183    let risk_level = match avg_risk {
1184        0..=25 => "Low",
1185        26..=50 => "Medium",
1186        51..=75 => "High",
1187        _ => "Extreme",
1188    }
1189    .to_string();
1190
1191    Ok(RiskAssessment {
1192        risk_level,
1193        risk_factors,
1194        liquidity_risk: liquidity_score as u32,
1195        volatility_risk: volatility_score as u32,
1196        contract_risk: contract_score as u32,
1197    })
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202    use super::*;
1203
1204    #[test]
1205    fn test_dexscreener_config_default() {
1206        let config = DexScreenerConfig::default();
1207        assert_eq!(config.base_url, "https://api.dexscreener.com");
1208        assert_eq!(config.rate_limit_per_minute, 300);
1209    }
1210
1211    #[test]
1212    fn test_token_info_serialization() {
1213        let token = TokenInfo {
1214            address: "0x123".to_string(),
1215            name: "Test Token".to_string(),
1216            symbol: "TEST".to_string(),
1217            decimals: 18,
1218            price_usd: Some(1.0),
1219            market_cap: Some(1000000.0),
1220            volume_24h: Some(50000.0),
1221            price_change_24h: Some(5.0),
1222            price_change_1h: Some(-1.0),
1223            price_change_5m: Some(0.5),
1224            circulating_supply: Some(1000000.0),
1225            total_supply: Some(10000000.0),
1226            pair_count: 1,
1227            pairs: vec![],
1228            chain: ChainInfo {
1229                id: "ethereum".to_string(),
1230                name: "Ethereum".to_string(),
1231                logo: None,
1232                native_token: "ETH".to_string(),
1233            },
1234            security: SecurityInfo {
1235                is_verified: true,
1236                liquidity_locked: Some(true),
1237                audit_status: None,
1238                honeypot_status: None,
1239                ownership_status: None,
1240                risk_score: Some(25),
1241            },
1242            socials: vec![],
1243            updated_at: Utc::now(),
1244        };
1245
1246        let json = serde_json::to_string(&token).unwrap();
1247        assert!(json.contains("Test Token"));
1248    }
1249
1250    #[test]
1251    fn test_dexscreener_config_custom_values() {
1252        let config = DexScreenerConfig {
1253            base_url: "https://custom.api.com".to_string(),
1254            rate_limit_per_minute: 100,
1255            request_timeout: 60,
1256        };
1257        assert_eq!(config.base_url, "https://custom.api.com");
1258        assert_eq!(config.rate_limit_per_minute, 100);
1259        assert_eq!(config.request_timeout, 60);
1260    }
1261
1262    #[test]
1263    fn test_format_dex_name_when_known_dex_should_return_formatted_name() {
1264        assert_eq!(format_dex_name("uniswap"), "Uniswap V2");
1265        assert_eq!(format_dex_name("uniswapv3"), "Uniswap V3");
1266        assert_eq!(format_dex_name("pancakeswap"), "PancakeSwap");
1267        assert_eq!(format_dex_name("sushiswap"), "SushiSwap");
1268        assert_eq!(format_dex_name("curve"), "Curve");
1269        assert_eq!(format_dex_name("balancer"), "Balancer");
1270        assert_eq!(format_dex_name("quickswap"), "QuickSwap");
1271        assert_eq!(format_dex_name("raydium"), "Raydium");
1272        assert_eq!(format_dex_name("orca"), "Orca");
1273    }
1274
1275    #[test]
1276    fn test_format_dex_name_when_unknown_dex_should_return_original() {
1277        assert_eq!(format_dex_name("unknown-dex"), "unknown-dex");
1278        assert_eq!(format_dex_name("custom_dex"), "custom_dex");
1279        assert_eq!(format_dex_name(""), "");
1280    }
1281
1282    #[test]
1283    fn test_format_chain_name_when_known_chain_should_return_formatted_name() {
1284        assert_eq!(format_chain_name("ethereum"), "Ethereum");
1285        assert_eq!(format_chain_name("bsc"), "Binance Smart Chain");
1286        assert_eq!(format_chain_name("polygon"), "Polygon");
1287        assert_eq!(format_chain_name("arbitrum"), "Arbitrum");
1288        assert_eq!(format_chain_name("optimism"), "Optimism");
1289        assert_eq!(format_chain_name("avalanche"), "Avalanche");
1290        assert_eq!(format_chain_name("fantom"), "Fantom");
1291        assert_eq!(format_chain_name("solana"), "Solana");
1292    }
1293
1294    #[test]
1295    fn test_format_chain_name_when_unknown_chain_should_return_original() {
1296        assert_eq!(format_chain_name("unknown-chain"), "unknown-chain");
1297        assert_eq!(format_chain_name("custom_chain"), "custom_chain");
1298        assert_eq!(format_chain_name(""), "");
1299    }
1300
1301    #[test]
1302    fn test_get_native_token_when_known_chain_should_return_correct_token() {
1303        assert_eq!(get_native_token("ethereum"), "ETH");
1304        assert_eq!(get_native_token("bsc"), "BNB");
1305        assert_eq!(get_native_token("polygon"), "MATIC");
1306        assert_eq!(get_native_token("arbitrum"), "ETH");
1307        assert_eq!(get_native_token("optimism"), "ETH");
1308        assert_eq!(get_native_token("avalanche"), "AVAX");
1309        assert_eq!(get_native_token("fantom"), "FTM");
1310        assert_eq!(get_native_token("solana"), "SOL");
1311    }
1312
1313    #[test]
1314    fn test_get_native_token_when_unknown_chain_should_return_native() {
1315        assert_eq!(get_native_token("unknown-chain"), "NATIVE");
1316        assert_eq!(get_native_token("custom_chain"), "NATIVE");
1317        assert_eq!(get_native_token(""), "NATIVE");
1318    }
1319
1320    #[tokio::test]
1321    async fn test_analyze_price_trends_when_bullish_data_should_return_bullish_trend() {
1322        let token = create_test_token_info(Some(10.0), Some(2.0), Some(1.0));
1323        let result = analyze_price_trends(&token).await.unwrap();
1324
1325        assert_eq!(result.direction, "Bullish");
1326        assert_eq!(result.strength, 1); // 10.0 / 10.0 = 1.0, clamped to 1
1327        assert_eq!(result.momentum, 26.0); // 2.0 * 24.0 - 10.0
1328        assert_eq!(result.velocity, 10.0 / 24.0);
1329        assert_eq!(result.support_levels.len(), 1);
1330        assert_eq!(result.resistance_levels.len(), 1);
1331    }
1332
1333    #[tokio::test]
1334    async fn test_analyze_price_trends_when_bearish_data_should_return_bearish_trend() {
1335        let token = create_test_token_info(Some(-10.0), Some(-1.0), Some(1.0));
1336        let result = analyze_price_trends(&token).await.unwrap();
1337
1338        assert_eq!(result.direction, "Bearish");
1339        assert_eq!(result.strength, 1); // 10.0 / 10.0 = 1.0, clamped to 1
1340        assert_eq!(result.momentum, -14.0); // -1.0 * 24.0 - (-10.0)
1341        assert_eq!(result.velocity, -10.0 / 24.0);
1342    }
1343
1344    #[tokio::test]
1345    async fn test_analyze_price_trends_when_neutral_data_should_return_neutral_trend() {
1346        let token = create_test_token_info(Some(2.0), Some(0.5), Some(1.0));
1347        let result = analyze_price_trends(&token).await.unwrap();
1348
1349        assert_eq!(result.direction, "Neutral");
1350        assert_eq!(result.strength, 1); // 2.0 / 10.0 = 0.2, clamped to 1.0
1351        assert_eq!(result.momentum, 10.0); // 0.5 * 24.0 - 2.0
1352    }
1353
1354    #[tokio::test]
1355    async fn test_analyze_price_trends_when_no_data_should_return_unknown_trend() {
1356        let token = create_test_token_info(None, None, Some(1.0));
1357        let result = analyze_price_trends(&token).await.unwrap();
1358
1359        assert_eq!(result.direction, "Unknown");
1360        assert_eq!(result.strength, 5); // Default when no data
1361        assert_eq!(result.momentum, 0.0);
1362        assert_eq!(result.velocity, 0.0);
1363        assert_eq!(result.support_levels.len(), 0);
1364        assert_eq!(result.resistance_levels.len(), 0);
1365    }
1366
1367    #[tokio::test]
1368    async fn test_analyze_price_trends_when_no_price_should_return_empty_levels() {
1369        let token = create_test_token_info(Some(5.0), Some(1.0), None);
1370        let result = analyze_price_trends(&token).await.unwrap();
1371
1372        assert_eq!(result.support_levels.len(), 0);
1373        assert_eq!(result.resistance_levels.len(), 0);
1374    }
1375
1376    #[tokio::test]
1377    async fn test_analyze_volume_patterns_when_valid_data_should_calculate_ratio() {
1378        let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1379        token.volume_24h = Some(50000.0);
1380        token.market_cap = Some(1000000.0);
1381
1382        let result = analyze_volume_patterns(&token).await.unwrap();
1383
1384        assert_eq!(result.volume_mcap_ratio, Some(0.05)); // 50000 / 1000000
1385        assert_eq!(result.volume_trend, "Unknown");
1386        assert_eq!(result.volume_rank, None);
1387        assert_eq!(result.avg_volume_7d, None);
1388        assert_eq!(result.spike_factor, None);
1389    }
1390
1391    #[tokio::test]
1392    async fn test_analyze_volume_patterns_when_zero_market_cap_should_return_none_ratio() {
1393        let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1394        token.volume_24h = Some(50000.0);
1395        token.market_cap = Some(0.0);
1396
1397        let result = analyze_volume_patterns(&token).await.unwrap();
1398
1399        assert_eq!(result.volume_mcap_ratio, None);
1400    }
1401
1402    #[tokio::test]
1403    async fn test_analyze_volume_patterns_when_missing_data_should_return_none_ratio() {
1404        let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1405        token.volume_24h = None;
1406        token.market_cap = None;
1407
1408        let result = analyze_volume_patterns(&token).await.unwrap();
1409
1410        assert_eq!(result.volume_mcap_ratio, None);
1411    }
1412
1413    #[tokio::test]
1414    async fn test_analyze_liquidity_when_multiple_pairs_should_aggregate_liquidity() {
1415        let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1416        token.pairs = vec![
1417            create_test_token_pair("dex1", 100000.0),
1418            create_test_token_pair("dex2", 200000.0),
1419        ];
1420
1421        let result = analyze_liquidity(&token).await.unwrap();
1422
1423        assert_eq!(result.total_liquidity_usd, 300000.0);
1424        assert_eq!(result.dex_distribution.len(), 2);
1425        assert_eq!(result.dex_distribution.get("dex1"), Some(&100000.0));
1426        assert_eq!(result.dex_distribution.get("dex2"), Some(&200000.0));
1427        assert_eq!(result.depth_score, 60); // Less than 1M threshold
1428    }
1429
1430    #[tokio::test]
1431    async fn test_analyze_liquidity_when_high_liquidity_should_return_high_depth_score() {
1432        let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1433        token.pairs = vec![create_test_token_pair("dex1", 2000000.0)];
1434
1435        let result = analyze_liquidity(&token).await.unwrap();
1436
1437        assert_eq!(result.total_liquidity_usd, 2000000.0);
1438        assert_eq!(result.depth_score, 85); // Above 1M threshold
1439    }
1440
1441    #[tokio::test]
1442    async fn test_analyze_liquidity_when_no_pairs_should_return_zero_liquidity() {
1443        let token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1444
1445        let result = analyze_liquidity(&token).await.unwrap();
1446
1447        assert_eq!(result.total_liquidity_usd, 0.0);
1448        assert_eq!(result.dex_distribution.len(), 0);
1449        assert_eq!(result.depth_score, 60);
1450    }
1451
1452    #[tokio::test]
1453    async fn test_analyze_price_levels_when_positive_change_should_estimate_at_high() {
1454        let token = create_test_token_info(Some(10.0), Some(1.0), Some(100.0));
1455
1456        let result = analyze_price_levels(&token).await.unwrap();
1457
1458        assert_eq!(result.high_24h, Some(100.0));
1459        assert!(result.low_24h.is_some());
1460        assert!(result.low_24h.unwrap() < 100.0);
1461        assert_eq!(result.range_position, Some(1.0)); // At high
1462        assert_eq!(result.ath, None);
1463        assert_eq!(result.atl, None);
1464    }
1465
1466    #[tokio::test]
1467    async fn test_analyze_price_levels_when_negative_change_should_estimate_at_low() {
1468        let token = create_test_token_info(Some(-10.0), Some(1.0), Some(90.0));
1469
1470        let result = analyze_price_levels(&token).await.unwrap();
1471
1472        assert!(result.high_24h.is_some());
1473        assert!(result.high_24h.unwrap() > 90.0);
1474        assert_eq!(result.low_24h, Some(90.0));
1475        assert_eq!(result.range_position, Some(0.0)); // At low
1476    }
1477
1478    #[tokio::test]
1479    async fn test_analyze_price_levels_when_no_price_change_should_return_none() {
1480        let mut token = create_test_token_info(None, Some(1.0), Some(100.0));
1481        token.price_change_24h = None;
1482
1483        let result = analyze_price_levels(&token).await.unwrap();
1484
1485        assert_eq!(result.high_24h, None);
1486        assert_eq!(result.low_24h, None);
1487        assert_eq!(result.range_position, None);
1488    }
1489
1490    #[tokio::test]
1491    async fn test_analyze_price_levels_when_no_price_should_return_none() {
1492        let token = create_test_token_info(Some(10.0), Some(1.0), None);
1493
1494        let result = analyze_price_levels(&token).await.unwrap();
1495
1496        assert_eq!(result.high_24h, None);
1497        assert_eq!(result.low_24h, None);
1498        assert_eq!(result.range_position, None);
1499    }
1500
1501    #[tokio::test]
1502    async fn test_assess_token_risks_when_low_liquidity_should_add_liquidity_risk() {
1503        let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1504        token.pairs = vec![create_test_token_pair("dex1", 50000.0)]; // Low liquidity
1505
1506        let result = assess_token_risks(&token).await.unwrap();
1507
1508        assert_eq!(result.liquidity_risk, 75);
1509        assert!(result
1510            .risk_factors
1511            .iter()
1512            .any(|r| r.category == "Liquidity"));
1513        assert!(matches!(result.risk_level.as_str(), "High" | "Extreme"));
1514    }
1515
1516    #[tokio::test]
1517    async fn test_assess_token_risks_when_high_liquidity_should_have_low_liquidity_risk() {
1518        let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1519        token.pairs = vec![create_test_token_pair("dex1", 500000.0)]; // High liquidity
1520
1521        let result = assess_token_risks(&token).await.unwrap();
1522
1523        assert_eq!(result.liquidity_risk, 25);
1524    }
1525
1526    #[tokio::test]
1527    async fn test_assess_token_risks_when_unverified_contract_should_add_contract_risk() {
1528        let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1529        token.security.is_verified = false;
1530        token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
1531
1532        let result = assess_token_risks(&token).await.unwrap();
1533
1534        assert_eq!(result.contract_risk, 80);
1535        assert!(result.risk_factors.iter().any(|r| r.category == "Contract"));
1536    }
1537
1538    #[tokio::test]
1539    async fn test_assess_token_risks_when_verified_contract_should_have_low_contract_risk() {
1540        let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1541        token.security.is_verified = true;
1542        token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
1543
1544        let result = assess_token_risks(&token).await.unwrap();
1545
1546        assert_eq!(result.contract_risk, 20);
1547    }
1548
1549    #[tokio::test]
1550    async fn test_assess_token_risks_when_high_volatility_should_add_volatility_risk() {
1551        let mut token = create_test_token_info(Some(25.0), Some(1.0), Some(1.0)); // High volatility
1552        token.security.is_verified = true;
1553        token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
1554
1555        let result = assess_token_risks(&token).await.unwrap();
1556
1557        assert_eq!(result.volatility_risk, 60);
1558        assert!(result
1559            .risk_factors
1560            .iter()
1561            .any(|r| r.category == "Volatility"));
1562    }
1563
1564    #[tokio::test]
1565    async fn test_assess_token_risks_when_normal_volatility_should_have_medium_volatility_risk() {
1566        let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0)); // Normal volatility
1567        token.security.is_verified = true;
1568        token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
1569
1570        let result = assess_token_risks(&token).await.unwrap();
1571
1572        assert_eq!(result.volatility_risk, 30);
1573    }
1574
1575    #[tokio::test]
1576    async fn test_assess_token_risks_when_no_volatility_data_should_add_data_risk() {
1577        let mut token = create_test_token_info(None, Some(1.0), Some(1.0));
1578        token.security.is_verified = true;
1579        token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
1580
1581        let result = assess_token_risks(&token).await.unwrap();
1582
1583        assert_eq!(result.volatility_risk, 20);
1584        assert!(result.risk_factors.iter().any(|r| r.category == "Data"));
1585    }
1586
1587    #[tokio::test]
1588    async fn test_assess_token_risks_when_low_risk_should_return_low_risk_level() {
1589        let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
1590        token.security.is_verified = true;
1591        token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
1592
1593        let result = assess_token_risks(&token).await.unwrap();
1594
1595        assert_eq!(result.risk_level, "Low");
1596    }
1597
1598    #[tokio::test]
1599    async fn test_parse_search_results_when_invalid_json_should_return_error() {
1600        let invalid_json = "invalid json";
1601        let result = parse_search_results(invalid_json).await;
1602
1603        assert!(result.is_err());
1604        if let Err(crate::error::WebToolError::Parsing(msg)) = result {
1605            assert!(msg.contains("expected"));
1606        }
1607    }
1608
1609    #[tokio::test]
1610    async fn test_parse_trending_response_when_invalid_json_should_return_error() {
1611        let invalid_json = "invalid json";
1612        let result = parse_trending_response(invalid_json).await;
1613
1614        assert!(result.is_err());
1615    }
1616
1617    #[tokio::test]
1618    async fn test_parse_pairs_response_when_invalid_json_should_return_error() {
1619        let invalid_json = "invalid json";
1620        let result = parse_pairs_response(invalid_json).await;
1621
1622        assert!(result.is_err());
1623    }
1624
1625    #[tokio::test]
1626    async fn test_parse_token_response_when_invalid_json_should_return_parsing_error() {
1627        let invalid_json = "invalid json";
1628        let result = parse_token_response(invalid_json, "0x123", "ethereum", Some(true)).await;
1629
1630        assert!(result.is_err());
1631        if let Err(crate::error::WebToolError::Parsing(msg)) = result {
1632            assert!(msg.contains("Failed to parse DexScreener response"));
1633        }
1634    }
1635
1636    #[tokio::test]
1637    async fn test_parse_token_response_when_no_pairs_found_should_return_api_error() {
1638        let valid_json_no_pairs = r#"{"pairs": []}"#;
1639        let result =
1640            parse_token_response(valid_json_no_pairs, "0x123", "ethereum", Some(true)).await;
1641
1642        assert!(result.is_err());
1643        if let Err(crate::error::WebToolError::Api(msg)) = result {
1644            assert!(msg.contains("No pairs found for token address"));
1645        }
1646    }
1647
1648    #[tokio::test]
1649    async fn test_parse_token_response_when_no_valid_pairs_should_return_api_error() {
1650        let json_with_unmatched_pairs = r#"{"pairs": [{"base_token": {"address": "0x456", "name": "Other Token", "symbol": "OTHER"}, "quote_token": {"address": "0x789", "name": "USDC", "symbol": "USDC"}, "pair_address": "0xabc", "dex_id": "uniswap", "url": "https://example.com", "price_usd": "1.0", "price_native": "1.0"}]}"#;
1651        let result =
1652            parse_token_response(json_with_unmatched_pairs, "0x123", "ethereum", Some(true)).await;
1653
1654        assert!(result.is_err());
1655        if let Err(crate::error::WebToolError::Api(msg)) = result {
1656            assert!(msg.contains("No pairs found for token address"));
1657        }
1658    }
1659
1660    #[tokio::test]
1661    async fn test_parse_token_response_when_valid_data_should_return_token_info() {
1662        let valid_json = create_valid_dexscreener_response();
1663        let result = parse_token_response(&valid_json, "0x123", "ethereum", Some(true)).await;
1664
1665        assert!(result.is_ok());
1666        let token = result.unwrap();
1667        assert_eq!(token.address, "0x123");
1668        assert_eq!(token.symbol, "TEST");
1669        assert_eq!(token.name, "Test Token");
1670        assert_eq!(token.chain.id, "ethereum");
1671        assert_eq!(token.chain.name, "Ethereum");
1672        assert_eq!(token.chain.native_token, "ETH");
1673        assert_eq!(token.pair_count, 1);
1674        assert!(!token.pairs.is_empty());
1675    }
1676
1677    #[tokio::test]
1678    async fn test_parse_token_response_when_multiple_pairs_should_use_highest_liquidity() {
1679        let json_with_multiple_pairs = create_dexscreener_response_with_multiple_pairs();
1680        let result =
1681            parse_token_response(&json_with_multiple_pairs, "0x123", "ethereum", Some(true)).await;
1682
1683        assert!(result.is_ok());
1684        let token = result.unwrap();
1685        assert_eq!(token.pair_count, 2);
1686        // Should have aggregated volume from both pairs
1687        assert!(token.volume_24h.unwrap() > 10000.0);
1688        // Primary pair should be the one with higher liquidity
1689        assert_eq!(token.price_usd, Some(2.0)); // From high liquidity pair
1690    }
1691
1692    #[tokio::test]
1693    async fn test_parse_token_response_when_case_insensitive_address_should_match() {
1694        let valid_json = create_valid_dexscreener_response();
1695        let result = parse_token_response(&valid_json, "0X123", "ethereum", Some(true)).await; // Uppercase
1696
1697        assert!(result.is_ok());
1698        let token = result.unwrap();
1699        assert_eq!(token.address, "0X123"); // Should preserve input case
1700    }
1701
1702    #[tokio::test]
1703    async fn test_parse_token_response_when_no_liquidity_data_should_handle_gracefully() {
1704        let json_no_liquidity = create_dexscreener_response_without_liquidity();
1705        let result =
1706            parse_token_response(&json_no_liquidity, "0x123", "ethereum", Some(true)).await;
1707
1708        assert!(result.is_ok());
1709        let token = result.unwrap();
1710        assert_eq!(token.pairs[0].liquidity_usd, None);
1711    }
1712
1713    #[tokio::test]
1714    async fn test_parse_token_response_when_transaction_data_available_should_calculate_totals() {
1715        let json_with_txns = create_dexscreener_response_with_transaction_data();
1716        let result = parse_token_response(&json_with_txns, "0x123", "ethereum", Some(true)).await;
1717
1718        assert!(result.is_ok());
1719        let token = result.unwrap();
1720        let pair = &token.pairs[0];
1721        assert_eq!(pair.txns_24h.buys, Some(100));
1722        assert_eq!(pair.txns_24h.sells, Some(50));
1723        assert_eq!(pair.txns_24h.total, Some(150)); // Should calculate total
1724        assert!(pair.txns_24h.buy_volume_usd.is_some());
1725        assert!(pair.txns_24h.sell_volume_usd.is_some());
1726    }
1727
1728    #[tokio::test]
1729    async fn test_parse_token_response_when_unknown_chain_should_use_default_formatting() {
1730        let valid_json = create_valid_dexscreener_response();
1731        let result = parse_token_response(&valid_json, "0x123", "unknown-chain", Some(true)).await;
1732
1733        assert!(result.is_ok());
1734        let token = result.unwrap();
1735        assert_eq!(token.chain.id, "unknown-chain");
1736        assert_eq!(token.chain.name, "unknown-chain");
1737        assert_eq!(token.chain.native_token, "NATIVE");
1738    }
1739
1740    #[tokio::test]
1741    async fn test_parse_token_response_when_price_parsing_fails_should_handle_gracefully() {
1742        let json_invalid_price = create_dexscreener_response_with_invalid_price();
1743        let result =
1744            parse_token_response(&json_invalid_price, "0x123", "ethereum", Some(true)).await;
1745
1746        assert!(result.is_ok());
1747        let token = result.unwrap();
1748        assert_eq!(token.price_usd, None); // Should handle parse failure
1749    }
1750
1751    // Helper functions for parse_token_response tests
1752    fn create_valid_dexscreener_response() -> String {
1753        r#"{
1754            "schemaVersion": "1.0.0",
1755            "pairs": [{
1756                "chainId": "ethereum",
1757                "baseToken": {
1758                    "address": "0x123",
1759                    "name": "Test Token",
1760                    "symbol": "TEST"
1761                },
1762                "quoteToken": {
1763                    "address": "0x456",
1764                    "name": "USD Coin",
1765                    "symbol": "USDC"
1766                },
1767                "pairAddress": "0xabc",
1768                "dexId": "uniswap",
1769                "url": "https://example.com",
1770                "priceUsd": "1.5",
1771                "priceNative": "1.5",
1772                "marketCap": 1000000.0,
1773                "liquidity": {"usd": 500000.0},
1774                "volume": {"h24": 50000.0},
1775                "priceChange": {"h24": 5.0, "h1": 1.0, "m5": 0.5},
1776                "fdv": 2000000.0
1777            }]
1778        }"#
1779        .to_string()
1780    }
1781
1782    fn create_dexscreener_response_with_multiple_pairs() -> String {
1783        r#"{
1784            "schemaVersion": "1.0.0",
1785            "pairs": [{
1786                "chainId": "ethereum",
1787                "baseToken": {
1788                    "address": "0x123",
1789                    "name": "Test Token",
1790                    "symbol": "TEST"
1791                },
1792                "quoteToken": {
1793                    "address": "0x456",
1794                    "name": "USD Coin",
1795                    "symbol": "USDC"
1796                },
1797                "pairAddress": "0xabc",
1798                "dexId": "uniswap",
1799                "url": "https://example.com",
1800                "priceUsd": "1.0",
1801                "priceNative": "1.0",
1802                "marketCap": 1000000.0,
1803                "liquidity": {"usd": 300000.0},
1804                "volume": {"h24": 30000.0},
1805                "priceChange": {"h24": 3.0}
1806            }, {
1807                "chainId": "ethereum",
1808                "baseToken": {
1809                    "address": "0x123",
1810                    "name": "Test Token",
1811                    "symbol": "TEST"
1812                },
1813                "quoteToken": {
1814                    "address": "0x789",
1815                    "name": "Ethereum",
1816                    "symbol": "ETH"
1817                },
1818                "pairAddress": "0xdef",
1819                "dexId": "sushiswap",
1820                "url": "https://sushi.example.com",
1821                "priceUsd": "2.0",
1822                "priceNative": "2.0",
1823                "marketCap": 1500000.0,
1824                "liquidity": {"usd": 800000.0},
1825                "volume": {"h24": 80000.0},
1826                "priceChange": {"h24": 8.0}
1827            }]
1828        }"#
1829        .to_string()
1830    }
1831
1832    fn create_dexscreener_response_without_liquidity() -> String {
1833        r#"{
1834            "schemaVersion": "1.0.0",
1835            "pairs": [{
1836                "chainId": "ethereum",
1837                "baseToken": {
1838                    "address": "0x123",
1839                    "name": "Test Token",
1840                    "symbol": "TEST"
1841                },
1842                "quoteToken": {
1843                    "address": "0x456",
1844                    "name": "USD Coin",
1845                    "symbol": "USDC"
1846                },
1847                "pairAddress": "0xabc",
1848                "dexId": "uniswap",
1849                "url": "https://example.com",
1850                "priceUsd": "1.5",
1851                "priceNative": "1.5"
1852            }]
1853        }"#
1854        .to_string()
1855    }
1856
1857    fn create_dexscreener_response_with_transaction_data() -> String {
1858        r#"{
1859            "schemaVersion": "1.0.0",
1860            "pairs": [{
1861                "chainId": "ethereum",
1862                "baseToken": {
1863                    "address": "0x123",
1864                    "name": "Test Token",
1865                    "symbol": "TEST"
1866                },
1867                "quoteToken": {
1868                    "address": "0x456",
1869                    "name": "USD Coin",
1870                    "symbol": "USDC"
1871                },
1872                "pairAddress": "0xabc",
1873                "dexId": "uniswap",
1874                "url": "https://example.com",
1875                "priceUsd": "1.5",
1876                "priceNative": "1.5",
1877                "volume": {"h24": 60000.0},
1878                "txns": {
1879                    "h24": {
1880                        "buys": 100,
1881                        "sells": 50
1882                    }
1883                }
1884            }]
1885        }"#
1886        .to_string()
1887    }
1888
1889    fn create_dexscreener_response_with_invalid_price() -> String {
1890        r#"{
1891            "schemaVersion": "1.0.0",
1892            "pairs": [{
1893                "chainId": "ethereum",
1894                "baseToken": {
1895                    "address": "0x123",
1896                    "name": "Test Token",
1897                    "symbol": "TEST"
1898                },
1899                "quoteToken": {
1900                    "address": "0x456",
1901                    "name": "USD Coin",
1902                    "symbol": "USDC"
1903                },
1904                "pairAddress": "0xabc",
1905                "dexId": "uniswap",
1906                "url": "https://example.com",
1907                "priceUsd": "invalid_price",
1908                "priceNative": "invalid_price"
1909            }]
1910        }"#
1911        .to_string()
1912    }
1913
1914    // Helper functions for tests
1915    fn create_test_token_info(
1916        price_change_24h: Option<f64>,
1917        price_change_1h: Option<f64>,
1918        price_usd: Option<f64>,
1919    ) -> TokenInfo {
1920        TokenInfo {
1921            address: "0x123".to_string(),
1922            name: "Test Token".to_string(),
1923            symbol: "TEST".to_string(),
1924            decimals: 18,
1925            price_usd,
1926            market_cap: Some(1000000.0),
1927            volume_24h: Some(50000.0),
1928            price_change_24h,
1929            price_change_1h,
1930            price_change_5m: Some(0.5),
1931            circulating_supply: Some(1000000.0),
1932            total_supply: Some(10000000.0),
1933            pair_count: 0,
1934            pairs: vec![],
1935            chain: ChainInfo {
1936                id: "ethereum".to_string(),
1937                name: "Ethereum".to_string(),
1938                logo: None,
1939                native_token: "ETH".to_string(),
1940            },
1941            security: SecurityInfo {
1942                is_verified: true,
1943                liquidity_locked: Some(true),
1944                audit_status: None,
1945                honeypot_status: None,
1946                ownership_status: None,
1947                risk_score: Some(25),
1948            },
1949            socials: vec![],
1950            updated_at: Utc::now(),
1951        }
1952    }
1953
1954    fn create_test_token_pair(dex_name: &str, liquidity: f64) -> TokenPair {
1955        TokenPair {
1956            pair_id: "pair123".to_string(),
1957            dex: DexInfo {
1958                id: dex_name.to_string(),
1959                name: dex_name.to_string(),
1960                url: Some("https://dex.com".to_string()),
1961                logo: None,
1962            },
1963            base_token: PairToken {
1964                address: "0x123".to_string(),
1965                name: "Test Token".to_string(),
1966                symbol: "TEST".to_string(),
1967            },
1968            quote_token: PairToken {
1969                address: "0x456".to_string(),
1970                name: "USD Coin".to_string(),
1971                symbol: "USDC".to_string(),
1972            },
1973            price_usd: Some(1.0),
1974            price_native: Some(1.0),
1975            volume_24h: Some(10000.0),
1976            price_change_24h: Some(5.0),
1977            liquidity_usd: Some(liquidity),
1978            fdv: Some(2000000.0),
1979            created_at: None,
1980            last_trade_at: Utc::now(),
1981            txns_24h: TransactionStats {
1982                buys: Some(100),
1983                sells: Some(80),
1984                total: Some(180),
1985                buy_volume_usd: Some(6000.0),
1986                sell_volume_usd: Some(4000.0),
1987            },
1988            url: "https://dex.com/pair/123".to_string(),
1989        }
1990    }
1991}