Some checks failed
CI / build (push) Failing after 3s
PHASE 2.3 (AUDIT-2026-Q3.md):
Task 1 — Wire-in Wave 3 (1 router mounted, 2 deferred):
- app.routers.unified_scanner_router mounted at /api/v2/scanner/* (2 routes:
POST /api/v2/scanner/token/scan, POST /api/v2/scanner/wallet/scan).
Refactored prefix from /api/v2 -> /api/v2/scanner to avoid future conflicts
with the v1 /api/v1/scanner/ stub.
- app.routers.unified_wallet_scanner DEFERRED (no router APIRouter attribute;
library module consumed by unified_scanner_router via get_wallet_scanner()).
- app.routers.admin_extensions DEFERRED (DORMANT per audit; 25 routes at
/api/v1/admin/* would shadow /api/v1/admin/alerts_webhook).
Task 2 — Archive 136 dead-code files to app/_archive/legacy_2026_07/:
- 73 routers in app/routers/ (reach graph showed zero reach into mount.py).
- 63 flat app/*.py (domain modules never imported by live code).
- 1 file RESTORED post-archive: app/routers/x402_bridge_health.py (caught by
tests/unit/test_bridge_health.py which directly imports it; reach graph
considered tests/ only as transitive reach — to be patched in next cycle).
Forced-LIVE (NOT archived per user directive):
- app/ai_pipeline_v3.py (3 importers in audit window, importers themselves DEAD)
- app/splade_bm25.py (LIVE via app.rag_service)
- app/wallet_manager_v2.py (LIVE via x402_enforcement, x402_tools, sweep_all, sweep_now)
- app/crypto_embeddings.py (NOT in audit ARCHIVE list; heavy import graph)
Verification (forward-import closure from mount.py + main.py + factory.py + lifespan.py):
- imports = 348 app.* modules
- reached = 194 files reachable from roots
- archive set = audit_dead (186) - reached - forced_live (4) - test_live (1) = 136
- Net delta: 136 files moved, 44,932 LOC reduction, 293->295 active routes (+2 from Wave 3)
pyproject.toml updates:
- setuptools.packages.find: added exclude for app._archive*
- ruff.extend-exclude: added "app/_archive/"
- mypy.exclude: added "app/_archive/"
Smoke test: pytest tests/ — 817 passed, 3 pre-existing failures unchanged
(0 new failures; 0 routes lost; all 4 forced-LIVE files still importable).
Restoration: git mv app/_archive/legacy_2026_07/<name>.py <original-path>
and add the import to app/mount.py ROUTER_MODULES.
Refs: AUDIT-2026-Q3.md /home/dev/pry/rmi-final-deadcode-2026-07-06.md
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,
|
|
}
|