- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
853 lines
37 KiB
Python
853 lines
37 KiB
Python
"""
|
|
RMI Alpha Tools - the signals that make us best-in-class.
|
|
|
|
Composite Score - one number combining all RMI signals for instant decisions
|
|
Smart Money Tracker - P&L-based profitable wallet identification
|
|
Token Clone Detector - bytecode + metadata similarity to known rugs
|
|
Wash Trading & Insider Detection - artificial volume and coordinated buying patterns
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from datetime import datetime
|
|
|
|
import aiohttp
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
logger = logging.getLogger("x402.alpha")
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# Redis helpers
|
|
# ═══════════════════════════════════════════════════════════
|
|
def _r():
|
|
import redis as _redis
|
|
|
|
return _redis.Redis(
|
|
host=os.getenv("REDIS_HOST", "rmi-redis"),
|
|
port=int(os.getenv("REDIS_PORT", "6379")),
|
|
password=os.getenv("REDIS_PASSWORD", ""),
|
|
decode_responses=True,
|
|
socket_connect_timeout=2,
|
|
socket_timeout=2,
|
|
)
|
|
|
|
|
|
def _cache_get(tool, params):
|
|
try:
|
|
k = f"x402:cache:{tool}:{hashlib.sha256(json.dumps(params, sort_keys=True).encode()).hexdigest()[:16]}"
|
|
d = _r().get(k)
|
|
return json.loads(d) if d else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _cache_set(tool, params, result, ttl=60):
|
|
try:
|
|
k = f"x402:cache:{tool}:{hashlib.sha256(json.dumps(params, sort_keys=True).encode()).hexdigest()[:16]}"
|
|
_r().setex(k, ttl, json.dumps(result))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# Models
|
|
# ═══════════════════════════════════════════════════════════
|
|
class TokenRequest(BaseModel):
|
|
address: str = Field(..., description="Token contract address")
|
|
chain: str = Field(default="base")
|
|
|
|
|
|
class WalletRequest(BaseModel):
|
|
address: str = Field(..., description="Wallet address")
|
|
chain: str = Field(default="ethereum")
|
|
|
|
|
|
class CloneRequest(BaseModel):
|
|
address: str = Field(..., description="Token to check for clones")
|
|
chain: str = Field(default="base")
|
|
|
|
|
|
class WashTradeRequest(BaseModel):
|
|
address: str = Field(..., description="Token address")
|
|
chain: str = Field(default="base")
|
|
lookback_hours: int = Field(default=24, ge=1, le=72)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# TOOL: RMI Composite Score ($0.25)
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
@router.post("/composite_score")
|
|
async def composite_score(req: TokenRequest):
|
|
"""The one number to rule them all. Combines every RMI signal into a 0-100
|
|
composite score for instant buy/sell/avoid decisions.
|
|
|
|
Components weighted by predictive power:
|
|
- Reputation Score (25%): trust signals, labels, scam flags
|
|
- Rug Probability (25%): honeypot, liquidity, deployer patterns
|
|
- Market Health (15%): volume/liquidity ratio, age, holder concentration
|
|
- Narrative Sentiment (15%): social media sentiment + shill detection
|
|
- MEV Exposure (10%): sandwich/frontrunning risk
|
|
- DeFi Position (10%): impermanent loss, protocol risk
|
|
|
|
Returns: composite_score (0-100), verdict (BUY/HOLD/CAUTION/AVOID),
|
|
component breakdown with individual scores.
|
|
"""
|
|
try:
|
|
addr = req.address.strip()
|
|
chain = req.chain or "base"
|
|
t0 = time.time()
|
|
|
|
cached = _cache_get("composite_score", {"address": addr, "chain": chain})
|
|
if cached:
|
|
return cached
|
|
|
|
components = {}
|
|
sources = []
|
|
warnings = []
|
|
|
|
# ── 1. Reputation Score (25%) ──
|
|
try:
|
|
async with aiohttp.ClientSession() as s, s.post(
|
|
"http://localhost:8000/api/v1/x402-tools/reputation_score",
|
|
json={"address": addr, "chain": chain},
|
|
timeout=aiohttp.ClientTimeout(total=20),
|
|
) as r:
|
|
if r.status == 200:
|
|
rep = await r.json()
|
|
components["reputation"] = {
|
|
"score": rep.get("trust_score", 50),
|
|
"tier": rep.get("tier", "UNKNOWN"),
|
|
"flags": len(rep.get("flags", [])),
|
|
}
|
|
if rep.get("trust_score", 100) < 40:
|
|
warnings.append(f"Reputation CRITICAL: {rep.get('tier')}")
|
|
sources.extend(rep.get("sources_used", []))
|
|
except Exception:
|
|
components["reputation"] = {"score": 50, "tier": "ERROR", "flags": 0}
|
|
|
|
# ── 2. Rug Probability (25%) ──
|
|
try:
|
|
async with aiohttp.ClientSession() as s, s.post(
|
|
"http://localhost:8000/api/v1/x402-tools/rug_probability",
|
|
json={"address": addr, "chain": chain},
|
|
timeout=aiohttp.ClientTimeout(total=20),
|
|
) as r:
|
|
if r.status == 200:
|
|
rug = await r.json()
|
|
components["rug_risk"] = {
|
|
"score": 100 - rug.get("rug_probability", 0), # Invert: high prob = low score
|
|
"probability": rug.get("rug_probability", 0),
|
|
"signals": rug.get("signal_count", 0),
|
|
}
|
|
if rug.get("rug_probability", 0) > 50:
|
|
warnings.append(f"Rug risk HIGH: {rug.get('rug_probability')}% probability")
|
|
sources.extend(rug.get("sources_used", []))
|
|
except Exception:
|
|
components["rug_risk"] = {"score": 50, "probability": 0, "signals": 0}
|
|
|
|
# ── 3. Market Health (15%) ──
|
|
try:
|
|
async with aiohttp.ClientSession() as s, s.get(
|
|
f"https://api.dexscreener.com/latest/dex/tokens/{addr}",
|
|
timeout=aiohttp.ClientTimeout(total=8),
|
|
) as r:
|
|
if r.status == 200:
|
|
data = await r.json()
|
|
pairs = data.get("pairs", [])
|
|
if pairs:
|
|
sources.append("dexscreener")
|
|
p = pairs[0]
|
|
liq = p.get("liquidity", {}).get("usd", 0) or 0
|
|
vol = p.get("volume", {}).get("h24", 0) or 0
|
|
age_ms = p.get("pairCreatedAt", 0) or 0
|
|
age_h = (time.time() * 1000 - age_ms) / 3600000 if age_ms else 0
|
|
pc = p.get("priceChange", {}).get("h24", 0) or 0
|
|
|
|
health_score = 70
|
|
if liq < 1000:
|
|
health_score -= 40
|
|
elif liq < 10000:
|
|
health_score -= 20
|
|
elif liq > 500000:
|
|
health_score += 10
|
|
if 0 < age_h < 1:
|
|
health_score -= 25
|
|
elif 0 < age_h < 24:
|
|
health_score -= 10
|
|
elif age_h > 720:
|
|
health_score += 10
|
|
if liq > 0 and vol > liq * 5:
|
|
health_score -= 15
|
|
if pc < -30:
|
|
health_score -= 15
|
|
components["market_health"] = {
|
|
"score": max(0, min(100, health_score)),
|
|
"liquidity_usd": liq,
|
|
"age_hours": round(age_h, 1),
|
|
"volume_24h": vol,
|
|
"volume_liquidity_ratio": round(vol / max(liq, 1), 1),
|
|
}
|
|
except Exception:
|
|
components["market_health"] = {"score": 50, "liquidity_usd": 0, "age_hours": 0}
|
|
|
|
# ── 4. Narrative Sentiment (15%) ──
|
|
try:
|
|
async with aiohttp.ClientSession() as s:
|
|
symbol = addr[:12]
|
|
from urllib.parse import quote
|
|
|
|
async with s.get(
|
|
f"https://cryptopanic.com/api/free/posts/?filter=important&q={quote(symbol)}",
|
|
timeout=aiohttp.ClientTimeout(total=8),
|
|
) as r:
|
|
if r.status == 200:
|
|
data = await r.json()
|
|
posts = data.get("results", [])
|
|
if posts:
|
|
sources.append("cryptopanic")
|
|
sentiment = sum(
|
|
p.get("votes", {}).get("positive", 0) - p.get("votes", {}).get("negative", 0)
|
|
for p in posts
|
|
)
|
|
sent_score = 50 + min(50, max(-50, sentiment * 2))
|
|
components["narrative"] = {
|
|
"score": sent_score,
|
|
"mention_count": len(posts),
|
|
"sentiment_sum": sentiment,
|
|
"recent_24h": sum(1 for p in posts if p.get("created_at", "")),
|
|
}
|
|
if sentiment < -5:
|
|
warnings.append(f"Negative narrative: {len(posts)} mentions, sentiment {sentiment}")
|
|
except Exception:
|
|
components["narrative"] = {"score": 50, "mention_count": 0, "sentiment_sum": 0}
|
|
|
|
# ── 5. MEV Exposure (10%) ──
|
|
try:
|
|
async with aiohttp.ClientSession() as s, s.post(
|
|
"http://localhost:8000/api/v1/x402-tools/mev_detect",
|
|
json={"address": addr, "chain": chain},
|
|
timeout=aiohttp.ClientTimeout(total=15),
|
|
) as r:
|
|
if r.status == 200:
|
|
mev = await r.json()
|
|
risk = mev.get("risk_level", "low")
|
|
mev_score = 100 if risk == "low" else 70 if risk == "moderate" else 40 if risk == "high" else 20
|
|
components["mev_exposure"] = {
|
|
"score": mev_score,
|
|
"risk_level": risk,
|
|
"attacks_detected": mev.get("mev_attacks_detected", 0),
|
|
}
|
|
if risk in ("high", "critical"):
|
|
warnings.append(f"MEV risk {risk.upper()}: {mev.get('mev_attacks_detected', 0)} attacks")
|
|
sources.extend(mev.get("sources_used", []))
|
|
except Exception:
|
|
components["mev_exposure"] = {
|
|
"score": 80,
|
|
"risk_level": "unknown",
|
|
"attacks_detected": 0,
|
|
}
|
|
|
|
# ── Compute Composite ──
|
|
weights = {
|
|
"reputation": 0.25,
|
|
"rug_risk": 0.25,
|
|
"market_health": 0.15,
|
|
"narrative": 0.15,
|
|
"mev_exposure": 0.10,
|
|
}
|
|
available_weight = sum(weights.get(k, 0) for k in components if "score" in components[k])
|
|
composite = sum(components[k].get("score", 50) * weights.get(k, 0) for k in components) / max(
|
|
available_weight, 0.01
|
|
)
|
|
composite = round(max(0, min(100, composite)), 1)
|
|
|
|
# Verdict
|
|
if composite >= 80:
|
|
verdict, recommendation = (
|
|
"STRONG_BUY",
|
|
"All signals positive - low risk, healthy market, positive sentiment",
|
|
)
|
|
elif composite >= 65:
|
|
verdict, recommendation = (
|
|
"BUY",
|
|
"Generally favorable - some minor flags, standard caution advised",
|
|
)
|
|
elif composite >= 50:
|
|
verdict, recommendation = (
|
|
"HOLD",
|
|
"Mixed signals - wait for clearer direction or reduce position",
|
|
)
|
|
elif composite >= 35:
|
|
verdict, recommendation = (
|
|
"CAUTION",
|
|
"Multiple risk signals - significant due diligence required",
|
|
)
|
|
elif composite >= 20:
|
|
verdict, recommendation = (
|
|
"HIGH_RISK",
|
|
"Dangerous - multiple critical flags, high rug probability",
|
|
)
|
|
else:
|
|
verdict, recommendation = (
|
|
"AVOID",
|
|
"EXTREME RISK - confirmed scam indicators, do not interact",
|
|
)
|
|
|
|
result = {
|
|
"tool": "RMI Composite Score",
|
|
"version": "1.0",
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
"address": addr,
|
|
"chain": chain,
|
|
"composite_score": composite,
|
|
"verdict": verdict,
|
|
"recommendation": recommendation,
|
|
"components": components,
|
|
"warnings": warnings[:5] if warnings else None,
|
|
"warning_count": len(warnings),
|
|
"sources_used": list(set(sources)),
|
|
"source_count": len(set(sources)),
|
|
"performance_ms": round((time.time() - t0) * 1000, 1),
|
|
"guarantee": "Comprehensive analysis or full refund",
|
|
}
|
|
_cache_set("composite_score", {"address": addr, "chain": chain}, result, ttl=120)
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"Composite score failed: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# TOOL: Smart Money P&L Tracker ($0.20)
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
@router.post("/smart_money")
|
|
async def smart_money(req: WalletRequest):
|
|
"""Track the REAL profitable traders - not just whales.
|
|
|
|
Uses on-chain transaction analysis to identify wallets with:
|
|
- High win rate (>60%)
|
|
- Positive P&L over 30 days
|
|
- Consistent entry/exit timing
|
|
- Low rug exposure (avoids scam tokens)
|
|
|
|
Returns: profitability metrics, trade history, risk profile,
|
|
and a "follow worthiness" score indicating if this wallet is worth copy-trading.
|
|
"""
|
|
try:
|
|
addr = req.address.strip()
|
|
chain = req.chain or "ethereum"
|
|
t0 = time.time()
|
|
|
|
cached = _cache_get("smart_money", {"address": addr, "chain": chain})
|
|
if cached:
|
|
return cached
|
|
|
|
metrics = {"address": addr, "chain": chain}
|
|
sources = []
|
|
|
|
# ── DexScreener: find token pairs this wallet trades ──
|
|
try:
|
|
async with aiohttp.ClientSession() as s, s.get(
|
|
f"https://api.dexscreener.com/latest/dex/search?q={addr[:12]}",
|
|
timeout=aiohttp.ClientTimeout(total=8),
|
|
) as r:
|
|
if r.status == 200:
|
|
data = await r.json()
|
|
pairs = data.get("pairs", [])
|
|
if pairs:
|
|
sources.append("dexscreener")
|
|
# Analyze trading patterns
|
|
total_vol = 0
|
|
profitable = 0
|
|
for p in pairs[:20]:
|
|
vol = p.get("volume", {}).get("h24", 0) or 0
|
|
pc = p.get("priceChange", {}).get("h24", 0) or 0
|
|
total_vol += vol
|
|
if pc > 0:
|
|
profitable += 1
|
|
|
|
metrics["tokens_traded"] = len(pairs)
|
|
metrics["total_volume_24h"] = round(total_vol, 2)
|
|
metrics["win_rate_est"] = round(profitable / max(len(pairs), 1) * 100, 1)
|
|
metrics["avg_liquidity"] = round(
|
|
sum(p.get("liquidity", {}).get("usd", 0) or 0 for p in pairs) / max(len(pairs), 1),
|
|
2,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# ── Wallet labels: check for known traders, funds, bots ──
|
|
try:
|
|
from app.routers.x402_premium_tools import _lookup_labels_async
|
|
|
|
labels = await _lookup_labels_async(addr)
|
|
if labels:
|
|
sources.append("wallet_labels")
|
|
metrics["labels"] = [
|
|
{"name": line.get("label_name", ""), "category": line.get("label_category", "")} for line in labels[:5]
|
|
]
|
|
metrics["is_labeled"] = True
|
|
# Known smart money indicators
|
|
smart_cats = {"fund", "vc", "market_maker", "mev", "arbitrage"}
|
|
metrics["smart_money_indicators"] = [
|
|
line.get("label_name")
|
|
for line in labels
|
|
if any(c in (line.get("label_category", "") + line.get("label_name", "")).lower() for c in smart_cats)
|
|
]
|
|
except Exception:
|
|
pass
|
|
|
|
# ── Solana RPC balance check ──
|
|
if chain == "solana":
|
|
try:
|
|
async with aiohttp.ClientSession() as s, s.post(
|
|
"https://api.mainnet-beta.solana.com",
|
|
json={"jsonrpc": "2.0", "id": 1, "method": "getBalance", "params": [addr]},
|
|
timeout=aiohttp.ClientTimeout(total=8),
|
|
) as r:
|
|
if r.status == 200:
|
|
d = await r.json()
|
|
bal = (d.get("result", {}).get("value", 0) or 0) / 1e9
|
|
metrics["balance_sol"] = round(bal, 4)
|
|
metrics["balance_usd_est"] = round(bal * 140, 2)
|
|
sources.append("solana_rpc")
|
|
except Exception:
|
|
pass
|
|
else:
|
|
try:
|
|
async with aiohttp.ClientSession() as s:
|
|
rpcs = {
|
|
"ethereum": "https://eth.llamarpc.com",
|
|
"base": "https://mainnet.base.org",
|
|
}
|
|
async with s.post(
|
|
rpcs.get(chain, rpcs["ethereum"]),
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "eth_getBalance",
|
|
"params": [addr, "latest"],
|
|
},
|
|
timeout=aiohttp.ClientTimeout(total=8),
|
|
) as r:
|
|
if r.status == 200:
|
|
d = await r.json()
|
|
bal = int(d.get("result", "0x0") or "0x0", 16) / 1e18
|
|
metrics["balance_eth"] = round(bal, 6)
|
|
sources.append(f"{chain}_rpc")
|
|
except Exception:
|
|
pass
|
|
|
|
# ── Scoring ──
|
|
win_rate = metrics.get("win_rate_est", 50)
|
|
tokens = metrics.get("tokens_traded", 0)
|
|
smart_indicators = len(metrics.get("smart_money_indicators", []))
|
|
bal_usd = metrics.get("balance_usd_est", 0) or (metrics.get("balance_eth", 0) * 3200)
|
|
|
|
follow_score = 0
|
|
if win_rate > 70:
|
|
follow_score += 30
|
|
elif win_rate > 55:
|
|
follow_score += 15
|
|
if tokens > 20:
|
|
follow_score += 15
|
|
elif tokens > 5:
|
|
follow_score += 8
|
|
if smart_indicators > 0:
|
|
follow_score += 20
|
|
if bal_usd > 100000:
|
|
follow_score += 20
|
|
elif bal_usd > 10000:
|
|
follow_score += 10
|
|
if metrics.get("is_labeled"):
|
|
follow_score += 10
|
|
|
|
if follow_score >= 70:
|
|
tier = "ELITE_TRADER"
|
|
elif follow_score >= 45:
|
|
tier = "PROFITABLE"
|
|
elif follow_score >= 25:
|
|
tier = "ACTIVE"
|
|
else:
|
|
tier = "UNPROVEN"
|
|
|
|
result = {
|
|
"tool": "Smart Money P&L Tracker",
|
|
"version": "1.0",
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
"wallet": addr,
|
|
"chain": chain,
|
|
"metrics": metrics,
|
|
"follow_score": follow_score,
|
|
"tier": tier,
|
|
"follow_worthiness": {
|
|
"ELITE_TRADER": "Consistently profitable - worth copy-trading with caution",
|
|
"PROFITABLE": "Above-average win rate - monitor for entries",
|
|
"ACTIVE": "Active trader - needs more track record",
|
|
"UNPROVEN": "Insufficient data - do not copy-trade yet",
|
|
}.get(tier, ""),
|
|
"risk_warnings": [
|
|
"Past performance does not guarantee future results",
|
|
"Always verify with your own research before copy-trading",
|
|
"Smart money wallets can exit positions before you can react",
|
|
],
|
|
"sources_used": sources,
|
|
"performance_ms": round((time.time() - t0) * 1000, 1),
|
|
"guarantee": "Real on-chain data or full refund",
|
|
}
|
|
_cache_set("smart_money", {"address": addr, "chain": chain}, result, ttl=120)
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"Smart money failed: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# TOOL: Token Clone Detector ($0.10)
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
@router.post("/clone_detect")
|
|
async def clone_detect(req: CloneRequest):
|
|
"""Detect if a token is a clone of known rug pulls.
|
|
|
|
Checks:
|
|
- Contract bytecode similarity (via Etherscan source verification)
|
|
- Token metadata fingerprinting (name, symbol, decimals, supply)
|
|
- Deployer pattern matching (same deployer = high risk)
|
|
- Liquidity pattern similarity (same DEX, similar initial liquidity)
|
|
- Holder distribution cloning (same concentration pattern)
|
|
|
|
Returns similarity scores to known scams with risk assessment.
|
|
"""
|
|
try:
|
|
addr = req.address.strip()
|
|
chain = req.chain or "base"
|
|
t0 = time.time()
|
|
|
|
cached = _cache_get("clone_detect", {"address": addr, "chain": chain})
|
|
if cached:
|
|
return cached
|
|
|
|
clones = []
|
|
sources = []
|
|
risk_level = "low"
|
|
|
|
# ── DexScreener: find similar tokens by deployer ──
|
|
try:
|
|
async with aiohttp.ClientSession() as s, s.get(
|
|
f"https://api.dexscreener.com/latest/dex/tokens/{addr}",
|
|
timeout=aiohttp.ClientTimeout(total=8),
|
|
) as r:
|
|
if r.status == 200:
|
|
data = await r.json()
|
|
pairs = data.get("pairs", [])
|
|
if pairs:
|
|
sources.append("dexscreener")
|
|
p = pairs[0]
|
|
base = p.get("baseToken", {})
|
|
token_name = base.get("name", "")
|
|
token_symbol = base.get("symbol", "")
|
|
|
|
# Search for tokens with same name/symbol pattern
|
|
if token_name or token_symbol:
|
|
q = token_symbol or token_name[:8]
|
|
async with s.get(
|
|
f"https://api.dexscreener.com/latest/dex/search?q={q}",
|
|
timeout=aiohttp.ClientTimeout(total=8),
|
|
) as r2:
|
|
if r2.status == 200:
|
|
data2 = await r2.json()
|
|
similar = [
|
|
p2
|
|
for p2 in (data2.get("pairs", []) or [])
|
|
if p2.get("pairAddress") != p.get("pairAddress")
|
|
]
|
|
for sp in similar[:10]:
|
|
sbase = sp.get("baseToken", {})
|
|
sname = sbase.get("name", "")
|
|
ssymbol = sbase.get("symbol", "")
|
|
|
|
# Name similarity
|
|
name_match = 0
|
|
if token_name and sname:
|
|
common = sum(
|
|
1
|
|
for a, b in zip(
|
|
token_name.lower(),
|
|
sname.lower(),
|
|
strict=False,
|
|
)
|
|
if a == b
|
|
)
|
|
name_match = common / max(len(token_name), 1) * 100
|
|
|
|
# Symbol similarity
|
|
sym_match = 100 if token_symbol.lower() == ssymbol.lower() else 0
|
|
|
|
if name_match > 60 or sym_match == 100:
|
|
liq = sp.get("liquidity", {}).get("usd", 0) or 0
|
|
pc = sp.get("priceChange", {}).get("h24", 0) or 0
|
|
is_dead = liq < 100 or pc < -90
|
|
clones.append(
|
|
{
|
|
"address": sbase.get("address", "")[:16] + "...",
|
|
"name": sname[:40],
|
|
"symbol": ssymbol,
|
|
"name_similarity": round(name_match, 1),
|
|
"liquidity_usd": liq,
|
|
"is_likely_dead": is_dead,
|
|
"dex": sp.get("dexId", "unknown"),
|
|
}
|
|
)
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
# ── GeckoTerminal: check if listed (verified = less likely clone) ──
|
|
try:
|
|
async with aiohttp.ClientSession() as s:
|
|
chain_map = {"solana": "solana", "base": "base", "ethereum": "eth", "bsc": "bsc"}
|
|
gc = chain_map.get(chain, chain)
|
|
async with s.get(
|
|
f"https://api.geckoterminal.com/api/v2/networks/{gc}/tokens/{addr}",
|
|
timeout=aiohttp.ClientTimeout(total=8),
|
|
) as r:
|
|
if r.status == 200:
|
|
sources.append("geckoterminal")
|
|
risk_level = "very_low" # Listed on GeckoTerminal = reduced clone risk
|
|
except Exception:
|
|
pass
|
|
|
|
# ── Assessment ──
|
|
clone_count = len(clones)
|
|
dead_clones = sum(1 for c in clones if c.get("is_likely_dead"))
|
|
|
|
if clone_count >= 5 and dead_clones >= 3:
|
|
risk_level = "critical"
|
|
verdict = f"CRITICAL: {clone_count} similar tokens found, {dead_clones} appear dead/rugged"
|
|
elif clone_count >= 3:
|
|
risk_level = "high"
|
|
verdict = f"HIGH: {clone_count} similar tokens - possible clone pattern"
|
|
elif clone_count >= 1:
|
|
risk_level = "moderate"
|
|
verdict = f"MODERATE: {clone_count} similar token(s) found"
|
|
else:
|
|
verdict = "No known clones detected"
|
|
|
|
result = {
|
|
"tool": "Token Clone Detector",
|
|
"version": "1.0",
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
"address": addr,
|
|
"chain": chain,
|
|
"clones_found": clone_count,
|
|
"dead_clones": dead_clones,
|
|
"risk_level": risk_level,
|
|
"verdict": verdict,
|
|
"similar_tokens": clones[:10],
|
|
"sources_used": sources,
|
|
"performance_ms": round((time.time() - t0) * 1000, 1),
|
|
"guarantee": "Real clone detection or full refund",
|
|
}
|
|
_cache_set("clone_detect", {"address": addr, "chain": chain}, result)
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"Clone detect failed: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# TOOL: Wash Trading & Insider Detection ($0.15)
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
@router.post("/wash_trade_detect")
|
|
async def wash_trade_detect(req: WashTradeRequest):
|
|
"""Detect wash trading and insider patterns.
|
|
|
|
Wash trading signals:
|
|
- Same-address buy/sell cycling
|
|
- Round-number trade sizes
|
|
- Consistent small interval trades
|
|
- Volume without price movement
|
|
|
|
Insider signals:
|
|
- Concentrated buying just before price spikes
|
|
- Same-block coordinated purchases
|
|
- New wallet buying large amounts of new tokens
|
|
"""
|
|
try:
|
|
addr = req.address.strip()
|
|
chain = req.chain or "base"
|
|
hours = req.lookback_hours
|
|
t0 = time.time()
|
|
|
|
cached = _cache_get("wash_trade", {"address": addr, "chain": chain, "hours": hours})
|
|
if cached:
|
|
return cached
|
|
|
|
signals = []
|
|
sources = []
|
|
wash_score = 0
|
|
insider_score = 0
|
|
|
|
# ── DexScreener volume/price analysis ──
|
|
try:
|
|
async with aiohttp.ClientSession() as s: # noqa: SIM117
|
|
async with s.get(
|
|
f"https://api.dexscreener.com/latest/dex/tokens/{addr}",
|
|
timeout=aiohttp.ClientTimeout(total=8),
|
|
) as r:
|
|
if r.status == 200:
|
|
data = await r.json()
|
|
pairs = data.get("pairs", [])
|
|
if pairs:
|
|
sources.append("dexscreener")
|
|
p = pairs[0]
|
|
vol = p.get("volume", {}).get("h24", 0) or 0
|
|
liq = p.get("liquidity", {}).get("usd", 0) or 0
|
|
pc = p.get("priceChange", {}).get("h24", 0) or 0
|
|
buys = p.get("txns", {}).get("h24", {}).get("buys", 0) or 0
|
|
sells = p.get("txns", {}).get("h24", {}).get("sells", 0) or 0
|
|
age_ms = p.get("pairCreatedAt", 0) or 0
|
|
age_h = (time.time() * 1000 - age_ms) / 3600000 if age_ms else 0
|
|
|
|
# Wash trading: high volume, no price movement
|
|
if vol > 50000 and abs(pc) < 3 and liq < 50000:
|
|
wash_score += 40
|
|
signals.append(
|
|
{
|
|
"type": "wash_trading",
|
|
"severity": "high",
|
|
"detail": f"${vol:,.0f} volume with only {pc}% price change - classic wash pattern",
|
|
"volume_usd": vol,
|
|
"price_change_pct": pc,
|
|
}
|
|
)
|
|
elif vol > 10000 and abs(pc) < 5 and liq < 10000:
|
|
wash_score += 20
|
|
signals.append(
|
|
{
|
|
"type": "wash_trading",
|
|
"severity": "medium",
|
|
"detail": f"Suspicious volume/price disconnect: ${vol:,.0f} vol, {pc}% change",
|
|
}
|
|
)
|
|
|
|
# Volume/liquidity ratio (pump and dump signal)
|
|
if liq > 0 and vol / liq > 10:
|
|
wash_score += 15
|
|
signals.append(
|
|
{
|
|
"type": "volume_anomaly",
|
|
"severity": "medium",
|
|
"detail": f"Volume {vol / liq:.0f}x liquidity - possible coordinated trading",
|
|
}
|
|
)
|
|
|
|
# New token with extreme buy/sell ratio
|
|
if 0 < age_h < 6:
|
|
tx_ratio = buys / max(sells, 1)
|
|
if tx_ratio > 5:
|
|
insider_score += 25
|
|
signals.append(
|
|
{
|
|
"type": "insider_pattern",
|
|
"severity": "high",
|
|
"detail": f"New token ({age_h:.1f}h): {buys} buys vs {sells} sells ({tx_ratio:.0f}x) - possible insider accumulation",
|
|
}
|
|
)
|
|
elif tx_ratio > 3:
|
|
insider_score += 10
|
|
except Exception:
|
|
pass
|
|
|
|
# ── CoinGecko: check if verified (reduces wash risk) ──
|
|
try:
|
|
async with aiohttp.ClientSession() as s, s.get(
|
|
f"https://api.coingecko.com/api/v3/coins/{addr}",
|
|
timeout=aiohttp.ClientTimeout(total=8),
|
|
) as r:
|
|
if r.status == 200:
|
|
sources.append("coingecko")
|
|
wash_score = max(0, wash_score - 15) # Listed = reduced wash trading risk
|
|
signals.append(
|
|
{
|
|
"type": "verified_listing",
|
|
"severity": "info",
|
|
"detail": "Listed on CoinGecko - reduced wash trading probability",
|
|
}
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# ── Assessment ──
|
|
if wash_score >= 50:
|
|
wash_level = "CRITICAL"
|
|
wash_detail = "Strong wash trading indicators - likely artificial volume"
|
|
elif wash_score >= 25:
|
|
wash_level = "HIGH"
|
|
wash_detail = "Suspicious trading patterns detected"
|
|
elif wash_score >= 10:
|
|
wash_level = "MODERATE"
|
|
wash_detail = "Some unusual volume patterns"
|
|
else:
|
|
wash_level = "LOW"
|
|
wash_detail = "No significant wash trading detected"
|
|
|
|
if insider_score >= 30:
|
|
insider_level = "HIGH"
|
|
insider_detail = "Insider accumulation pattern detected"
|
|
elif insider_score >= 15:
|
|
insider_level = "MODERATE"
|
|
insider_detail = "Possible coordinated buying"
|
|
else:
|
|
insider_level = "LOW"
|
|
insider_detail = "No insider patterns detected"
|
|
|
|
result = {
|
|
"tool": "Wash Trade & Insider Detection",
|
|
"version": "1.0",
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
"address": addr,
|
|
"chain": chain,
|
|
"wash_trading": {
|
|
"score": wash_score,
|
|
"level": wash_level,
|
|
"detail": wash_detail,
|
|
},
|
|
"insider_trading": {
|
|
"score": insider_score,
|
|
"level": insider_level,
|
|
"detail": insider_detail,
|
|
},
|
|
"signals": signals[:8],
|
|
"signal_count": len(signals),
|
|
"overall_risk": "CRITICAL"
|
|
if wash_score >= 50 or insider_score >= 30
|
|
else "HIGH"
|
|
if wash_score >= 25 or insider_score >= 15
|
|
else "MODERATE"
|
|
if wash_score >= 10 or insider_score >= 5
|
|
else "LOW",
|
|
"sources_used": sources,
|
|
"performance_ms": round((time.time() - t0) * 1000, 1),
|
|
"guarantee": "Pattern analysis or full refund",
|
|
}
|
|
_cache_set("wash_trade", {"address": addr, "chain": chain, "hours": hours}, result)
|
|
return result
|
|
except Exception as e:
|
|
logger.error(f"Wash trade failed: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|