use crate::cli::crawl::{self, Period};
use clap::Args;
use scope::chains::{ChainClientFactory, TokenAnalytics};
use scope::config::Config;
use super::health_args::HealthArgs;
use scope::display::report;
use scope::error::{Result, ScopeError};
use scope::market::{MarketSummary, VenueRegistry, order_book_from_analytics};
#[derive(Debug, Args)]
#[command(
after_help = "\x1b[1mExamples:\x1b[0m
scope token-health USDC
scope token-health @dai-token --with-market \x1b[2m# address book shortcut\x1b[0m
scope token-health DAI --with-market --venue binance
scope token-health 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 --format json",
after_long_help = "\x1b[1mExamples:\x1b[0m
\x1b[1m$ scope token-health USDC\x1b[0m
+-- USDC (USD Coin) ---------------------------------+
| |
|-- DEX Analytics |
| Price $0.9999 |
| 24h Change -0.01% |
| 24h Volume $5.00M |
| Liquidity $100.00M |
| Market Cap $30.00B |
| Top 10 Holders 35.5% |
+-----------------------------------------------------+
\x1b[1m$ scope token-health DAI --with-market --venue binance\x1b[0m
+-- DAI (Dai Stablecoin) -----------------------------+
| |
|-- DEX Analytics |
| Price $0.9999 |
| 24h Change +0.02% |
| 24h Volume $50.00K |
| Liquidity $250.00K |
| |
|-- Market / Order Book |
| Venue binance |
| Best Bid 0.9999 |
| Best Ask 1.0001 |
| Bid Depth 6000 USDT |
| Ask Depth 5000 USDT |
| |
| + No sells below peg |
| + Bid/Ask ratio: 1.20x |
| |
| HEALTHY |
+-----------------------------------------------------+"
)]
pub struct TokenHealthArgs {
pub token: String,
#[arg(short, long, default_value = "ethereum")]
pub chain: String,
#[arg(long)]
pub with_market: bool,
#[arg(long, default_value = "binance")]
pub venue: String,
#[arg(short, long, default_value = "table")]
pub format: scope::config::OutputFormat,
#[command(flatten)]
pub health: HealthArgs,
}
pub async fn run(
mut args: TokenHealthArgs,
config: &Config,
clients: &dyn ChainClientFactory,
) -> Result<()> {
if let Some((address, chain)) =
crate::cli::address_book::resolve_address_book_input(&args.token, config)?
{
args.token = address;
if args.chain == "ethereum" {
args.chain = chain;
}
}
let format = if config.output.format == scope::config::OutputFormat::Markdown {
config.output.format
} else {
args.format
};
let sp = crate::cli::progress::Spinner::new("Fetching token health data...");
let analytics = crawl::fetch_analytics_for_input(
&args.token,
&args.chain,
Period::Hour24,
10,
clients,
Some(&sp),
)
.await?;
let market_summary = if args.with_market {
sp.set_message("Fetching market data...");
let thresholds = args.health.resolve(config);
if is_dex_venue(&args.venue) {
let venue_chain = dex_venue_to_chain(&args.venue);
if analytics.chain.eq_ignore_ascii_case(venue_chain) && !analytics.dex_pairs.is_empty()
{
let best_pair = analytics
.dex_pairs
.iter()
.max_by(|a, b| {
a.liquidity_usd
.partial_cmp(&b.liquidity_usd)
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap();
let book =
order_book_from_analytics(&analytics.chain, best_pair, &analytics.token.symbol);
let volume_24h = Some(best_pair.volume_24h);
Some(MarketSummary::from_order_book(
&book,
&thresholds,
volume_24h,
))
} else {
if analytics.chain.ne(venue_chain) {
sp.println(&format!(
" Warning: DEX venue '{}' requires --chain {} (got {})",
args.venue, venue_chain, analytics.chain
));
} else if analytics.dex_pairs.is_empty() {
sp.println(&format!(
" Warning: No DEX pairs found for {} on {}",
analytics.token.symbol, analytics.chain
));
}
None
}
} else {
match VenueRegistry::load().and_then(|r| r.create_exchange_client(&args.venue)) {
Ok(exchange) => {
let pair = exchange.format_pair(&analytics.token.symbol);
match exchange.fetch_order_book(&pair).await {
Ok(book) => {
let volume_24h = if exchange.has_ticker() {
exchange
.fetch_ticker(&pair)
.await
.ok()
.and_then(|t| t.quote_volume_24h.or(t.volume_24h))
} else {
None
};
Some(MarketSummary::from_order_book(
&book,
&thresholds,
volume_24h,
))
}
Err(e) => {
sp.println(&format!(
" Warning: Market data unavailable for {} on {}",
analytics.token.symbol, args.venue
));
tracing::debug!("Market data error: {}", e);
None
}
}
}
Err(e) => {
sp.println(&format!(" Warning: {}", e));
None
}
}
}
} else {
None
};
sp.finish("Token health data loaded.");
let venue_label = if args.with_market {
Some(args.venue.as_str())
} else {
None
};
match format {
scope::config::OutputFormat::Markdown => {
let md = token_health_to_markdown(&analytics, market_summary.as_ref(), venue_label);
println!("{}", md);
}
scope::config::OutputFormat::Json => {
let json = token_health_to_json(&analytics, market_summary.as_ref())?;
println!("{}", json);
}
scope::config::OutputFormat::Table | scope::config::OutputFormat::Csv => {
output_token_health_table(&analytics, market_summary.as_ref(), venue_label)?;
}
}
Ok(())
}
fn is_dex_venue(venue: &str) -> bool {
matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
}
fn dex_venue_to_chain(venue: &str) -> &str {
match venue.to_lowercase().as_str() {
"ethereum" | "eth" => "ethereum",
"solana" => "solana",
_ => "ethereum",
}
}
fn token_health_to_markdown(
analytics: &TokenAnalytics,
market: Option<&MarketSummary>,
venue: Option<&str>,
) -> String {
let mut md = report::generate_report(analytics);
if let Some(summary) = market {
md.push_str("\n---\n\n");
md.push_str("## Market / Order Book\n\n");
if let Some(v) = venue {
md.push_str(&format!("**Venue:** {} \n\n", v));
}
md.push_str(&format!(
"| Metric | Value |\n|--------|-------|\n\
| Peg Target | {:.4} |\n\
| Best Bid | {} |\n\
| Best Ask | {} |\n\
| Mid Price | {} |\n\
| Spread | {} |\n\
| Bid Depth | {:.0} |\n\
| Ask Depth | {:.0} |\n\
| Healthy | {} |\n",
summary.peg_target,
summary
.best_bid
.map(|b| format!("{:.4}", b))
.unwrap_or_else(|| "-".to_string()),
summary
.best_ask
.map(|a| format!("{:.4}", a))
.unwrap_or_else(|| "-".to_string()),
summary
.mid_price
.map(|m| format!("{:.4}", m))
.unwrap_or_else(|| "-".to_string()),
summary
.spread
.map(|s| format!("{:.4}", s))
.unwrap_or_else(|| "-".to_string()),
summary.bid_depth,
summary.ask_depth,
if summary.healthy { "Yes" } else { "No" }
));
if !summary.checks.is_empty() {
md.push_str("\n**Health Checks:**\n");
for check in &summary.checks {
let (icon, msg) = match check {
scope::market::HealthCheck::Pass(m) => ("✓", m.as_str()),
scope::market::HealthCheck::Fail(m) => ("✗", m.as_str()),
};
md.push_str(&format!("- {} {}\n", icon, msg));
}
}
}
md.push_str(&report::report_footer());
md
}
fn token_health_to_json(
analytics: &TokenAnalytics,
market: Option<&MarketSummary>,
) -> Result<String> {
let market_json = market.map(|m| {
serde_json::json!({
"peg_target": m.peg_target,
"best_bid": m.best_bid,
"best_ask": m.best_ask,
"mid_price": m.mid_price,
"spread": m.spread,
"bid_depth": m.bid_depth,
"ask_depth": m.ask_depth,
"healthy": m.healthy,
"checks": m.checks.iter().map(|c| match c {
scope::market::HealthCheck::Pass(msg) => serde_json::json!({"status": "pass", "message": msg}),
scope::market::HealthCheck::Fail(msg) => serde_json::json!({"status": "fail", "message": msg}),
}).collect::<Vec<_>>()
})
});
let json = serde_json::json!({
"analytics": analytics,
"market": market_json
});
serde_json::to_string_pretty(&json).map_err(ScopeError::Json)
}
fn output_token_health_table(
analytics: &TokenAnalytics,
market: Option<&MarketSummary>,
venue: Option<&str>,
) -> Result<()> {
use scope::display::terminal as t;
let title = format!("{} ({})", analytics.token.symbol, analytics.token.name);
println!("{}", t::section_header(&title));
println!("{}", t::subsection_header("DEX Analytics"));
println!(
"{}",
t::kv_row("Price", &format!("${:.6}", analytics.price_usd))
);
println!(
"{}",
t::kv_row_delta(
"24h Change",
analytics.price_change_24h,
&format!("{:+.2}%", analytics.price_change_24h)
)
);
println!(
"{}",
t::kv_row(
"24h Volume",
&format!(
"${}",
scope::display::format_large_number(analytics.volume_24h)
)
)
);
println!(
"{}",
t::kv_row(
"Liquidity",
&format!(
"${}",
scope::display::format_large_number(analytics.liquidity_usd)
)
)
);
if let Some(mc) = analytics.market_cap {
println!(
"{}",
t::kv_row(
"Market Cap",
&format!("${}", scope::display::format_large_number(mc))
)
);
}
if let Some(top10) = analytics.top_10_concentration {
println!("{}", t::kv_row("Top 10 Holders", &format!("{:.1}%", top10)));
}
if let Some(summary) = market {
println!("{}", t::subsection_header("Market / Order Book"));
if let Some(v) = venue {
println!("{}", t::kv_row("Venue", v));
}
println!(
"{}",
t::kv_row("Peg Target", &format!("{:.4}", summary.peg_target))
);
if let Some(b) = summary.best_bid {
println!(
"{}",
t::kv_row("Best Bid", &t::format_price_peg(b, summary.peg_target))
);
}
if let Some(a) = summary.best_ask {
println!(
"{}",
t::kv_row("Best Ask", &t::format_price_peg(a, summary.peg_target))
);
}
if let Some(m) = summary.mid_price {
println!(
"{}",
t::kv_row("Mid Price", &t::format_price_peg(m, summary.peg_target))
);
}
println!(
"{}",
t::kv_row("Bid Depth", &format!("{:.0} USDT", summary.bid_depth))
);
println!(
"{}",
t::kv_row("Ask Depth", &format!("{:.0} USDT", summary.ask_depth))
);
println!("{}", t::blank_row());
for check in &summary.checks {
match check {
scope::market::HealthCheck::Pass(m) => println!("{}", t::check_pass(m)),
scope::market::HealthCheck::Fail(m) => println!("{}", t::check_fail(m)),
}
}
println!("{}", t::blank_row());
println!("{}", t::status_line(summary.healthy));
}
println!("{}", t::section_footer());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use scope::chains::dex::DexTokenData;
use scope::chains::mocks::MockClientFactory;
use scope::chains::{DexPair, Token, TokenAnalytics, TokenHolder, TokenSocial};
use scope::config::OutputFormat;
use scope::market::{HealthCheck, MarketSummary};
fn make_test_analytics(with_dex_pairs: bool) -> TokenAnalytics {
TokenAnalytics {
token: Token {
contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
symbol: "USDC".to_string(),
name: "USD Coin".to_string(),
decimals: 6,
},
chain: "ethereum".to_string(),
holders: vec![TokenHolder {
address: "0x1234".to_string(),
balance: "1000000".to_string(),
formatted_balance: "1.0".to_string(),
percentage: 10.0,
rank: 1,
}],
total_holders: 1000,
volume_24h: 5_000_000.0,
volume_7d: 25_000_000.0,
price_usd: 0.9999,
price_change_24h: -0.01,
price_change_7d: 0.02,
liquidity_usd: 100_000_000.0,
market_cap: Some(30_000_000_000.0),
fdv: None,
total_supply: None,
circulating_supply: None,
price_history: vec![],
volume_history: vec![],
holder_history: vec![],
dex_pairs: if with_dex_pairs {
vec![DexPair {
dex_name: "Uniswap V3".to_string(),
pair_address: "0xpair".to_string(),
base_token: "USDC".to_string(),
quote_token: "WETH".to_string(),
price_usd: 0.9999,
volume_24h: 5_000_000.0,
liquidity_usd: 50_000_000.0,
price_change_24h: -0.01,
buys_24h: 1000,
sells_24h: 900,
buys_6h: 300,
sells_6h: 250,
buys_1h: 50,
sells_1h: 45,
pair_created_at: Some(1600000000),
url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
}]
} else {
vec![]
},
fetched_at: 1700003600,
top_10_concentration: Some(35.5),
top_50_concentration: Some(55.0),
top_100_concentration: Some(65.0),
price_change_6h: 0.01,
price_change_1h: -0.005,
total_buys_24h: 1000,
total_sells_24h: 900,
total_buys_6h: 300,
total_sells_6h: 250,
total_buys_1h: 50,
total_sells_1h: 45,
token_age_hours: Some(25000.0),
image_url: None,
websites: vec!["https://centre.io".to_string()],
socials: vec![TokenSocial {
platform: "twitter".to_string(),
url: "https://twitter.com/circle".to_string(),
}],
dexscreener_url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
}
}
fn make_test_market_summary() -> MarketSummary {
use scope::market::{ExecutionEstimate, ExecutionSide};
MarketSummary {
pair: "USDC/USDT".to_string(),
peg_target: 1.0,
best_bid: Some(0.9999),
best_ask: Some(1.0001),
mid_price: Some(1.0),
spread: Some(0.0002),
volume_24h: Some(1_000_000.0),
execution_10k_buy: Some(ExecutionEstimate {
notional_usdt: 10_000.0,
side: ExecutionSide::Buy,
vwap: 1.0001,
slippage_bps: 1.0,
fillable: true,
}),
execution_10k_sell: Some(ExecutionEstimate {
notional_usdt: 10_000.0,
side: ExecutionSide::Sell,
vwap: 0.9999,
slippage_bps: 1.0,
fillable: true,
}),
asks: vec![],
bids: vec![],
ask_outliers: 0,
bid_outliers: 0,
ask_depth: 5000.0,
bid_depth: 6000.0,
checks: vec![
HealthCheck::Pass("No sells below peg".to_string()),
HealthCheck::Pass("Bid/Ask ratio: 1.20x".to_string()),
],
healthy: true,
}
}
#[test]
fn test_is_dex_venue() {
assert!(is_dex_venue("eth"));
assert!(is_dex_venue("ethereum"));
assert!(is_dex_venue("solana"));
assert!(!is_dex_venue("binance"));
assert!(!is_dex_venue("okx"));
}
#[test]
fn test_is_dex_venue_values() {
assert!(is_dex_venue("eth"));
assert!(is_dex_venue("ethereum"));
assert!(is_dex_venue("solana"));
assert!(!is_dex_venue("binance"));
assert!(!is_dex_venue("mexc"));
}
#[test]
fn test_dex_venue_to_chain_values() {
assert_eq!(dex_venue_to_chain("eth"), "ethereum");
assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
assert_eq!(dex_venue_to_chain("solana"), "solana");
assert_eq!(dex_venue_to_chain("unknown"), "ethereum");
}
#[test]
fn test_token_health_args_debug() {
let args = TokenHealthArgs {
token: "USDC".to_string(),
chain: "ethereum".to_string(),
with_market: false,
venue: "binance".to_string(),
format: scope::config::OutputFormat::Table,
health: Default::default(),
};
let debug = format!("{:?}", args);
assert!(debug.contains("TokenHealthArgs"));
}
#[test]
fn test_format_large_number() {
assert_eq!(
scope::display::format_large_number(1_500_000_000.0),
"1.50B"
);
assert_eq!(scope::display::format_large_number(2_500_000.0), "2.50M");
assert_eq!(scope::display::format_large_number(3_500.0), "3.50K");
assert_eq!(scope::display::format_large_number(99.99), "99.99");
}
#[test]
fn test_token_health_to_markdown_without_market() {
let analytics = make_test_analytics(false);
let md = token_health_to_markdown(&analytics, None, None);
assert!(md.contains("USDC"));
assert!(md.contains("USD Coin"));
assert!(!md.contains("Market / Order Book"));
}
#[test]
fn test_token_health_to_markdown_with_market() {
let analytics = make_test_analytics(false);
let market = make_test_market_summary();
let md = token_health_to_markdown(&analytics, Some(&market), Some("binance"));
assert!(md.contains("Market / Order Book"));
assert!(md.contains("binance"));
assert!(md.contains("0.9999"));
assert!(md.contains("Yes"));
assert!(md.contains("Health Checks"));
}
#[test]
fn test_token_health_to_markdown_without_venue() {
let analytics = make_test_analytics(false);
let market = make_test_market_summary();
let md = token_health_to_markdown(&analytics, Some(&market), None);
assert!(md.contains("Market / Order Book"));
assert!(!md.contains("Venue:")); assert!(md.contains("0.9999"));
assert!(md.contains("Yes"));
}
#[test]
fn test_token_health_to_markdown_unhealthy_market() {
let analytics = make_test_analytics(false);
let mut market = make_test_market_summary();
market.healthy = false;
market.checks = vec![
HealthCheck::Pass("Some check passed".to_string()),
HealthCheck::Fail("Peg deviation too high".to_string()),
HealthCheck::Fail("Insufficient bid depth".to_string()),
];
let md = token_health_to_markdown(&analytics, Some(&market), Some("binance"));
assert!(md.contains("Market / Order Book"));
assert!(md.contains("No")); assert!(md.contains("Health Checks"));
assert!(md.contains("✗")); assert!(md.contains("Peg deviation too high"));
assert!(md.contains("Insufficient bid depth"));
}
#[test]
fn test_token_health_to_json_without_market() {
let analytics = make_test_analytics(false);
let json = token_health_to_json(&analytics, None).unwrap();
assert!(json.contains("\"analytics\""));
assert!(json.contains("\"market\": null"));
assert!(json.contains("USDC"));
}
#[test]
fn test_token_health_to_json_with_market() {
let analytics = make_test_analytics(false);
let market = make_test_market_summary();
let json = token_health_to_json(&analytics, Some(&market)).unwrap();
assert!(json.contains("\"market\""));
assert!(json.contains("\"peg_target\": 1.0"));
assert!(json.contains("\"healthy\": true"));
}
#[test]
fn test_token_health_to_json_with_fail_checks() {
let analytics = make_test_analytics(false);
let mut market = make_test_market_summary();
market.healthy = false;
market.checks = vec![
HealthCheck::Pass("Bid/Ask ratio OK".to_string()),
HealthCheck::Fail("Peg deviation exceeds threshold".to_string()),
HealthCheck::Fail("Ask depth below minimum".to_string()),
];
let json = token_health_to_json(&analytics, Some(&market)).unwrap();
assert!(json.contains("\"market\""));
assert!(json.contains("\"healthy\": false"));
assert!(json.contains("\"status\": \"pass\""));
assert!(json.contains("\"status\": \"fail\""));
assert!(json.contains("Peg deviation exceeds threshold"));
assert!(json.contains("Ask depth below minimum"));
}
#[test]
fn test_output_token_health_table_without_market() {
let analytics = make_test_analytics(false);
let result = output_token_health_table(&analytics, None, None);
assert!(result.is_ok());
}
#[test]
fn test_output_token_health_table_with_market() {
let analytics = make_test_analytics(false);
let market = make_test_market_summary();
let result = output_token_health_table(&analytics, Some(&market), Some("biconomy"));
assert!(result.is_ok());
}
#[test]
fn test_output_token_health_table_no_market_cap() {
let mut analytics = make_test_analytics(false);
analytics.market_cap = None;
analytics.top_10_concentration = None;
let result = output_token_health_table(&analytics, None, None);
assert!(result.is_ok());
}
fn make_test_dex_token_data(pairs: Vec<DexPair>) -> DexTokenData {
DexTokenData {
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
symbol: "USDC".to_string(),
name: "USD Coin".to_string(),
price_usd: 0.9999,
price_change_24h: -0.01,
price_change_6h: 0.01,
price_change_1h: -0.005,
price_change_5m: 0.0,
volume_24h: 5_000_000.0,
volume_6h: 1_250_000.0,
volume_1h: 250_000.0,
liquidity_usd: 100_000_000.0,
market_cap: Some(30_000_000_000.0),
fdv: Some(30_000_000_000.0),
pairs,
price_history: vec![],
volume_history: vec![],
total_buys_24h: 1000,
total_sells_24h: 900,
total_buys_6h: 300,
total_sells_6h: 250,
total_buys_1h: 50,
total_sells_1h: 45,
earliest_pair_created_at: Some(1600000000),
image_url: None,
websites: vec![],
socials: vec![scope::chains::dex::TokenSocial {
platform: "twitter".to_string(),
url: "https://twitter.com/circle".to_string(),
}],
dexscreener_url: None,
}
}
#[tokio::test]
async fn test_run_token_health_table() {
let mut factory = MockClientFactory::new();
factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
let config = Config::default();
let args = TokenHealthArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
with_market: false,
venue: "binance".to_string(),
format: OutputFormat::Table,
health: Default::default(),
};
let result = run(args, &config, &factory).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_run_token_health_json() {
let mut factory = MockClientFactory::new();
let mut data = make_test_dex_token_data(vec![]);
data.price_usd = 1.0;
data.volume_24h = 1_000_000.0;
data.liquidity_usd = 5_000_000.0;
factory.mock_dex.token_data = Some(data);
let config = Config::default();
let args = TokenHealthArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
with_market: false,
venue: "binance".to_string(),
format: OutputFormat::Json,
health: Default::default(),
};
let result = run(args, &config, &factory).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_run_token_health_markdown() {
let mut factory = MockClientFactory::new();
factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
let config = Config::default();
let args = TokenHealthArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
with_market: false,
venue: "binance".to_string(),
format: OutputFormat::Markdown,
health: Default::default(),
};
let result = run(args, &config, &factory).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_run_token_health_dex_market() {
let mut factory = MockClientFactory::new();
let pair = DexPair {
dex_name: "Uniswap V3".to_string(),
pair_address: "0xpair".to_string(),
base_token: "USDC".to_string(),
quote_token: "WETH".to_string(),
price_usd: 0.9999,
volume_24h: 5_000_000.0,
liquidity_usd: 50_000_000.0,
price_change_24h: -0.01,
buys_24h: 1000,
sells_24h: 900,
buys_6h: 300,
sells_6h: 250,
buys_1h: 50,
sells_1h: 45,
pair_created_at: Some(1600000000),
url: Some("https://dexscreener.com/ethereum/0xpair".to_string()),
};
factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![pair]));
let config = Config::default();
let args = TokenHealthArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
with_market: true,
venue: "eth".to_string(),
format: OutputFormat::Table,
health: Default::default(),
};
let result = run(args, &config, &factory).await;
assert!(result.is_ok());
}
fn dex_market_args(venue: &str) -> TokenHealthArgs {
TokenHealthArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
with_market: true,
venue: venue.to_string(),
format: OutputFormat::Json,
health: Default::default(),
}
}
fn pair_with_liquidity(liquidity_usd: f64) -> DexPair {
DexPair {
dex_name: "Uniswap V3".to_string(),
pair_address: format!("0xpair{liquidity_usd}"),
base_token: "USDC".to_string(),
quote_token: "USDT".to_string(),
price_usd: 1.0,
volume_24h: 1_000.0,
liquidity_usd,
price_change_24h: 0.0,
buys_24h: 0,
sells_24h: 0,
buys_6h: 0,
sells_6h: 0,
buys_1h: 0,
sells_1h: 0,
pair_created_at: None,
url: None,
}
}
#[tokio::test]
async fn test_run_token_health_dex_market_multiple_pairs() {
let mut factory = MockClientFactory::new();
factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![
pair_with_liquidity(1_000.0),
pair_with_liquidity(90_000.0),
pair_with_liquidity(5_000.0),
]));
let result = run(dex_market_args("eth"), &Config::default(), &factory).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_run_token_health_dex_market_chain_mismatch_still_reports() {
let mut factory = MockClientFactory::new();
factory.mock_dex.token_data =
Some(make_test_dex_token_data(vec![pair_with_liquidity(1_000.0)]));
let result = run(dex_market_args("solana"), &Config::default(), &factory).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_run_token_health_dex_market_no_pairs_still_reports() {
let mut factory = MockClientFactory::new();
factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
let result = run(dex_market_args("eth"), &Config::default(), &factory).await;
assert!(result.is_ok());
}
#[test]
fn test_dex_venue_to_chain_case_insensitive() {
assert_eq!(dex_venue_to_chain("ETH"), "ethereum");
assert_eq!(dex_venue_to_chain("Ethereum"), "ethereum");
assert_eq!(dex_venue_to_chain("SOLANA"), "solana");
}
#[test]
fn test_is_dex_venue_uppercase() {
assert!(is_dex_venue("ETH"));
assert!(is_dex_venue("SOLANA"));
assert!(!is_dex_venue("BINANCE"));
}
#[test]
fn test_token_health_to_markdown_market_missing_prices() {
let analytics = make_test_analytics(false);
let mut market = make_test_market_summary();
market.best_bid = None;
market.best_ask = None;
market.mid_price = None;
market.spread = None;
let md = token_health_to_markdown(&analytics, Some(&market), Some("binance"));
assert!(md.contains("Market / Order Book"));
assert!(md.contains("-")); }
#[test]
fn test_token_health_to_markdown_market_empty_checks() {
let analytics = make_test_analytics(false);
let mut market = make_test_market_summary();
market.checks = vec![];
let md = token_health_to_markdown(&analytics, Some(&market), Some("binance"));
assert!(md.contains("Market / Order Book"));
assert!(!md.contains("Health Checks:")); }
#[test]
fn test_token_health_to_json_empty_checks() {
let analytics = make_test_analytics(false);
let mut market = make_test_market_summary();
market.checks = vec![];
let json = token_health_to_json(&analytics, Some(&market)).unwrap();
assert!(json.contains("\"market\""));
assert!(json.contains("\"checks\": []"));
}
#[test]
fn test_output_token_health_table_market_missing_prices() {
let analytics = make_test_analytics(false);
let mut market = make_test_market_summary();
market.best_bid = None;
market.best_ask = None;
market.mid_price = None;
let result = output_token_health_table(&analytics, Some(&market), Some("binance"));
assert!(result.is_ok());
}
#[test]
fn test_output_token_health_table_market_empty_checks() {
let analytics = make_test_analytics(false);
let mut market = make_test_market_summary();
market.checks = vec![];
let result = output_token_health_table(&analytics, Some(&market), None);
assert!(result.is_ok());
}
#[test]
fn test_output_token_health_table_market_without_venue() {
let analytics = make_test_analytics(false);
let market = make_test_market_summary();
let result = output_token_health_table(&analytics, Some(&market), None);
assert!(result.is_ok());
}
#[test]
fn test_token_health_args_all_formats() {
for format in [
OutputFormat::Table,
OutputFormat::Json,
OutputFormat::Csv,
OutputFormat::Markdown,
] {
let args = TokenHealthArgs {
token: "USDC".to_string(),
chain: "ethereum".to_string(),
with_market: false,
venue: "binance".to_string(),
format,
health: Default::default(),
};
assert_eq!(args.format, format);
}
}
#[tokio::test]
async fn test_run_token_health_csv() {
let mut factory = MockClientFactory::new();
factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
let config = Config::default();
let args = TokenHealthArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
with_market: false,
venue: "binance".to_string(),
format: OutputFormat::Csv,
health: Default::default(),
};
let result = run(args, &config, &factory).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_run_token_health_config_markdown_override() {
let mut factory = MockClientFactory::new();
factory.mock_dex.token_data = Some(make_test_dex_token_data(vec![]));
let mut config = Config::default();
config.output.format = OutputFormat::Markdown;
let args = TokenHealthArgs {
token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
with_market: false,
venue: "binance".to_string(),
format: OutputFormat::Table, health: Default::default(),
};
let result = run(args, &config, &factory).await;
assert!(result.is_ok());
}
#[test]
fn test_token_health_to_json_market_none_values() {
let analytics = make_test_analytics(false);
let mut market = make_test_market_summary();
market.best_bid = None;
market.best_ask = None;
market.mid_price = None;
market.spread = None;
let json = token_health_to_json(&analytics, Some(&market)).unwrap();
assert!(json.contains("\"market\""));
assert!(json.contains("null")); }
#[test]
fn test_token_health_to_markdown_report_footer() {
let analytics = make_test_analytics(false);
let md = token_health_to_markdown(&analytics, None, None);
assert!(!md.is_empty());
assert!(md.contains("USDC"));
assert!(md.contains("USD Coin"));
}
#[test]
fn test_output_token_health_table_with_dex_pairs_analytics() {
let analytics = make_test_analytics(true);
let result = output_token_health_table(&analytics, None, None);
assert!(result.is_ok());
}
#[test]
fn test_token_health_to_markdown_market_with_venue_none() {
let analytics = make_test_analytics(false);
let market = make_test_market_summary();
let md = token_health_to_markdown(&analytics, Some(&market), None);
assert!(md.contains("Market / Order Book"));
assert!(!md.contains("**Venue:**"));
}
#[test]
fn test_output_token_health_table_market_no_bid_ask() {
let analytics = make_test_analytics(false);
let mut market = make_test_market_summary();
market.best_bid = None;
market.best_ask = None;
market.mid_price = None;
let result = output_token_health_table(&analytics, Some(&market), Some("binance"));
assert!(result.is_ok());
}
}