diff --git a/defi_service.py b/defi_service.py new file mode 100644 index 0000000..f7c9c57 --- /dev/null +++ b/defi_service.py @@ -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": , ...} + 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 [] diff --git a/main.py b/main.py index 9250db2..5e9710b 100644 --- a/main.py +++ b/main.py @@ -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]) diff --git a/routes_defi.py b/routes_defi.py new file mode 100644 index 0000000..0302bd2 --- /dev/null +++ b/routes_defi.py @@ -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) diff --git a/tests/test_defi_service.py b/tests/test_defi_service.py new file mode 100644 index 0000000..ebedac6 --- /dev/null +++ b/tests/test_defi_service.py @@ -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 diff --git a/tests/test_routes_defi.py b/tests/test_routes_defi.py new file mode 100644 index 0000000..c9f2752 --- /dev/null +++ b/tests/test_routes_defi.py @@ -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