#!/usr/bin/env python3 """#11 — Liquidity Pool Health Monitor. Tracks LPs across DEXes on all DataBus chains. Alerts on LP withdrawals, depletion, single-holder dominance.""" import os from datetime import UTC, datetime import httpx from fastapi import APIRouter, Query from pydantic import BaseModel router = APIRouter(prefix="/api/v1/lp-health", tags=["lp-health-monitor"]) DATABUS = os.environ.get("DATABUS_URL", "http://localhost:8000/api/v1/databus") BACKEND = os.environ.get("BACKEND_URL", "http://localhost:8000") TG_BOT = os.environ.get("TELEGRAM_BOT_TOKEN", "") # Tracked LPs (in-memory, Redis in prod) _tracked_lps: dict[str, dict] = {} # lp_id -> tracking config class LPHealthResult(BaseModel): lp_address: str chain: str token_pair: str total_liquidity_usd: float liquidity_change_24h: float largest_holder_pct: float holder_count: int lp_locked_pct: float health_score: int # 0-100 alerts: list[str] def _compute_health(ll: dict) -> tuple[int, list[str]]: """Compute LP health score from metrics.""" score = 100 alerts: list[str] = [] if ll.get("largest_holder_pct", 0) > 50: score -= 40 alerts.append(f"Single holder dominates {ll['largest_holder_pct']:.0f}% of LP") elif ll.get("largest_holder_pct", 0) > 30: score -= 20 alerts.append(f"Concentrated LP: largest holder has {ll['largest_holder_pct']:.0f}%") if ll.get("lp_locked_pct", 100) < 50: score -= 30 alerts.append(f"LP only {ll['lp_locked_pct']:.0f}% locked") elif ll.get("lp_locked_pct", 100) < 80: score -= 10 change = ll.get("liquidity_change_24h", 0) if change < -50: score -= 35 alerts.append(f"Massive LP drain: {change:.0f}% in 24h") elif change < -20: score -= 15 alerts.append(f"Significant LP decrease: {change:.0f}% in 24h") if ll.get("total_liquidity_usd", 0) < 10000: score -= 10 alerts.append(f"Low liquidity: ${ll['total_liquidity_usd']:,.0f}") return max(0, score), alerts @router.get("/check/{lp_address}") async def check_lp_health(lp_address: str, chain: str = Query("ethereum")): """Check health of a specific liquidity pool.""" try: async with httpx.AsyncClient(timeout=10) as client: resp = await client.get(f"{DATABUS}/{chain}/pool/{lp_address}") if resp.status_code != 200: return {"error": f"LP not found on {chain}"} lp_data = resp.json().get("data", {}) health, alerts = _compute_health(lp_data) return { "lp_address": lp_address, "chain": chain, "token_pair": lp_data.get("pair", f"{chain}/?"), "total_liquidity_usd": lp_data.get("liquidity_usd", 0) or 0, "liquidity_change_24h": lp_data.get("change_24h_pct", 0) or 0, "largest_holder_pct": lp_data.get("top_holder_pct", 0) or 0, "holder_count": lp_data.get("holder_count", 0) or 0, "lp_locked_pct": lp_data.get("locked_pct", 0) or 0, "health_score": health, "alerts": alerts, } except Exception as e: return {"lp_address": lp_address, "chain": chain, "error": str(e)} @router.post("/track") async def track_lp(lp_address: str, chain: str = Query("ethereum"), notify_tg: bool = Query(True)): """Start tracking an LP for health changes.""" lp_id = f"{chain}:{lp_address}" _tracked_lps[lp_id] = { "lp_address": lp_address, "chain": chain, "notify": notify_tg, "added_at": datetime.now(UTC).isoformat(), } return {"id": lp_id, "status": "tracking"} @router.delete("/track/{lp_id}") async def untrack_lp(lp_id: str): """Stop tracking an LP.""" _tracked_lps.pop(lp_id, None) return {"id": lp_id, "status": "removed"} @router.get("/tracked") async def list_tracked(): return {"tracked": list(_tracked_lps.values()), "count": len(_tracked_lps)} @router.get("/unhealthy") async def find_unhealthy_pools( chain: str = Query("ethereum"), limit: int = Query(10, le=20), ): """Scan for unhealthy pools: low locked LP, LP drain, single-holder dominance.""" unhealthy: list[dict] = [] try: async with httpx.AsyncClient(timeout=15) as client: resp = await client.get(f"{DATABUS}/{chain}/pools/top?sort=risk&limit={limit * 3}") if resp.status_code == 200: pools = resp.json().get("data", {}).get("pools", []) for p in pools: health, alerts = _compute_health(p) if health < 70: unhealthy.append( { "lp_address": p.get("address", ""), "chain": chain, "pair": p.get("pair", "?"), "health_score": health, "alerts": alerts, } ) except Exception: pass unhealthy.sort(key=lambda p: p["health_score"]) return {"chain": chain, "unhealthy_pools": unhealthy[:limit], "total_unhealthy": len(unhealthy)}