""" 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, }