merge: chore/cleanup-remove-bloat-and-secrets into main
This commit is contained in:
commit
bde2f3a97d
1173 changed files with 437609 additions and 0 deletions
902
app/routers/x402_advanced_tools.py
Normal file
902
app/routers/x402_advanced_tools.py
Normal file
|
|
@ -0,0 +1,902 @@
|
|||
"""
|
||||
Advanced x402 tools — caching, confidence, real-time streams, predictive scoring.
|
||||
|
||||
Layer 1: Response caching (Redis, 60s TTL) — <50ms repeat calls
|
||||
Layer 2: Confidence scores — every data point gets low/medium/high
|
||||
Layer 3: SSE alert stream — real-time rug/whale/price alerts
|
||||
Layer 4: Rug probability — predictive 0-100 "will this rug in 24h?"
|
||||
Layer 5: Historical scanner data — time-series risk/liquidity/holders
|
||||
Layer 6: Narrative engine — what's the market saying RIGHT NOW
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger("x402.advanced")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# LAYER 1: Response Caching
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
CACHE_TTL = 60 # seconds
|
||||
CACHEABLE_TOOLS = {
|
||||
"audit",
|
||||
"wallet",
|
||||
"reputation_score",
|
||||
"honeypot_check",
|
||||
"rugshield",
|
||||
"forensics",
|
||||
"whale",
|
||||
"token_deep_dive",
|
||||
"market_overview",
|
||||
"chain_health",
|
||||
"sentiment",
|
||||
"rug_pull_predictor",
|
||||
"rug_probability",
|
||||
"narrative",
|
||||
}
|
||||
|
||||
|
||||
def _redis_conn():
|
||||
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 get_cached(tool: str, params: dict) -> dict | None:
|
||||
"""Check Redis cache for a tool result."""
|
||||
try:
|
||||
r = _redis_conn()
|
||||
key = _cache_key(tool, params)
|
||||
data = r.get(key)
|
||||
if data:
|
||||
return json.loads(data)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def set_cached(tool: str, params: dict, result: dict, ttl: int = CACHE_TTL):
|
||||
"""Store tool result in Redis cache."""
|
||||
try:
|
||||
r = _redis_conn()
|
||||
key = _cache_key(tool, params)
|
||||
result["_cached"] = True
|
||||
result["_cached_at"] = datetime.utcnow().isoformat()
|
||||
r.setex(key, ttl, json.dumps(result))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _cache_key(tool: str, params: dict) -> str:
|
||||
raw = f"{tool}:{json.dumps(params, sort_keys=True)}"
|
||||
return f"x402:cache:{hashlib.sha256(raw.encode()).hexdigest()[:16]}"
|
||||
|
||||
|
||||
def invalidate_cache(tool: str | None = None):
|
||||
"""Clear cache for a tool or all cached results."""
|
||||
try:
|
||||
r = _redis_conn()
|
||||
if tool:
|
||||
for key in r.scan_iter("x402:cache:*"):
|
||||
r.delete(key)
|
||||
else:
|
||||
for key in r.scan_iter("x402:cache:*"):
|
||||
r.delete(key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# LAYER 2: Confidence Scores
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def compute_confidence(result: dict) -> dict:
|
||||
"""Add confidence scoring to any tool result."""
|
||||
sources = result.get("sources_used", [])
|
||||
source_count = len(sources)
|
||||
|
||||
# Source quality weights
|
||||
high_quality = {
|
||||
"clickhouse",
|
||||
"wallet_labels",
|
||||
"etherscan",
|
||||
"coingecko",
|
||||
"defillama",
|
||||
"solana_rpc",
|
||||
"ethereum_rpc",
|
||||
"base_rpc",
|
||||
"dexscreener",
|
||||
"geckoterminal",
|
||||
"tron_rpc",
|
||||
"bitcoin_rpc",
|
||||
}
|
||||
medium_quality = {
|
||||
"cryptopanic",
|
||||
"reddit",
|
||||
"coincap",
|
||||
"coinmarketcap",
|
||||
"moralis",
|
||||
"birdeye",
|
||||
"helius",
|
||||
"solscan",
|
||||
"rmi_intel",
|
||||
"scam_detector",
|
||||
"rag_similarity",
|
||||
"pumpfun",
|
||||
}
|
||||
|
||||
high_count = sum(1 for s in sources if s.lower() in high_quality)
|
||||
medium_count = sum(1 for s in sources if s.lower() in medium_quality)
|
||||
source_count - high_count - medium_count
|
||||
|
||||
# Score computation
|
||||
if source_count >= 4 and high_count >= 2:
|
||||
level = "high"
|
||||
score = min(100, 70 + source_count * 5)
|
||||
elif source_count >= 2 and high_count >= 1:
|
||||
level = "medium"
|
||||
score = 40 + source_count * 8
|
||||
elif source_count >= 1:
|
||||
level = "low"
|
||||
score = 20 + source_count * 10
|
||||
else:
|
||||
level = "unverified"
|
||||
score = 5
|
||||
|
||||
# Flag freshness
|
||||
age_flags = []
|
||||
if "dexscreener" in [s.lower() for s in sources]:
|
||||
age_flags.append("market_data_live")
|
||||
if "wallet_labels" in [s.lower() for s in sources]:
|
||||
age_flags.append("labels_cached")
|
||||
|
||||
return {
|
||||
"score": min(100, score),
|
||||
"level": level,
|
||||
"sources_total": source_count,
|
||||
"sources_high_quality": high_count,
|
||||
"sources_medium_quality": medium_count,
|
||||
"flags": age_flags,
|
||||
"interpretation": {
|
||||
"high": "Multiple verified sources confirm this data",
|
||||
"medium": "Adequate coverage from trusted sources",
|
||||
"low": "Limited source coverage — verify independently",
|
||||
"unverified": "Insufficient data — treat as directional only",
|
||||
}.get(level, ""),
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# LAYER 3: SSE Real-Time Alert Stream
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/stream/alerts")
|
||||
async def sse_alert_stream(
|
||||
events: str = Query(default="rug_pull,whale_move,price_crash", description="Comma-separated event types"),
|
||||
chain: str = Query(default="all"),
|
||||
):
|
||||
"""Server-Sent Events stream for real-time security alerts.
|
||||
|
||||
Connect with: EventSource('/api/v1/x402-tools/stream/alerts?events=rug_pull,whale_move')
|
||||
|
||||
Events emitted as JSON:
|
||||
{"event":"rug_pull","address":"0x...","chain":"base","severity":"critical","data":{...}}
|
||||
"""
|
||||
event_list = [e.strip() for e in events.split(",") if e.strip()]
|
||||
chain_filter = chain.strip().lower()
|
||||
|
||||
async def event_generator():
|
||||
import redis as _redis
|
||||
|
||||
r = _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=3,
|
||||
)
|
||||
last_id = "0"
|
||||
|
||||
# Send initial connection event
|
||||
yield f"event: connected\ndata: {json.dumps({'status': 'connected', 'events': event_list, 'chain': chain_filter, 'timestamp': datetime.utcnow().isoformat()})}\n\n"
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Check Redis for new alerts
|
||||
alerts = r.lrange("x402:alerts:high_risk", 0, 9)
|
||||
for alert_json in alerts:
|
||||
try:
|
||||
alert = json.loads(alert_json)
|
||||
alert_id = alert.get("timestamp", "")
|
||||
if alert_id > last_id:
|
||||
event_type = alert.get("type", "unknown")
|
||||
if event_type in event_list or "all" in event_list:
|
||||
yield f"event: {event_type}\ndata: {json.dumps(alert)}\n\n"
|
||||
last_id = alert_id
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Also check for new events in webhook-triggered alerts
|
||||
for evt in event_list:
|
||||
evt_alerts = r.lrange(f"x402:alert:{evt}", 0, 4)
|
||||
for a in evt_alerts:
|
||||
try:
|
||||
data = json.loads(a)
|
||||
if data.get("timestamp", "") > last_id:
|
||||
yield f"event: {evt}\ndata: {json.dumps(data)}\n\n"
|
||||
last_id = data.get("timestamp", "")
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Keep-alive ping
|
||||
yield f": keepalive {int(time.time())}\n\n"
|
||||
await asyncio.sleep(5)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"SSE stream error: {e}")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Request Models
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class AddressRequest(BaseModel):
|
||||
address: str = Field(..., description="Wallet or token address")
|
||||
chain: str = Field(default="base")
|
||||
|
||||
|
||||
class HistoryRequest(BaseModel):
|
||||
address: str = Field(..., description="Token or wallet address")
|
||||
chain: str = Field(default="base")
|
||||
hours: int = Field(default=24, ge=1, le=168, description="Lookback window in hours (max 168)")
|
||||
|
||||
|
||||
class NarrativeRequest(BaseModel):
|
||||
token: str = Field(..., description="Token symbol or address")
|
||||
chain: str = Field(default="all")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# LAYER 4: Rug Probability Score
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post("/rug_probability")
|
||||
async def rug_probability(req: AddressRequest):
|
||||
"""Predictive rug pull probability: 0-100 score for "will this token rug in 24h?"
|
||||
|
||||
Combines 7 signals:
|
||||
- Honeypot check (can you sell?)
|
||||
- Liquidity depth and lock status
|
||||
- Holder concentration (whale dominance)
|
||||
- Deployer history (serial rugger?)
|
||||
- Contract age (new = higher risk)
|
||||
- Social signal anomalies (coordinated shilling)
|
||||
- Market context (volume/liquidity ratio)
|
||||
"""
|
||||
try:
|
||||
addr = req.address.strip()
|
||||
chain = req.chain or "base"
|
||||
t0 = time.time()
|
||||
|
||||
# Check cache
|
||||
cached = get_cached("rug_probability", {"address": addr, "chain": chain})
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
probability = 0
|
||||
signals = []
|
||||
sources = []
|
||||
|
||||
# ── Signal 1: Honeypot Check ──
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session, session.post(
|
||||
"http://localhost:8000/api/v1/x402-tools/honeypot_check",
|
||||
json={"address": addr, "chain": chain},
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
if data.get("is_honeypot"):
|
||||
probability += 40
|
||||
signals.append(
|
||||
{
|
||||
"signal": "honeypot_detected",
|
||||
"weight": 40,
|
||||
"detail": data.get("reason", "Cannot sell — confirmed honeypot"),
|
||||
}
|
||||
)
|
||||
sources.append("honeypot_check")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Signal 2: DexScreener Liquidity & Age ──
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
url = f"https://api.dexscreener.com/latest/dex/tokens/{addr}"
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=8)) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.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
|
||||
|
||||
# Liquidity signal
|
||||
if liq < 1000:
|
||||
probability += 25
|
||||
signals.append(
|
||||
{
|
||||
"signal": "critical_low_liquidity",
|
||||
"weight": 25,
|
||||
"detail": f"Liquidity ${liq:,.0f} — extremely low, easy to drain",
|
||||
}
|
||||
)
|
||||
elif liq < 10000:
|
||||
probability += 15
|
||||
signals.append(
|
||||
{
|
||||
"signal": "low_liquidity",
|
||||
"weight": 15,
|
||||
"detail": f"Liquidity ${liq:,.0f} — below safe threshold",
|
||||
}
|
||||
)
|
||||
elif liq < 50000:
|
||||
probability += 5
|
||||
signals.append(
|
||||
{
|
||||
"signal": "moderate_liquidity",
|
||||
"weight": 5,
|
||||
"detail": f"Liquidity ${liq:,.0f} — moderate",
|
||||
}
|
||||
)
|
||||
|
||||
# Age signal
|
||||
if 0 < age_h < 1:
|
||||
probability += 20
|
||||
signals.append(
|
||||
{
|
||||
"signal": "brand_new",
|
||||
"weight": 20,
|
||||
"detail": f"Only {age_h:.1f}h old — highest rug risk window",
|
||||
}
|
||||
)
|
||||
elif 0 < age_h < 6:
|
||||
probability += 12
|
||||
signals.append(
|
||||
{
|
||||
"signal": "very_new",
|
||||
"weight": 12,
|
||||
"detail": f"Only {age_h:.1f}h old — early risk period",
|
||||
}
|
||||
)
|
||||
elif 0 < age_h < 24:
|
||||
probability += 5
|
||||
signals.append(
|
||||
{
|
||||
"signal": "new_token",
|
||||
"weight": 5,
|
||||
"detail": f"{age_h:.0f}h old — still in risk window",
|
||||
}
|
||||
)
|
||||
|
||||
# Volume/Liquidity ratio (pump and dump signal)
|
||||
if liq > 0 and vol > liq * 3:
|
||||
probability += 10
|
||||
signals.append(
|
||||
{
|
||||
"signal": "pump_dump_pattern",
|
||||
"weight": 10,
|
||||
"detail": f"Volume {vol / liq:.0f}x liquidity — possible pump and dump",
|
||||
}
|
||||
)
|
||||
|
||||
# Price crash signal
|
||||
pc = p.get("priceChange", {}).get("h24", 0) or 0
|
||||
if pc < -50:
|
||||
probability += 15
|
||||
signals.append(
|
||||
{
|
||||
"signal": "price_crashing",
|
||||
"weight": 15,
|
||||
"detail": f"Down {pc:.0f}% in 24h — possible exit scam in progress",
|
||||
}
|
||||
)
|
||||
elif pc < -20:
|
||||
probability += 8
|
||||
signals.append(
|
||||
{
|
||||
"signal": "price_declining",
|
||||
"weight": 8,
|
||||
"detail": f"Down {pc:.0f}% in 24h",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Signal 3: Holder Concentration ──
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# GeckoTerminal for holder data
|
||||
chain_map = {"solana": "solana", "base": "base", "ethereum": "eth", "bsc": "bsc"}
|
||||
geo_chain = chain_map.get(chain, chain)
|
||||
url = f"https://api.geckoterminal.com/api/v2/networks/{geo_chain}/tokens/{addr}"
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=8)) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
attrs = data.get("data", {}).get("attributes", {})
|
||||
if attrs:
|
||||
sources.append("geckoterminal")
|
||||
# Check top holder concentration from available data
|
||||
top_pool = attrs.get("top_pool_id")
|
||||
if top_pool:
|
||||
# Pool exists — check if it's the only one
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Signal 4: Deployer History (scam pattern check) ──
|
||||
try:
|
||||
import asyncio as _asyncio
|
||||
|
||||
from app.rag_service import detect_scam_patterns
|
||||
|
||||
result = _asyncio.run(detect_scam_patterns({"address": addr, "chain": chain}, 0.4))
|
||||
if result and result.get("risk_score", 0) > 0:
|
||||
sources.append("scam_detector")
|
||||
risk = result.get("risk_score", 0)
|
||||
probability += min(risk, 30)
|
||||
patterns = result.get("patterns", [])
|
||||
if patterns:
|
||||
signals.append(
|
||||
{
|
||||
"signal": "scam_pattern_match",
|
||||
"weight": min(risk, 30),
|
||||
"detail": f"Matches known scam patterns: {', '.join(patterns[:3])}",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Signal 5: Social Anomaly Check ──
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
from urllib.parse import quote
|
||||
|
||||
symbol = addr[:12]
|
||||
url = f"https://cryptopanic.com/api/free/posts/?filter=important&q={quote(symbol)}"
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=8)) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
posts = data.get("results", [])
|
||||
if posts:
|
||||
sources.append("cryptopanic")
|
||||
# Check for sudden spike in mentions
|
||||
recent = [p for p in posts if p.get("created_at")]
|
||||
if len(recent) > 20:
|
||||
probability += 5
|
||||
signals.append(
|
||||
{
|
||||
"signal": "social_spike",
|
||||
"weight": 5,
|
||||
"detail": f"{len(recent)} social mentions — unusual activity",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Compute final probability ──
|
||||
probability = max(0, min(100, probability))
|
||||
|
||||
if probability >= 75:
|
||||
tier = "EXTREME_RISK"
|
||||
recommendation = "DO NOT BUY — extremely high rug probability"
|
||||
elif probability >= 50:
|
||||
tier = "HIGH_RISK"
|
||||
recommendation = "Avoid — significant rug indicators present"
|
||||
elif probability >= 25:
|
||||
tier = "MODERATE_RISK"
|
||||
recommendation = "Caution — monitor closely before entry"
|
||||
elif probability >= 10:
|
||||
tier = "LOW_RISK"
|
||||
recommendation = "Standard risk — normal market activity"
|
||||
else:
|
||||
tier = "MINIMAL_RISK"
|
||||
recommendation = "Low rug probability — relatively safe"
|
||||
|
||||
result = {
|
||||
"tool": "Rug Probability Score",
|
||||
"version": "1.0",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"address": addr,
|
||||
"chain": chain,
|
||||
"rug_probability": probability,
|
||||
"tier": tier,
|
||||
"recommendation": recommendation,
|
||||
"signals": signals,
|
||||
"signal_count": len(signals),
|
||||
"sources_used": sources,
|
||||
"_confidence": compute_confidence({"sources_used": sources}),
|
||||
"performance_ms": round((time.time() - t0) * 1000, 1),
|
||||
"guarantee": "Data delivered or auto-refund via x402 receipt",
|
||||
}
|
||||
|
||||
set_cached("rug_probability", {"address": addr, "chain": chain}, result)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Rug probability failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# LAYER 5: Historical Scanner Data
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post("/history")
|
||||
async def token_history(req: HistoryRequest):
|
||||
"""Historical risk/liquidity/holder data for any token.
|
||||
|
||||
Returns time-series data from the RMI scanner (runs every 10 min).
|
||||
Shows how risk profile, liquidity, volume, and holder metrics change over time.
|
||||
|
||||
Data points: timestamp, risk_score, liquidity_usd, volume_24h,
|
||||
price_usd, holder_count, whale_dominance_pct.
|
||||
"""
|
||||
try:
|
||||
addr = req.address.strip()
|
||||
chain = req.chain or "base"
|
||||
hours = req.hours
|
||||
|
||||
cached = get_cached("history", {"address": addr, "chain": chain, "hours": hours})
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Read scanner snapshots from Redis
|
||||
import redis as _redis
|
||||
|
||||
r = _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,
|
||||
)
|
||||
|
||||
data_points = []
|
||||
cutoff = time.time() - (hours * 3600)
|
||||
|
||||
# Scanner stores data as x402:scan:{address}:{timestamp}
|
||||
for key in r.scan_iter(f"x402:scan:{addr}:*"):
|
||||
try:
|
||||
ts = float(key.decode().split(":")[-1]) if isinstance(key, bytes) else float(key.split(":")[-1])
|
||||
if ts < cutoff:
|
||||
continue
|
||||
raw = r.get(key)
|
||||
if raw:
|
||||
dp = json.loads(raw)
|
||||
dp["timestamp"] = ts
|
||||
data_points.append(dp)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
# Also try the token scanner's own keys
|
||||
for key in r.scan_iter(f"token:scan:{addr}:*"):
|
||||
try:
|
||||
parts = key.decode().split(":") if isinstance(key, bytes) else key.split(":")
|
||||
ts = float(parts[-1])
|
||||
if ts < cutoff:
|
||||
continue
|
||||
raw = r.get(key)
|
||||
if raw:
|
||||
dp = json.loads(raw)
|
||||
dp["timestamp"] = ts
|
||||
data_points.append(dp)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
data_points.sort(key=lambda d: d.get("timestamp", 0))
|
||||
|
||||
# Add current snapshot
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
url = f"https://api.dexscreener.com/latest/dex/tokens/{addr}"
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=8)) as resp:
|
||||
if resp.status == 200:
|
||||
dex_data = await resp.json()
|
||||
pairs = dex_data.get("pairs", [])
|
||||
if pairs:
|
||||
p = pairs[0]
|
||||
current = {
|
||||
"timestamp": time.time(),
|
||||
"price_usd": p.get("priceUsd"),
|
||||
"liquidity_usd": p.get("liquidity", {}).get("usd"),
|
||||
"volume_24h": p.get("volume", {}).get("h24"),
|
||||
"price_change_24h": p.get("priceChange", {}).get("h24"),
|
||||
"is_current": True,
|
||||
}
|
||||
data_points.append(current)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Compute trends
|
||||
trends = {}
|
||||
if len(data_points) >= 2:
|
||||
first = data_points[0]
|
||||
last = data_points[-1]
|
||||
for metric in ["price_usd", "liquidity_usd", "volume_24h"]:
|
||||
fv = first.get(metric)
|
||||
lv = last.get(metric)
|
||||
if fv and lv and fv > 0:
|
||||
change_pct = ((lv - fv) / fv) * 100
|
||||
trends[metric] = {
|
||||
"start": fv,
|
||||
"end": lv,
|
||||
"change_pct": round(change_pct, 1),
|
||||
"direction": "up" if change_pct > 0 else "down" if change_pct < 0 else "flat",
|
||||
}
|
||||
|
||||
result = {
|
||||
"tool": "Historical Scanner Data",
|
||||
"version": "1.0",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"address": addr,
|
||||
"chain": chain,
|
||||
"lookback_hours": hours,
|
||||
"data_points": len(data_points),
|
||||
"history": data_points[-50:], # Last 50 data points
|
||||
"trends": trends,
|
||||
"scanner_interval": "10 minutes",
|
||||
"_confidence": compute_confidence(
|
||||
{
|
||||
"sources_used": ["rmi_scanner"]
|
||||
+ (["dexscreener"] if any(d.get("is_current") for d in data_points) else []),
|
||||
}
|
||||
),
|
||||
"guarantee": "Historical data or full refund",
|
||||
}
|
||||
|
||||
set_cached("history", {"address": addr, "chain": chain, "hours": hours}, result, ttl=120)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token history failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# LAYER 6: Narrative Engine
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post("/narrative")
|
||||
async def narrative_engine(req: NarrativeRequest):
|
||||
"""What's the market saying about this token RIGHT NOW?
|
||||
|
||||
Aggregates Twitter, Reddit, Telegram, and news sentiment into a
|
||||
narrative summary with confidence scoring and shill detection.
|
||||
"""
|
||||
try:
|
||||
token = req.token.strip()
|
||||
chain = req.chain or "all"
|
||||
t0 = time.time()
|
||||
|
||||
cached = get_cached("narrative", {"token": token, "chain": chain})
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
sources = []
|
||||
posts = []
|
||||
|
||||
# ── CryptoPanic (news + social) ──
|
||||
try:
|
||||
from urllib.parse import quote
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
url = f"https://cryptopanic.com/api/free/posts/?filter=important&q={quote(token)}"
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=8)) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
results = data.get("results", [])
|
||||
if results:
|
||||
sources.append("cryptopanic")
|
||||
for r in results[:15]:
|
||||
posts.append(
|
||||
{
|
||||
"source": "cryptopanic",
|
||||
"title": r.get("title", "")[:150],
|
||||
"sentiment": r.get("votes", {}).get("positive", 0)
|
||||
- r.get("votes", {}).get("negative", 0),
|
||||
"created": r.get("created_at", ""),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Reddit ──
|
||||
try:
|
||||
from urllib.parse import quote
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
url = f"https://www.reddit.com/search.json?q={quote(token)}&sort=new&limit=10"
|
||||
async with session.get(
|
||||
url,
|
||||
headers={"User-Agent": "RMI-Narrative/1.0"},
|
||||
timeout=aiohttp.ClientTimeout(total=8),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
children = data.get("data", {}).get("children", [])
|
||||
if children:
|
||||
sources.append("reddit")
|
||||
for c in children[:10]:
|
||||
d = c.get("data", {})
|
||||
posts.append(
|
||||
{
|
||||
"source": "reddit",
|
||||
"title": d.get("title", "")[:150],
|
||||
"subreddit": d.get("subreddit", ""),
|
||||
"score": d.get("score", 0),
|
||||
"comments": d.get("num_comments", 0),
|
||||
"created": datetime.utcfromtimestamp(d.get("created_utc", 0)).isoformat()
|
||||
if d.get("created_utc")
|
||||
else "",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Compute narrative ──
|
||||
total_posts = len(posts)
|
||||
if total_posts == 0:
|
||||
return {
|
||||
"tool": "Narrative Engine",
|
||||
"version": "1.0",
|
||||
"address": token,
|
||||
"narrative": "Insufficient data — no recent mentions found",
|
||||
"sentiment": "neutral",
|
||||
"confidence": "low",
|
||||
"posts_analyzed": 0,
|
||||
"sources_used": [],
|
||||
}
|
||||
|
||||
# Sentiment scoring
|
||||
positive = sum(1 for p in posts if p.get("sentiment", 0) > 0)
|
||||
negative = sum(1 for p in posts if p.get("sentiment", 0) < 0)
|
||||
neutral_count = total_posts - positive - negative
|
||||
|
||||
if positive > negative * 2:
|
||||
sentiment = "bullish"
|
||||
sentiment_pct = round(positive / max(total_posts, 1) * 100)
|
||||
elif negative > positive * 2:
|
||||
sentiment = "bearish"
|
||||
sentiment_pct = round(negative / max(total_posts, 1) * 100)
|
||||
elif positive > negative:
|
||||
sentiment = "slightly_bullish"
|
||||
sentiment_pct = round(positive / max(total_posts, 1) * 100)
|
||||
elif negative > positive:
|
||||
sentiment = "slightly_bearish"
|
||||
sentiment_pct = round(negative / max(total_posts, 1) * 100)
|
||||
else:
|
||||
sentiment = "neutral"
|
||||
sentiment_pct = 50
|
||||
|
||||
# Shill detection
|
||||
shill_signals = []
|
||||
reddit_posts = [p for p in posts if p.get("source") == "reddit"]
|
||||
if total_posts > 10 and positive > total_posts * 0.8:
|
||||
shill_signals.append("Suspiciously high positive ratio — possible coordinated shilling")
|
||||
if len(reddit_posts) >= 3 and all(p.get("score", 0) == 0 for p in reddit_posts):
|
||||
shill_signals.append("Same-timestamp posts detected — possible bot activity")
|
||||
|
||||
# Build narrative summary
|
||||
subreddits = list({p.get("subreddit", "") for p in posts if p.get("subreddit")})
|
||||
narrative = (
|
||||
f"{sentiment.replace('_', ' ').title()} on {token}. "
|
||||
f"{sentiment_pct}% positive across {total_posts} posts from {len(sources)} sources. "
|
||||
+ (f"Active in r/{', r/'.join(subreddits[:3])}. " if subreddits else "")
|
||||
+ (f"Risk: {'; '.join(shill_signals)}" if shill_signals else "No shill signals detected.")
|
||||
)
|
||||
|
||||
result = {
|
||||
"tool": "Narrative Engine",
|
||||
"version": "1.0",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"token": token,
|
||||
"chain": chain,
|
||||
"narrative": narrative,
|
||||
"sentiment": sentiment,
|
||||
"sentiment_score": sentiment_pct,
|
||||
"posts_analyzed": total_posts,
|
||||
"breakdown": {
|
||||
"positive": positive,
|
||||
"negative": negative,
|
||||
"neutral": neutral_count,
|
||||
},
|
||||
"sources_used": sources,
|
||||
"shill_signals": shill_signals if shill_signals else None,
|
||||
"_confidence": compute_confidence({"sources_used": sources}),
|
||||
"performance_ms": round((time.time() - t0) * 1000, 1),
|
||||
"guarantee": "Real-time social data or full refund",
|
||||
}
|
||||
|
||||
set_cached("narrative", {"token": token, "chain": chain}, result)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Narrative engine failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Cache Management Endpoint
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post("/cache/clear")
|
||||
async def clear_cache(tool: str = Query(default=None)):
|
||||
"""Clear response cache for a specific tool or all tools."""
|
||||
ok = invalidate_cache(tool)
|
||||
return {
|
||||
"status": "cleared" if ok else "failed",
|
||||
"tool": tool or "all",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/cache/stats")
|
||||
async def cache_stats():
|
||||
"""Get cache hit/miss statistics."""
|
||||
try:
|
||||
r = _redis_conn()
|
||||
keys = list(r.scan_iter("x402:cache:*"))
|
||||
return {
|
||||
"cached_entries": len(keys),
|
||||
"memory_estimate_bytes": sum(len(r.get(k) or "") for k in keys[:100]),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "cached_entries": 0}
|
||||
Loading…
Add table
Add a link
Reference in a new issue