397 lines
15 KiB
Python
397 lines
15 KiB
Python
"""
|
||
Wash Trading Manipulation Detector
|
||
====================================
|
||
Detects artificial volume inflation through wash trading patterns.
|
||
Identifies circular transfers, self-trading via controlled wallets,
|
||
cross-DEX wash loops, and volume pumping through coordinated activity.
|
||
|
||
Tier: Premium ($0.15)
|
||
Endpoint: POST /api/v1/x402-tools/wash_trade_detect
|
||
"""
|
||
|
||
import logging
|
||
import re
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger("wash_trading_detector")
|
||
|
||
# ── Free API sources for wash trading signals ─────────────────────
|
||
DEXSCREENER_API = "https://api.dexscreener.com/latest/dex/search?q={}"
|
||
BIRDEYE_API = "https://public-api.birdeye.so/defi/v3/token/holder?address={}"
|
||
BIRDEYE_FALLBACK = "https://api.birdeye.so/defi/holder?token={}"
|
||
HELIUS_RPC = "https://mainnet.helius-rpc.com"
|
||
|
||
# ── URL safety check ──────────────────────────────────────────────
|
||
_URL_SAFE = re.compile(r"^https?://[a-zA-Z0-9.-]+(?::\d+)?(?:/.*)?$")
|
||
|
||
|
||
def _validate_url(url: str) -> bool:
|
||
"""Basic URL validation to prevent SSRF / injection."""
|
||
return bool(_URL_SAFE.match(url))
|
||
|
||
|
||
async def _fetch(url: str, timeout: int = 10) -> dict | None:
|
||
"""Single URL fetch with aiohttp. Rejects malformed URLs."""
|
||
if not _validate_url(url):
|
||
logger.debug(f"Invalid URL rejected: {url[:60]}")
|
||
return None
|
||
|
||
import aiohttp
|
||
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=timeout)) as resp:
|
||
if resp.status == 200:
|
||
return await resp.json()
|
||
logger.debug(f"Non-200 from {url}: {resp.status}")
|
||
except (TimeoutError, aiohttp.ClientError) as e:
|
||
logger.debug(f"Fetch failed: {url[:60]} — {e}")
|
||
except Exception as e:
|
||
logger.debug(f"Unexpected fetch error: {url[:60]} — {e}")
|
||
return None
|
||
|
||
|
||
async def _rpc_call(chain: str, method: str, params: list) -> Any:
|
||
"""Call into the x402_tools RPC fallback system."""
|
||
try:
|
||
from app.routers.x402_tools import rpc_call
|
||
|
||
return await rpc_call(chain, method, params)
|
||
except Exception as e:
|
||
logger.debug(f"RPC call failed: {e}")
|
||
return None
|
||
|
||
|
||
async def _post_json(url: str, payload: dict, timeout: int = 10) -> dict | None:
|
||
"""POST JSON to URL and return parsed response."""
|
||
if not _validate_url(url):
|
||
logger.debug(f"Invalid POST URL rejected: {url[:60]}")
|
||
return None
|
||
|
||
import aiohttp
|
||
|
||
try:
|
||
async with aiohttp.ClientSession() as session, session.post(
|
||
url, json=payload, timeout=aiohttp.ClientTimeout(total=timeout)
|
||
) as resp:
|
||
if resp.status == 200:
|
||
return await resp.json()
|
||
logger.debug(f"POST non-200 from {url}: {resp.status}")
|
||
except (TimeoutError, aiohttp.ClientError) as e:
|
||
logger.debug(f"POST failed: {url[:60]} — {e}")
|
||
except Exception as e:
|
||
logger.debug(f"Unexpected POST error: {url[:60]} — {e}")
|
||
return None
|
||
|
||
|
||
def _compute_wash_score(
|
||
volume_tx_ratio: float,
|
||
top_trader_concentration: float,
|
||
buy_sell_correlation: float,
|
||
small_trade_ratio: float,
|
||
reapearring_address_count: int,
|
||
liquidity_depth_ratio: float,
|
||
) -> float:
|
||
"""
|
||
Compute a 0-100 wash trading risk score.
|
||
|
||
Factors (weighted):
|
||
- volume_tx_ratio (20%): Trade size distribution — unusually small
|
||
trades vs volume suggests wash activity (normalized 0-1)
|
||
- top_trader_concentration (25%): % of volume from few addresses
|
||
(higher = more concentrated wash risk)
|
||
- buy_sell_correlation (20%): How closely buys mirror sells in
|
||
timing and size (higher = more reciprocal trading)
|
||
- small_trade_ratio (15%): Proportion of trades that are
|
||
suspiciously small and frequent (volume pumping)
|
||
- reapearring_address_count (10%): Count of addresses that
|
||
trade the same pair repeatedly (circular pattern indicator)
|
||
- liquidity_depth_ratio (10%): Volume vs liquidity ratio —
|
||
abnormally high volume relative to depth = wash signal
|
||
"""
|
||
score = 0.0
|
||
|
||
# Volume/tx ratio (0-20 points) - unusually high volume per tx
|
||
vtr = min(volume_tx_ratio, 1.0)
|
||
score += vtr * 20
|
||
|
||
# Top trader concentration (0-25 points)
|
||
ttc = min(top_trader_concentration, 1.0)
|
||
score += ttc * 25
|
||
|
||
# Buy/sell correlation (0-20 points)
|
||
bsc = min(buy_sell_correlation, 1.0)
|
||
score += bsc * 20
|
||
|
||
# Small trade ratio (0-15 points)
|
||
str_ratio = min(small_trade_ratio, 1.0)
|
||
score += str_ratio * 15
|
||
|
||
# Reappearing address count (0-10 points)
|
||
rac = min(reapearring_address_count / 5.0, 1.0)
|
||
score += rac * 10
|
||
|
||
# Liquidity depth ratio (0-10 points)
|
||
ldr = min(liquidity_depth_ratio, 1.0)
|
||
score += ldr * 10
|
||
|
||
return round(min(score, 100), 1)
|
||
|
||
|
||
def _classify_wash_risk(score: float) -> str:
|
||
"""Classify wash trading risk intensity."""
|
||
if score >= 75:
|
||
return "critical"
|
||
elif score >= 55:
|
||
return "high"
|
||
elif score >= 35:
|
||
return "moderate"
|
||
elif score >= 15:
|
||
return "low"
|
||
return "none"
|
||
|
||
|
||
def _generate_recommendation(score: float, confidence: float) -> str:
|
||
"""Generate human-readable recommendation with confidence context."""
|
||
conf_note = f" (confidence: {confidence:.0%})" if confidence > 0 else ""
|
||
|
||
if score >= 75:
|
||
base = (
|
||
"🚨 CRITICAL WASH TRADING DETECTED. Abnormal trade patterns indicate "
|
||
"coordinated volume manipulation. Strongly avoid this token — the "
|
||
"volume and price action are artificial."
|
||
)
|
||
elif score >= 55:
|
||
base = (
|
||
"⚠️ HIGH wash trading probability. Significant indicators of artificial "
|
||
"volume inflation detected. Proceed with extreme caution — real "
|
||
"liquidity may be far lower than reported."
|
||
)
|
||
elif score >= 35:
|
||
base = (
|
||
"🔍 MODERATE wash trading signals. Some suspicious trading patterns "
|
||
"detected but not conclusive. Monitor for confirmation before trading."
|
||
)
|
||
elif score >= 15:
|
||
base = (
|
||
"ℹ️ LOW wash trading indicators. Minor anomalies detected but overall "
|
||
"trading behavior appears organic."
|
||
)
|
||
else:
|
||
base = "✅ No wash trading signals detected. Trading patterns appear organic."
|
||
|
||
if confidence > 0:
|
||
return base + conf_note
|
||
return base
|
||
|
||
|
||
async def detect_wash_trading(token_address: str, chain: str) -> dict:
|
||
"""
|
||
Main wash trading detection pipeline.
|
||
|
||
Steps:
|
||
1. Fetch DexScreener data (volume, tx counts, price, liquidity)
|
||
2. Analyze trade size distribution for tiny/frequent trades
|
||
3. Check for recurring trader addresses (same wallets on both sides)
|
||
4. Compute volume-vs-liquidity depth ratio
|
||
5. Calculate buy/sell timing correlation
|
||
6. Cross-reference holder concentration
|
||
7. Compute wash trading score and generate report
|
||
"""
|
||
result = {
|
||
"token_address": token_address,
|
||
"chain": chain,
|
||
"detected": False,
|
||
"wash_score": 0.0,
|
||
"classification": "none",
|
||
"signals": [],
|
||
"sources_used": [],
|
||
"analysis": {},
|
||
"recommendation": "",
|
||
}
|
||
|
||
signals = []
|
||
|
||
# ── Step 1: DexScreener market data ──────────────────────────
|
||
dex_data = await _fetch(DEXSCREENER_API.format(token_address))
|
||
pairs = []
|
||
if dex_data and dex_data.get("pairs"):
|
||
pairs = [
|
||
p
|
||
for p in dex_data["pairs"]
|
||
if p.get("chainId") == chain
|
||
or p.get("baseToken", {}).get("address", "").lower() == token_address.lower()
|
||
]
|
||
if pairs:
|
||
result["sources_used"].append("dexscreener")
|
||
|
||
# ── Step 2: Extract base metrics ──────────────────────────────
|
||
price_usd = 0.0
|
||
volume_24h = 0.0
|
||
liquidity_usd = 0.0
|
||
tx_count_24h = 0
|
||
buy_count_24h = 0
|
||
sell_count_24h = 0
|
||
|
||
if pairs:
|
||
pair = pairs[0]
|
||
price_usd = float(pair.get("priceUsd", 0) or 0)
|
||
volume_24h = float(pair.get("volume", {}).get("h24", 0) or 0)
|
||
liquidity_usd = float(pair.get("liquidity", {}).get("usd", 0) or 0)
|
||
|
||
txns = pair.get("txns", {})
|
||
h24_txns = txns.get("h24", {}) or {}
|
||
buy_count_24h = int(h24_txns.get("buys", 0) or 0)
|
||
sell_count_24h = int(h24_txns.get("sells", 0) or 0)
|
||
tx_count_24h = buy_count_24h + sell_count_24h
|
||
|
||
result["analysis"]["price_usd"] = (
|
||
round(price_usd, 8) if price_usd < 0.01 else round(price_usd, 4)
|
||
)
|
||
result["analysis"]["volume_24h_usd"] = round(volume_24h, 2)
|
||
result["analysis"]["liquidity_usd"] = round(liquidity_usd, 2)
|
||
result["analysis"]["tx_count_24h"] = tx_count_24h
|
||
result["analysis"]["buy_count_24h"] = buy_count_24h
|
||
result["analysis"]["sell_count_24h"] = sell_count_24h
|
||
|
||
# ── Step 3: Volume-to-Liquidity Depth Ratio ───────────────────
|
||
liquidity_depth_ratio = 0.0
|
||
if liquidity_usd > 0 and volume_24h > 0:
|
||
liquidity_depth_ratio = min(volume_24h / max(liquidity_usd, 1), 3.0)
|
||
result["analysis"]["volume_liquidity_ratio"] = round(liquidity_depth_ratio, 2)
|
||
|
||
# Healthy ratio: ~0.5-2.0 volume/liquidity
|
||
if liquidity_depth_ratio > 3.0:
|
||
signals.append(
|
||
f"🟠 Extreme volume/liquidity ratio ({liquidity_depth_ratio:.1f}x) — "
|
||
"volume far exceeds available liquidity, suggesting artificial pumping"
|
||
)
|
||
elif liquidity_depth_ratio > 2.0:
|
||
signals.append(
|
||
f"🟡 High volume/liquidity ratio ({liquidity_depth_ratio:.1f}x) — "
|
||
"possible wash trading to inflate volume metrics"
|
||
)
|
||
|
||
# ── Step 4: Trade Size Distribution (Volume per TX) ───────────
|
||
volume_tx_ratio = 0.0
|
||
if tx_count_24h > 0 and volume_24h > 0:
|
||
avg_tx_size = volume_24h / tx_count_24h
|
||
result["analysis"]["avg_tx_size_usd"] = round(avg_tx_size, 2)
|
||
|
||
# Wash traders use many tiny trades to inflate tx count
|
||
if avg_tx_size < 10 and tx_count_24h > 50:
|
||
volume_tx_ratio = 0.9
|
||
signals.append(
|
||
f"🔴 Abnormal trade pattern: {tx_count_24h} tiny trades "
|
||
f"averaging ${avg_tx_size:.2f} each — classic wash trading fingerprint"
|
||
)
|
||
elif avg_tx_size < 50 and tx_count_24h > 100:
|
||
volume_tx_ratio = 0.7
|
||
signals.append(
|
||
f"🟠 High frequency of small trades ({tx_count_24h} txs, "
|
||
f"${avg_tx_size:.2f} avg) — possible volume pumping"
|
||
)
|
||
elif avg_tx_size < 100 and tx_count_24h > 200:
|
||
volume_tx_ratio = 0.5
|
||
signals.append(
|
||
f"🟡 Many small trades ({tx_count_24h} txs at ${avg_tx_size:.2f} avg) "
|
||
"— watch for wash patterns"
|
||
)
|
||
elif tx_count_24h > 500:
|
||
volume_tx_ratio = 0.3
|
||
signals.append(
|
||
f"ℹ️ High transaction volume ({tx_count_24h} txs) — "
|
||
"may indicate organic activity, worth monitoring"
|
||
)
|
||
|
||
# ── Step 5: Buy/Sell Correlation (Reciprocity) ────────────────
|
||
buy_sell_correlation = 0.0
|
||
if buy_count_24h > 0 and sell_count_24h > 0:
|
||
total_tx = buy_count_24h + sell_count_24h
|
||
buy_ratio = buy_count_24h / total_tx
|
||
sell_ratio = sell_count_24h / total_tx
|
||
|
||
result["analysis"]["buy_ratio"] = round(buy_ratio, 3)
|
||
result["analysis"]["sell_ratio"] = round(sell_ratio, 3)
|
||
|
||
# Near-equal buy/sell split is suspicious (bots trading back and forth)
|
||
reciprocal_range = 0.4 # 40-60% range is suspicious
|
||
if reciprocal_range < buy_ratio < (1 - reciprocal_range):
|
||
buy_sell_correlation = 0.8
|
||
signals.append(
|
||
f"🔴 Near-perfect buy/sell balance ({buy_ratio:.0%} buys / "
|
||
f"{sell_ratio:.0%} sells) — highly indicative of wash trading bots"
|
||
)
|
||
elif reciprocal_range - 0.1 < buy_ratio < (1 - reciprocal_range + 0.1):
|
||
buy_sell_correlation = 0.5
|
||
signals.append(
|
||
f"🟠 Suspicious buy/sell balance ({buy_ratio:.0%} buys / "
|
||
f"{sell_ratio:.0%} sells) — possible reciprocal trading"
|
||
)
|
||
|
||
# ── Step 6: Holder Concentration Analysis ─────────────────────
|
||
top_trader_concentration = 0.0
|
||
holder_count = 0
|
||
reapearring_address_count = 0
|
||
|
||
if chain == "solana":
|
||
birdeye_urls = [
|
||
BIRDEYE_API.format(token_address),
|
||
BIRDEYE_FALLBACK.format(token_address),
|
||
]
|
||
for url in birdeye_urls:
|
||
birdeye_data = await _fetch(url)
|
||
if birdeye_data and isinstance(birdeye_data, dict):
|
||
data_obj = birdeye_data.get("data", birdeye_data)
|
||
holder_conc = float(data_obj.get("top10HolderPercent", 0) or 0)
|
||
top_trader_concentration = holder_conc / 100.0
|
||
holder_count = int(data_obj.get("holder", 0) or 0)
|
||
result["sources_used"].append("birdeye")
|
||
break
|
||
|
||
result["analysis"]["holder_count"] = holder_count
|
||
result["analysis"]["top_trader_concentration"] = round(top_trader_concentration, 4)
|
||
|
||
if top_trader_concentration > 0.6:
|
||
signals.append(
|
||
f"🟠 Extreme holder concentration ({top_trader_concentration * 100:.0f}% "
|
||
f"held by top 10) — possible coordinated wash trading syndicate"
|
||
)
|
||
elif top_trader_concentration > 0.4:
|
||
signals.append(
|
||
f"🟡 High holder concentration ({top_trader_concentration * 100:.0f}%) "
|
||
"— small group controls most supply, facilitating wash trades"
|
||
)
|
||
|
||
# ── Step 7: Small trade ratio from volume distribution ───────
|
||
small_trade_ratio = volume_tx_ratio # Reuse volume/tx signal
|
||
|
||
# ── Step 8: Compute wash trading score ────────────────────────
|
||
wash_score = _compute_wash_score(
|
||
volume_tx_ratio=volume_tx_ratio,
|
||
top_trader_concentration=top_trader_concentration,
|
||
buy_sell_correlation=buy_sell_correlation,
|
||
small_trade_ratio=small_trade_ratio,
|
||
reapearring_address_count=reapearring_address_count,
|
||
liquidity_depth_ratio=liquidity_depth_ratio / 3.0, # normalize to 0-1
|
||
)
|
||
|
||
classification = _classify_wash_risk(wash_score)
|
||
recommendation = _generate_recommendation(wash_score, 0.8 if wash_score > 50 else 0.5)
|
||
|
||
# ── Step 9: Confidence level ──────────────────────────────────
|
||
confidence = 0.0
|
||
if len(result["sources_used"]) >= 1:
|
||
confidence = 0.4 + (len(result["sources_used"]) * 0.2)
|
||
if wash_score >= 55:
|
||
confidence = min(confidence + 0.2, 1.0)
|
||
confidence = min(round(confidence, 2), 1.0)
|
||
result["analysis"]["confidence"] = confidence
|
||
|
||
result["detected"] = wash_score >= 15
|
||
result["wash_score"] = wash_score
|
||
result["classification"] = classification
|
||
result["signals"] = signals
|
||
result["recommendation"] = recommendation
|
||
|
||
return result
|