Add data layer (DuckDB storage, fetcher) and spot adapter with tests
- Add DataStorage class for DuckDB-based market data persistence - Add DataFetcher for historical candle backfill and sync operations - Add BinanceSpotAdapter for spot wallet balance queries - Add binance_spot_base_url to Settings for spot testnet support - Add comprehensive unit tests (50 new tests, 82 total) - Coverage increased from 62% to 86%
This commit is contained in:
295
tests/test_spot_adapter.py
Normal file
295
tests/test_spot_adapter.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""Unit tests for BinanceSpotAdapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import UTC
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from tradefinder.adapters.base import AuthenticationError, ExchangeError
|
||||
from tradefinder.adapters.binance_spot import BinanceSpotAdapter, SpotBalance
|
||||
from tradefinder.core.config import BinanceSettings, Settings, TradingMode
|
||||
|
||||
|
||||
def _make_response(status_code: int, payload: Any) -> Mock:
|
||||
"""Create a mock HTTP response."""
|
||||
response = Mock()
|
||||
response.status_code = status_code
|
||||
response.json.return_value = payload
|
||||
return response
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings() -> Settings:
|
||||
"""Create test settings without loading .env file."""
|
||||
return Settings(
|
||||
trading_mode=TradingMode.TESTNET,
|
||||
binance=BinanceSettings(
|
||||
testnet_api_key=SecretStr("test-key"),
|
||||
testnet_secret=SecretStr("test-secret"),
|
||||
),
|
||||
_env_file=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(settings: Settings) -> BinanceSpotAdapter:
|
||||
"""Create adapter with test settings."""
|
||||
return BinanceSpotAdapter(settings)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_http_client() -> AsyncMock:
|
||||
"""Create mock HTTP client."""
|
||||
client = AsyncMock()
|
||||
client.get = AsyncMock()
|
||||
client.aclose = AsyncMock()
|
||||
return client
|
||||
|
||||
|
||||
def _sample_account_response() -> dict[str, Any]:
|
||||
"""Sample Binance spot account response."""
|
||||
return {
|
||||
"makerCommission": 10,
|
||||
"takerCommission": 10,
|
||||
"balances": [
|
||||
{"asset": "BTC", "free": "0.50000000", "locked": "0.10000000"},
|
||||
{"asset": "ETH", "free": "5.00000000", "locked": "0.00000000"},
|
||||
{"asset": "USDT", "free": "1000.00000000", "locked": "500.00000000"},
|
||||
{"asset": "BNB", "free": "0.00000000", "locked": "0.00000000"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestSpotBalanceDataclass:
|
||||
"""Tests for SpotBalance dataclass."""
|
||||
|
||||
def test_total_property(self) -> None:
|
||||
"""Total equals free plus locked."""
|
||||
from datetime import datetime
|
||||
|
||||
balance = SpotBalance(
|
||||
asset="BTC",
|
||||
free=Decimal("1.5"),
|
||||
locked=Decimal("0.5"),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
assert balance.total == Decimal("2.0")
|
||||
|
||||
def test_zero_balance_total(self) -> None:
|
||||
"""Zero balance returns zero total."""
|
||||
from datetime import datetime
|
||||
|
||||
balance = SpotBalance(
|
||||
asset="XRP",
|
||||
free=Decimal("0"),
|
||||
locked=Decimal("0"),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
assert balance.total == Decimal("0")
|
||||
|
||||
|
||||
class TestBinanceSpotAdapterInit:
|
||||
"""Initialization and credential tests."""
|
||||
|
||||
def test_credentials_are_accessible(self, adapter: BinanceSpotAdapter) -> None:
|
||||
"""API key and secret are exposed through properties."""
|
||||
assert adapter._api_key == "test-key"
|
||||
assert adapter._secret == "test-secret"
|
||||
|
||||
def test_base_url_uses_testnet(self, settings: Settings) -> None:
|
||||
"""Testnet mode uses testnet URL."""
|
||||
adapter = BinanceSpotAdapter(settings)
|
||||
assert "testnet" in adapter.base_url.lower()
|
||||
|
||||
def test_sign_generates_valid_signature(self, adapter: BinanceSpotAdapter) -> None:
|
||||
"""HMAC-SHA256 signature matches stdlib implementation."""
|
||||
params = {"symbol": "BTCUSDT", "timestamp": "1234567890"}
|
||||
expected = hmac.new(
|
||||
adapter._secret.encode("utf-8"),
|
||||
urlencode(params).encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
assert adapter._sign(params) == expected
|
||||
|
||||
|
||||
class TestBinanceSpotAdapterConnection:
|
||||
"""Connection lifecycle tests."""
|
||||
|
||||
async def test_connect_creates_client(self, adapter: BinanceSpotAdapter) -> None:
|
||||
"""Connect creates HTTP client."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=_make_response(200, _sample_account_response()))
|
||||
mock_client.aclose = AsyncMock()
|
||||
|
||||
import httpx
|
||||
|
||||
original_client = httpx.AsyncClient
|
||||
|
||||
try:
|
||||
# Patch httpx.AsyncClient
|
||||
httpx.AsyncClient = lambda **kwargs: mock_client
|
||||
await adapter.connect()
|
||||
assert adapter._client is mock_client
|
||||
finally:
|
||||
httpx.AsyncClient = original_client
|
||||
await adapter.disconnect()
|
||||
|
||||
async def test_disconnect_closes_client(
|
||||
self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock
|
||||
) -> None:
|
||||
"""Disconnect closes HTTP client."""
|
||||
adapter._client = mock_http_client
|
||||
|
||||
await adapter.disconnect()
|
||||
|
||||
mock_http_client.aclose.assert_awaited_once()
|
||||
assert adapter._client is None
|
||||
|
||||
async def test_disconnect_when_not_connected(self, adapter: BinanceSpotAdapter) -> None:
|
||||
"""Disconnect when not connected is safe."""
|
||||
await adapter.disconnect() # Should not raise
|
||||
|
||||
|
||||
class TestBinanceSpotAdapterRequest:
|
||||
"""Request handling tests."""
|
||||
|
||||
async def test_request_adds_auth_headers(
|
||||
self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock
|
||||
) -> None:
|
||||
"""Requests include X-MBX-APIKEY header."""
|
||||
adapter._client = mock_http_client
|
||||
response = _make_response(200, {"ok": True})
|
||||
mock_http_client.get.return_value = response
|
||||
|
||||
result = await adapter._request("GET", "/api/v3/test")
|
||||
|
||||
assert result == {"ok": True}
|
||||
mock_http_client.get.assert_awaited_once()
|
||||
called_headers = mock_http_client.get.call_args[1]["headers"]
|
||||
assert called_headers["X-MBX-APIKEY"] == "test-key"
|
||||
|
||||
async def test_request_adds_signature_when_signed(
|
||||
self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock
|
||||
) -> None:
|
||||
"""Signed requests include timestamp and signature."""
|
||||
adapter._client = mock_http_client
|
||||
response = _make_response(200, {"ok": True})
|
||||
mock_http_client.get.return_value = response
|
||||
|
||||
await adapter._request("GET", "/api/v3/test", params={}, signed=True)
|
||||
|
||||
call_kwargs = mock_http_client.get.call_args[1]
|
||||
params = call_kwargs["params"]
|
||||
assert "timestamp" in params
|
||||
assert "signature" in params
|
||||
assert "recvWindow" in params
|
||||
|
||||
async def test_request_raises_when_not_connected(self, adapter: BinanceSpotAdapter) -> None:
|
||||
"""Request raises when not connected."""
|
||||
with pytest.raises(ExchangeError, match="Not connected"):
|
||||
await adapter._request("GET", "/api/v3/test")
|
||||
|
||||
async def test_request_handles_auth_error(
|
||||
self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock
|
||||
) -> None:
|
||||
"""Authentication errors raise AuthenticationError."""
|
||||
adapter._client = mock_http_client
|
||||
response = _make_response(401, {"code": -1022, "msg": "Invalid signature"})
|
||||
mock_http_client.get.return_value = response
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
await adapter._request("GET", "/api/v3/test")
|
||||
|
||||
async def test_request_handles_general_error(
|
||||
self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock
|
||||
) -> None:
|
||||
"""General API errors raise ExchangeError."""
|
||||
adapter._client = mock_http_client
|
||||
response = _make_response(400, {"code": -1100, "msg": "Illegal characters"})
|
||||
mock_http_client.get.return_value = response
|
||||
|
||||
with pytest.raises(ExchangeError, match="Illegal characters"):
|
||||
await adapter._request("GET", "/api/v3/test")
|
||||
|
||||
|
||||
class TestBinanceSpotAdapterBalance:
|
||||
"""Balance retrieval tests."""
|
||||
|
||||
async def test_get_balance_returns_asset(
|
||||
self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock
|
||||
) -> None:
|
||||
"""get_balance returns balance for specific asset."""
|
||||
adapter._client = mock_http_client
|
||||
response = _make_response(200, _sample_account_response())
|
||||
mock_http_client.get.return_value = response
|
||||
|
||||
balance = await adapter.get_balance("BTC")
|
||||
|
||||
assert balance.asset == "BTC"
|
||||
assert balance.free == Decimal("0.50000000")
|
||||
assert balance.locked == Decimal("0.10000000")
|
||||
assert balance.total == Decimal("0.60000000")
|
||||
|
||||
async def test_get_balance_raises_when_not_found(
|
||||
self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock
|
||||
) -> None:
|
||||
"""get_balance raises when asset not found."""
|
||||
adapter._client = mock_http_client
|
||||
response = _make_response(200, _sample_account_response())
|
||||
mock_http_client.get.return_value = response
|
||||
|
||||
with pytest.raises(ExchangeError, match="not found"):
|
||||
await adapter.get_balance("UNKNOWN")
|
||||
|
||||
async def test_get_all_balances_returns_non_zero(
|
||||
self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock
|
||||
) -> None:
|
||||
"""get_all_balances returns only non-zero balances."""
|
||||
adapter._client = mock_http_client
|
||||
response = _make_response(200, _sample_account_response())
|
||||
mock_http_client.get.return_value = response
|
||||
|
||||
balances = await adapter.get_all_balances()
|
||||
|
||||
# BTC, ETH, USDT have non-zero balances; BNB is zero
|
||||
assets = {b.asset for b in balances}
|
||||
assert "BTC" in assets
|
||||
assert "ETH" in assets
|
||||
assert "USDT" in assets
|
||||
assert "BNB" not in assets
|
||||
|
||||
async def test_get_all_balances_with_min_balance(
|
||||
self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock
|
||||
) -> None:
|
||||
"""get_all_balances filters by minimum balance."""
|
||||
adapter._client = mock_http_client
|
||||
response = _make_response(200, _sample_account_response())
|
||||
mock_http_client.get.return_value = response
|
||||
|
||||
# min_balance of 100 should only include USDT (1500 total)
|
||||
balances = await adapter.get_all_balances(min_balance=Decimal("100"))
|
||||
|
||||
assets = {b.asset for b in balances}
|
||||
assert "USDT" in assets
|
||||
assert "BTC" not in assets # Only 0.6 total
|
||||
assert "ETH" not in assets # Only 5.0 total
|
||||
|
||||
async def test_get_all_balances_empty_response(
|
||||
self, adapter: BinanceSpotAdapter, mock_http_client: AsyncMock
|
||||
) -> None:
|
||||
"""get_all_balances handles empty balances."""
|
||||
adapter._client = mock_http_client
|
||||
response = _make_response(200, {"balances": []})
|
||||
mock_http_client.get.return_value = response
|
||||
|
||||
balances = await adapter.get_all_balances()
|
||||
|
||||
assert balances == []
|
||||
Reference in New Issue
Block a user