Files
CryptoTrading/tests/test_spot_adapter.py
bnair123 7d63e43b7b 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%
2025-12-27 14:38:26 +04:00

296 lines
10 KiB
Python

"""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 == []