- 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%
296 lines
10 KiB
Python
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 == []
|