rmi-backend/app/routers/rugcharts.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- 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>
2026-07-06 15:43:20 +02:00

633 lines
23 KiB
Python

"""
RugCharts Backend Router
========================
Real OHLCV candles via GeckoTerminal, volume authenticity, dev reputation,
and comprehensive token intelligence for the RugCharts page.
"""
import json
import logging
import os
import httpx
import redis
from fastapi import APIRouter, Query
logger = logging.getLogger("rugcharts_router")
router = APIRouter(prefix="/api/v1/rugcharts", tags=["rugcharts"])
REDIS_HOST = os.getenv("REDIS_HOST", "rmi-redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
CACHE_TTL = 120 # 2 minutes for trending, 5 minutes for OHLCV
OHLCV_TTL = 300
# GeckoTerminal network mapping
GECKO_NETWORKS = {
"solana": "solana",
"ethereum": "eth",
"base": "base",
"bsc": "bsc",
"arbitrum": "arbitrum",
"tron": "tron",
"polygon": "polygon_pos",
"avalanche": "avax",
"optimism": "optimism",
}
# Timeframe mapping for GeckoTerminal
GECKO_TIMEFRAMES = {
"1m": ("minute", 1),
"5m": ("minute", 5),
"15m": ("minute", 15),
"1h": ("hour", 1),
"4h": ("hour", 4),
"1d": ("day", 1),
"7d": ("day", 1),
"30d": ("day", 1),
}
def _r():
return redis.Redis(
host=REDIS_HOST,
port=REDIS_PORT,
password=REDIS_PASSWORD,
decode_responses=True,
socket_connect_timeout=2,
)
async def _fetch_dexscreener_trending(chain: str, limit: int = 20) -> list[dict]:
"""Fetch trending tokens from DexScreener with real price data."""
cache_key = f"rugcharts:trending:{chain}:{limit}"
try:
r = _r()
cached = r.get(cache_key)
if cached:
r.close()
return json.loads(cached)
r.close()
except Exception:
pass
chain_map = {
"solana": "solana",
"ethereum": "ethereum",
"base": "base",
"bsc": "bsc",
"arbitrum": "arbitrum",
"tron": "tron",
}
dex_chain = chain_map.get(chain, chain)
tokens = []
try:
async with httpx.AsyncClient(timeout=15) as client:
# Get boosted tokens (trending)
r = await client.get("https://api.dexscreener.com/token-boosts/top/v1")
if r.status_code == 200:
data = r.json()
items = data if isinstance(data, list) else data.get("tokens", [])
for item in items:
# Filter by chain if specified
item_chain = item.get("chainId", "")
if chain and item_chain != dex_chain:
continue
tokens.append(
{
"address": item.get("tokenAddress", ""),
"name": item.get("name", item.get("description", "")),
"symbol": item.get("symbol", ""),
"chain": item_chain,
"icon": item.get("icon", ""),
"url": item.get("url", ""),
"source": "boosted",
}
)
if len(tokens) >= limit:
break
# Also get new pairs for the chain (high volume new launches)
r2 = await client.get("https://api.dexscreener.com/token-profiles/latest/v1")
if r2.status_code == 200:
data2 = r2.json()
items2 = data2 if isinstance(data2, list) else []
for item in items2:
item_chain = item.get("chainId", "")
if chain and item_chain != dex_chain:
continue
# Check if already in list
addr = item.get("tokenAddress", "")
if any(t["address"] == addr for t in tokens):
continue
tokens.append(
{
"address": addr,
"name": item.get("name", ""),
"symbol": item.get("symbol", ""),
"chain": item_chain,
"icon": item.get("icon", ""),
"url": item.get("url", ""),
"source": "new_launch",
}
)
if len(tokens) >= limit:
break
# Enrich with pair data for volume/price
if tokens:
addresses = [t["address"] for t in tokens[:10]]
for addr in addresses:
try:
r3 = await client.get(f"https://api.dexscreener.com/tokens/v1/{dex_chain}/{addr}")
if r3.status_code == 200:
pairs = r3.json()
if isinstance(pairs, list) and pairs:
pair = pairs[0] # Best pair
for t in tokens:
if t["address"] == addr:
t["price_usd"] = float(pair.get("priceUsd", 0) or 0)
t["change_24h"] = float((pair.get("priceChange", {}) or {}).get("h24", 0) or 0)
t["volume_24h"] = float((pair.get("volume", {}) or {}).get("h24", 0) or 0)
t["liquidity_usd"] = float((pair.get("liquidity", {}) or {}).get("usd", 0) or 0)
t["fdv"] = float(pair.get("fdv", 0) or 0)
t["dex"] = pair.get("dexId", "")
t["pair_address"] = pair.get("pairAddress", "")
t["buys_24h"] = (pair.get("txns", {}) or {}).get("h24", {}).get("buys", 0)
t["sells_24h"] = (pair.get("txns", {}) or {}).get("h24", {}).get("sells", 0)
t["makers"] = (pair.get("txns", {}) or {}).get("h24", {}).get("makers", 0)
break
except Exception:
pass
except Exception as e:
logger.warning(f"DexScreener trending fetch failed: {e}")
# Sort by volume
tokens.sort(key=lambda t: t.get("volume_24h", 0), reverse=True)
if tokens:
try:
r = _r()
r.setex(cache_key, CACHE_TTL, json.dumps(tokens))
r.close()
except Exception:
pass
return tokens
async def _fetch_ohlcv_gecko(pair_address: str, chain: str, timeframe: str = "1h", limit: int = 100) -> list[dict]:
"""Fetch OHLCV candles from GeckoTerminal."""
network = GECKO_NETWORKS.get(chain, chain)
tf_info = GECKO_TIMEFRAMES.get(timeframe, ("hour", 1))
tf_aggregate = tf_info[1]
tf_unit = tf_info[0]
# For longer timeframes, adjust limit
if timeframe in ("7d", "30d"):
limit = 168 if timeframe == "7d" else 30
tf_unit = "hour" if timeframe == "7d" else "day"
tf_aggregate = 1
cache_key = f"rugcharts:ohlcv:{chain}:{pair_address}:{timeframe}:{limit}"
try:
r = _r()
cached = r.get(cache_key)
if cached:
r.close()
return json.loads(cached)
r.close()
except Exception:
pass
candles = []
try:
async with httpx.AsyncClient(timeout=15) as client:
url = f"https://api.geckoterminal.com/api/v2/networks/{network}/pools/{pair_address}/ohlcv/{tf_unit}"
params = {"aggregate": tf_aggregate, "limit": min(limit, 1000), "currency": "usd"}
r = await client.get(url, params=params)
if r.status_code == 200:
data = r.json()
ohlcv_list = data.get("data", {}).get("attributes", {}).get("ohlcv_list", [])
for c in reversed(ohlcv_list): # Reverse to chronological order
if len(c) >= 6:
candles.append(
{
"time": int(c[0]),
"open": float(c[1]),
"high": float(c[2]),
"low": float(c[3]),
"close": float(c[4]),
"volume": float(c[5]),
}
)
except Exception as e:
logger.warning(f"GeckoTerminal OHLCV fetch failed: {e}")
if candles:
try:
r = _r()
r.setex(cache_key, OHLCV_TTL, json.dumps(candles))
r.close()
except Exception:
pass
return candles
async def _fetch_pair_info(pair_address: str, chain: str) -> dict:
"""Fetch detailed pair info from GeckoTerminal."""
network = GECKO_NETWORKS.get(chain, chain)
cache_key = f"rugcharts:pair:{chain}:{pair_address}"
try:
r = _r()
cached = r.get(cache_key)
if cached:
r.close()
return json.loads(cached)
r.close()
except Exception:
pass
info = {}
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"https://api.geckoterminal.com/api/v2/networks/{network}/pools/{pair_address}")
if r.status_code == 200:
data = r.json().get("data", {}).get("attributes", {})
info = {
"name": data.get("name", ""),
"address": data.get("address", ""),
"base_token_price_usd": float(data.get("base_token_price_usd", 0) or 0),
"quote_token_price_usd": float(data.get("quote_token_price_usd", 0) or 0),
"price_change_24h": float(data.get("price_change_percentage", {}).get("h24", 0) or 0),
"volume_24h": float(data.get("volume_usd", {}).get("h24", 0) or 0),
"volume_6h": float(data.get("volume_usd", {}).get("h6", 0) or 0),
"volume_1h": float(data.get("volume_usd", {}).get("h1", 0) or 0),
"liquidity_usd": float(data.get("reserve_in_usd", 0) or 0),
"fdv": float(data.get("fdv", 0) or 0),
"market_cap": float(data.get("market_cap_usd", 0) or 0),
"txns_24h_buys": int((data.get("transactions", {}) or {}).get("h24", {}).get("buys", 0) or 0),
"txns_24h_sells": int((data.get("transactions", {}) or {}).get("h24", {}).get("sells", 0) or 0),
"txns_6h_buys": int((data.get("transactions", {}) or {}).get("h6", {}).get("buys", 0) or 0),
"txns_6h_sells": int((data.get("transactions", {}) or {}).get("h6", {}).get("sells", 0) or 0),
"txns_1h_buys": int((data.get("transactions", {}) or {}).get("h1", {}).get("buys", 0) or 0),
"txns_1h_sells": int((data.get("transactions", {}) or {}).get("h1", {}).get("sells", 0) or 0),
"dex": data.get("dex_id", ""),
"pool_created_at": data.get("pool_created_at", ""),
}
except Exception as e:
logger.warning(f"GeckoTerminal pair info failed: {e}")
if info:
try:
r = _r()
r.setex(cache_key, CACHE_TTL, json.dumps(info))
r.close()
except Exception:
pass
return info
def _compute_volume_authenticity(pair_info: dict, candles: list[dict]) -> dict:
"""Compute volume authenticity score from available data."""
vol_24h = pair_info.get("volume_24h", 0)
vol_6h = pair_info.get("volume_6h", 0)
pair_info.get("volume_1h", 0)
liq = pair_info.get("liquidity_usd", 0)
buys = pair_info.get("txns_24h_buys", 0)
sells = pair_info.get("txns_24h_sells", 0)
total_txns = buys + sells
score = 100
risk_flags = []
# 1. Volume/Liquidity ratio check
if liq > 0 and vol_24h > 0:
ratio = vol_24h / liq
if ratio > 100:
score -= 30
risk_flags.append(f"Extreme vol/liq ratio ({ratio:.0f}x) - likely wash trading")
elif ratio > 50:
score -= 20
risk_flags.append(f"Very high vol/liq ratio ({ratio:.0f}x)")
elif ratio > 20:
score -= 10
risk_flags.append(f"Elevated vol/liq ratio ({ratio:.0f}x)")
# 2. Volume distribution across timeframes
if vol_24h > 0 and vol_6h > 0:
expected_6h = vol_24h * 0.25 # 6h should be ~25% of 24h
if vol_6h > expected_6h * 3:
score -= 15
risk_flags.append("Volume concentrated in recent 6h (burst pattern)")
elif vol_6h < expected_6h * 0.1:
score -= 10
risk_flags.append("Almost no recent volume (dying token)")
# 3. Buy/sell ratio
if total_txns > 0:
buy_pct = buys / total_txns
if buy_pct < 0.2:
score -= 15
risk_flags.append(f"Heavy sell dominance ({sells} sells vs {buys} buys)")
elif buy_pct > 0.9:
score -= 10
risk_flags.append(f"Suspiciously high buy ratio ({buy_pct * 100:.0f}%) - possible bot activity")
# 4. Candle analysis (if available)
if len(candles) >= 3:
volumes = [c["volume"] for c in candles]
avg_vol = sum(volumes) / len(volumes)
if avg_vol > 0:
# Check for volume spikes (single candle >> average)
max_vol = max(volumes)
if max_vol > avg_vol * 10:
score -= 10
risk_flags.append(f"Volume spike detected ({max_vol / avg_vol:.0f}x average)")
# Check for uniform volume (bot-like)
if len(volumes) > 5:
std_dev = (sum((v - avg_vol) ** 2 for v in volumes) / len(volumes)) ** 0.5
cv = std_dev / avg_vol if avg_vol > 0 else 0
if cv < 0.05:
score -= 15
risk_flags.append("Unnaturally uniform volume distribution (bot pattern)")
# 5. Zero volume check
if vol_24h == 0:
score -= 40
risk_flags.append("No 24h trading volume")
score = max(0, min(100, score))
risk_level = "LOW" if score >= 70 else "MEDIUM" if score >= 40 else "HIGH"
return {
"authentic_score": score,
"fake_volume_pct": max(0, 100 - score),
"risk_level": risk_level,
"risk_flags": risk_flags,
"metrics": {
"volume_24h": vol_24h,
"liquidity_usd": liq,
"vol_liq_ratio": round(vol_24h / liq, 2) if liq > 0 else 0,
"buy_count": buys,
"sell_count": sells,
"buy_sell_ratio": round(buys / sells, 2) if sells > 0 else 0,
},
}
def _compute_rug_score(pair_info: dict, vol_auth: dict) -> dict:
"""Compute overall rug risk score."""
score = 0
factors = []
liq = pair_info.get("liquidity_usd", 0)
fdv = pair_info.get("fdv", 0)
pair_info.get("volume_24h", 0)
change = pair_info.get("price_change_24h", 0)
buys = pair_info.get("txns_24h_buys", 0)
sells = pair_info.get("txns_24h_sells", 0)
# Liquidity risk
if liq < 1e4:
score += 30
factors.append("Critically low liquidity (<$10K)")
elif liq < 5e4:
score += 15
factors.append("Low liquidity (<$50K)")
elif liq > 1e6:
score -= 5
factors.append("Deep liquidity pool (>$1M)")
# FDV/Liquidity ratio
if fdv > 0 and liq > 0:
ratio = fdv / liq
if ratio > 100:
score += 20
factors.append(f"Extreme FDV/Liq ratio ({ratio:.0f}x)")
elif ratio > 20:
score += 10
factors.append(f"Elevated FDV/Liq ratio ({ratio:.0f}x)")
# Sell pressure
if sells > buys * 1.5 and buys > 0:
score += 15
factors.append(f"Heavy sell pressure ({sells} sells vs {buys} buys)")
elif buys > sells * 1.5 and sells > 0:
score -= 10
factors.append("Strong organic buy pressure")
# Price action
if change < -20:
score += 20
factors.append(f"Massive dump ({change:.1f}%)")
elif change > 50:
score += 8
factors.append(f"Extreme pump (+{change:.1f}%) - potential exit liquidity")
elif change > 10:
score += 5
factors.append("Rapid price increase")
# Volume authenticity penalty
auth_score = vol_auth.get("authentic_score", 100)
if auth_score < 50:
score += 15
factors.append(f"Low volume authenticity ({auth_score}/100)")
# Pool age
created = pair_info.get("pool_created_at", "")
if created:
try:
from datetime import datetime
created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
age_hours = (datetime.now(created_dt.tzinfo) - created_dt).total_seconds() / 3600
if age_hours < 1:
score += 15
factors.append(f"Brand new pool ({age_hours:.1f}h old)")
elif age_hours < 24:
score += 8
factors.append("New pool (<24h old)")
except Exception:
pass
score = max(0, min(100, score))
level = "SAFE" if score < 30 else "CAUTION" if score < 60 else "DANGER"
color = "#10b981" if score < 30 else "#f59e0b" if score < 60 else "#ef4444"
return {"score": score, "level": level, "color": color, "factors": factors}
@router.get("/trending")
async def rugcharts_trending(
chain: str = Query("solana", description="Blockchain to query"),
limit: int = Query(30, description="Max tokens to return"),
):
"""Get trending tokens sorted by volume for RugCharts."""
tokens = await _fetch_dexscreener_trending(chain, limit)
return {"tokens": tokens[:limit], "count": len(tokens), "chain": chain, "source": "dexscreener"}
@router.get("/ohlcv/{chain}/{pair_address}")
async def rugcharts_ohlcv(
chain: str,
pair_address: str,
timeframe: str = Query("1h", description="Candle timeframe"),
limit: int = Query(100, description="Number of candles"),
):
"""Get OHLCV candle data for a specific pair."""
candles = await _fetch_ohlcv_gecko(pair_address, chain, timeframe, limit)
pair_info = await _fetch_pair_info(pair_address, chain)
vol_auth = _compute_volume_authenticity(pair_info, candles)
# Summary from candles
summary = {}
if candles:
prices = [c["close"] for c in candles]
volumes = [c["volume"] for c in candles]
summary = {
"current_price": prices[-1],
"price_change_pct": round(((prices[-1] - prices[0]) / prices[0] * 100), 2) if prices[0] > 0 else 0,
"high": max(c["high"] for c in candles),
"low": min(c["low"] for c in candles),
"volume": sum(volumes),
"candle_count": len(candles),
}
return {
"candles": candles,
"summary": summary,
"pair_info": pair_info,
"authenticity": vol_auth,
"timeframe": timeframe,
"chain": chain,
}
@router.get("/intel/{chain}/{pair_address}")
async def rugcharts_intel(
chain: str,
pair_address: str,
):
"""Get comprehensive intelligence on a token pair."""
pair_info = await _fetch_pair_info(pair_address, chain)
candles = await _fetch_ohlcv_gecko(pair_address, chain, "1h", 48)
vol_auth = _compute_volume_authenticity(pair_info, candles)
rug_score = _compute_rug_score(pair_info, vol_auth)
# Simple TA from candles
ta = {}
if len(candles) >= 20:
closes = [c["close"] for c in candles]
# SMA 20
sma20 = sum(closes[-20:]) / 20
# SMA 7
sma7 = sum(closes[-7:]) / 7
# RSI 14
gains, losses = [], []
for i in range(-14, 0):
diff = closes[i] - closes[i - 1]
gains.append(max(0, diff))
losses.append(max(0, -diff))
avg_gain = sum(gains) / 14
avg_loss = sum(losses) / 14
rs = avg_gain / avg_loss if avg_loss > 0 else 100
rsi = 100 - (100 / (1 + rs))
# Bollinger Bands (20-period, 2 std dev)
bb_mean = sma20
bb_std = (sum((c - bb_mean) ** 2 for c in closes[-20:]) / 20) ** 0.5
bb_upper = bb_mean + 2 * bb_std
bb_lower = bb_mean - 2 * bb_std
# Volume trend
vols = [c["volume"] for c in candles]
vol_sma = sum(vols[-20:]) / 20
vol_current = vols[-1] if vols else 0
ta = {
"sma_7": round(sma7, 10),
"sma_20": round(sma20, 10),
"rsi_14": round(rsi, 2),
"bb_upper": round(bb_upper, 10),
"bb_lower": round(bb_lower, 10),
"bb_middle": round(bb_mean, 10),
"volume_sma_20": round(vol_sma, 2),
"volume_current": round(vol_current, 2),
"price_vs_sma20": "ABOVE" if closes[-1] > sma20 else "BELOW",
"rsi_signal": "OVERBOUGHT" if rsi > 70 else "OVERSOLD" if rsi < 30 else "NEUTRAL",
"trend": "BULLISH" if sma7 > sma20 else "BEARISH",
"volume_trend": "HIGH"
if vol_current > vol_sma * 1.5
else "LOW"
if vol_current < vol_sma * 0.5
else "NORMAL",
}
# Predictive signals
predictions = []
if ta:
if ta.get("rsi_signal") == "OVERBOUGHT" and rug_score["score"] > 50:
predictions.append(
{
"signal": "DUMP_LIKELY",
"confidence": 75,
"reason": "Overbought RSI + high rug score",
}
)
elif ta.get("rsi_signal") == "OVERSOLD" and vol_auth["authentic_score"] > 70:
predictions.append(
{
"signal": "BOUNCE_POSSIBLE",
"confidence": 60,
"reason": "Oversold with authentic volume",
}
)
if ta.get("trend") == "BEARISH" and pair_info.get("txns_24h_sells", 0) > pair_info.get("txns_24h_buys", 0) * 2:
predictions.append(
{
"signal": "DEATH_SPIRAL",
"confidence": 70,
"reason": "Bearish trend + heavy selling",
}
)
if vol_auth["authentic_score"] < 40:
predictions.append(
{
"signal": "FAKE_VOLUME",
"confidence": 80,
"reason": f"Volume authenticity only {vol_auth['authentic_score']}%",
}
)
if not predictions:
if rug_score["score"] > 60:
predictions.append(
{
"signal": "RUG_RISK_HIGH",
"confidence": 65,
"reason": "Multiple rug indicators present",
}
)
else:
predictions.append(
{
"signal": "NO_CLEAR_SIGNAL",
"confidence": 50,
"reason": "Insufficient data for prediction",
}
)
return {
"pair_info": pair_info,
"rug_score": rug_score,
"volume_authenticity": vol_auth,
"technical_analysis": ta,
"predictions": predictions,
"chain": chain,
"pair_address": pair_address,
}