use crate::chain::dexscreener::{liq, DexPair};
use crate::chain::SolClient;
use crate::types::{Flag, Severity};
use serde_json::json;
pub struct LiquidityAnalysis {
pub pool_addresses: Vec<String>,
pub total_liquidity_usd: f64,
pub pair_count: usize,
pub top_dex: Option<String>,
pub flags: Vec<Flag>,
pub section: serde_json::Value,
pub fetch_error: Option<String>,
}
fn round2(n: f64) -> f64 {
(n * 100.0).round() / 100.0
}
pub async fn analyze(client: &SolClient, mint: &str, now_ms: i64) -> LiquidityAnalysis {
let mut flags = vec![];
let (pairs, fetch_error): (Vec<DexPair>, Option<String>) =
match client.pairs_for_token(mint).await {
Ok(p) => (p, None),
Err(e) => (vec![], Some(e.to_string())),
};
if let Some(err) = &fetch_error {
flags.push(Flag::new(
"liquidity_unknown",
Severity::Warning,
"Liquidity could not be verified",
format!("DexScreener unreachable ({err}) — liquidity, FDV ratio and pair age were not assessed. Re-run before trusting."),
5,
));
return LiquidityAnalysis {
pool_addresses: vec![],
total_liquidity_usd: 0.0,
pair_count: 0,
top_dex: None,
flags,
section: json!({"fetchError": err, "pairs": []}),
fetch_error,
};
}
let total: f64 = round2(pairs.iter().map(liq).sum());
let top = pairs.first();
let fdv = top.and_then(|p| p.fdv.or(p.market_cap));
let liq_to_fdv = fdv.filter(|f| *f > 0.0).map(|f| round2(total / f * 100.0));
let has_socials = top
.and_then(|p| p.info.as_ref())
.map(|i| !i.socials.is_empty() || !i.websites.is_empty())
.unwrap_or(false);
if pairs.is_empty() {
flags.push(
Flag::new(
"no_liquidity",
Severity::Danger,
"No DEX liquidity found",
"No tradable pair is indexed for this mint — it cannot be bought or sold on a DEX right now.".into(),
25,
)
.hard(),
);
} else {
let depth_reported = pairs
.iter()
.any(|p| p.liquidity.as_ref().and_then(|l| l.usd).is_some());
if !depth_reported {
flags.push(Flag::new(
"liquidity_unreported",
Severity::Info,
"Pool depth not reported",
"DexScreener doesn't publish liquidity for this venue (bonding curve) — assume it is tiny.".into(),
3,
));
} else if total < 5000.0 {
flags.push(Flag::new(
"low_liquidity",
Severity::Danger,
"Very low liquidity",
format!(
"Only ${} of total liquidity — the pool can be drained or the price wrecked with pocket change.",
fmt(total)
),
20,
));
} else if total < 25000.0 {
flags.push(Flag::new(
"low_liquidity",
Severity::Warning,
"Low liquidity",
format!(
"${} total liquidity — exits get expensive fast.",
fmt(total)
),
8,
));
}
if let Some(r) = liq_to_fdv {
if depth_reported && r < 2.0 && total < 150_000.0 {
flags.push(Flag::new(
"liq_fdv_ratio",
Severity::Warning,
"Liquidity tiny vs market cap",
format!("Liquidity is only {r}% of the market cap — the price is mostly on paper and exit is hard."),
10,
));
}
}
let youngest = pairs
.iter()
.filter_map(|p| p.pair_created_at)
.map(|c| (now_ms - c) / 3_600_000)
.min();
if let Some(h) = youngest {
if h < 6 {
flags.push(Flag::new(
"very_new",
Severity::Warning,
"Brand-new pair",
format!("The main pair is ~{h}h old — most rugs happen in the first hours."),
6,
));
}
}
if !has_socials {
flags.push(Flag::new(
"no_socials",
Severity::Info,
"No socials listed",
"No website/X/Telegram on the DexScreener profile.".into(),
3,
));
}
if let Some(p) = top {
let (b, s) = (p.txns.h24.buys, p.txns.h24.sells);
if b + s >= 50 && s > 0 && b as f64 / s as f64 > 5.0 {
flags.push(Flag::new(
"buy_sell_imbalance",
Severity::Warning,
"Buys massively outnumber sells",
format!("{b} buys vs {s} sells in 24h — either nobody CAN sell (test a tiny sell first) or bots are painting volume."),
8,
));
}
}
}
let section = json!({
"totalLiquidityUsd": total,
"fdvUsd": fdv,
"liqToFdvPct": liq_to_fdv,
"hasSocials": has_socials,
"pairs": pairs.iter().take(8).map(|p| json!({
"dex": p.dex_id,
"labels": p.labels,
"pairAddress": p.pair_address,
"quoteSymbol": p.quote_token.symbol,
"liquidityUsd": liq(p),
"priceUsd": p.price_usd.as_ref().and_then(|s| s.parse::<f64>().ok()),
"ageHours": p.pair_created_at.map(|c| (now_ms - c) / 3_600_000),
"txns24h": {"buys": p.txns.h24.buys, "sells": p.txns.h24.sells},
"txns1h": {"buys": p.txns.h1.buys, "sells": p.txns.h1.sells},
"priceChange": {"m5": p.price_change.m5, "h1": p.price_change.h1, "h6": p.price_change.h6, "h24": p.price_change.h24},
"volume": {"m5": p.volume.m5, "h1": p.volume.h1, "h6": p.volume.h6, "h24": p.volume.h24},
})).collect::<Vec<_>>(),
});
LiquidityAnalysis {
pool_addresses: pairs.iter().map(|p| p.pair_address.clone()).collect(),
total_liquidity_usd: total,
pair_count: pairs.len(),
top_dex: pairs.first().map(|p| p.dex_id.clone()),
flags,
section,
fetch_error: None,
}
}
fn fmt(n: f64) -> String {
let mut s = String::new();
let int = n.round() as i64;
let digits = int.abs().to_string();
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i) % 3 == 0 {
s.push(',');
}
s.push(c);
}
s
}