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:
Yaojia Wang
2026-03-19 23:03:01 +01:00
parent 4915f1bae4
commit 37c46e76ae
5 changed files with 1291 additions and 0 deletions

195
defi_service.py Normal file
View 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 []

View File

@@ -40,6 +40,7 @@ from routes_technical import router as technical_router # noqa: E402
from routes_portfolio import router as portfolio_router # noqa: E402
from routes_backtest import router as backtest_router # noqa: E402
from routes_cn import router as cn_router # noqa: E402
from routes_defi import router as defi_router # noqa: E402
logging.basicConfig(
level=settings.log_level.upper(),
@@ -87,6 +88,7 @@ app.include_router(regulators_router)
app.include_router(portfolio_router)
app.include_router(backtest_router)
app.include_router(cn_router)
app.include_router(defi_router)
@app.get("/health", response_model=dict[str, str])

75
routes_defi.py Normal file
View File

@@ -0,0 +1,75 @@
"""DeFi data routes via DefiLlama API."""
from fastapi import APIRouter, HTTPException, Query
import defi_service
from models import ApiResponse
from route_utils import safe
router = APIRouter(prefix="/api/v1/defi")
@router.get("/tvl/protocols", response_model=ApiResponse)
@safe
async def tvl_protocols() -> ApiResponse:
"""Get top DeFi protocols ranked by TVL."""
data = await defi_service.get_top_protocols()
return ApiResponse(data=data)
@router.get("/tvl/chains", response_model=ApiResponse)
@safe
async def tvl_chains() -> ApiResponse:
"""Get TVL rankings for all chains."""
data = await defi_service.get_chain_tvls()
return ApiResponse(data=data)
@router.get("/tvl/{protocol}", response_model=ApiResponse)
@safe
async def protocol_tvl(protocol: str) -> ApiResponse:
"""Get current TVL for a specific protocol slug."""
tvl = await defi_service.get_protocol_tvl(protocol)
if tvl is None:
raise HTTPException(status_code=404, detail=f"Protocol '{protocol}' not found")
return ApiResponse(data={"protocol": protocol, "tvl": tvl})
@router.get("/yields", response_model=ApiResponse)
@safe
async def yield_pools(
chain: str | None = Query(default=None, description="Filter by chain name"),
project: str | None = Query(default=None, description="Filter by project name"),
) -> ApiResponse:
"""Get top yield pools, optionally filtered by chain and/or project."""
data = await defi_service.get_yield_pools(chain=chain, project=project)
return ApiResponse(data=data)
@router.get("/stablecoins", response_model=ApiResponse)
@safe
async def stablecoins() -> ApiResponse:
"""Get top stablecoins by circulating supply."""
data = await defi_service.get_stablecoins()
return ApiResponse(data=data)
@router.get("/volumes/dexs", response_model=ApiResponse)
@safe
async def dex_volumes() -> ApiResponse:
"""Get DEX volume overview including top protocols."""
data = await defi_service.get_dex_volumes()
if data is None:
raise HTTPException(
status_code=502,
detail="Failed to fetch DEX volume data from DefiLlama",
)
return ApiResponse(data=data)
@router.get("/fees", response_model=ApiResponse)
@safe
async def protocol_fees() -> ApiResponse:
"""Get protocol fees and revenue overview."""
data = await defi_service.get_protocol_fees()
return ApiResponse(data=data)

656
tests/test_defi_service.py Normal file
View File

@@ -0,0 +1,656 @@
"""Tests for defi_service.py - DefiLlama API integration.
TDD: these tests are written before implementation.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import defi_service
from defi_service import (
get_chain_tvls,
get_dex_volumes,
get_protocol_fees,
get_protocol_tvl,
get_stablecoins,
get_top_protocols,
get_yield_pools,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
SAMPLE_PROTOCOLS = [
{
"name": "Aave",
"symbol": "AAVE",
"tvl": 10_000_000_000.0,
"chain": "Ethereum",
"chains": ["Ethereum", "Polygon"],
"category": "Lending",
"change_1d": 0.5,
"change_7d": -1.2,
},
{
"name": "Uniswap",
"symbol": "UNI",
"tvl": 8_000_000_000.0,
"chain": "Ethereum",
"chains": ["Ethereum"],
"category": "DEX",
"change_1d": 1.0,
"change_7d": 2.0,
},
]
SAMPLE_CHAINS = [
{"name": "Ethereum", "tvl": 50_000_000_000.0, "tokenSymbol": "ETH"},
{"name": "BSC", "tvl": 5_000_000_000.0, "tokenSymbol": "BNB"},
]
SAMPLE_POOLS = [
{
"pool": "0xabcd",
"chain": "Ethereum",
"project": "aave-v3",
"symbol": "USDC",
"tvlUsd": 1_000_000_000.0,
"apy": 3.5,
"apyBase": 3.0,
"apyReward": 0.5,
},
{
"pool": "0x1234",
"chain": "Polygon",
"project": "curve",
"symbol": "DAI",
"tvlUsd": 500_000_000.0,
"apy": 4.2,
"apyBase": 2.5,
"apyReward": 1.7,
},
]
SAMPLE_STABLECOINS_RESPONSE = {
"peggedAssets": [
{
"name": "Tether",
"symbol": "USDT",
"pegType": "peggedUSD",
"circulating": {"peggedUSD": 100_000_000_000.0},
"price": 1.0,
},
{
"name": "USD Coin",
"symbol": "USDC",
"pegType": "peggedUSD",
"circulating": {"peggedUSD": 40_000_000_000.0},
"price": 1.0,
},
]
}
SAMPLE_DEX_RESPONSE = {
"total24h": 5_000_000_000.0,
"total7d": 30_000_000_000.0,
"protocols": [
{"name": "Uniswap", "total24h": 2_000_000_000.0},
{"name": "Curve", "total24h": 500_000_000.0},
],
}
SAMPLE_FEES_RESPONSE = {
"protocols": [
{"name": "Uniswap", "total24h": 1_000_000.0, "revenue24h": 500_000.0},
{"name": "Aave", "total24h": 800_000.0, "revenue24h": 800_000.0},
],
}
def _make_mock_client(json_return):
"""Build a fully configured AsyncMock for httpx.AsyncClient context manager.
Uses MagicMock for the response object so that resp.json() (a sync method
in httpx) returns the value directly rather than a coroutine.
"""
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = json_return
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
return mock_client
# ---------------------------------------------------------------------------
# get_top_protocols
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_top_protocols_returns_top_20(mock_client_cls):
raw = [
{
"name": f"Protocol{i}",
"symbol": f"P{i}",
"tvl": float(100 - i),
"chain": "Ethereum",
"chains": ["Ethereum"],
"category": "Lending",
"change_1d": 0.1,
"change_7d": 0.2,
}
for i in range(30)
]
mock_client_cls.return_value = _make_mock_client(raw)
result = await get_top_protocols()
assert len(result) == 20
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_top_protocols_returns_correct_fields(mock_client_cls):
mock_client_cls.return_value = _make_mock_client(SAMPLE_PROTOCOLS)
result = await get_top_protocols()
assert len(result) == 2
first = result[0]
assert first["name"] == "Aave"
assert first["symbol"] == "AAVE"
assert first["tvl"] == 10_000_000_000.0
assert first["chain"] == "Ethereum"
assert first["chains"] == ["Ethereum", "Polygon"]
assert first["category"] == "Lending"
assert first["change_1d"] == 0.5
assert first["change_7d"] == -1.2
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_top_protocols_respects_custom_limit(mock_client_cls):
raw = [
{"name": f"P{i}", "symbol": f"S{i}", "tvl": float(i), "chain": "ETH",
"chains": [], "category": "DEX", "change_1d": 0.0, "change_7d": 0.0}
for i in range(25)
]
mock_client_cls.return_value = _make_mock_client(raw)
result = await get_top_protocols(limit=5)
assert len(result) == 5
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_top_protocols_handles_missing_fields(mock_client_cls):
raw = [{"name": "Sparse"}] # missing all optional fields
mock_client_cls.return_value = _make_mock_client(raw)
result = await get_top_protocols()
assert len(result) == 1
assert result[0]["name"] == "Sparse"
assert result[0]["tvl"] is None
assert result[0]["symbol"] is None
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_top_protocols_returns_empty_on_http_error(mock_client_cls):
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = Exception("HTTP 500")
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client
result = await get_top_protocols()
assert result == []
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_top_protocols_calls_correct_url(mock_client_cls):
mock_client = _make_mock_client([])
mock_client_cls.return_value = mock_client
await get_top_protocols()
mock_client.get.assert_called_once_with("https://api.llama.fi/protocols")
# ---------------------------------------------------------------------------
# get_chain_tvls
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_chain_tvls_returns_all_chains(mock_client_cls):
mock_client_cls.return_value = _make_mock_client(SAMPLE_CHAINS)
result = await get_chain_tvls()
assert len(result) == 2
assert result[0]["name"] == "Ethereum"
assert result[0]["tvl"] == 50_000_000_000.0
assert result[0]["tokenSymbol"] == "ETH"
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_chain_tvls_handles_missing_token_symbol(mock_client_cls):
raw = [{"name": "SomeChain", "tvl": 100.0}]
mock_client_cls.return_value = _make_mock_client(raw)
result = await get_chain_tvls()
assert result[0]["tokenSymbol"] is None
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_chain_tvls_returns_empty_on_error(mock_client_cls):
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = Exception("timeout")
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client
result = await get_chain_tvls()
assert result == []
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_chain_tvls_calls_correct_url(mock_client_cls):
mock_client = _make_mock_client([])
mock_client_cls.return_value = mock_client
await get_chain_tvls()
mock_client.get.assert_called_once_with("https://api.llama.fi/v2/chains")
# ---------------------------------------------------------------------------
# get_protocol_tvl
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_protocol_tvl_returns_numeric_value(mock_client_cls):
mock_client_cls.return_value = _make_mock_client(10_000_000_000.0)
result = await get_protocol_tvl("aave")
assert result == 10_000_000_000.0
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_protocol_tvl_calls_correct_url(mock_client_cls):
mock_client = _make_mock_client(1234.0)
mock_client_cls.return_value = mock_client
await get_protocol_tvl("uniswap")
mock_client.get.assert_called_once_with("https://api.llama.fi/tvl/uniswap")
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_protocol_tvl_returns_none_on_error(mock_client_cls):
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = Exception("404 not found")
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client
result = await get_protocol_tvl("nonexistent")
assert result is None
# ---------------------------------------------------------------------------
# get_yield_pools
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_returns_top_20_by_tvl(mock_client_cls):
# Create 30 pools with decreasing TVL
raw_data = {
"data": [
{
"pool": f"pool{i}",
"chain": "Ethereum",
"project": "aave",
"symbol": "USDC",
"tvlUsd": float(1000 - i),
"apy": 3.0,
"apyBase": 2.5,
"apyReward": 0.5,
}
for i in range(30)
]
}
mock_client_cls.return_value = _make_mock_client(raw_data)
result = await get_yield_pools()
assert len(result) == 20
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_returns_correct_fields(mock_client_cls):
mock_client_cls.return_value = _make_mock_client({"data": SAMPLE_POOLS})
result = await get_yield_pools()
assert len(result) == 2
first = result[0]
assert first["pool"] == "0xabcd"
assert first["chain"] == "Ethereum"
assert first["project"] == "aave-v3"
assert first["symbol"] == "USDC"
assert first["tvlUsd"] == 1_000_000_000.0
assert first["apy"] == 3.5
assert first["apyBase"] == 3.0
assert first["apyReward"] == 0.5
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_filters_by_chain(mock_client_cls):
mock_client_cls.return_value = _make_mock_client({"data": SAMPLE_POOLS})
result = await get_yield_pools(chain="Ethereum")
assert len(result) == 1
assert result[0]["chain"] == "Ethereum"
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_filters_by_project(mock_client_cls):
mock_client_cls.return_value = _make_mock_client({"data": SAMPLE_POOLS})
result = await get_yield_pools(project="curve")
assert len(result) == 1
assert result[0]["project"] == "curve"
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_filters_by_chain_and_project(mock_client_cls):
pools = SAMPLE_POOLS + [
{
"pool": "0xzzzz",
"chain": "Ethereum",
"project": "curve",
"symbol": "USDT",
"tvlUsd": 200_000_000.0,
"apy": 2.0,
"apyBase": 2.0,
"apyReward": 0.0,
}
]
mock_client_cls.return_value = _make_mock_client({"data": pools})
result = await get_yield_pools(chain="Ethereum", project="curve")
assert len(result) == 1
assert result[0]["pool"] == "0xzzzz"
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_returns_empty_on_error(mock_client_cls):
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = Exception("Connection error")
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client
result = await get_yield_pools()
assert result == []
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_calls_correct_url(mock_client_cls):
mock_client = _make_mock_client({"data": []})
mock_client_cls.return_value = mock_client
await get_yield_pools()
mock_client.get.assert_called_once_with("https://yields.llama.fi/pools")
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_sorts_by_tvl_descending(mock_client_cls):
unsorted_pools = [
{
"pool": "low", "chain": "Ethereum", "project": "aave",
"symbol": "DAI", "tvlUsd": 100.0, "apy": 1.0,
"apyBase": 1.0, "apyReward": 0.0,
},
{
"pool": "high", "chain": "Ethereum", "project": "aave",
"symbol": "USDC", "tvlUsd": 9000.0, "apy": 2.0,
"apyBase": 2.0, "apyReward": 0.0,
},
]
mock_client_cls.return_value = _make_mock_client({"data": unsorted_pools})
result = await get_yield_pools()
assert result[0]["pool"] == "high"
assert result[1]["pool"] == "low"
# ---------------------------------------------------------------------------
# get_stablecoins
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_stablecoins_returns_correct_fields(mock_client_cls):
mock_client_cls.return_value = _make_mock_client(SAMPLE_STABLECOINS_RESPONSE)
result = await get_stablecoins()
assert len(result) == 2
first = result[0]
assert first["name"] == "Tether"
assert first["symbol"] == "USDT"
assert first["pegType"] == "peggedUSD"
assert first["circulating"] == 100_000_000_000.0
assert first["price"] == 1.0
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_stablecoins_returns_top_20(mock_client_cls):
assets = [
{
"name": f"Stable{i}",
"symbol": f"S{i}",
"pegType": "peggedUSD",
"circulating": {"peggedUSD": float(1000 - i)},
"price": 1.0,
}
for i in range(25)
]
mock_client_cls.return_value = _make_mock_client({"peggedAssets": assets})
result = await get_stablecoins()
assert len(result) == 20
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_stablecoins_handles_missing_circulating(mock_client_cls):
raw = {"peggedAssets": [{"name": "NoCirc", "symbol": "NC", "pegType": "peggedUSD", "price": 1.0}]}
mock_client_cls.return_value = _make_mock_client(raw)
result = await get_stablecoins()
assert result[0]["circulating"] is None
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_stablecoins_returns_empty_on_error(mock_client_cls):
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = Exception("timeout")
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client
result = await get_stablecoins()
assert result == []
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_stablecoins_calls_correct_url(mock_client_cls):
mock_client = _make_mock_client({"peggedAssets": []})
mock_client_cls.return_value = mock_client
await get_stablecoins()
mock_client.get.assert_called_once_with("https://stablecoins.llama.fi/stablecoins")
# ---------------------------------------------------------------------------
# get_dex_volumes
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_dex_volumes_returns_correct_structure(mock_client_cls):
mock_client_cls.return_value = _make_mock_client(SAMPLE_DEX_RESPONSE)
result = await get_dex_volumes()
assert result["totalVolume24h"] == 5_000_000_000.0
assert result["totalVolume7d"] == 30_000_000_000.0
assert len(result["protocols"]) == 2
assert result["protocols"][0]["name"] == "Uniswap"
assert result["protocols"][0]["volume24h"] == 2_000_000_000.0
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_dex_volumes_returns_none_on_error(mock_client_cls):
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = Exception("server error")
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client
result = await get_dex_volumes()
assert result is None
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_dex_volumes_calls_correct_url(mock_client_cls):
mock_client = _make_mock_client({"total24h": 0, "total7d": 0, "protocols": []})
mock_client_cls.return_value = mock_client
await get_dex_volumes()
mock_client.get.assert_called_once_with("https://api.llama.fi/overview/dexs")
# ---------------------------------------------------------------------------
# get_protocol_fees
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_protocol_fees_returns_correct_structure(mock_client_cls):
mock_client_cls.return_value = _make_mock_client(SAMPLE_FEES_RESPONSE)
result = await get_protocol_fees()
assert len(result) == 2
first = result[0]
assert first["name"] == "Uniswap"
assert first["fees24h"] == 1_000_000.0
assert first["revenue24h"] == 500_000.0
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_protocol_fees_returns_empty_on_error(mock_client_cls):
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = Exception("connection reset")
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client
result = await get_protocol_fees()
assert result == []
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_protocol_fees_calls_correct_url(mock_client_cls):
mock_client = _make_mock_client({"protocols": []})
mock_client_cls.return_value = mock_client
await get_protocol_fees()
mock_client.get.assert_called_once_with("https://api.llama.fi/overview/fees")
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_protocol_fees_handles_missing_revenue(mock_client_cls):
raw = {"protocols": [{"name": "SomeProtocol", "total24h": 500_000.0}]}
mock_client_cls.return_value = _make_mock_client(raw)
result = await get_protocol_fees()
assert result[0]["revenue24h"] is None

363
tests/test_routes_defi.py Normal file
View File

@@ -0,0 +1,363 @@
"""Tests for routes_defi.py - DeFi API routes.
TDD: these tests are written before implementation.
"""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from main import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
# ---------------------------------------------------------------------------
# GET /api/v1/defi/tvl/protocols
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_top_protocols", new_callable=AsyncMock)
async def test_tvl_protocols_happy_path(mock_fn, client):
mock_fn.return_value = [
{
"name": "Aave",
"symbol": "AAVE",
"tvl": 10_000_000_000.0,
"chain": "Ethereum",
"chains": ["Ethereum"],
"category": "Lending",
"change_1d": 0.5,
"change_7d": -1.2,
}
]
resp = await client.get("/api/v1/defi/tvl/protocols")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert len(data["data"]) == 1
assert data["data"][0]["name"] == "Aave"
assert data["data"][0]["tvl"] == 10_000_000_000.0
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_top_protocols", new_callable=AsyncMock)
async def test_tvl_protocols_empty(mock_fn, client):
mock_fn.return_value = []
resp = await client.get("/api/v1/defi/tvl/protocols")
assert resp.status_code == 200
assert resp.json()["data"] == []
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_top_protocols", new_callable=AsyncMock)
async def test_tvl_protocols_service_error_returns_502(mock_fn, client):
mock_fn.side_effect = RuntimeError("DefiLlama unavailable")
resp = await client.get("/api/v1/defi/tvl/protocols")
assert resp.status_code == 502
# ---------------------------------------------------------------------------
# GET /api/v1/defi/tvl/chains
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_chain_tvls", new_callable=AsyncMock)
async def test_tvl_chains_happy_path(mock_fn, client):
mock_fn.return_value = [
{"name": "Ethereum", "tvl": 50_000_000_000.0, "tokenSymbol": "ETH"},
{"name": "BSC", "tvl": 5_000_000_000.0, "tokenSymbol": "BNB"},
]
resp = await client.get("/api/v1/defi/tvl/chains")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert len(data["data"]) == 2
assert data["data"][0]["name"] == "Ethereum"
assert data["data"][0]["tokenSymbol"] == "ETH"
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_chain_tvls", new_callable=AsyncMock)
async def test_tvl_chains_empty(mock_fn, client):
mock_fn.return_value = []
resp = await client.get("/api/v1/defi/tvl/chains")
assert resp.status_code == 200
assert resp.json()["data"] == []
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_chain_tvls", new_callable=AsyncMock)
async def test_tvl_chains_service_error_returns_502(mock_fn, client):
mock_fn.side_effect = RuntimeError("upstream error")
resp = await client.get("/api/v1/defi/tvl/chains")
assert resp.status_code == 502
# ---------------------------------------------------------------------------
# GET /api/v1/defi/tvl/{protocol}
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_protocol_tvl", new_callable=AsyncMock)
async def test_protocol_tvl_happy_path(mock_fn, client):
mock_fn.return_value = 10_000_000_000.0
resp = await client.get("/api/v1/defi/tvl/aave")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["data"]["protocol"] == "aave"
assert data["data"]["tvl"] == 10_000_000_000.0
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_protocol_tvl", new_callable=AsyncMock)
async def test_protocol_tvl_not_found_returns_404(mock_fn, client):
mock_fn.return_value = None
resp = await client.get("/api/v1/defi/tvl/nonexistent-protocol")
assert resp.status_code == 404
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_protocol_tvl", new_callable=AsyncMock)
async def test_protocol_tvl_service_error_returns_502(mock_fn, client):
mock_fn.side_effect = RuntimeError("HTTP error")
resp = await client.get("/api/v1/defi/tvl/aave")
assert resp.status_code == 502
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_protocol_tvl", new_callable=AsyncMock)
async def test_protocol_tvl_passes_slug_to_service(mock_fn, client):
mock_fn.return_value = 5_000_000_000.0
await client.get("/api/v1/defi/tvl/uniswap-v3")
mock_fn.assert_called_once_with("uniswap-v3")
# ---------------------------------------------------------------------------
# GET /api/v1/defi/yields
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_yield_pools", new_callable=AsyncMock)
async def test_yields_happy_path(mock_fn, client):
mock_fn.return_value = [
{
"pool": "0xabcd",
"chain": "Ethereum",
"project": "aave-v3",
"symbol": "USDC",
"tvlUsd": 1_000_000_000.0,
"apy": 3.5,
"apyBase": 3.0,
"apyReward": 0.5,
}
]
resp = await client.get("/api/v1/defi/yields")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert len(data["data"]) == 1
assert data["data"][0]["pool"] == "0xabcd"
assert data["data"][0]["apy"] == 3.5
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_yield_pools", new_callable=AsyncMock)
async def test_yields_with_chain_filter(mock_fn, client):
mock_fn.return_value = []
resp = await client.get("/api/v1/defi/yields?chain=Ethereum")
assert resp.status_code == 200
mock_fn.assert_called_once_with(chain="Ethereum", project=None)
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_yield_pools", new_callable=AsyncMock)
async def test_yields_with_project_filter(mock_fn, client):
mock_fn.return_value = []
resp = await client.get("/api/v1/defi/yields?project=aave-v3")
assert resp.status_code == 200
mock_fn.assert_called_once_with(chain=None, project="aave-v3")
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_yield_pools", new_callable=AsyncMock)
async def test_yields_with_chain_and_project_filter(mock_fn, client):
mock_fn.return_value = []
resp = await client.get("/api/v1/defi/yields?chain=Polygon&project=curve")
assert resp.status_code == 200
mock_fn.assert_called_once_with(chain="Polygon", project="curve")
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_yield_pools", new_callable=AsyncMock)
async def test_yields_empty(mock_fn, client):
mock_fn.return_value = []
resp = await client.get("/api/v1/defi/yields")
assert resp.status_code == 200
assert resp.json()["data"] == []
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_yield_pools", new_callable=AsyncMock)
async def test_yields_service_error_returns_502(mock_fn, client):
mock_fn.side_effect = RuntimeError("yields API down")
resp = await client.get("/api/v1/defi/yields")
assert resp.status_code == 502
# ---------------------------------------------------------------------------
# GET /api/v1/defi/stablecoins
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_stablecoins", new_callable=AsyncMock)
async def test_stablecoins_happy_path(mock_fn, client):
mock_fn.return_value = [
{
"name": "Tether",
"symbol": "USDT",
"pegType": "peggedUSD",
"circulating": 100_000_000_000.0,
"price": 1.0,
}
]
resp = await client.get("/api/v1/defi/stablecoins")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert len(data["data"]) == 1
assert data["data"][0]["symbol"] == "USDT"
assert data["data"][0]["circulating"] == 100_000_000_000.0
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_stablecoins", new_callable=AsyncMock)
async def test_stablecoins_empty(mock_fn, client):
mock_fn.return_value = []
resp = await client.get("/api/v1/defi/stablecoins")
assert resp.status_code == 200
assert resp.json()["data"] == []
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_stablecoins", new_callable=AsyncMock)
async def test_stablecoins_service_error_returns_502(mock_fn, client):
mock_fn.side_effect = RuntimeError("stables API error")
resp = await client.get("/api/v1/defi/stablecoins")
assert resp.status_code == 502
# ---------------------------------------------------------------------------
# GET /api/v1/defi/volumes/dexs
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_dex_volumes", new_callable=AsyncMock)
async def test_dex_volumes_happy_path(mock_fn, client):
mock_fn.return_value = {
"totalVolume24h": 5_000_000_000.0,
"totalVolume7d": 30_000_000_000.0,
"protocols": [
{"name": "Uniswap", "volume24h": 2_000_000_000.0},
],
}
resp = await client.get("/api/v1/defi/volumes/dexs")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["data"]["totalVolume24h"] == 5_000_000_000.0
assert data["data"]["totalVolume7d"] == 30_000_000_000.0
assert len(data["data"]["protocols"]) == 1
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_dex_volumes", new_callable=AsyncMock)
async def test_dex_volumes_returns_502_when_service_returns_none(mock_fn, client):
mock_fn.return_value = None
resp = await client.get("/api/v1/defi/volumes/dexs")
assert resp.status_code == 502
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_dex_volumes", new_callable=AsyncMock)
async def test_dex_volumes_service_error_returns_502(mock_fn, client):
mock_fn.side_effect = RuntimeError("volume API error")
resp = await client.get("/api/v1/defi/volumes/dexs")
assert resp.status_code == 502
# ---------------------------------------------------------------------------
# GET /api/v1/defi/fees
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_protocol_fees", new_callable=AsyncMock)
async def test_fees_happy_path(mock_fn, client):
mock_fn.return_value = [
{"name": "Uniswap", "fees24h": 1_000_000.0, "revenue24h": 500_000.0},
{"name": "Aave", "fees24h": 800_000.0, "revenue24h": 800_000.0},
]
resp = await client.get("/api/v1/defi/fees")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert len(data["data"]) == 2
assert data["data"][0]["name"] == "Uniswap"
assert data["data"][0]["fees24h"] == 1_000_000.0
assert data["data"][0]["revenue24h"] == 500_000.0
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_protocol_fees", new_callable=AsyncMock)
async def test_fees_empty(mock_fn, client):
mock_fn.return_value = []
resp = await client.get("/api/v1/defi/fees")
assert resp.status_code == 200
assert resp.json()["data"] == []
@pytest.mark.asyncio
@patch("routes_defi.defi_service.get_protocol_fees", new_callable=AsyncMock)
async def test_fees_service_error_returns_502(mock_fn, client):
mock_fn.side_effect = RuntimeError("fees API error")
resp = await client.get("/api/v1/defi/fees")
assert resp.status_code == 502