use crate::{client::WebClient, error::WebToolError};
use chrono::{DateTime, Utc};
use riglr_macros::tool;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::{debug, info};
#[derive(Debug, Clone)]
pub struct DexScreenerConfig {
pub base_url: String,
pub rate_limit_per_minute: u32,
pub request_timeout: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TokenInfo {
pub address: String,
pub name: String,
pub symbol: String,
pub decimals: u32,
pub price_usd: Option<f64>,
pub market_cap: Option<f64>,
pub volume_24h: Option<f64>,
pub price_change_24h: Option<f64>,
pub price_change_1h: Option<f64>,
pub price_change_5m: Option<f64>,
pub circulating_supply: Option<f64>,
pub total_supply: Option<f64>,
pub pair_count: u32,
pub pairs: Vec<TokenPair>,
pub chain: ChainInfo,
pub security: SecurityInfo,
pub socials: Vec<SocialLink>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TokenPair {
pub pair_id: String,
pub dex: DexInfo,
pub base_token: PairToken,
pub quote_token: PairToken,
pub price_usd: Option<f64>,
pub price_native: Option<f64>,
pub volume_24h: Option<f64>,
pub price_change_24h: Option<f64>,
pub liquidity_usd: Option<f64>,
pub fdv: Option<f64>,
pub created_at: Option<DateTime<Utc>>,
pub last_trade_at: DateTime<Utc>,
pub txns_24h: TransactionStats,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DexInfo {
pub id: String,
pub name: String,
pub url: Option<String>,
pub logo: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PairToken {
pub address: String,
pub name: String,
pub symbol: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TransactionStats {
pub buys: Option<u32>,
pub sells: Option<u32>,
pub total: Option<u32>,
pub buy_volume_usd: Option<f64>,
pub sell_volume_usd: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ChainInfo {
pub id: String,
pub name: String,
pub logo: Option<String>,
pub native_token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SecurityInfo {
pub is_verified: bool,
pub liquidity_locked: Option<bool>,
pub audit_status: Option<String>,
pub honeypot_status: Option<String>,
pub ownership_status: Option<String>,
pub risk_score: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SocialLink {
pub platform: String,
pub url: String,
pub followers: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MarketAnalysis {
pub token: TokenInfo,
pub trend_analysis: TrendAnalysis,
pub volume_analysis: VolumeAnalysis,
pub liquidity_analysis: LiquidityAnalysis,
pub price_levels: PriceLevelAnalysis,
pub risk_assessment: RiskAssessment,
pub analyzed_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TrendAnalysis {
pub direction: String,
pub strength: u32,
pub momentum: f64,
pub velocity: f64,
pub support_levels: Vec<f64>,
pub resistance_levels: Vec<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct VolumeAnalysis {
pub volume_rank: Option<u32>,
pub volume_trend: String,
pub volume_mcap_ratio: Option<f64>,
pub avg_volume_7d: Option<f64>,
pub spike_factor: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct LiquidityAnalysis {
pub total_liquidity_usd: f64,
pub dex_distribution: HashMap<String, f64>,
pub price_impact: HashMap<String, f64>, pub depth_score: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PriceLevelAnalysis {
pub ath: Option<f64>,
pub atl: Option<f64>,
pub ath_distance_pct: Option<f64>,
pub atl_distance_pct: Option<f64>,
pub high_24h: Option<f64>,
pub low_24h: Option<f64>,
pub range_position: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct RiskAssessment {
pub risk_level: String,
pub risk_factors: Vec<RiskFactor>,
pub liquidity_risk: u32,
pub volatility_risk: u32,
pub contract_risk: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct RiskFactor {
pub category: String,
pub description: String,
pub severity: String,
pub impact: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TokenSearchResult {
pub query: String,
pub tokens: Vec<TokenInfo>,
pub metadata: SearchMetadata,
pub searched_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SearchMetadata {
pub result_count: u32,
pub execution_time_ms: u32,
pub limited: bool,
pub suggestions: Vec<String>,
}
impl Default for DexScreenerConfig {
fn default() -> Self {
Self {
base_url: "https://api.dexscreener.com".to_string(),
rate_limit_per_minute: 300,
request_timeout: 30,
}
}
}
#[tool]
pub async fn get_token_info(
_context: &riglr_core::provider::ApplicationContext,
token_address: String,
chain_id: Option<String>,
include_pairs: Option<bool>,
include_security: Option<bool>,
) -> crate::error::Result<TokenInfo> {
debug!(
"Fetching token info for address: {} on chain: {:?}",
token_address,
chain_id.as_deref().unwrap_or("auto-detect")
);
let config = DexScreenerConfig::default();
let client = WebClient::default();
let chain = chain_id.unwrap_or_else(|| "ethereum".to_string());
let url = if include_pairs.unwrap_or(true) {
format!("{}/tokens/v1/{}/{}", config.base_url, chain, token_address)
} else {
format!(
"{}/tokens/v1/{}/{}?fields=basic",
config.base_url, chain, token_address
)
};
let response = client.get(&url).await.map_err(|e| {
if e.to_string().contains("timeout") || e.to_string().contains("connection") {
WebToolError::Network(format!("Failed to fetch token info: {}", e))
} else {
WebToolError::Api(format!("Failed to fetch token info: {}", e))
}
})?;
let token_info = parse_token_response(&response, &token_address, &chain, include_security)
.await
.map_err(|e| WebToolError::Api(format!("Failed to parse token response: {}", e)))?;
info!(
"Retrieved token info for {} ({}): ${:.6}",
token_info.symbol,
token_info.name,
token_info.price_usd.unwrap_or(0.0)
);
Ok(token_info)
}
#[tool]
pub async fn search_tokens(
_context: &riglr_core::provider::ApplicationContext,
query: String,
chain_filter: Option<String>,
min_market_cap: Option<f64>,
min_liquidity: Option<f64>,
limit: Option<u32>,
) -> crate::error::Result<TokenSearchResult> {
debug!("Searching tokens for query: '{}' with filters", query);
let config = DexScreenerConfig::default();
let client = WebClient::default();
let mut params = HashMap::new();
params.insert("q".to_string(), query.clone());
if let Some(chain) = chain_filter {
params.insert("chain".to_string(), chain);
}
if let Some(min_mc) = min_market_cap {
params.insert("min_market_cap".to_string(), min_mc.to_string());
}
if let Some(min_liq) = min_liquidity {
params.insert("min_liquidity".to_string(), min_liq.to_string());
}
params.insert("limit".to_string(), limit.unwrap_or(20).to_string());
let url = format!("{}/dex/search", config.base_url);
let response = client
.get_with_params(&url, ¶ms)
.await
.map_err(|e| WebToolError::Network(format!("Search request failed: {}", e)))?;
let tokens = parse_search_results(&response)
.await
.map_err(|e| WebToolError::Api(format!("Failed to parse search results: {}", e)))?;
let result = TokenSearchResult {
query: query.clone(),
tokens: tokens.clone(),
metadata: SearchMetadata {
result_count: tokens.len() as u32,
execution_time_ms: 150, limited: tokens.len() >= limit.unwrap_or(20) as usize,
suggestions: vec![], },
searched_at: Utc::now(),
};
info!(
"Token search completed: {} results for '{}'",
result.tokens.len(),
query
);
Ok(result)
}
#[tool]
pub async fn get_trending_tokens(
_context: &riglr_core::provider::ApplicationContext,
time_window: Option<String>, chain_filter: Option<String>,
min_volume: Option<f64>,
limit: Option<u32>,
) -> crate::error::Result<Vec<TokenInfo>> {
debug!(
"Fetching trending tokens for window: {:?}",
time_window.as_deref().unwrap_or("1h")
);
let config = DexScreenerConfig::default();
let client = WebClient::default();
let window = time_window.unwrap_or_else(|| "1h".to_string());
let mut params = HashMap::new();
params.insert("window".to_string(), window);
params.insert("limit".to_string(), limit.unwrap_or(50).to_string());
if let Some(chain) = chain_filter {
params.insert("chain".to_string(), chain);
}
if let Some(min_vol) = min_volume {
params.insert("min_volume".to_string(), min_vol.to_string());
}
let url = format!("{}/dex/tokens/trending", config.base_url);
let response = client
.get_with_params(&url, ¶ms)
.await
.map_err(|e| WebToolError::Network(format!("Failed to fetch trending tokens: {}", e)))?;
let trending_tokens = parse_trending_response(&response)
.await
.map_err(|e| WebToolError::Api(format!("Failed to parse trending response: {}", e)))?;
info!("Retrieved {} trending tokens", trending_tokens.len());
Ok(trending_tokens)
}
#[tool]
pub async fn analyze_token_market(
_context: &riglr_core::provider::ApplicationContext,
token_address: String,
chain_id: Option<String>,
_include_technical: Option<bool>,
include_risk: Option<bool>,
) -> crate::error::Result<MarketAnalysis> {
debug!("Performing market analysis for token: {}", token_address);
let token_info = get_token_info(
_context,
token_address.clone(),
chain_id,
Some(true),
include_risk,
)
.await?;
let trend_analysis = analyze_price_trends(&token_info)
.await
.map_err(|e| WebToolError::Api(format!("Trend analysis failed: {}", e)))?;
let volume_analysis = analyze_volume_patterns(&token_info)
.await
.map_err(|e| WebToolError::Api(format!("Volume analysis failed: {}", e)))?;
let liquidity_analysis = analyze_liquidity(&token_info)
.await
.map_err(|e| WebToolError::Api(format!("Liquidity analysis failed: {}", e)))?;
let price_levels = analyze_price_levels(&token_info)
.await
.map_err(|e| WebToolError::Api(format!("Price level analysis failed: {}", e)))?;
let risk_assessment = if include_risk.unwrap_or(true) {
assess_token_risks(&token_info)
.await
.map_err(|e| WebToolError::Api(format!("Risk assessment failed: {}", e)))?
} else {
RiskAssessment {
risk_level: "Unknown".to_string(),
risk_factors: vec![],
liquidity_risk: 50,
volatility_risk: 50,
contract_risk: 50,
}
};
let analysis = MarketAnalysis {
token: token_info.clone(),
trend_analysis,
volume_analysis,
liquidity_analysis,
price_levels,
risk_assessment,
analyzed_at: Utc::now(),
};
info!(
"Market analysis completed for {} - Risk: {}, Trend: {}",
token_info.symbol, analysis.risk_assessment.risk_level, analysis.trend_analysis.direction
);
Ok(analysis)
}
#[tool]
pub async fn get_top_pairs(
_context: &riglr_core::provider::ApplicationContext,
time_window: Option<String>, chain_filter: Option<String>,
dex_filter: Option<String>,
min_liquidity: Option<f64>,
limit: Option<u32>,
) -> crate::error::Result<Vec<TokenPair>> {
debug!(
"Fetching top pairs for window: {:?}",
time_window.as_deref().unwrap_or("24h")
);
let config = DexScreenerConfig::default();
let client = WebClient::default();
let mut params = HashMap::new();
params.insert("sort".to_string(), "volume".to_string());
params.insert(
"window".to_string(),
time_window.unwrap_or_else(|| "24h".to_string()),
);
params.insert("limit".to_string(), limit.unwrap_or(100).to_string());
if let Some(chain) = chain_filter {
params.insert("chain".to_string(), chain);
}
if let Some(dex) = dex_filter {
params.insert("dex".to_string(), dex);
}
if let Some(min_liq) = min_liquidity {
params.insert("min_liquidity".to_string(), min_liq.to_string());
}
let url = format!("{}/dex/pairs/top", config.base_url);
let response = client
.get_with_params(&url, ¶ms)
.await
.map_err(|e| WebToolError::Network(format!("Failed to fetch top pairs: {}", e)))?;
let pairs = parse_pairs_response(&response)
.await
.map_err(|e| WebToolError::Api(format!("Failed to parse pairs response: {}", e)))?;
info!("Retrieved {} top trading pairs", pairs.len());
Ok(pairs)
}
#[tool]
pub async fn get_latest_token_profiles(
_context: &riglr_core::provider::ApplicationContext,
limit: Option<u32>,
) -> crate::error::Result<Vec<crate::dexscreener_api::TokenProfile>> {
debug!("Fetching latest token profiles");
let profiles = crate::dexscreener_api::get_latest_token_profiles()
.await
.map_err(|e| WebToolError::Api(format!("Failed to fetch token profiles: {}", e)))?;
let limited_profiles = if let Some(limit) = limit {
profiles.into_iter().take(limit as usize).collect()
} else {
profiles
};
info!("Retrieved {} token profiles", limited_profiles.len());
Ok(limited_profiles)
}
#[tool]
pub async fn get_latest_boosted_tokens(
_context: &riglr_core::provider::ApplicationContext,
limit: Option<u32>,
) -> crate::error::Result<Vec<crate::dexscreener_api::BoostsResponse>> {
debug!("Fetching latest boosted tokens");
let boosts = crate::dexscreener_api::get_latest_token_boosts()
.await
.map_err(|e| WebToolError::Api(format!("Failed to fetch latest boosts: {}", e)))?;
let limited_boosts = if let Some(limit) = limit {
boosts.into_iter().take(limit as usize).collect()
} else {
boosts
};
info!("Retrieved {} boosted tokens", limited_boosts.len());
Ok(limited_boosts)
}
#[tool]
pub async fn get_top_boosted_tokens(
_context: &riglr_core::provider::ApplicationContext,
limit: Option<u32>,
) -> crate::error::Result<Vec<crate::dexscreener_api::BoostsResponse>> {
debug!("Fetching top boosted tokens");
let boosts = crate::dexscreener_api::get_top_token_boosts()
.await
.map_err(|e| WebToolError::Api(format!("Failed to fetch top boosts: {}", e)))?;
let limited_boosts = if let Some(limit) = limit {
boosts.into_iter().take(limit as usize).collect()
} else {
boosts
};
info!("Retrieved {} top boosted tokens", limited_boosts.len());
Ok(limited_boosts)
}
#[tool]
pub async fn check_token_orders(
_context: &riglr_core::provider::ApplicationContext,
chain_id: String,
token_address: String,
) -> crate::error::Result<Vec<crate::dexscreener_api::Order>> {
debug!(
"Checking orders for token {} on chain {}",
token_address, chain_id
);
let orders = crate::dexscreener_api::get_token_orders(&chain_id, &token_address)
.await
.map_err(|e| WebToolError::Api(format!("Failed to fetch token orders: {}", e)))?;
info!(
"Retrieved {} orders for token {} on {}",
orders.len(),
token_address,
chain_id
);
Ok(orders)
}
async fn parse_token_response(
response: &str,
token_address: &str,
chain: &str,
_include_security: Option<bool>,
) -> crate::error::Result<TokenInfo> {
let dex_response_raw: crate::dexscreener_api::DexScreenerResponseRaw =
serde_json::from_str(response).map_err(|e| {
crate::error::WebToolError::Parsing(format!(
"Failed to parse DexScreener response: {}",
e
))
})?;
let dex_response: crate::dexscreener_api::DexScreenerResponse = dex_response_raw.into();
debug!("Parsed response with {} pairs", dex_response.pairs.len());
let token_info_opt =
crate::dexscreener_api::aggregate_token_info(dex_response.pairs, token_address);
if let Some(mut token_info) = token_info_opt {
token_info.chain = ChainInfo {
id: chain.to_string(),
name: format_chain_name(chain),
logo: None,
native_token: get_native_token(chain),
};
return Ok(token_info);
}
Err(crate::error::WebToolError::Api(format!(
"No pairs found for token address: {}",
token_address
)))
}
pub fn format_dex_name(dex_id: &str) -> String {
match dex_id {
"uniswap" => "Uniswap V2".to_string(),
"uniswapv3" => "Uniswap V3".to_string(),
"pancakeswap" => "PancakeSwap".to_string(),
"sushiswap" => "SushiSwap".to_string(),
"curve" => "Curve".to_string(),
"balancer" => "Balancer".to_string(),
"quickswap" => "QuickSwap".to_string(),
"raydium" => "Raydium".to_string(),
"orca" => "Orca".to_string(),
_ => dex_id.to_string(),
}
}
pub fn format_chain_name(chain_id: &str) -> String {
match chain_id {
"ethereum" => "Ethereum".to_string(),
"bsc" => "Binance Smart Chain".to_string(),
"polygon" => "Polygon".to_string(),
"arbitrum" => "Arbitrum".to_string(),
"optimism" => "Optimism".to_string(),
"avalanche" => "Avalanche".to_string(),
"fantom" => "Fantom".to_string(),
"solana" => "Solana".to_string(),
_ => chain_id.to_string(),
}
}
pub fn get_native_token(chain_id: &str) -> String {
match chain_id {
"ethereum" => "ETH".to_string(),
"bsc" => "BNB".to_string(),
"polygon" => "MATIC".to_string(),
"arbitrum" => "ETH".to_string(),
"optimism" => "ETH".to_string(),
"avalanche" => "AVAX".to_string(),
"fantom" => "FTM".to_string(),
"solana" => "SOL".to_string(),
_ => "NATIVE".to_string(),
}
}
async fn parse_search_results(response: &str) -> crate::error::Result<Vec<TokenInfo>> {
let dex_response_raw: crate::dexscreener_api::DexScreenerResponseRaw =
serde_json::from_str(response)
.map_err(|e| crate::error::WebToolError::Parsing(e.to_string()))?;
let dex_response: crate::dexscreener_api::DexScreenerResponse = dex_response_raw.into();
let mut tokens_map = std::collections::HashMap::new();
for pair in dex_response.pairs {
let token_address = pair.base_token.address.clone();
let entry = tokens_map
.entry(token_address.clone())
.or_insert(TokenInfo {
address: token_address,
name: pair.base_token.name.clone(),
symbol: pair.base_token.symbol.clone(),
decimals: 18, price_usd: pair.price_usd,
market_cap: pair.market_cap,
volume_24h: pair.volume.as_ref().and_then(|v| v.h24),
price_change_24h: pair.price_change.as_ref().and_then(|pc| pc.h24),
price_change_1h: pair.price_change.as_ref().and_then(|pc| pc.h1),
price_change_5m: pair.price_change.as_ref().and_then(|pc| pc.m5),
circulating_supply: None,
total_supply: None,
pair_count: 0,
pairs: vec![],
chain: ChainInfo {
id: pair.chain_id.clone(),
name: pair.chain_id.clone(), logo: None,
native_token: "ETH".to_string(), },
security: SecurityInfo {
is_verified: false,
liquidity_locked: None,
audit_status: None,
honeypot_status: None,
ownership_status: None,
risk_score: None,
},
socials: vec![],
updated_at: chrono::Utc::now(),
});
entry.pairs.push(TokenPair {
pair_id: pair.pair_address.clone(),
dex: DexInfo {
id: pair.dex_id.clone(),
name: pair.dex_id.clone(), url: Some(pair.url.clone()),
logo: None,
},
base_token: PairToken {
address: pair.base_token.address.clone(),
name: pair.base_token.name.clone(),
symbol: pair.base_token.symbol.clone(),
},
quote_token: PairToken {
address: pair.quote_token.address.clone(),
name: pair.quote_token.name.clone(),
symbol: pair.quote_token.symbol.clone(),
},
price_usd: pair.price_usd,
price_native: Some(pair.price_native),
volume_24h: pair.volume.and_then(|v| v.h24),
price_change_24h: pair.price_change.and_then(|pc| pc.h24),
liquidity_usd: pair.liquidity.and_then(|l| l.usd),
fdv: pair.fdv,
created_at: None,
last_trade_at: chrono::Utc::now(),
txns_24h: TransactionStats {
buys: pair
.txns
.as_ref()
.and_then(|t| t.h24.as_ref().and_then(|h| h.buys.map(|b| b as u32))),
sells: pair
.txns
.as_ref()
.and_then(|t| t.h24.as_ref().and_then(|h| h.sells.map(|s| s as u32))),
total: None,
buy_volume_usd: None,
sell_volume_usd: None,
},
url: pair.url,
});
entry.pair_count += 1;
}
Ok(tokens_map.into_values().collect())
}
async fn parse_trending_response(response: &str) -> crate::error::Result<Vec<TokenInfo>> {
parse_search_results(response).await
}
async fn parse_pairs_response(response: &str) -> crate::error::Result<Vec<TokenPair>> {
let dex_response_raw: crate::dexscreener_api::DexScreenerResponseRaw =
serde_json::from_str(response)
.map_err(|e| crate::error::WebToolError::Parsing(e.to_string()))?;
let dex_response: crate::dexscreener_api::DexScreenerResponse = dex_response_raw.into();
let pairs: Vec<TokenPair> = dex_response
.pairs
.into_iter()
.map(|pair| TokenPair {
pair_id: pair.pair_address.clone(),
dex: DexInfo {
id: pair.dex_id.clone(),
name: pair.dex_id.clone(),
url: Some(pair.url.clone()),
logo: None,
},
base_token: PairToken {
address: pair.base_token.address.clone(),
name: pair.base_token.name.clone(),
symbol: pair.base_token.symbol.clone(),
},
quote_token: PairToken {
address: pair.quote_token.address.clone(),
name: pair.quote_token.name.clone(),
symbol: pair.quote_token.symbol.clone(),
},
price_usd: pair.price_usd,
price_native: Some(pair.price_native),
volume_24h: pair.volume.and_then(|v| v.h24),
price_change_24h: pair.price_change.and_then(|pc| pc.h24),
liquidity_usd: pair.liquidity.and_then(|l| l.usd),
fdv: pair.fdv,
created_at: None,
last_trade_at: chrono::Utc::now(),
txns_24h: TransactionStats {
buys: pair
.txns
.as_ref()
.and_then(|t| t.h24.as_ref().and_then(|h| h.buys.map(|b| b as u32))),
sells: pair
.txns
.as_ref()
.and_then(|t| t.h24.as_ref().and_then(|h| h.sells.map(|s| s as u32))),
total: None,
buy_volume_usd: None,
sell_volume_usd: None,
},
url: pair.url,
})
.collect();
Ok(pairs)
}
async fn analyze_price_trends(token: &TokenInfo) -> crate::error::Result<TrendAnalysis> {
let price_change_24h = token.price_change_24h;
let price_change_1h = token.price_change_1h;
let direction = match price_change_24h {
Some(change) if change > 5.0 => "Bullish",
Some(change) if change < -5.0 => "Bearish",
Some(_) => "Neutral",
None => "Unknown",
}
.to_string();
let strength =
price_change_24h.map_or(5, |change| ((change.abs() / 10.0).clamp(1.0, 10.0)) as u32);
let momentum = match (price_change_1h, price_change_24h) {
(Some(h1), Some(h24)) => h1 * 24.0 - h24, _ => 0.0,
};
let velocity = price_change_24h.map_or(0.0, |c| c / 24.0);
let (support_levels, resistance_levels) = if let Some(price) = token.price_usd {
(vec![price * 0.95], vec![price * 1.05])
} else {
(vec![], vec![])
};
Ok(TrendAnalysis {
direction,
strength,
momentum,
velocity,
support_levels,
resistance_levels,
})
}
async fn analyze_volume_patterns(token: &TokenInfo) -> crate::error::Result<VolumeAnalysis> {
let volume_mcap_ratio = match (token.volume_24h, token.market_cap) {
(Some(vol), Some(mcap)) if mcap > 0.0 => Some(vol / mcap),
_ => None,
};
Ok(VolumeAnalysis {
volume_rank: None, volume_trend: "Unknown".to_string(), volume_mcap_ratio,
avg_volume_7d: None, spike_factor: None, })
}
async fn analyze_liquidity(token: &TokenInfo) -> crate::error::Result<LiquidityAnalysis> {
let total_liquidity = token
.pairs
.iter()
.map(|p| p.liquidity_usd.unwrap_or(0.0))
.sum();
let mut dex_distribution = HashMap::new();
for pair in &token.pairs {
let current = dex_distribution.get(&pair.dex.name).unwrap_or(&0.0);
dex_distribution.insert(
pair.dex.name.clone(),
current + pair.liquidity_usd.unwrap_or(0.0),
);
}
let mut price_impact = HashMap::new();
price_impact.insert("1k".to_string(), 0.1);
price_impact.insert("10k".to_string(), 0.5);
price_impact.insert("100k".to_string(), 2.0);
Ok(LiquidityAnalysis {
total_liquidity_usd: total_liquidity,
dex_distribution,
price_impact,
depth_score: if total_liquidity > 1_000_000.0 {
85
} else {
60
},
})
}
async fn analyze_price_levels(token: &TokenInfo) -> crate::error::Result<PriceLevelAnalysis> {
let (high_24h, low_24h, range_position) = if let Some(price) = token.price_usd {
match token.price_change_24h {
Some(change_pct) => {
let change_factor = 1.0 + (change_pct / 100.0);
if change_pct > 0.0 {
let low = price / change_factor;
(Some(price), Some(low), Some(1.0)) } else {
let high = price / change_factor;
(Some(high), Some(price), Some(0.0)) }
}
None => (None, None, None),
}
} else {
(None, None, None)
};
Ok(PriceLevelAnalysis {
ath: None, atl: None, ath_distance_pct: None,
atl_distance_pct: None,
high_24h,
low_24h,
range_position,
})
}
async fn assess_token_risks(token: &TokenInfo) -> crate::error::Result<RiskAssessment> {
let mut risk_factors = vec![];
let mut total_risk = 0;
let liquidity_score = if token
.pairs
.iter()
.map(|p| p.liquidity_usd.unwrap_or(0.0))
.sum::<f64>()
< 100_000.0
{
risk_factors.push(RiskFactor {
category: "Liquidity".to_string(),
description: "Low liquidity may cause high price impact".to_string(),
severity: "High".to_string(),
impact: 75,
});
75
} else {
25
};
total_risk += liquidity_score;
let contract_score = if !token.security.is_verified {
risk_factors.push(RiskFactor {
category: "Contract".to_string(),
description: "Contract is not verified".to_string(),
severity: "High".to_string(),
impact: 80,
});
80
} else {
20
};
total_risk += contract_score;
let volatility_score = match token.price_change_24h {
Some(change) if change.abs() > 20.0 => {
risk_factors.push(RiskFactor {
category: "Volatility".to_string(),
description: "High price volatility detected".to_string(),
severity: "Medium".to_string(),
impact: 60,
});
60
}
Some(_) => 30, None => {
risk_factors.push(RiskFactor {
category: "Data".to_string(),
description: "Volatility data unavailable".to_string(),
severity: "Low".to_string(),
impact: 20,
});
20
}
};
total_risk += volatility_score;
let avg_risk = total_risk / 3;
let risk_level = match avg_risk {
0..=25 => "Low",
26..=50 => "Medium",
51..=75 => "High",
_ => "Extreme",
}
.to_string();
Ok(RiskAssessment {
risk_level,
risk_factors,
liquidity_risk: liquidity_score as u32,
volatility_risk: volatility_score as u32,
contract_risk: contract_score as u32,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dexscreener_config_default() {
let config = DexScreenerConfig::default();
assert_eq!(config.base_url, "https://api.dexscreener.com");
assert_eq!(config.rate_limit_per_minute, 300);
}
#[test]
fn test_token_info_serialization() {
let token = TokenInfo {
address: "0x123".to_string(),
name: "Test Token".to_string(),
symbol: "TEST".to_string(),
decimals: 18,
price_usd: Some(1.0),
market_cap: Some(1000000.0),
volume_24h: Some(50000.0),
price_change_24h: Some(5.0),
price_change_1h: Some(-1.0),
price_change_5m: Some(0.5),
circulating_supply: Some(1000000.0),
total_supply: Some(10000000.0),
pair_count: 1,
pairs: vec![],
chain: ChainInfo {
id: "ethereum".to_string(),
name: "Ethereum".to_string(),
logo: None,
native_token: "ETH".to_string(),
},
security: SecurityInfo {
is_verified: true,
liquidity_locked: Some(true),
audit_status: None,
honeypot_status: None,
ownership_status: None,
risk_score: Some(25),
},
socials: vec![],
updated_at: Utc::now(),
};
let json = serde_json::to_string(&token).unwrap();
assert!(json.contains("Test Token"));
}
#[test]
fn test_dexscreener_config_custom_values() {
let config = DexScreenerConfig {
base_url: "https://custom.api.com".to_string(),
rate_limit_per_minute: 100,
request_timeout: 60,
};
assert_eq!(config.base_url, "https://custom.api.com");
assert_eq!(config.rate_limit_per_minute, 100);
assert_eq!(config.request_timeout, 60);
}
#[test]
fn test_format_dex_name_when_known_dex_should_return_formatted_name() {
assert_eq!(format_dex_name("uniswap"), "Uniswap V2");
assert_eq!(format_dex_name("uniswapv3"), "Uniswap V3");
assert_eq!(format_dex_name("pancakeswap"), "PancakeSwap");
assert_eq!(format_dex_name("sushiswap"), "SushiSwap");
assert_eq!(format_dex_name("curve"), "Curve");
assert_eq!(format_dex_name("balancer"), "Balancer");
assert_eq!(format_dex_name("quickswap"), "QuickSwap");
assert_eq!(format_dex_name("raydium"), "Raydium");
assert_eq!(format_dex_name("orca"), "Orca");
}
#[test]
fn test_format_dex_name_when_unknown_dex_should_return_original() {
assert_eq!(format_dex_name("unknown-dex"), "unknown-dex");
assert_eq!(format_dex_name("custom_dex"), "custom_dex");
assert_eq!(format_dex_name(""), "");
}
#[test]
fn test_format_chain_name_when_known_chain_should_return_formatted_name() {
assert_eq!(format_chain_name("ethereum"), "Ethereum");
assert_eq!(format_chain_name("bsc"), "Binance Smart Chain");
assert_eq!(format_chain_name("polygon"), "Polygon");
assert_eq!(format_chain_name("arbitrum"), "Arbitrum");
assert_eq!(format_chain_name("optimism"), "Optimism");
assert_eq!(format_chain_name("avalanche"), "Avalanche");
assert_eq!(format_chain_name("fantom"), "Fantom");
assert_eq!(format_chain_name("solana"), "Solana");
}
#[test]
fn test_format_chain_name_when_unknown_chain_should_return_original() {
assert_eq!(format_chain_name("unknown-chain"), "unknown-chain");
assert_eq!(format_chain_name("custom_chain"), "custom_chain");
assert_eq!(format_chain_name(""), "");
}
#[test]
fn test_get_native_token_when_known_chain_should_return_correct_token() {
assert_eq!(get_native_token("ethereum"), "ETH");
assert_eq!(get_native_token("bsc"), "BNB");
assert_eq!(get_native_token("polygon"), "MATIC");
assert_eq!(get_native_token("arbitrum"), "ETH");
assert_eq!(get_native_token("optimism"), "ETH");
assert_eq!(get_native_token("avalanche"), "AVAX");
assert_eq!(get_native_token("fantom"), "FTM");
assert_eq!(get_native_token("solana"), "SOL");
}
#[test]
fn test_get_native_token_when_unknown_chain_should_return_native() {
assert_eq!(get_native_token("unknown-chain"), "NATIVE");
assert_eq!(get_native_token("custom_chain"), "NATIVE");
assert_eq!(get_native_token(""), "NATIVE");
}
#[tokio::test]
async fn test_analyze_price_trends_when_bullish_data_should_return_bullish_trend() {
let token = create_test_token_info(Some(10.0), Some(2.0), Some(1.0));
let result = analyze_price_trends(&token).await.unwrap();
assert_eq!(result.direction, "Bullish");
assert_eq!(result.strength, 1); assert_eq!(result.momentum, 26.0); assert_eq!(result.velocity, 10.0 / 24.0);
assert_eq!(result.support_levels.len(), 1);
assert_eq!(result.resistance_levels.len(), 1);
}
#[tokio::test]
async fn test_analyze_price_trends_when_bearish_data_should_return_bearish_trend() {
let token = create_test_token_info(Some(-10.0), Some(-1.0), Some(1.0));
let result = analyze_price_trends(&token).await.unwrap();
assert_eq!(result.direction, "Bearish");
assert_eq!(result.strength, 1); assert_eq!(result.momentum, -14.0); assert_eq!(result.velocity, -10.0 / 24.0);
}
#[tokio::test]
async fn test_analyze_price_trends_when_neutral_data_should_return_neutral_trend() {
let token = create_test_token_info(Some(2.0), Some(0.5), Some(1.0));
let result = analyze_price_trends(&token).await.unwrap();
assert_eq!(result.direction, "Neutral");
assert_eq!(result.strength, 1); assert_eq!(result.momentum, 10.0); }
#[tokio::test]
async fn test_analyze_price_trends_when_no_data_should_return_unknown_trend() {
let token = create_test_token_info(None, None, Some(1.0));
let result = analyze_price_trends(&token).await.unwrap();
assert_eq!(result.direction, "Unknown");
assert_eq!(result.strength, 5); assert_eq!(result.momentum, 0.0);
assert_eq!(result.velocity, 0.0);
assert_eq!(result.support_levels.len(), 0);
assert_eq!(result.resistance_levels.len(), 0);
}
#[tokio::test]
async fn test_analyze_price_trends_when_no_price_should_return_empty_levels() {
let token = create_test_token_info(Some(5.0), Some(1.0), None);
let result = analyze_price_trends(&token).await.unwrap();
assert_eq!(result.support_levels.len(), 0);
assert_eq!(result.resistance_levels.len(), 0);
}
#[tokio::test]
async fn test_analyze_volume_patterns_when_valid_data_should_calculate_ratio() {
let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
token.volume_24h = Some(50000.0);
token.market_cap = Some(1000000.0);
let result = analyze_volume_patterns(&token).await.unwrap();
assert_eq!(result.volume_mcap_ratio, Some(0.05)); assert_eq!(result.volume_trend, "Unknown");
assert_eq!(result.volume_rank, None);
assert_eq!(result.avg_volume_7d, None);
assert_eq!(result.spike_factor, None);
}
#[tokio::test]
async fn test_analyze_volume_patterns_when_zero_market_cap_should_return_none_ratio() {
let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
token.volume_24h = Some(50000.0);
token.market_cap = Some(0.0);
let result = analyze_volume_patterns(&token).await.unwrap();
assert_eq!(result.volume_mcap_ratio, None);
}
#[tokio::test]
async fn test_analyze_volume_patterns_when_missing_data_should_return_none_ratio() {
let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
token.volume_24h = None;
token.market_cap = None;
let result = analyze_volume_patterns(&token).await.unwrap();
assert_eq!(result.volume_mcap_ratio, None);
}
#[tokio::test]
async fn test_analyze_liquidity_when_multiple_pairs_should_aggregate_liquidity() {
let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
token.pairs = vec![
create_test_token_pair("dex1", 100000.0),
create_test_token_pair("dex2", 200000.0),
];
let result = analyze_liquidity(&token).await.unwrap();
assert_eq!(result.total_liquidity_usd, 300000.0);
assert_eq!(result.dex_distribution.len(), 2);
assert_eq!(result.dex_distribution.get("dex1"), Some(&100000.0));
assert_eq!(result.dex_distribution.get("dex2"), Some(&200000.0));
assert_eq!(result.depth_score, 60); }
#[tokio::test]
async fn test_analyze_liquidity_when_high_liquidity_should_return_high_depth_score() {
let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
token.pairs = vec![create_test_token_pair("dex1", 2000000.0)];
let result = analyze_liquidity(&token).await.unwrap();
assert_eq!(result.total_liquidity_usd, 2000000.0);
assert_eq!(result.depth_score, 85); }
#[tokio::test]
async fn test_analyze_liquidity_when_no_pairs_should_return_zero_liquidity() {
let token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
let result = analyze_liquidity(&token).await.unwrap();
assert_eq!(result.total_liquidity_usd, 0.0);
assert_eq!(result.dex_distribution.len(), 0);
assert_eq!(result.depth_score, 60);
}
#[tokio::test]
async fn test_analyze_price_levels_when_positive_change_should_estimate_at_high() {
let token = create_test_token_info(Some(10.0), Some(1.0), Some(100.0));
let result = analyze_price_levels(&token).await.unwrap();
assert_eq!(result.high_24h, Some(100.0));
assert!(result.low_24h.is_some());
assert!(result.low_24h.unwrap() < 100.0);
assert_eq!(result.range_position, Some(1.0)); assert_eq!(result.ath, None);
assert_eq!(result.atl, None);
}
#[tokio::test]
async fn test_analyze_price_levels_when_negative_change_should_estimate_at_low() {
let token = create_test_token_info(Some(-10.0), Some(1.0), Some(90.0));
let result = analyze_price_levels(&token).await.unwrap();
assert!(result.high_24h.is_some());
assert!(result.high_24h.unwrap() > 90.0);
assert_eq!(result.low_24h, Some(90.0));
assert_eq!(result.range_position, Some(0.0)); }
#[tokio::test]
async fn test_analyze_price_levels_when_no_price_change_should_return_none() {
let mut token = create_test_token_info(None, Some(1.0), Some(100.0));
token.price_change_24h = None;
let result = analyze_price_levels(&token).await.unwrap();
assert_eq!(result.high_24h, None);
assert_eq!(result.low_24h, None);
assert_eq!(result.range_position, None);
}
#[tokio::test]
async fn test_analyze_price_levels_when_no_price_should_return_none() {
let token = create_test_token_info(Some(10.0), Some(1.0), None);
let result = analyze_price_levels(&token).await.unwrap();
assert_eq!(result.high_24h, None);
assert_eq!(result.low_24h, None);
assert_eq!(result.range_position, None);
}
#[tokio::test]
async fn test_assess_token_risks_when_low_liquidity_should_add_liquidity_risk() {
let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
token.pairs = vec![create_test_token_pair("dex1", 50000.0)];
let result = assess_token_risks(&token).await.unwrap();
assert_eq!(result.liquidity_risk, 75);
assert!(result
.risk_factors
.iter()
.any(|r| r.category == "Liquidity"));
assert!(matches!(result.risk_level.as_str(), "High" | "Extreme"));
}
#[tokio::test]
async fn test_assess_token_risks_when_high_liquidity_should_have_low_liquidity_risk() {
let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
let result = assess_token_risks(&token).await.unwrap();
assert_eq!(result.liquidity_risk, 25);
}
#[tokio::test]
async fn test_assess_token_risks_when_unverified_contract_should_add_contract_risk() {
let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
token.security.is_verified = false;
token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
let result = assess_token_risks(&token).await.unwrap();
assert_eq!(result.contract_risk, 80);
assert!(result.risk_factors.iter().any(|r| r.category == "Contract"));
}
#[tokio::test]
async fn test_assess_token_risks_when_verified_contract_should_have_low_contract_risk() {
let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
token.security.is_verified = true;
token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
let result = assess_token_risks(&token).await.unwrap();
assert_eq!(result.contract_risk, 20);
}
#[tokio::test]
async fn test_assess_token_risks_when_high_volatility_should_add_volatility_risk() {
let mut token = create_test_token_info(Some(25.0), Some(1.0), Some(1.0)); token.security.is_verified = true;
token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
let result = assess_token_risks(&token).await.unwrap();
assert_eq!(result.volatility_risk, 60);
assert!(result
.risk_factors
.iter()
.any(|r| r.category == "Volatility"));
}
#[tokio::test]
async fn test_assess_token_risks_when_normal_volatility_should_have_medium_volatility_risk() {
let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0)); token.security.is_verified = true;
token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
let result = assess_token_risks(&token).await.unwrap();
assert_eq!(result.volatility_risk, 30);
}
#[tokio::test]
async fn test_assess_token_risks_when_no_volatility_data_should_add_data_risk() {
let mut token = create_test_token_info(None, Some(1.0), Some(1.0));
token.security.is_verified = true;
token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
let result = assess_token_risks(&token).await.unwrap();
assert_eq!(result.volatility_risk, 20);
assert!(result.risk_factors.iter().any(|r| r.category == "Data"));
}
#[tokio::test]
async fn test_assess_token_risks_when_low_risk_should_return_low_risk_level() {
let mut token = create_test_token_info(Some(5.0), Some(1.0), Some(1.0));
token.security.is_verified = true;
token.pairs = vec![create_test_token_pair("dex1", 500000.0)];
let result = assess_token_risks(&token).await.unwrap();
assert_eq!(result.risk_level, "Low");
}
#[tokio::test]
async fn test_parse_search_results_when_invalid_json_should_return_error() {
let invalid_json = "invalid json";
let result = parse_search_results(invalid_json).await;
assert!(result.is_err());
if let Err(crate::error::WebToolError::Parsing(msg)) = result {
assert!(msg.contains("expected"));
}
}
#[tokio::test]
async fn test_parse_trending_response_when_invalid_json_should_return_error() {
let invalid_json = "invalid json";
let result = parse_trending_response(invalid_json).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_parse_pairs_response_when_invalid_json_should_return_error() {
let invalid_json = "invalid json";
let result = parse_pairs_response(invalid_json).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_parse_token_response_when_invalid_json_should_return_parsing_error() {
let invalid_json = "invalid json";
let result = parse_token_response(invalid_json, "0x123", "ethereum", Some(true)).await;
assert!(result.is_err());
if let Err(crate::error::WebToolError::Parsing(msg)) = result {
assert!(msg.contains("Failed to parse DexScreener response"));
}
}
#[tokio::test]
async fn test_parse_token_response_when_no_pairs_found_should_return_api_error() {
let valid_json_no_pairs = r#"{"pairs": []}"#;
let result =
parse_token_response(valid_json_no_pairs, "0x123", "ethereum", Some(true)).await;
assert!(result.is_err());
if let Err(crate::error::WebToolError::Api(msg)) = result {
assert!(msg.contains("No pairs found for token address"));
}
}
#[tokio::test]
async fn test_parse_token_response_when_no_valid_pairs_should_return_api_error() {
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"}]}"#;
let result =
parse_token_response(json_with_unmatched_pairs, "0x123", "ethereum", Some(true)).await;
assert!(result.is_err());
if let Err(crate::error::WebToolError::Api(msg)) = result {
assert!(msg.contains("No pairs found for token address"));
}
}
#[tokio::test]
async fn test_parse_token_response_when_valid_data_should_return_token_info() {
let valid_json = create_valid_dexscreener_response();
let result = parse_token_response(&valid_json, "0x123", "ethereum", Some(true)).await;
assert!(result.is_ok());
let token = result.unwrap();
assert_eq!(token.address, "0x123");
assert_eq!(token.symbol, "TEST");
assert_eq!(token.name, "Test Token");
assert_eq!(token.chain.id, "ethereum");
assert_eq!(token.chain.name, "Ethereum");
assert_eq!(token.chain.native_token, "ETH");
assert_eq!(token.pair_count, 1);
assert!(!token.pairs.is_empty());
}
#[tokio::test]
async fn test_parse_token_response_when_multiple_pairs_should_use_highest_liquidity() {
let json_with_multiple_pairs = create_dexscreener_response_with_multiple_pairs();
let result =
parse_token_response(&json_with_multiple_pairs, "0x123", "ethereum", Some(true)).await;
assert!(result.is_ok());
let token = result.unwrap();
assert_eq!(token.pair_count, 2);
assert!(token.volume_24h.unwrap() > 10000.0);
assert_eq!(token.price_usd, Some(2.0)); }
#[tokio::test]
async fn test_parse_token_response_when_case_insensitive_address_should_match() {
let valid_json = create_valid_dexscreener_response();
let result = parse_token_response(&valid_json, "0X123", "ethereum", Some(true)).await;
assert!(result.is_ok());
let token = result.unwrap();
assert_eq!(token.address, "0X123"); }
#[tokio::test]
async fn test_parse_token_response_when_no_liquidity_data_should_handle_gracefully() {
let json_no_liquidity = create_dexscreener_response_without_liquidity();
let result =
parse_token_response(&json_no_liquidity, "0x123", "ethereum", Some(true)).await;
assert!(result.is_ok());
let token = result.unwrap();
assert_eq!(token.pairs[0].liquidity_usd, None);
}
#[tokio::test]
async fn test_parse_token_response_when_transaction_data_available_should_calculate_totals() {
let json_with_txns = create_dexscreener_response_with_transaction_data();
let result = parse_token_response(&json_with_txns, "0x123", "ethereum", Some(true)).await;
assert!(result.is_ok());
let token = result.unwrap();
let pair = &token.pairs[0];
assert_eq!(pair.txns_24h.buys, Some(100));
assert_eq!(pair.txns_24h.sells, Some(50));
assert_eq!(pair.txns_24h.total, Some(150)); assert!(pair.txns_24h.buy_volume_usd.is_some());
assert!(pair.txns_24h.sell_volume_usd.is_some());
}
#[tokio::test]
async fn test_parse_token_response_when_unknown_chain_should_use_default_formatting() {
let valid_json = create_valid_dexscreener_response();
let result = parse_token_response(&valid_json, "0x123", "unknown-chain", Some(true)).await;
assert!(result.is_ok());
let token = result.unwrap();
assert_eq!(token.chain.id, "unknown-chain");
assert_eq!(token.chain.name, "unknown-chain");
assert_eq!(token.chain.native_token, "NATIVE");
}
#[tokio::test]
async fn test_parse_token_response_when_price_parsing_fails_should_handle_gracefully() {
let json_invalid_price = create_dexscreener_response_with_invalid_price();
let result =
parse_token_response(&json_invalid_price, "0x123", "ethereum", Some(true)).await;
assert!(result.is_ok());
let token = result.unwrap();
assert_eq!(token.price_usd, None); }
fn create_valid_dexscreener_response() -> String {
r#"{
"schemaVersion": "1.0.0",
"pairs": [{
"chainId": "ethereum",
"baseToken": {
"address": "0x123",
"name": "Test Token",
"symbol": "TEST"
},
"quoteToken": {
"address": "0x456",
"name": "USD Coin",
"symbol": "USDC"
},
"pairAddress": "0xabc",
"dexId": "uniswap",
"url": "https://example.com",
"priceUsd": "1.5",
"priceNative": "1.5",
"marketCap": 1000000.0,
"liquidity": {"usd": 500000.0},
"volume": {"h24": 50000.0},
"priceChange": {"h24": 5.0, "h1": 1.0, "m5": 0.5},
"fdv": 2000000.0
}]
}"#
.to_string()
}
fn create_dexscreener_response_with_multiple_pairs() -> String {
r#"{
"schemaVersion": "1.0.0",
"pairs": [{
"chainId": "ethereum",
"baseToken": {
"address": "0x123",
"name": "Test Token",
"symbol": "TEST"
},
"quoteToken": {
"address": "0x456",
"name": "USD Coin",
"symbol": "USDC"
},
"pairAddress": "0xabc",
"dexId": "uniswap",
"url": "https://example.com",
"priceUsd": "1.0",
"priceNative": "1.0",
"marketCap": 1000000.0,
"liquidity": {"usd": 300000.0},
"volume": {"h24": 30000.0},
"priceChange": {"h24": 3.0}
}, {
"chainId": "ethereum",
"baseToken": {
"address": "0x123",
"name": "Test Token",
"symbol": "TEST"
},
"quoteToken": {
"address": "0x789",
"name": "Ethereum",
"symbol": "ETH"
},
"pairAddress": "0xdef",
"dexId": "sushiswap",
"url": "https://sushi.example.com",
"priceUsd": "2.0",
"priceNative": "2.0",
"marketCap": 1500000.0,
"liquidity": {"usd": 800000.0},
"volume": {"h24": 80000.0},
"priceChange": {"h24": 8.0}
}]
}"#
.to_string()
}
fn create_dexscreener_response_without_liquidity() -> String {
r#"{
"schemaVersion": "1.0.0",
"pairs": [{
"chainId": "ethereum",
"baseToken": {
"address": "0x123",
"name": "Test Token",
"symbol": "TEST"
},
"quoteToken": {
"address": "0x456",
"name": "USD Coin",
"symbol": "USDC"
},
"pairAddress": "0xabc",
"dexId": "uniswap",
"url": "https://example.com",
"priceUsd": "1.5",
"priceNative": "1.5"
}]
}"#
.to_string()
}
fn create_dexscreener_response_with_transaction_data() -> String {
r#"{
"schemaVersion": "1.0.0",
"pairs": [{
"chainId": "ethereum",
"baseToken": {
"address": "0x123",
"name": "Test Token",
"symbol": "TEST"
},
"quoteToken": {
"address": "0x456",
"name": "USD Coin",
"symbol": "USDC"
},
"pairAddress": "0xabc",
"dexId": "uniswap",
"url": "https://example.com",
"priceUsd": "1.5",
"priceNative": "1.5",
"volume": {"h24": 60000.0},
"txns": {
"h24": {
"buys": 100,
"sells": 50
}
}
}]
}"#
.to_string()
}
fn create_dexscreener_response_with_invalid_price() -> String {
r#"{
"schemaVersion": "1.0.0",
"pairs": [{
"chainId": "ethereum",
"baseToken": {
"address": "0x123",
"name": "Test Token",
"symbol": "TEST"
},
"quoteToken": {
"address": "0x456",
"name": "USD Coin",
"symbol": "USDC"
},
"pairAddress": "0xabc",
"dexId": "uniswap",
"url": "https://example.com",
"priceUsd": "invalid_price",
"priceNative": "invalid_price"
}]
}"#
.to_string()
}
fn create_test_token_info(
price_change_24h: Option<f64>,
price_change_1h: Option<f64>,
price_usd: Option<f64>,
) -> TokenInfo {
TokenInfo {
address: "0x123".to_string(),
name: "Test Token".to_string(),
symbol: "TEST".to_string(),
decimals: 18,
price_usd,
market_cap: Some(1000000.0),
volume_24h: Some(50000.0),
price_change_24h,
price_change_1h,
price_change_5m: Some(0.5),
circulating_supply: Some(1000000.0),
total_supply: Some(10000000.0),
pair_count: 0,
pairs: vec![],
chain: ChainInfo {
id: "ethereum".to_string(),
name: "Ethereum".to_string(),
logo: None,
native_token: "ETH".to_string(),
},
security: SecurityInfo {
is_verified: true,
liquidity_locked: Some(true),
audit_status: None,
honeypot_status: None,
ownership_status: None,
risk_score: Some(25),
},
socials: vec![],
updated_at: Utc::now(),
}
}
fn create_test_token_pair(dex_name: &str, liquidity: f64) -> TokenPair {
TokenPair {
pair_id: "pair123".to_string(),
dex: DexInfo {
id: dex_name.to_string(),
name: dex_name.to_string(),
url: Some("https://dex.com".to_string()),
logo: None,
},
base_token: PairToken {
address: "0x123".to_string(),
name: "Test Token".to_string(),
symbol: "TEST".to_string(),
},
quote_token: PairToken {
address: "0x456".to_string(),
name: "USD Coin".to_string(),
symbol: "USDC".to_string(),
},
price_usd: Some(1.0),
price_native: Some(1.0),
volume_24h: Some(10000.0),
price_change_24h: Some(5.0),
liquidity_usd: Some(liquidity),
fdv: Some(2000000.0),
created_at: None,
last_trade_at: Utc::now(),
txns_24h: TransactionStats {
buys: Some(100),
sells: Some(80),
total: Some(180),
buy_volume_usd: Some(6000.0),
sell_volume_usd: Some(4000.0),
},
url: "https://dex.com/pair/123".to_string(),
}
}
}