riglr_web_tools/
dexscreener_api.rs

1//! Complete DexScreener API implementation with real market data fetching
2//!
3//! This module provides production-ready integration with the DexScreener API
4//! for fetching token prices, liquidity, and market data.
5
6use anyhow::Result;
7use reqwest::Client;
8use serde::{Deserialize, Serialize};
9
10/// Raw response from DexScreener API containing token pair information
11#[derive(Debug, Serialize, Deserialize, Clone)]
12#[serde(rename_all = "camelCase")]
13pub struct DexScreenerResponseRaw {
14    /// Schema version of the API response
15    #[serde(rename = "schemaVersion")]
16    pub schema_version: String,
17    /// List of token pairs returned by the API
18    pub pairs: Vec<PairInfoRaw>,
19}
20
21/// Raw information about a trading pair from DexScreener
22#[derive(Debug, Serialize, Deserialize, Clone)]
23#[serde(rename_all = "camelCase")]
24pub struct PairInfoRaw {
25    /// Blockchain network identifier
26    #[serde(rename = "chainId")]
27    pub chain_id: String,
28    /// Decentralized exchange identifier
29    #[serde(rename = "dexId")]
30    pub dex_id: String,
31    /// URL to view this pair on DexScreener
32    pub url: String,
33    /// Smart contract address of the trading pair
34    #[serde(rename = "pairAddress")]
35    pub pair_address: String,
36    /// Optional labels associated with this pair
37    pub labels: Option<Vec<String>>,
38    /// Base token information
39    pub base_token: TokenRaw,
40    /// Quote token information
41    pub quote_token: TokenRaw,
42    /// Price in native chain token (e.g., ETH, SOL)
43    #[serde(rename = "priceNative")]
44    pub price_native: String,
45    /// Price in USD
46    #[serde(rename = "priceUsd")]
47    pub price_usd: Option<String>,
48    /// Liquidity information for this pair
49    pub liquidity: Option<LiquidityRaw>,
50    /// Trading volume statistics
51    pub volume: Option<VolumeRaw>,
52    /// Price change statistics
53    pub price_change: Option<PriceChangeRaw>,
54    /// Transaction statistics
55    pub txns: Option<TransactionsRaw>,
56    /// Market capitalization in USD
57    #[serde(rename = "marketCap")]
58    pub market_cap: Option<f64>,
59    /// Fully diluted valuation in USD
60    #[serde(rename = "fdv")]
61    pub fdv: Option<f64>,
62}
63
64/// Raw liquidity information for a trading pair
65#[derive(Debug, Serialize, Deserialize, Clone)]
66pub struct LiquidityRaw {
67    /// Total liquidity in USD
68    pub usd: Option<f64>,
69    /// Liquidity of the base token
70    pub base: Option<f64>,
71    /// Liquidity of the quote token
72    pub quote: Option<f64>,
73}
74
75/// Raw trading volume statistics over different time periods
76#[derive(Debug, Serialize, Deserialize, Clone, Default)]
77pub struct VolumeRaw {
78    /// Trading volume in the last 24 hours
79    #[serde(default)]
80    pub h24: Option<f64>,
81    /// Trading volume in the last 6 hours
82    #[serde(default)]
83    pub h6: Option<f64>,
84    /// Trading volume in the last 1 hour
85    #[serde(default)]
86    pub h1: Option<f64>,
87    /// Trading volume in the last 5 minutes
88    #[serde(default)]
89    pub m5: Option<f64>,
90}
91
92/// Raw price change statistics over different time periods
93#[derive(Debug, Serialize, Deserialize, Clone)]
94pub struct PriceChangeRaw {
95    /// Price change percentage in the last 24 hours
96    #[serde(default)]
97    pub h24: Option<f64>,
98    /// Price change percentage in the last 6 hours
99    #[serde(default)]
100    pub h6: Option<f64>,
101    /// Price change percentage in the last 1 hour
102    #[serde(default)]
103    pub h1: Option<f64>,
104    /// Price change percentage in the last 5 minutes
105    #[serde(default)]
106    pub m5: Option<f64>,
107}
108
109/// Raw transaction statistics over different time periods
110#[derive(Debug, Serialize, Deserialize, Clone)]
111pub struct TransactionsRaw {
112    /// Transaction statistics for the last 24 hours
113    #[serde(default)]
114    pub h24: Option<TransactionStatsRaw>,
115    /// Transaction statistics for the last 6 hours
116    #[serde(default)]
117    pub h6: Option<TransactionStatsRaw>,
118    /// Transaction statistics for the last 1 hour
119    #[serde(default)]
120    pub h1: Option<TransactionStatsRaw>,
121    /// Transaction statistics for the last 5 minutes
122    #[serde(default)]
123    pub m5: Option<TransactionStatsRaw>,
124}
125
126/// Raw buy and sell transaction statistics
127#[derive(Debug, Serialize, Deserialize, Clone)]
128pub struct TransactionStatsRaw {
129    /// Number of buy transactions
130    pub buys: Option<u64>,
131    /// Number of sell transactions
132    pub sells: Option<u64>,
133}
134
135/// Raw token information
136#[derive(Debug, Serialize, Deserialize, Clone)]
137pub struct TokenRaw {
138    /// Token contract address
139    pub address: String,
140    /// Full name of the token
141    pub name: String,
142    /// Token symbol/ticker
143    pub symbol: String,
144}
145
146// ==================== New API Types (from OpenAPI spec) ====================
147
148/// Token profile information (new v1 endpoint)
149#[derive(Debug, Serialize, Deserialize, Clone)]
150#[serde(rename_all = "camelCase")]
151pub struct TokenProfile {
152    /// URL to the token profile page
153    pub url: String,
154    /// Chain identifier
155    #[serde(rename = "chainId")]
156    pub chain_id: String,
157    /// Token address
158    #[serde(rename = "tokenAddress")]
159    pub token_address: String,
160    /// Token icon URL
161    pub icon: String,
162    /// Header image URL (optional)
163    pub header: Option<String>,
164    /// Token description (optional)
165    pub description: Option<String>,
166    /// Social and website links (optional)
167    pub links: Option<Vec<TokenLink>>,
168}
169
170/// Token link information
171#[derive(Debug, Serialize, Deserialize, Clone)]
172pub struct TokenLink {
173    /// Link type (e.g., "website", "twitter", "telegram")
174    #[serde(rename = "type")]
175    pub link_type: Option<String>,
176    /// Link label
177    pub label: Option<String>,
178    /// Link URL
179    pub url: String,
180}
181
182/// Boosted token response (new v1 endpoint)
183#[derive(Debug, Serialize, Deserialize, Clone)]
184#[serde(rename_all = "camelCase")]
185pub struct BoostsResponse {
186    /// URL to the token page
187    pub url: String,
188    /// Chain identifier
189    #[serde(rename = "chainId")]
190    pub chain_id: String,
191    /// Token address
192    #[serde(rename = "tokenAddress")]
193    pub token_address: String,
194    /// Current boost amount
195    pub amount: f64,
196    /// Total boost amount
197    #[serde(rename = "totalAmount")]
198    pub total_amount: f64,
199    /// Token icon URL (optional)
200    pub icon: Option<String>,
201    /// Header image URL (optional)
202    pub header: Option<String>,
203    /// Token description (optional)
204    pub description: Option<String>,
205    /// Social and website links (optional)
206    pub links: Option<Vec<TokenLink>>,
207}
208
209/// Orders response for a token (new v1 endpoint)
210#[derive(Debug, Serialize, Deserialize, Clone)]
211pub struct OrdersResponse {
212    /// List of orders
213    pub orders: Vec<Order>,
214}
215
216/// Individual order information
217#[derive(Debug, Serialize, Deserialize, Clone)]
218#[serde(rename_all = "camelCase")]
219pub struct Order {
220    /// Order type
221    #[serde(rename = "type")]
222    pub order_type: OrderType,
223    /// Order status
224    pub status: OrderStatus,
225    /// Payment timestamp
226    #[serde(rename = "paymentTimestamp")]
227    pub payment_timestamp: Option<f64>,
228}
229
230/// Order type enum
231#[derive(Debug, Serialize, Deserialize, Clone)]
232#[serde(rename_all = "camelCase")]
233pub enum OrderType {
234    /// Token profile order type
235    TokenProfile,
236    /// Community takeover order type
237    CommunityTakeover,
238    /// Token advertisement order type
239    TokenAd,
240    /// Trending bar advertisement order type
241    TrendingBarAd,
242}
243
244/// Order status enum
245#[derive(Debug, Serialize, Deserialize, Clone)]
246#[serde(rename_all = "kebab-case")]
247pub enum OrderStatus {
248    /// Order is currently being processed
249    Processing,
250    /// Order has been cancelled
251    Cancelled,
252    /// Order is on hold
253    #[serde(rename = "on-hold")]
254    OnHold,
255    /// Order has been approved
256    Approved,
257    /// Order has been rejected
258    Rejected,
259}
260
261/// Search for tokens or pairs on DexScreener
262pub async fn search_ticker(ticker: String) -> Result<DexScreenerResponseRaw> {
263    let client = Client::new();
264    let url = format!(
265        "https://api.dexscreener.com/latest/dex/search/?q={}&limit=8",
266        ticker
267    );
268
269    let response = client.get(&url).send().await?;
270
271    if response.status().is_client_error() {
272        let res = response.text().await?;
273        tracing::error!("DexScreener API error: {:?}", res);
274        return Err(anyhow::anyhow!("DexScreener API error: {:?}", res));
275    }
276
277    let data: serde_json::Value = response.json().await?;
278    let mut dex_response: DexScreenerResponseRaw = serde_json::from_value(data)?;
279
280    // Limit results to 8
281    dex_response.pairs.truncate(8);
282
283    Ok(dex_response)
284}
285
286/// Get token pairs by token address (requires chainId in new API)
287pub async fn get_pairs_by_token_v1(
288    chain_id: &str,
289    token_address: &str,
290) -> Result<DexScreenerResponseRaw> {
291    let client = Client::new();
292    let url = format!(
293        "https://api.dexscreener.com/tokens/v1/{}/{}",
294        chain_id, token_address
295    );
296
297    let response = client.get(&url).send().await?;
298
299    if !response.status().is_success() {
300        let res = response.text().await?;
301        return Err(anyhow::anyhow!("Failed to fetch token pairs: {}", res));
302    }
303
304    let data: serde_json::Value = response.json().await?;
305    let dex_response: DexScreenerResponseRaw = serde_json::from_value(data)?;
306
307    Ok(dex_response)
308}
309
310/// Get pairs by pair address (requires chainId in new API)
311pub async fn get_pair_by_address_v1(chain_id: &str, pair_address: &str) -> Result<PairInfoRaw> {
312    let client = Client::new();
313    let url = format!(
314        "https://api.dexscreener.com/latest/dex/pairs/{}/{}",
315        chain_id, pair_address
316    );
317
318    let response = client.get(&url).send().await?;
319
320    if !response.status().is_success() {
321        let res = response.text().await?;
322        return Err(anyhow::anyhow!("Failed to fetch pair: {}", res));
323    }
324
325    let data: serde_json::Value = response.json().await?;
326    let pair_response: DexScreenerResponseRaw = serde_json::from_value(data)?;
327
328    pair_response
329        .pairs
330        .into_iter()
331        .next()
332        .ok_or_else(|| anyhow::anyhow!("No pair found for address: {}", pair_address))
333}
334
335/// Legacy: Get token pairs by token address (without chainId)
336/// Deprecated: Use get_pairs_by_token_v1 instead
337pub async fn get_pairs_by_token(token_address: &str) -> Result<DexScreenerResponseRaw> {
338    // Default to Ethereum for backwards compatibility
339    get_pairs_by_token_v1("ethereum", token_address).await
340}
341
342/// Legacy: Get pairs by pair address (without chainId)
343/// Deprecated: Use get_pair_by_address_v1 instead
344pub async fn get_pair_by_address(pair_address: &str) -> Result<PairInfoRaw> {
345    // Try to infer chain from pair address format or default to ethereum
346    let chain_id = if pair_address.starts_with("solana_") {
347        "solana"
348    } else if pair_address.starts_with("bsc_") {
349        "bsc"
350    } else {
351        "ethereum"
352    };
353    get_pair_by_address_v1(chain_id, pair_address).await
354}
355
356/// Get the pools of a given token address (new v1 endpoint)
357pub async fn get_token_pairs_v1(
358    chain_id: &str,
359    token_address: &str,
360) -> Result<DexScreenerResponseRaw> {
361    let client = Client::new();
362    let url = format!(
363        "https://api.dexscreener.com/token-pairs/v1/{}/{}",
364        chain_id, token_address
365    );
366
367    let response = client.get(&url).send().await?;
368
369    if !response.status().is_success() {
370        let res = response.text().await?;
371        return Err(anyhow::anyhow!("Failed to fetch token pairs: {}", res));
372    }
373
374    let data: serde_json::Value = response.json().await?;
375    let dex_response: DexScreenerResponseRaw = serde_json::from_value(data)?;
376
377    Ok(dex_response)
378}
379
380// ==================== New V1 Endpoint Functions ====================
381
382/// Get the latest token profiles (rate-limit 60 requests per minute)
383pub async fn get_latest_token_profiles() -> Result<Vec<TokenProfile>> {
384    let client = Client::new();
385    let url = "https://api.dexscreener.com/token-profiles/latest/v1";
386
387    let response = client.get(url).send().await?;
388
389    if !response.status().is_success() {
390        let res = response.text().await?;
391        return Err(anyhow::anyhow!("Failed to fetch token profiles: {}", res));
392    }
393
394    let profiles: Vec<TokenProfile> = response.json().await?;
395    Ok(profiles)
396}
397
398/// Get the latest boosted tokens (rate-limit 60 requests per minute)
399pub async fn get_latest_token_boosts() -> Result<Vec<BoostsResponse>> {
400    let client = Client::new();
401    let url = "https://api.dexscreener.com/token-boosts/latest/v1";
402
403    let response = client.get(url).send().await?;
404
405    if !response.status().is_success() {
406        let res = response.text().await?;
407        return Err(anyhow::anyhow!("Failed to fetch latest boosts: {}", res));
408    }
409
410    let boosts: Vec<BoostsResponse> = response.json().await?;
411    Ok(boosts)
412}
413
414/// Get the tokens with most active boosts (rate-limit 60 requests per minute)
415pub async fn get_top_token_boosts() -> Result<Vec<BoostsResponse>> {
416    let client = Client::new();
417    let url = "https://api.dexscreener.com/token-boosts/top/v1";
418
419    let response = client.get(url).send().await?;
420
421    if !response.status().is_success() {
422        let res = response.text().await?;
423        return Err(anyhow::anyhow!("Failed to fetch top boosts: {}", res));
424    }
425
426    let boosts: Vec<BoostsResponse> = response.json().await?;
427    Ok(boosts)
428}
429
430/// Check orders paid for a token (rate-limit 60 requests per minute)
431pub async fn get_token_orders(chain_id: &str, token_address: &str) -> Result<Vec<Order>> {
432    let client = Client::new();
433    let url = format!(
434        "https://api.dexscreener.com/orders/v1/{}/{}",
435        chain_id, token_address
436    );
437
438    let response = client.get(&url).send().await?;
439
440    if !response.status().is_success() {
441        let res = response.text().await?;
442        return Err(anyhow::anyhow!("Failed to fetch token orders: {}", res));
443    }
444
445    let orders: Vec<Order> = response.json().await?;
446    Ok(orders)
447}
448
449/// Find the best liquidity pair for a token
450pub fn find_best_liquidity_pair(mut pairs: Vec<PairInfoRaw>) -> Option<PairInfoRaw> {
451    if pairs.is_empty() {
452        return None;
453    }
454
455    // Sort by liquidity (descending), maintaining original order for equal values
456    pairs.sort_by(|a, b| {
457        let a_liq = a.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
458        let b_liq = b.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
459        b_liq
460            .partial_cmp(&a_liq)
461            .unwrap_or(std::cmp::Ordering::Equal)
462    });
463
464    pairs.into_iter().next()
465}
466
467/// Extract token price from the best pair
468pub fn get_token_price(pairs: &[PairInfoRaw], token_address: &str) -> Option<String> {
469    // Filter pairs for the token
470    let mut matching_pairs: Vec<_> = pairs
471        .iter()
472        .filter(|p| p.base_token.address.eq_ignore_ascii_case(token_address))
473        .collect();
474
475    // Sort by liquidity (descending), maintaining original order for equal values
476    matching_pairs.sort_by(|a, b| {
477        let a_liq = a.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
478        let b_liq = b.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
479        b_liq
480            .partial_cmp(&a_liq)
481            .unwrap_or(std::cmp::Ordering::Equal)
482    });
483
484    matching_pairs.first().and_then(|p| p.price_usd.clone())
485}
486
487// ==================== Clean Public Types ====================
488
489/// Clean response from DexScreener API
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct DexScreenerResponse {
492    /// Schema version of the API response
493    pub schema_version: String,
494    /// List of token pairs returned by the API
495    pub pairs: Vec<PairInfo>,
496}
497
498/// Clean pair information
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct PairInfo {
501    /// Blockchain network identifier
502    pub chain_id: String,
503    /// Decentralized exchange identifier
504    pub dex_id: String,
505    /// URL to view this pair on DexScreener
506    pub url: String,
507    /// Smart contract address of the trading pair
508    pub pair_address: String,
509    /// Labels associated with this pair
510    pub labels: Vec<String>,
511    /// Base token information
512    pub base_token: Token,
513    /// Quote token information
514    pub quote_token: Token,
515    /// Price in native chain token (e.g., ETH, SOL)
516    pub price_native: f64,
517    /// Price in USD
518    pub price_usd: Option<f64>,
519    /// Liquidity information for this pair
520    pub liquidity: Option<Liquidity>,
521    /// Trading volume statistics
522    pub volume: Option<Volume>,
523    /// Price change statistics
524    pub price_change: Option<PriceChange>,
525    /// Transaction statistics
526    pub txns: Option<Transactions>,
527    /// Market capitalization in USD
528    pub market_cap: Option<f64>,
529    /// Fully diluted valuation in USD
530    pub fdv: Option<f64>,
531}
532
533/// Clean token information
534#[derive(Debug, Clone, Serialize, Deserialize)]
535pub struct Token {
536    /// Token contract address
537    pub address: String,
538    /// Full name of the token
539    pub name: String,
540    /// Token symbol/ticker
541    pub symbol: String,
542}
543
544/// Clean liquidity information
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct Liquidity {
547    /// Total liquidity in USD
548    pub usd: Option<f64>,
549    /// Liquidity of the base token
550    pub base: Option<f64>,
551    /// Liquidity of the quote token
552    pub quote: Option<f64>,
553}
554
555/// Clean volume statistics
556#[derive(Debug, Clone, Default, Serialize, Deserialize)]
557pub struct Volume {
558    /// Trading volume in the last 24 hours
559    pub h24: Option<f64>,
560    /// Trading volume in the last 6 hours
561    pub h6: Option<f64>,
562    /// Trading volume in the last 1 hour
563    pub h1: Option<f64>,
564    /// Trading volume in the last 5 minutes
565    pub m5: Option<f64>,
566}
567
568/// Clean price change statistics
569#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct PriceChange {
571    /// Price change percentage in the last 24 hours
572    pub h24: Option<f64>,
573    /// Price change percentage in the last 6 hours
574    pub h6: Option<f64>,
575    /// Price change percentage in the last 1 hour
576    pub h1: Option<f64>,
577    /// Price change percentage in the last 5 minutes
578    pub m5: Option<f64>,
579}
580
581/// Clean transaction statistics
582#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct Transactions {
584    /// Transaction statistics for the last 24 hours
585    pub h24: Option<TransactionStats>,
586    /// Transaction statistics for the last 6 hours
587    pub h6: Option<TransactionStats>,
588    /// Transaction statistics for the last 1 hour
589    pub h1: Option<TransactionStats>,
590    /// Transaction statistics for the last 5 minutes
591    pub m5: Option<TransactionStats>,
592}
593
594/// Clean transaction stats
595#[derive(Debug, Clone, Serialize, Deserialize)]
596pub struct TransactionStats {
597    /// Number of buy transactions
598    pub buys: Option<u64>,
599    /// Number of sell transactions
600    pub sells: Option<u64>,
601}
602
603// ==================== From Implementations ====================
604
605impl From<DexScreenerResponseRaw> for DexScreenerResponse {
606    fn from(raw: DexScreenerResponseRaw) -> Self {
607        Self {
608            schema_version: raw.schema_version,
609            pairs: raw.pairs.into_iter().map(Into::into).collect(),
610        }
611    }
612}
613
614impl From<PairInfoRaw> for PairInfo {
615    fn from(raw: PairInfoRaw) -> Self {
616        Self {
617            chain_id: raw.chain_id,
618            dex_id: raw.dex_id,
619            url: raw.url,
620            pair_address: raw.pair_address,
621            labels: raw.labels.unwrap_or_default(),
622            base_token: raw.base_token.into(),
623            quote_token: raw.quote_token.into(),
624            price_native: raw.price_native.parse().unwrap_or(0.0),
625            price_usd: raw.price_usd.and_then(|p| p.parse().ok()),
626            liquidity: raw.liquidity.map(Into::into),
627            volume: raw.volume.map(Into::into),
628            price_change: raw.price_change.map(Into::into),
629            txns: raw.txns.map(Into::into),
630            market_cap: raw.market_cap,
631            fdv: raw.fdv,
632        }
633    }
634}
635
636impl From<TokenRaw> for Token {
637    fn from(raw: TokenRaw) -> Self {
638        Self {
639            address: raw.address,
640            name: raw.name,
641            symbol: raw.symbol,
642        }
643    }
644}
645
646impl From<LiquidityRaw> for Liquidity {
647    fn from(raw: LiquidityRaw) -> Self {
648        Self {
649            usd: raw.usd,
650            base: raw.base,
651            quote: raw.quote,
652        }
653    }
654}
655
656impl From<VolumeRaw> for Volume {
657    fn from(raw: VolumeRaw) -> Self {
658        Self {
659            h24: raw.h24,
660            h6: raw.h6,
661            h1: raw.h1,
662            m5: raw.m5,
663        }
664    }
665}
666
667impl From<PriceChangeRaw> for PriceChange {
668    fn from(raw: PriceChangeRaw) -> Self {
669        Self {
670            h24: raw.h24,
671            h6: raw.h6,
672            h1: raw.h1,
673            m5: raw.m5,
674        }
675    }
676}
677
678impl From<TransactionsRaw> for Transactions {
679    fn from(raw: TransactionsRaw) -> Self {
680        Self {
681            h24: raw.h24.map(Into::into),
682            h6: raw.h6.map(Into::into),
683            h1: raw.h1.map(Into::into),
684            m5: raw.m5.map(Into::into),
685        }
686    }
687}
688
689impl From<TransactionStatsRaw> for TransactionStats {
690    fn from(raw: TransactionStatsRaw) -> Self {
691        Self {
692            buys: raw.buys,
693            sells: raw.sells,
694        }
695    }
696}
697
698/// Helper function to aggregate data from multiple pairs into a single TokenInfo
699pub fn aggregate_token_info(
700    pairs: Vec<PairInfo>,
701    token_address: &str,
702) -> Option<crate::dexscreener::TokenInfo> {
703    // Find pairs for this token
704    let token_pairs: Vec<PairInfo> = pairs
705        .into_iter()
706        .filter(|p| p.base_token.address.eq_ignore_ascii_case(token_address))
707        .collect();
708
709    if token_pairs.is_empty() {
710        return None;
711    }
712
713    // Use the pair with highest liquidity as primary source
714    let primary_pair = token_pairs.iter().max_by_key(|p| {
715        p.liquidity
716            .as_ref()
717            .and_then(|l| l.usd)
718            .map_or(0, |usd| (usd * 1000.0) as u64)
719    })?;
720
721    // Aggregate volume across all pairs
722    let total_volume_24h: f64 = token_pairs
723        .iter()
724        .filter_map(|p| p.volume.as_ref())
725        .filter_map(|v| v.h24)
726        .sum();
727
728    // Convert to TokenInfo (from dexscreener.rs)
729    Some(crate::dexscreener::TokenInfo {
730        address: token_address.to_string(),
731        name: primary_pair.base_token.name.clone(),
732        symbol: primary_pair.base_token.symbol.clone(),
733        decimals: 18, // Default
734        price_usd: primary_pair.price_usd,
735        market_cap: primary_pair.market_cap,
736        volume_24h: Some(total_volume_24h),
737        price_change_24h: primary_pair.price_change.as_ref().and_then(|pc| pc.h24),
738        price_change_1h: primary_pair.price_change.as_ref().and_then(|pc| pc.h1),
739        price_change_5m: primary_pair.price_change.as_ref().and_then(|pc| pc.m5),
740        circulating_supply: None,
741        total_supply: None,
742        pair_count: token_pairs.len() as u32,
743        pairs: token_pairs
744            .iter()
745            .map(|p| convert_to_token_pair(p))
746            .collect(),
747        chain: crate::dexscreener::ChainInfo {
748            id: primary_pair.chain_id.clone(),
749            name: crate::dexscreener::format_chain_name(&primary_pair.chain_id),
750            logo: None,
751            native_token: crate::dexscreener::get_native_token(&primary_pair.chain_id),
752        },
753        security: crate::dexscreener::SecurityInfo {
754            is_verified: false,
755            liquidity_locked: None,
756            audit_status: None,
757            honeypot_status: None,
758            ownership_status: None,
759            risk_score: None,
760        },
761        socials: vec![],
762        updated_at: chrono::Utc::now(),
763    })
764}
765
766/// Converts a PairInfo to a TokenPair for integration with the dexscreener module
767fn convert_to_token_pair(pair: &PairInfo) -> crate::dexscreener::TokenPair {
768    crate::dexscreener::TokenPair {
769        pair_id: pair.pair_address.clone(),
770        dex: crate::dexscreener::DexInfo {
771            id: pair.dex_id.clone(),
772            name: crate::dexscreener::format_dex_name(&pair.dex_id),
773            url: Some(pair.url.clone()),
774            logo: None,
775        },
776        base_token: crate::dexscreener::PairToken {
777            address: pair.base_token.address.clone(),
778            name: pair.base_token.name.clone(),
779            symbol: pair.base_token.symbol.clone(),
780        },
781        quote_token: crate::dexscreener::PairToken {
782            address: pair.quote_token.address.clone(),
783            name: pair.quote_token.name.clone(),
784            symbol: pair.quote_token.symbol.clone(),
785        },
786        price_usd: pair.price_usd,
787        price_native: Some(pair.price_native),
788        volume_24h: pair.volume.as_ref().and_then(|v| v.h24),
789        price_change_24h: pair.price_change.as_ref().and_then(|pc| pc.h24),
790        liquidity_usd: pair.liquidity.as_ref().and_then(|l| l.usd),
791        fdv: pair.fdv,
792        created_at: None,
793        last_trade_at: chrono::Utc::now(),
794        txns_24h: {
795            let buys = pair
796                .txns
797                .as_ref()
798                .and_then(|t| t.h24.as_ref())
799                .and_then(|h| h.buys.map(|b| b as u32));
800            let sells = pair
801                .txns
802                .as_ref()
803                .and_then(|t| t.h24.as_ref())
804                .and_then(|h| h.sells.map(|s| s as u32));
805            let total = match (buys, sells) {
806                (Some(b), Some(s)) => Some(b + s),
807                _ => None,
808            };
809
810            // Calculate volume estimates if we have volume and transaction data
811            let (buy_volume_usd, sell_volume_usd) = if let (Some(volume), Some(b), Some(s)) =
812                (pair.volume.as_ref().and_then(|v| v.h24), buys, sells)
813            {
814                let total_txns = (b + s) as f64;
815                if total_txns > 0.0 {
816                    let buy_ratio = b as f64 / total_txns;
817                    let sell_ratio = s as f64 / total_txns;
818                    (Some(volume * buy_ratio), Some(volume * sell_ratio))
819                } else {
820                    (None, None)
821                }
822            } else {
823                (None, None)
824            };
825
826            crate::dexscreener::TransactionStats {
827                buys,
828                sells,
829                total,
830                buy_volume_usd,
831                sell_volume_usd,
832            }
833        },
834        url: pair.url.clone(),
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841    use serde_json::json;
842
843    // Helper function to create test Token
844    fn create_test_token() -> TokenRaw {
845        TokenRaw {
846            address: "0x1234567890123456789012345678901234567890".to_string(),
847            name: "Test Token".to_string(),
848            symbol: "TEST".to_string(),
849        }
850    }
851
852    // Helper function to create test PairInfo
853    fn create_test_pair_info() -> PairInfoRaw {
854        PairInfoRaw {
855            chain_id: "ethereum".to_string(),
856            dex_id: "uniswap".to_string(),
857            url: "https://dexscreener.com/test".to_string(),
858            pair_address: "0xabcdef1234567890".to_string(),
859            labels: Some(vec!["test".to_string()]),
860            base_token: create_test_token(),
861            quote_token: TokenRaw {
862                address: "0x9876543210987654321098765432109876543210".to_string(),
863                name: "Quote Token".to_string(),
864                symbol: "QUOTE".to_string(),
865            },
866            price_native: "0.001".to_string(),
867            price_usd: Some("1.50".to_string()),
868            liquidity: Some(LiquidityRaw {
869                usd: Some(100000.0),
870                base: Some(50000.0),
871                quote: Some(50000.0),
872            }),
873            volume: Some(VolumeRaw {
874                h24: Some(10000.0),
875                h6: Some(2500.0),
876                h1: Some(416.0),
877                m5: Some(35.0),
878            }),
879            price_change: Some(PriceChangeRaw {
880                h24: Some(5.5),
881                h6: Some(2.1),
882                h1: Some(0.8),
883                m5: Some(0.1),
884            }),
885            txns: Some(TransactionsRaw {
886                h24: Some(TransactionStatsRaw {
887                    buys: Some(100),
888                    sells: Some(80),
889                }),
890                h6: Some(TransactionStatsRaw {
891                    buys: Some(25),
892                    sells: Some(20),
893                }),
894                h1: Some(TransactionStatsRaw {
895                    buys: Some(4),
896                    sells: Some(3),
897                }),
898                m5: Some(TransactionStatsRaw {
899                    buys: Some(1),
900                    sells: Some(0),
901                }),
902            }),
903            market_cap: Some(1000000.0),
904            fdv: Some(1500000.0),
905        }
906    }
907
908    // Struct Serialization/Deserialization Tests
909
910    #[test]
911    fn test_token_serialization() {
912        let token = create_test_token();
913        let serialized = serde_json::to_string(&token).unwrap();
914        let deserialized: TokenRaw = serde_json::from_str(&serialized).unwrap();
915
916        assert_eq!(token.address, deserialized.address);
917        assert_eq!(token.name, deserialized.name);
918        assert_eq!(token.symbol, deserialized.symbol);
919    }
920
921    #[test]
922    fn test_liquidity_serialization_with_all_fields() {
923        let liquidity = LiquidityRaw {
924            usd: Some(100000.0),
925            base: Some(50000.0),
926            quote: Some(50000.0),
927        };
928        let serialized = serde_json::to_string(&liquidity).unwrap();
929        let deserialized: LiquidityRaw = serde_json::from_str(&serialized).unwrap();
930
931        assert_eq!(liquidity.usd, deserialized.usd);
932        assert_eq!(liquidity.base, deserialized.base);
933        assert_eq!(liquidity.quote, deserialized.quote);
934    }
935
936    #[test]
937    fn test_liquidity_serialization_with_none_fields() {
938        let liquidity = LiquidityRaw {
939            usd: None,
940            base: None,
941            quote: None,
942        };
943        let serialized = serde_json::to_string(&liquidity).unwrap();
944        let deserialized: LiquidityRaw = serde_json::from_str(&serialized).unwrap();
945
946        assert_eq!(liquidity.usd, deserialized.usd);
947        assert_eq!(liquidity.base, deserialized.base);
948        assert_eq!(liquidity.quote, deserialized.quote);
949    }
950
951    #[test]
952    fn test_volume_default_serialization() {
953        let volume = VolumeRaw::default();
954        let serialized = serde_json::to_string(&volume).unwrap();
955        let deserialized: VolumeRaw = serde_json::from_str(&serialized).unwrap();
956
957        assert_eq!(volume.h24, deserialized.h24);
958        assert_eq!(volume.h6, deserialized.h6);
959        assert_eq!(volume.h1, deserialized.h1);
960        assert_eq!(volume.m5, deserialized.m5);
961    }
962
963    #[test]
964    fn test_volume_serialization_with_values() {
965        let volume = VolumeRaw {
966            h24: Some(10000.0),
967            h6: Some(2500.0),
968            h1: Some(416.0),
969            m5: Some(35.0),
970        };
971        let serialized = serde_json::to_string(&volume).unwrap();
972        let deserialized: VolumeRaw = serde_json::from_str(&serialized).unwrap();
973
974        assert_eq!(volume.h24, deserialized.h24);
975        assert_eq!(volume.h6, deserialized.h6);
976        assert_eq!(volume.h1, deserialized.h1);
977        assert_eq!(volume.m5, deserialized.m5);
978    }
979
980    #[test]
981    fn test_price_change_serialization() {
982        let price_change = PriceChangeRaw {
983            h24: Some(5.5),
984            h6: Some(2.1),
985            h1: Some(0.8),
986            m5: Some(0.1),
987        };
988        let serialized = serde_json::to_string(&price_change).unwrap();
989        let deserialized: PriceChangeRaw = serde_json::from_str(&serialized).unwrap();
990
991        assert_eq!(price_change.h24, deserialized.h24);
992        assert_eq!(price_change.h6, deserialized.h6);
993        assert_eq!(price_change.h1, deserialized.h1);
994        assert_eq!(price_change.m5, deserialized.m5);
995    }
996
997    #[test]
998    fn test_transaction_stats_serialization() {
999        let stats = TransactionStatsRaw {
1000            buys: Some(100),
1001            sells: Some(80),
1002        };
1003        let serialized = serde_json::to_string(&stats).unwrap();
1004        let deserialized: TransactionStatsRaw = serde_json::from_str(&serialized).unwrap();
1005
1006        assert_eq!(stats.buys, deserialized.buys);
1007        assert_eq!(stats.sells, deserialized.sells);
1008    }
1009
1010    #[test]
1011    fn test_transaction_stats_serialization_with_none() {
1012        let stats = TransactionStatsRaw {
1013            buys: None,
1014            sells: None,
1015        };
1016        let serialized = serde_json::to_string(&stats).unwrap();
1017        let deserialized: TransactionStatsRaw = serde_json::from_str(&serialized).unwrap();
1018
1019        assert_eq!(stats.buys, deserialized.buys);
1020        assert_eq!(stats.sells, deserialized.sells);
1021    }
1022
1023    #[test]
1024    fn test_transactions_serialization() {
1025        let transactions = TransactionsRaw {
1026            h24: Some(TransactionStatsRaw {
1027                buys: Some(100),
1028                sells: Some(80),
1029            }),
1030            h6: None,
1031            h1: Some(TransactionStatsRaw {
1032                buys: None,
1033                sells: Some(3),
1034            }),
1035            m5: None,
1036        };
1037        let serialized = serde_json::to_string(&transactions).unwrap();
1038        let deserialized: TransactionsRaw = serde_json::from_str(&serialized).unwrap();
1039
1040        assert!(deserialized.h24.is_some());
1041        assert!(deserialized.h6.is_none());
1042        assert!(deserialized.h1.is_some());
1043        assert!(deserialized.m5.is_none());
1044    }
1045
1046    #[test]
1047    fn test_pair_info_serialization_complete() {
1048        let pair = create_test_pair_info();
1049        let serialized = serde_json::to_string(&pair).unwrap();
1050        let deserialized: PairInfoRaw = serde_json::from_str(&serialized).unwrap();
1051
1052        assert_eq!(pair.chain_id, deserialized.chain_id);
1053        assert_eq!(pair.dex_id, deserialized.dex_id);
1054        assert_eq!(pair.url, deserialized.url);
1055        assert_eq!(pair.pair_address, deserialized.pair_address);
1056        assert_eq!(pair.labels, deserialized.labels);
1057        assert_eq!(pair.price_native, deserialized.price_native);
1058        assert_eq!(pair.price_usd, deserialized.price_usd);
1059        assert!(deserialized.liquidity.is_some());
1060        assert!(deserialized.volume.is_some());
1061        assert!(deserialized.price_change.is_some());
1062        assert!(deserialized.txns.is_some());
1063        assert_eq!(pair.market_cap, deserialized.market_cap);
1064        assert_eq!(pair.fdv, deserialized.fdv);
1065    }
1066
1067    #[test]
1068    fn test_pair_info_serialization_minimal() {
1069        let pair = PairInfoRaw {
1070            chain_id: "ethereum".to_string(),
1071            dex_id: "uniswap".to_string(),
1072            url: "https://dexscreener.com/test".to_string(),
1073            pair_address: "0xabcdef1234567890".to_string(),
1074            labels: None,
1075            base_token: create_test_token(),
1076            quote_token: create_test_token(),
1077            price_native: "0.001".to_string(),
1078            price_usd: None,
1079            liquidity: None,
1080            volume: None,
1081            price_change: None,
1082            txns: None,
1083            market_cap: None,
1084            fdv: None,
1085        };
1086        let serialized = serde_json::to_string(&pair).unwrap();
1087        let deserialized: PairInfoRaw = serde_json::from_str(&serialized).unwrap();
1088
1089        assert_eq!(pair.chain_id, deserialized.chain_id);
1090        assert_eq!(pair.dex_id, deserialized.dex_id);
1091        assert!(deserialized.labels.is_none());
1092        assert!(deserialized.price_usd.is_none());
1093        assert!(deserialized.liquidity.is_none());
1094        assert!(deserialized.volume.is_none());
1095        assert!(deserialized.price_change.is_none());
1096        assert!(deserialized.txns.is_none());
1097        assert!(deserialized.market_cap.is_none());
1098        assert!(deserialized.fdv.is_none());
1099    }
1100
1101    #[test]
1102    fn test_dexscreener_response_serialization() {
1103        let response = DexScreenerResponseRaw {
1104            schema_version: "1.0.0".to_string(),
1105            pairs: vec![create_test_pair_info()],
1106        };
1107        let serialized = serde_json::to_string(&response).unwrap();
1108        let deserialized: DexScreenerResponseRaw = serde_json::from_str(&serialized).unwrap();
1109
1110        assert_eq!(response.schema_version, deserialized.schema_version);
1111        assert_eq!(response.pairs.len(), deserialized.pairs.len());
1112    }
1113
1114    #[test]
1115    fn test_dexscreener_response_empty_pairs() {
1116        let response = DexScreenerResponseRaw {
1117            schema_version: "1.0.0".to_string(),
1118            pairs: vec![],
1119        };
1120        let serialized = serde_json::to_string(&response).unwrap();
1121        let deserialized: DexScreenerResponseRaw = serde_json::from_str(&serialized).unwrap();
1122
1123        assert_eq!(response.schema_version, deserialized.schema_version);
1124        assert!(deserialized.pairs.is_empty());
1125    }
1126
1127    // Helper function tests
1128
1129    #[test]
1130    fn test_find_best_liquidity_pair_when_empty_should_return_none() {
1131        let pairs = vec![];
1132        let result = find_best_liquidity_pair(pairs);
1133        assert!(result.is_none());
1134    }
1135
1136    #[test]
1137    fn test_find_best_liquidity_pair_when_no_liquidity_should_return_first() {
1138        let mut pair1 = create_test_pair_info();
1139        pair1.liquidity = None;
1140        let mut pair2 = create_test_pair_info();
1141        pair2.liquidity = None;
1142        let pairs = vec![pair1.clone(), pair2];
1143        let result = find_best_liquidity_pair(pairs);
1144
1145        assert!(result.is_some());
1146        assert_eq!(result.unwrap().pair_address, pair1.pair_address);
1147    }
1148
1149    #[test]
1150    fn test_find_best_liquidity_pair_when_liquidity_none_usd_should_return_first() {
1151        let mut pair1 = create_test_pair_info();
1152        pair1.liquidity = Some(LiquidityRaw {
1153            usd: None,
1154            base: Some(100.0),
1155            quote: Some(200.0),
1156        });
1157        let mut pair2 = create_test_pair_info();
1158        pair2.liquidity = Some(LiquidityRaw {
1159            usd: None,
1160            base: Some(300.0),
1161            quote: Some(400.0),
1162        });
1163        let pairs = vec![pair1.clone(), pair2];
1164        let result = find_best_liquidity_pair(pairs);
1165
1166        assert!(result.is_some());
1167        assert_eq!(result.unwrap().pair_address, pair1.pair_address);
1168    }
1169
1170    #[test]
1171    fn test_find_best_liquidity_pair_when_has_liquidity_should_return_highest() {
1172        let mut pair1 = create_test_pair_info();
1173        pair1.pair_address = "low_liquidity".to_string();
1174        pair1.liquidity = Some(LiquidityRaw {
1175            usd: Some(50000.0),
1176            base: Some(25000.0),
1177            quote: Some(25000.0),
1178        });
1179        let mut pair2 = create_test_pair_info();
1180        pair2.pair_address = "high_liquidity".to_string();
1181        pair2.liquidity = Some(LiquidityRaw {
1182            usd: Some(200000.0),
1183            base: Some(100000.0),
1184            quote: Some(100000.0),
1185        });
1186        let mut pair3 = create_test_pair_info();
1187        pair3.pair_address = "medium_liquidity".to_string();
1188        pair3.liquidity = Some(LiquidityRaw {
1189            usd: Some(100000.0),
1190            base: Some(50000.0),
1191            quote: Some(50000.0),
1192        });
1193        let pairs = vec![pair1, pair2.clone(), pair3];
1194        let result = find_best_liquidity_pair(pairs);
1195
1196        assert!(result.is_some());
1197        assert_eq!(result.unwrap().pair_address, "high_liquidity");
1198    }
1199
1200    #[test]
1201    fn test_find_best_liquidity_pair_when_mixed_liquidity_should_return_highest() {
1202        let mut pair1 = create_test_pair_info();
1203        pair1.pair_address = "no_liquidity".to_string();
1204        pair1.liquidity = None;
1205        let mut pair2 = create_test_pair_info();
1206        pair2.pair_address = "has_liquidity".to_string();
1207        pair2.liquidity = Some(LiquidityRaw {
1208            usd: Some(150000.0),
1209            base: Some(75000.0),
1210            quote: Some(75000.0),
1211        });
1212        let mut pair3 = create_test_pair_info();
1213        pair3.pair_address = "none_usd".to_string();
1214        pair3.liquidity = Some(LiquidityRaw {
1215            usd: None,
1216            base: Some(50000.0),
1217            quote: Some(50000.0),
1218        });
1219        let pairs = vec![pair1, pair2.clone(), pair3];
1220        let result = find_best_liquidity_pair(pairs);
1221
1222        assert!(result.is_some());
1223        assert_eq!(result.unwrap().pair_address, "has_liquidity");
1224    }
1225
1226    #[test]
1227    fn test_get_token_price_when_empty_pairs_should_return_none() {
1228        let pairs = vec![];
1229        let result = get_token_price(&pairs, "0x1234567890123456789012345678901234567890");
1230        assert!(result.is_none());
1231    }
1232
1233    #[test]
1234    fn test_get_token_price_when_no_matching_address_should_return_none() {
1235        let pairs = vec![create_test_pair_info()];
1236        let result = get_token_price(&pairs, "0xnonexistent");
1237        assert!(result.is_none());
1238    }
1239
1240    #[test]
1241    fn test_get_token_price_when_matching_address_case_insensitive_should_return_price() {
1242        let mut pair = create_test_pair_info();
1243        pair.base_token.address = "0xABCDEF1234567890".to_string();
1244        pair.price_usd = Some("2.50".to_string());
1245        let pairs = vec![pair];
1246
1247        let result = get_token_price(&pairs, "0xabcdef1234567890");
1248        assert!(result.is_some());
1249        assert_eq!(result.unwrap(), "2.50");
1250    }
1251
1252    #[test]
1253    fn test_get_token_price_when_matching_address_uppercase_should_return_price() {
1254        let mut pair = create_test_pair_info();
1255        pair.base_token.address = "0xabcdef1234567890".to_string();
1256        pair.price_usd = Some("3.75".to_string());
1257        let pairs = vec![pair];
1258
1259        let result = get_token_price(&pairs, "0XABCDEF1234567890");
1260        assert!(result.is_some());
1261        assert_eq!(result.unwrap(), "3.75");
1262    }
1263
1264    #[test]
1265    fn test_get_token_price_when_no_price_usd_should_return_none() {
1266        let mut pair = create_test_pair_info();
1267        pair.base_token.address = "0x1234567890123456789012345678901234567890".to_string();
1268        pair.price_usd = None;
1269        let pairs = vec![pair];
1270
1271        let result = get_token_price(&pairs, "0x1234567890123456789012345678901234567890");
1272        assert!(result.is_none());
1273    }
1274
1275    #[test]
1276    fn test_get_token_price_when_multiple_pairs_should_return_highest_liquidity() {
1277        let mut pair1 = create_test_pair_info();
1278        pair1.base_token.address = "0x1234567890123456789012345678901234567890".to_string();
1279        pair1.price_usd = Some("1.00".to_string());
1280        pair1.liquidity = Some(LiquidityRaw {
1281            usd: Some(50000.0),
1282            base: Some(25000.0),
1283            quote: Some(25000.0),
1284        });
1285
1286        let mut pair2 = create_test_pair_info();
1287        pair2.base_token.address = "0x1234567890123456789012345678901234567890".to_string();
1288        pair2.price_usd = Some("1.05".to_string());
1289        pair2.liquidity = Some(LiquidityRaw {
1290            usd: Some(150000.0),
1291            base: Some(75000.0),
1292            quote: Some(75000.0),
1293        });
1294
1295        let pairs = vec![pair1, pair2];
1296        let result = get_token_price(&pairs, "0x1234567890123456789012345678901234567890");
1297
1298        assert!(result.is_some());
1299        assert_eq!(result.unwrap(), "1.05");
1300    }
1301
1302    #[test]
1303    fn test_get_token_price_when_multiple_pairs_no_liquidity_should_return_first_match() {
1304        let mut pair1 = create_test_pair_info();
1305        pair1.base_token.address = "0x1234567890123456789012345678901234567890".to_string();
1306        pair1.price_usd = Some("1.00".to_string());
1307        pair1.liquidity = None;
1308
1309        let mut pair2 = create_test_pair_info();
1310        pair2.base_token.address = "0x1234567890123456789012345678901234567890".to_string();
1311        pair2.price_usd = Some("1.05".to_string());
1312        pair2.liquidity = None;
1313
1314        let pairs = vec![pair1, pair2];
1315        let result = get_token_price(&pairs, "0x1234567890123456789012345678901234567890");
1316
1317        assert!(result.is_some());
1318        assert_eq!(result.unwrap(), "1.00");
1319    }
1320
1321    #[test]
1322    fn test_get_token_price_when_mixed_liquidity_should_prefer_with_liquidity() {
1323        let mut pair1 = create_test_pair_info();
1324        pair1.base_token.address = "0x1234567890123456789012345678901234567890".to_string();
1325        pair1.price_usd = Some("1.00".to_string());
1326        pair1.liquidity = None;
1327
1328        let mut pair2 = create_test_pair_info();
1329        pair2.base_token.address = "0x1234567890123456789012345678901234567890".to_string();
1330        pair2.price_usd = Some("1.05".to_string());
1331        pair2.liquidity = Some(LiquidityRaw {
1332            usd: Some(100000.0),
1333            base: Some(50000.0),
1334            quote: Some(50000.0),
1335        });
1336
1337        let pairs = vec![pair1, pair2];
1338        let result = get_token_price(&pairs, "0x1234567890123456789012345678901234567890");
1339
1340        assert!(result.is_some());
1341        assert_eq!(result.unwrap(), "1.05");
1342    }
1343
1344    // JSON parsing with snake_case and camelCase field names
1345    #[test]
1346    fn test_deserialize_pair_info_with_camel_case() {
1347        let json = json!({
1348            "chainId": "ethereum",
1349            "dexId": "uniswap",
1350            "url": "https://dexscreener.com/test",
1351            "pairAddress": "0xabcdef1234567890",
1352            "labels": ["test"],
1353            "baseToken": {
1354                "address": "0x1234567890123456789012345678901234567890",
1355                "name": "Test Token",
1356                "symbol": "TEST"
1357            },
1358            "quoteToken": {
1359                "address": "0x9876543210987654321098765432109876543210",
1360                "name": "Quote Token",
1361                "symbol": "QUOTE"
1362            },
1363            "priceNative": "0.001",
1364            "priceUsd": "1.50",
1365            "liquidity": {
1366                "usd": 100000.0,
1367                "base": 50000.0,
1368                "quote": 50000.0
1369            },
1370            "volume": {
1371                "h24": 10000.0,
1372                "h6": 2500.0,
1373                "h1": 416.0,
1374                "m5": 35.0
1375            },
1376            "priceChange": {
1377                "h24": 5.5,
1378                "h6": 2.1,
1379                "h1": 0.8,
1380                "m5": 0.1
1381            },
1382            "txns": {
1383                "h24": {
1384                    "buys": 100,
1385                    "sells": 80
1386                },
1387                "h6": {
1388                    "buys": 25,
1389                    "sells": 20
1390                },
1391                "h1": {
1392                    "buys": 4,
1393                    "sells": 3
1394                },
1395                "m5": {
1396                    "buys": 1,
1397                    "sells": 0
1398                }
1399            },
1400            "marketCap": 1000000.0,
1401            "fdv": 1500000.0
1402        });
1403
1404        let pair: PairInfoRaw = serde_json::from_value(json).unwrap();
1405        assert_eq!(pair.chain_id, "ethereum");
1406        assert_eq!(pair.dex_id, "uniswap");
1407        assert_eq!(pair.pair_address, "0xabcdef1234567890");
1408        assert_eq!(
1409            pair.base_token.address,
1410            "0x1234567890123456789012345678901234567890"
1411        );
1412        assert_eq!(pair.quote_token.symbol, "QUOTE");
1413        assert_eq!(pair.price_native, "0.001");
1414        assert_eq!(pair.price_usd, Some("1.50".to_string()));
1415        assert!(pair.liquidity.is_some());
1416        assert!(pair.volume.is_some());
1417        assert!(pair.price_change.is_some());
1418        assert!(pair.txns.is_some());
1419        assert_eq!(pair.market_cap, Some(1000000.0));
1420        assert_eq!(pair.fdv, Some(1500000.0));
1421    }
1422
1423    #[test]
1424    fn test_deserialize_dexscreener_response_with_camel_case() {
1425        let json = json!({
1426            "schemaVersion": "1.0.0",
1427            "pairs": []
1428        });
1429
1430        let response: DexScreenerResponseRaw = serde_json::from_value(json).unwrap();
1431        assert_eq!(response.schema_version, "1.0.0");
1432        assert!(response.pairs.is_empty());
1433    }
1434
1435    // Test Volume default behavior
1436    #[test]
1437    fn test_volume_default_values() {
1438        let volume = VolumeRaw::default();
1439        assert!(volume.h24.is_none());
1440        assert!(volume.h6.is_none());
1441        assert!(volume.h1.is_none());
1442        assert!(volume.m5.is_none());
1443    }
1444
1445    #[test]
1446    fn test_volume_deserialization_with_missing_fields() {
1447        let json = json!({});
1448        let volume: VolumeRaw = serde_json::from_value(json).unwrap();
1449        assert!(volume.h24.is_none());
1450        assert!(volume.h6.is_none());
1451        assert!(volume.h1.is_none());
1452        assert!(volume.m5.is_none());
1453    }
1454
1455    #[test]
1456    fn test_price_change_deserialization_with_missing_fields() {
1457        let json = json!({});
1458        let price_change: PriceChangeRaw = serde_json::from_value(json).unwrap();
1459        assert!(price_change.h24.is_none());
1460        assert!(price_change.h6.is_none());
1461        assert!(price_change.h1.is_none());
1462        assert!(price_change.m5.is_none());
1463    }
1464
1465    #[test]
1466    fn test_transactions_deserialization_with_missing_fields() {
1467        let json = json!({});
1468        let transactions: TransactionsRaw = serde_json::from_value(json).unwrap();
1469        assert!(transactions.h24.is_none());
1470        assert!(transactions.h6.is_none());
1471        assert!(transactions.h1.is_none());
1472        assert!(transactions.m5.is_none());
1473    }
1474
1475    // Additional edge case tests for helper functions
1476
1477    #[test]
1478    fn test_find_best_liquidity_pair_with_single_pair() {
1479        let pair = create_test_pair_info();
1480        let pairs = vec![pair.clone()];
1481        let result = find_best_liquidity_pair(pairs);
1482
1483        assert!(result.is_some());
1484        assert_eq!(result.unwrap().pair_address, pair.pair_address);
1485    }
1486
1487    #[test]
1488    fn test_find_best_liquidity_pair_with_zero_liquidity() {
1489        let mut pair1 = create_test_pair_info();
1490        pair1.pair_address = "zero_liquidity_1".to_string();
1491        pair1.liquidity = Some(LiquidityRaw {
1492            usd: Some(0.0),
1493            base: Some(0.0),
1494            quote: Some(0.0),
1495        });
1496        let mut pair2 = create_test_pair_info();
1497        pair2.pair_address = "zero_liquidity_2".to_string();
1498        pair2.liquidity = Some(LiquidityRaw {
1499            usd: Some(0.0),
1500            base: Some(100.0),
1501            quote: Some(200.0),
1502        });
1503        let pairs = vec![pair1.clone(), pair2];
1504        let result = find_best_liquidity_pair(pairs);
1505
1506        assert!(result.is_some());
1507        assert_eq!(result.unwrap().pair_address, "zero_liquidity_1");
1508    }
1509
1510    #[test]
1511    fn test_find_best_liquidity_pair_with_negative_liquidity() {
1512        let mut pair = create_test_pair_info();
1513        pair.pair_address = "negative_liquidity".to_string();
1514        pair.liquidity = Some(LiquidityRaw {
1515            usd: Some(-1000.0),
1516            base: Some(-500.0),
1517            quote: Some(-500.0),
1518        });
1519        let pairs = vec![pair.clone()];
1520        let result = find_best_liquidity_pair(pairs);
1521
1522        assert!(result.is_some());
1523        assert_eq!(result.unwrap().pair_address, "negative_liquidity");
1524    }
1525
1526    #[test]
1527    fn test_get_token_price_with_empty_string_address() {
1528        let pairs = vec![create_test_pair_info()];
1529        let result = get_token_price(&pairs, "");
1530        assert!(result.is_none());
1531    }
1532
1533    #[test]
1534    fn test_get_token_price_with_partial_match() {
1535        let mut pair = create_test_pair_info();
1536        pair.base_token.address = "0x1234567890123456789012345678901234567890".to_string();
1537        let pairs = vec![pair];
1538
1539        // Should not match partial address
1540        let result = get_token_price(&pairs, "0x1234567890");
1541        assert!(result.is_none());
1542    }
1543
1544    #[test]
1545    fn test_get_token_price_with_special_characters() {
1546        let mut pair = create_test_pair_info();
1547        pair.base_token.address = "0x!@#$%^&*()_+".to_string();
1548        pair.price_usd = Some("1.00".to_string());
1549        let pairs = vec![pair];
1550
1551        let result = get_token_price(&pairs, "0x!@#$%^&*()_+");
1552        assert!(result.is_some());
1553        assert_eq!(result.unwrap(), "1.00");
1554    }
1555
1556    #[test]
1557    fn test_get_token_price_case_sensitivity_mixed() {
1558        let mut pair = create_test_pair_info();
1559        pair.base_token.address = "0xaBcDeF1234567890".to_string();
1560        pair.price_usd = Some("2.50".to_string());
1561        let pairs = vec![pair];
1562
1563        let result = get_token_price(&pairs, "0xAbCdEf1234567890");
1564        assert!(result.is_some());
1565        assert_eq!(result.unwrap(), "2.50");
1566    }
1567
1568    // Test struct Debug trait implementations
1569    #[test]
1570    fn test_token_debug_format() {
1571        let token = create_test_token();
1572        let debug_str = format!("{:?}", token);
1573        assert!(debug_str.contains("Test Token"));
1574        assert!(debug_str.contains("TEST"));
1575        assert!(debug_str.contains("0x1234567890123456789012345678901234567890"));
1576    }
1577
1578    #[test]
1579    fn test_liquidity_debug_format() {
1580        let liquidity = LiquidityRaw {
1581            usd: Some(100000.0),
1582            base: Some(50000.0),
1583            quote: Some(50000.0),
1584        };
1585        let debug_str = format!("{:?}", liquidity);
1586        assert!(debug_str.contains("100000"));
1587        assert!(debug_str.contains("50000"));
1588    }
1589
1590    #[test]
1591    fn test_volume_debug_format() {
1592        let volume = VolumeRaw {
1593            h24: Some(10000.0),
1594            h6: Some(2500.0),
1595            h1: Some(416.0),
1596            m5: Some(35.0),
1597        };
1598        let debug_str = format!("{:?}", volume);
1599        assert!(debug_str.contains("10000"));
1600        assert!(debug_str.contains("2500"));
1601        assert!(debug_str.contains("416"));
1602        assert!(debug_str.contains("35"));
1603    }
1604
1605    #[test]
1606    fn test_price_change_debug_format() {
1607        let price_change = PriceChangeRaw {
1608            h24: Some(5.5),
1609            h6: Some(2.1),
1610            h1: Some(0.8),
1611            m5: Some(0.1),
1612        };
1613        let debug_str = format!("{:?}", price_change);
1614        assert!(debug_str.contains("5.5"));
1615        assert!(debug_str.contains("2.1"));
1616        assert!(debug_str.contains("0.8"));
1617        assert!(debug_str.contains("0.1"));
1618    }
1619
1620    #[test]
1621    fn test_transaction_stats_debug_format() {
1622        let stats = TransactionStatsRaw {
1623            buys: Some(100),
1624            sells: Some(80),
1625        };
1626        let debug_str = format!("{:?}", stats);
1627        assert!(debug_str.contains("100"));
1628        assert!(debug_str.contains("80"));
1629    }
1630
1631    #[test]
1632    fn test_transactions_debug_format() {
1633        let transactions = TransactionsRaw {
1634            h24: Some(TransactionStatsRaw {
1635                buys: Some(100),
1636                sells: Some(80),
1637            }),
1638            h6: None,
1639            h1: None,
1640            m5: None,
1641        };
1642        let debug_str = format!("{:?}", transactions);
1643        assert!(debug_str.contains("100"));
1644        assert!(debug_str.contains("80"));
1645    }
1646
1647    #[test]
1648    fn test_pair_info_debug_format() {
1649        let pair = create_test_pair_info();
1650        let debug_str = format!("{:?}", pair);
1651        assert!(debug_str.contains("ethereum"));
1652        assert!(debug_str.contains("uniswap"));
1653        assert!(debug_str.contains("0xabcdef1234567890"));
1654    }
1655
1656    #[test]
1657    fn test_dexscreener_response_debug_format() {
1658        let response = DexScreenerResponseRaw {
1659            schema_version: "1.0.0".to_string(),
1660            pairs: vec![create_test_pair_info()],
1661        };
1662        let debug_str = format!("{:?}", response);
1663        assert!(debug_str.contains("1.0.0"));
1664        assert!(debug_str.contains("ethereum"));
1665    }
1666
1667    // Test Clone trait implementations
1668    #[test]
1669    fn test_token_clone() {
1670        let token = create_test_token();
1671        let cloned = token.clone();
1672        assert_eq!(token.address, cloned.address);
1673        assert_eq!(token.name, cloned.name);
1674        assert_eq!(token.symbol, cloned.symbol);
1675    }
1676
1677    #[test]
1678    fn test_liquidity_clone() {
1679        let liquidity = LiquidityRaw {
1680            usd: Some(100000.0),
1681            base: Some(50000.0),
1682            quote: Some(50000.0),
1683        };
1684        let cloned = liquidity.clone();
1685        assert_eq!(liquidity.usd, cloned.usd);
1686        assert_eq!(liquidity.base, cloned.base);
1687        assert_eq!(liquidity.quote, cloned.quote);
1688    }
1689
1690    #[test]
1691    fn test_volume_clone() {
1692        let volume = VolumeRaw {
1693            h24: Some(10000.0),
1694            h6: Some(2500.0),
1695            h1: Some(416.0),
1696            m5: Some(35.0),
1697        };
1698        let cloned = volume.clone();
1699        assert_eq!(volume.h24, cloned.h24);
1700        assert_eq!(volume.h6, cloned.h6);
1701        assert_eq!(volume.h1, cloned.h1);
1702        assert_eq!(volume.m5, cloned.m5);
1703    }
1704
1705    #[test]
1706    fn test_price_change_clone() {
1707        let price_change = PriceChangeRaw {
1708            h24: Some(5.5),
1709            h6: Some(2.1),
1710            h1: Some(0.8),
1711            m5: Some(0.1),
1712        };
1713        let cloned = price_change.clone();
1714        assert_eq!(price_change.h24, cloned.h24);
1715        assert_eq!(price_change.h6, cloned.h6);
1716        assert_eq!(price_change.h1, cloned.h1);
1717        assert_eq!(price_change.m5, cloned.m5);
1718    }
1719
1720    #[test]
1721    fn test_transaction_stats_clone() {
1722        let stats = TransactionStatsRaw {
1723            buys: Some(100),
1724            sells: Some(80),
1725        };
1726        let cloned = stats.clone();
1727        assert_eq!(stats.buys, cloned.buys);
1728        assert_eq!(stats.sells, cloned.sells);
1729    }
1730
1731    #[test]
1732    fn test_transactions_clone() {
1733        let transactions = TransactionsRaw {
1734            h24: Some(TransactionStatsRaw {
1735                buys: Some(100),
1736                sells: Some(80),
1737            }),
1738            h6: None,
1739            h1: None,
1740            m5: None,
1741        };
1742        let cloned = transactions.clone();
1743        assert!(cloned.h24.is_some());
1744        assert!(cloned.h6.is_none());
1745        assert!(cloned.h1.is_none());
1746        assert!(cloned.m5.is_none());
1747    }
1748
1749    #[test]
1750    fn test_pair_info_clone() {
1751        let pair = create_test_pair_info();
1752        let cloned = pair.clone();
1753        assert_eq!(pair.chain_id, cloned.chain_id);
1754        assert_eq!(pair.dex_id, cloned.dex_id);
1755        assert_eq!(pair.url, cloned.url);
1756        assert_eq!(pair.pair_address, cloned.pair_address);
1757    }
1758
1759    #[test]
1760    fn test_dexscreener_response_clone() {
1761        let response = DexScreenerResponseRaw {
1762            schema_version: "1.0.0".to_string(),
1763            pairs: vec![create_test_pair_info()],
1764        };
1765        let cloned = response.clone();
1766        assert_eq!(response.schema_version, cloned.schema_version);
1767        assert_eq!(response.pairs.len(), cloned.pairs.len());
1768    }
1769
1770    // Test edge cases for liquidity calculation in find_best_liquidity_pair
1771    #[test]
1772    fn test_find_best_liquidity_pair_with_very_large_numbers() {
1773        let mut pair = create_test_pair_info();
1774        pair.pair_address = "large_liquidity".to_string();
1775        pair.liquidity = Some(LiquidityRaw {
1776            usd: Some(f64::MAX),
1777            base: Some(f64::MAX),
1778            quote: Some(f64::MAX),
1779        });
1780        let pairs = vec![pair.clone()];
1781        let result = find_best_liquidity_pair(pairs);
1782
1783        assert!(result.is_some());
1784        assert_eq!(result.unwrap().pair_address, "large_liquidity");
1785    }
1786
1787    #[test]
1788    fn test_find_best_liquidity_pair_with_infinity() {
1789        let mut pair = create_test_pair_info();
1790        pair.pair_address = "infinity_liquidity".to_string();
1791        pair.liquidity = Some(LiquidityRaw {
1792            usd: Some(f64::INFINITY),
1793            base: Some(100.0),
1794            quote: Some(200.0),
1795        });
1796        let pairs = vec![pair.clone()];
1797        let result = find_best_liquidity_pair(pairs);
1798
1799        assert!(result.is_some());
1800        assert_eq!(result.unwrap().pair_address, "infinity_liquidity");
1801    }
1802
1803    #[test]
1804    fn test_find_best_liquidity_pair_with_nan() {
1805        let mut pair = create_test_pair_info();
1806        pair.pair_address = "nan_liquidity".to_string();
1807        pair.liquidity = Some(LiquidityRaw {
1808            usd: Some(f64::NAN),
1809            base: Some(100.0),
1810            quote: Some(200.0),
1811        });
1812        let pairs = vec![pair.clone()];
1813        let result = find_best_liquidity_pair(pairs);
1814
1815        assert!(result.is_some());
1816        assert_eq!(result.unwrap().pair_address, "nan_liquidity");
1817    }
1818
1819    // Test search_ticker truncation behavior
1820    #[tokio::test]
1821    async fn test_search_ticker_truncates_to_8_results() {
1822        // This test will verify that results are truncated to 8, but since we can't control
1823        // the actual API response, this is more of a documentation test
1824        let response = search_ticker("ETH".to_string()).await.unwrap();
1825        assert!(response.pairs.len() <= 8);
1826    }
1827
1828    // Original integration tests (preserved)
1829    #[tokio::test]
1830    async fn test_search_ticker() {
1831        let response = search_ticker("BONK".to_string()).await.unwrap();
1832        assert_eq!(response.schema_version, "1.0.0");
1833        assert!(!response.pairs.is_empty());
1834    }
1835
1836    #[tokio::test]
1837    async fn test_search_by_mint() {
1838        let response = search_ticker(
1839            "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263".to_string(), // BONK token
1840        )
1841        .await
1842        .unwrap();
1843        assert_eq!(response.schema_version, "1.0.0");
1844        assert!(!response.pairs.is_empty());
1845    }
1846
1847    #[tokio::test]
1848    async fn test_get_pairs_by_token() {
1849        let response = get_pairs_by_token("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") // USDC
1850            .await
1851            .unwrap();
1852        assert!(!response.pairs.is_empty());
1853    }
1854
1855    #[tokio::test]
1856    async fn test_get_pair_by_address_success() {
1857        // Test with a known pair address that should exist
1858        let result =
1859            get_pair_by_address("ethereum_0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852").await;
1860        match result {
1861            Ok(pair) => {
1862                assert!(!pair.pair_address.is_empty());
1863                assert!(!pair.chain_id.is_empty());
1864                assert!(!pair.dex_id.is_empty());
1865            }
1866            Err(_) => {
1867                // Some pair addresses might not exist, which is acceptable for this test
1868                // The important thing is that the function can be called without panicking
1869            }
1870        }
1871    }
1872
1873    #[tokio::test]
1874    async fn test_get_pair_by_address_nonexistent() {
1875        // Test with a clearly invalid pair address
1876        let result = get_pair_by_address("invalid_pair_address_that_does_not_exist").await;
1877        assert!(result.is_err());
1878    }
1879}