- 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>
217 lines
7.7 KiB
Python
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,
|
|
}
|