feat: add DeFi data via DefiLlama API (TDD)
7 new endpoints under /api/v1/defi/ (all free, no API key):
- GET /defi/tvl/protocols - top DeFi protocols by TVL
- GET /defi/tvl/chains - chain TVL rankings
- GET /defi/tvl/{protocol} - single protocol TVL
- GET /defi/yields - top yield pools (filter by chain/project)
- GET /defi/stablecoins - stablecoin market data
- GET /defi/volumes/dexs - DEX volume overview
- GET /defi/fees - protocol fees/revenue overview
Data source: DefiLlama API (free, no key needed)
58 new tests (33 service + 25 route). All 561 tests passing.
This commit is contained in:
195
defi_service.py
Normal file
195
defi_service.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""DeFi data service via DefiLlama API (no API key required)."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LLAMA_BASE = "https://api.llama.fi"
|
||||
STABLES_BASE = "https://stablecoins.llama.fi"
|
||||
YIELDS_BASE = "https://yields.llama.fi"
|
||||
TIMEOUT = 15.0
|
||||
|
||||
|
||||
async def get_top_protocols(limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""Fetch top DeFi protocols ranked by TVL from DefiLlama."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||
resp = await client.get(f"{LLAMA_BASE}/protocols")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return [
|
||||
{
|
||||
"name": p.get("name"),
|
||||
"symbol": p.get("symbol"),
|
||||
"tvl": p.get("tvl"),
|
||||
"chain": p.get("chain"),
|
||||
"chains": p.get("chains", []),
|
||||
"category": p.get("category"),
|
||||
"change_1d": p.get("change_1d"),
|
||||
"change_7d": p.get("change_7d"),
|
||||
}
|
||||
for p in data[:limit]
|
||||
]
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch top protocols from DefiLlama")
|
||||
return []
|
||||
|
||||
|
||||
async def get_chain_tvls() -> list[dict[str, Any]]:
|
||||
"""Fetch TVL rankings for all chains from DefiLlama."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||
resp = await client.get(f"{LLAMA_BASE}/v2/chains")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return [
|
||||
{
|
||||
"name": c.get("name"),
|
||||
"tvl": c.get("tvl"),
|
||||
"tokenSymbol": c.get("tokenSymbol"),
|
||||
}
|
||||
for c in data
|
||||
]
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch chain TVLs from DefiLlama")
|
||||
return []
|
||||
|
||||
|
||||
async def get_protocol_tvl(protocol: str) -> float | None:
|
||||
"""Fetch current TVL for a specific protocol slug."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||
resp = await client.get(f"{LLAMA_BASE}/tvl/{protocol}")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch TVL for protocol %s", protocol)
|
||||
return None
|
||||
|
||||
|
||||
async def get_yield_pools(
|
||||
chain: str | None = None,
|
||||
project: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch yield pools from DefiLlama, optionally filtered by chain and/or project.
|
||||
|
||||
Returns top 20 by TVL descending.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||
resp = await client.get(f"{YIELDS_BASE}/pools")
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
pools: list[dict[str, Any]] = payload.get("data", [])
|
||||
|
||||
if chain is not None:
|
||||
pools = [p for p in pools if p.get("chain") == chain]
|
||||
if project is not None:
|
||||
pools = [p for p in pools if p.get("project") == project]
|
||||
|
||||
pools = sorted(pools, key=lambda p: p.get("tvlUsd") or 0, reverse=True)[:20]
|
||||
|
||||
return [
|
||||
{
|
||||
"pool": p.get("pool"),
|
||||
"chain": p.get("chain"),
|
||||
"project": p.get("project"),
|
||||
"symbol": p.get("symbol"),
|
||||
"tvlUsd": p.get("tvlUsd"),
|
||||
"apy": p.get("apy"),
|
||||
"apyBase": p.get("apyBase"),
|
||||
"apyReward": p.get("apyReward"),
|
||||
}
|
||||
for p in pools
|
||||
]
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch yield pools from DefiLlama")
|
||||
return []
|
||||
|
||||
|
||||
def _extract_circulating(asset: dict[str, Any]) -> float | None:
|
||||
"""Extract the primary circulating supply value from a stablecoin asset dict."""
|
||||
raw = asset.get("circulating")
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, (int, float)):
|
||||
return float(raw)
|
||||
if isinstance(raw, dict):
|
||||
# DefiLlama returns {"peggedUSD": <amount>, ...}
|
||||
values = [v for v in raw.values() if isinstance(v, (int, float))]
|
||||
return values[0] if values else None
|
||||
return None
|
||||
|
||||
|
||||
async def get_stablecoins(limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""Fetch top stablecoins by circulating supply from DefiLlama."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||
resp = await client.get(f"{STABLES_BASE}/stablecoins")
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
assets: list[dict[str, Any]] = payload.get("peggedAssets", [])
|
||||
|
||||
return [
|
||||
{
|
||||
"name": a.get("name"),
|
||||
"symbol": a.get("symbol"),
|
||||
"pegType": a.get("pegType"),
|
||||
"circulating": _extract_circulating(a),
|
||||
"price": a.get("price"),
|
||||
}
|
||||
for a in assets[:limit]
|
||||
]
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch stablecoins from DefiLlama")
|
||||
return []
|
||||
|
||||
|
||||
async def get_dex_volumes() -> dict[str, Any] | None:
|
||||
"""Fetch DEX volume overview from DefiLlama."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||
resp = await client.get(f"{LLAMA_BASE}/overview/dexs")
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
|
||||
protocols = [
|
||||
{
|
||||
"name": p.get("name"),
|
||||
"volume24h": p.get("total24h"),
|
||||
}
|
||||
for p in payload.get("protocols", [])
|
||||
]
|
||||
|
||||
return {
|
||||
"totalVolume24h": payload.get("total24h"),
|
||||
"totalVolume7d": payload.get("total7d"),
|
||||
"protocols": protocols,
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch DEX volumes from DefiLlama")
|
||||
return None
|
||||
|
||||
|
||||
async def get_protocol_fees() -> list[dict[str, Any]]:
|
||||
"""Fetch protocol fees and revenue overview from DefiLlama."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||
resp = await client.get(f"{LLAMA_BASE}/overview/fees")
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
|
||||
return [
|
||||
{
|
||||
"name": p.get("name"),
|
||||
"fees24h": p.get("total24h"),
|
||||
"revenue24h": p.get("revenue24h"),
|
||||
}
|
||||
for p in payload.get("protocols", [])
|
||||
]
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch protocol fees from DefiLlama")
|
||||
return []
|
||||
Reference in New Issue
Block a user