rmi-backend/app/velocity_risk.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

217 lines
7.7 KiB
Python

"""
Velocity Risk Engine - Time-Series Anomaly Detection
=====================================================
Tracks how token metrics CHANGE over time, not just what they ARE.
Fast changes = higher risk than static bad metrics.
Premium feature: Catches rugs mid-flight, not just at launch.
"""
import logging
import time
from collections import defaultdict
from typing import Any
logger = logging.getLogger("sentinel.velocity")
# In-memory time-series cache (backed by Redis in production)
# { "chain:address": [(timestamp, {metrics}), ...] }
_series_cache: dict[str, list] = defaultdict(list)
MAX_SERIES_LENGTH = 10 # Keep last 10 snapshots per token
def record_snapshot(chain: str, address: str, metrics: dict[str, float]) -> None:
"""Record a metrics snapshot for velocity tracking."""
key = f"{chain}:{address.lower()}"
_series_cache[key].append((time.time(), metrics))
if len(_series_cache[key]) > MAX_SERIES_LENGTH:
_series_cache[key] = _series_cache[key][-MAX_SERIES_LENGTH:]
def analyze_velocity(chain: str, address: str, current: dict[str, float], window_seconds: int = 3600) -> dict[str, Any]:
"""Analyze how fast metrics are changing. Returns velocity scores and flags.
Metrics tracked:
- holder_concentration: top10% change per hour
- lp_depth: liquidity depth change per hour
- volume_liquidity_ratio: vol/liq ratio acceleration
- price: price change per hour
- tx_count: transaction velocity
"""
key = f"{chain}:{address.lower()}"
history = _series_cache.get(key, [])
# Record current
record_snapshot(chain, address, current)
if len(history) < 2:
return {"status": "insufficient_data", "snapshots": len(history) + 1}
# Find snapshots within window
now = time.time()
window_snapshots = [(ts, m) for ts, m in history if now - ts <= window_seconds]
if len(window_snapshots) < 2:
return {"status": "insufficient_window_data", "snapshots": len(window_snapshots) + 1}
oldest = window_snapshots[0][1]
newest = current
time_span = now - window_snapshots[0][0]
hours = max(time_span / 3600, 0.01)
velocities = {}
flags = []
risk_score = 0
# Holder concentration velocity
if "top10_pct" in oldest and "top10_pct" in newest:
holder_delta = newest["top10_pct"] - oldest["top10_pct"]
holder_velocity = holder_delta / hours
velocities["holder_concentration_delta_per_hour"] = round(holder_velocity, 2)
if holder_velocity > 20:
flags.append("HOLDER_CONCENTRATION_SURGING")
risk_score += 25
elif holder_velocity > 10:
flags.append("HOLDER_CONCENTRATION_RISING_FAST")
risk_score += 15
elif holder_velocity > 5:
flags.append("HOLDER_CONCENTRATION_RISING")
risk_score += 8
# LP depth velocity
if "liquidity_usd" in oldest and "liquidity_usd" in newest:
old_liq = max(oldest["liquidity_usd"], 1)
new_liq = max(newest["liquidity_usd"], 1)
lp_delta_pct = ((new_liq - old_liq) / old_liq) * 100
lp_velocity = lp_delta_pct / hours
velocities["lp_depth_change_pct_per_hour"] = round(lp_velocity, 2)
if lp_velocity < -30:
flags.append("LP_DRAINING_RAPIDLY")
risk_score += 30
elif lp_velocity < -15:
flags.append("LP_DECREASING_FAST")
risk_score += 20
elif lp_velocity < -5:
flags.append("LP_DECREASING")
risk_score += 10
# Volume/liquidity ratio acceleration
if all(k in oldest and k in newest for k in ["volume_24h", "liquidity_usd"]):
old_ratio = oldest["volume_24h"] / max(oldest["liquidity_usd"], 1)
new_ratio = newest["volume_24h"] / max(newest["liquidity_usd"], 1)
ratio_delta = new_ratio - old_ratio
velocities["volume_liquidity_ratio_change"] = round(ratio_delta, 3)
if ratio_delta > 10:
flags.append("VOLUME_SPIKE_VS_LIQUIDITY")
risk_score += 15
elif ratio_delta > 5:
flags.append("VOLUME_RISING_VS_LIQUIDITY")
risk_score += 8
# Price velocity
if "price_usd" in oldest and "price_usd" in newest:
old_price = max(oldest["price_usd"], 0.000001)
new_price = max(newest["price_usd"], 0.000001)
price_delta_pct = ((new_price - old_price) / old_price) * 100
price_velocity = price_delta_pct / hours
velocities["price_change_pct_per_hour"] = round(price_velocity, 2)
if price_velocity < -50:
flags.append("PRICE_CRASHING")
risk_score += 20
elif price_velocity > 500:
flags.append("PRICE_PUMPING_ABNORMALLY")
risk_score += 12
# Tx count velocity
if "tx_count" in oldest and "tx_count" in newest:
tx_delta = newest["tx_count"] - oldest["tx_count"]
tx_velocity = tx_delta / hours
velocities["tx_count_change_per_hour"] = round(tx_velocity, 1)
if tx_velocity > 1000:
flags.append("TX_VOLUME_SURGING")
risk_score += 10
return {
"status": "ok",
"snapshots": len(window_snapshots) + 1,
"time_window_hours": round(hours, 2),
"velocities": velocities,
"flags": flags,
"velocity_risk_score": min(risk_score, 100),
"risk_level": "critical"
if risk_score > 60
else "high"
if risk_score > 30
else "medium"
if risk_score > 10
else "low",
}
def get_market_context() -> dict[str, Any]:
"""Get current market conditions for contextualized scoring."""
try:
import asyncio
import httpx
async def _fetch():
async with httpx.AsyncClient(timeout=5) as c:
resp = await c.get("https://api.alternative.me/fng/?limit=1")
if resp.status_code == 200:
data = resp.json()
fng = data.get("data", [{}])[0]
return {
"fear_greed_value": int(fng.get("value", 50)),
"fear_greed_classification": fng.get("value_classification", "Neutral"),
"timestamp": fng.get("timestamp", ""),
}
return {"fear_greed_value": 50, "fear_greed_classification": "Unknown"}
return asyncio.run(_fetch())
except Exception as e:
logger.warning(f"Failed to fetch market context: {e}")
return {"fear_greed_value": 50, "fear_greed_classification": "Unknown"}
def contextualize_score(base_safety: int, market_context: dict[str, Any]) -> dict[str, Any]:
"""Adjust safety score based on market conditions.
During Extreme Greed (>75): scammers launch more - raise sensitivity
During Extreme Fear (<25): fewer scams launch - lower sensitivity
"""
fng = market_context.get("fear_greed_value", 50)
# Market pressure: how much to adjust
if fng > 75: # Extreme Greed - more scams
pressure = 1.3
context = "Scam alert elevated - market in Extreme Greed"
elif fng > 60: # Greed
pressure = 1.15
context = "Elevated scam risk - market in Greed"
elif fng < 25: # Extreme Fear - fewer new scams
pressure = 0.85
context = "Reduced scam activity - market in Extreme Fear"
elif fng < 40: # Fear
pressure = 0.95
context = "Slightly reduced risk - market in Fear"
else: # Neutral
pressure = 1.0
context = "Normal market conditions"
adjusted = max(0, min(100, round(base_safety / pressure)))
return {
"base_safety": base_safety,
"contextualized_safety": adjusted,
"market_pressure": round(pressure, 2),
"fear_greed": fng,
"classification": market_context.get("fear_greed_classification", "Neutral"),
"context": context,
}