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:
bnair123
2025-12-27 14:38:26 +04:00
parent 17d51c4f78
commit 7d63e43b7b
10 changed files with 1635 additions and 1 deletions

286
tests/test_data_fetcher.py Normal file
View File

@@ -0,0 +1,286 @@
"""Unit tests for DataFetcher (backfill and sync logic)."""
import tempfile
from datetime import datetime, timedelta
from decimal import Decimal
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from tradefinder.adapters.types import Candle
from tradefinder.data.fetcher import TIMEFRAME_MS, DataFetcher
from tradefinder.data.storage import DataStorage
def make_candle(timestamp: datetime) -> Candle:
"""Create a test candle at given timestamp."""
return Candle(
timestamp=timestamp,
open=Decimal("50000.00"),
high=Decimal("51000.00"),
low=Decimal("49000.00"),
close=Decimal("50500.00"),
volume=Decimal("1000.00"),
)
class TestTimeframeMappings:
"""Tests for timeframe constant mappings."""
def test_common_timeframes_are_defined(self) -> None:
"""All expected timeframes have millisecond mappings."""
expected = ["1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w"]
for tf in expected:
assert tf in TIMEFRAME_MS
assert TIMEFRAME_MS[tf] > 0
def test_timeframe_values_are_correct(self) -> None:
"""Timeframe millisecond values are accurate."""
assert TIMEFRAME_MS["1m"] == 60 * 1000
assert TIMEFRAME_MS["1h"] == 60 * 60 * 1000
assert TIMEFRAME_MS["4h"] == 4 * 60 * 60 * 1000
assert TIMEFRAME_MS["1d"] == 24 * 60 * 60 * 1000
class TestDataFetcherBackfill:
"""Tests for backfill_candles functionality."""
async def test_backfill_fetches_and_stores_candles(self) -> None:
"""Candles are fetched from adapter and stored."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
# Mock adapter
adapter = MagicMock()
start = datetime(2024, 1, 1, 0, 0, 0)
end = datetime(2024, 1, 1, 4, 0, 0)
# Return 4 candles then empty (end of data)
candles = [make_candle(start + timedelta(hours=i)) for i in range(4)]
adapter.get_candles = AsyncMock(side_effect=[candles, []])
with DataStorage(db_path) as storage:
storage.initialize_schema()
fetcher = DataFetcher(adapter, storage)
total = await fetcher.backfill_candles("BTCUSDT", "1h", start, end)
assert total == 4
assert storage.get_candle_count("BTCUSDT", "1h") == 4
async def test_backfill_uses_default_end_date(self) -> None:
"""End date defaults to now if not provided."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
adapter = MagicMock()
adapter.get_candles = AsyncMock(return_value=[]) # No data
with DataStorage(db_path) as storage:
storage.initialize_schema()
fetcher = DataFetcher(adapter, storage)
start = datetime.now() - timedelta(hours=1)
await fetcher.backfill_candles("BTCUSDT", "1h", start)
# Should have been called (even if no data returned)
adapter.get_candles.assert_called()
async def test_backfill_raises_on_unknown_timeframe(self) -> None:
"""ValueError raised for unknown timeframe."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
adapter = MagicMock()
with DataStorage(db_path) as storage:
storage.initialize_schema()
fetcher = DataFetcher(adapter, storage)
with pytest.raises(ValueError, match="Unknown timeframe"):
await fetcher.backfill_candles(
"BTCUSDT",
"invalid_tf",
datetime(2024, 1, 1),
)
async def test_backfill_handles_empty_response(self) -> None:
"""Empty response from adapter is handled gracefully."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
adapter = MagicMock()
adapter.get_candles = AsyncMock(return_value=[])
with DataStorage(db_path) as storage:
storage.initialize_schema()
fetcher = DataFetcher(adapter, storage)
total = await fetcher.backfill_candles(
"BTCUSDT",
"1h",
datetime(2024, 1, 1),
datetime(2024, 1, 2),
)
assert total == 0
async def test_backfill_respects_batch_size(self) -> None:
"""Batch size is respected and capped at 1500."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
adapter = MagicMock()
adapter.get_candles = AsyncMock(return_value=[])
with DataStorage(db_path) as storage:
storage.initialize_schema()
fetcher = DataFetcher(adapter, storage)
# Request with batch_size > 1500
await fetcher.backfill_candles(
"BTCUSDT",
"1h",
datetime(2024, 1, 1),
datetime(2024, 1, 2),
batch_size=2000,
)
# Verify limit was capped at 1500
call_kwargs = adapter.get_candles.call_args.kwargs
assert call_kwargs["limit"] == 1500
async def test_backfill_paginates_correctly(self) -> None:
"""Multiple batches are fetched for large date ranges."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
adapter = MagicMock()
start = datetime(2024, 1, 1, 0, 0, 0)
# Return 3 candles per batch, simulate 2 full batches + partial
batch1 = [make_candle(start + timedelta(hours=i)) for i in range(3)]
batch2 = [make_candle(start + timedelta(hours=i + 3)) for i in range(3)]
batch3 = [make_candle(start + timedelta(hours=6))] # Partial batch
adapter.get_candles = AsyncMock(side_effect=[batch1, batch2, batch3])
with DataStorage(db_path) as storage:
storage.initialize_schema()
fetcher = DataFetcher(adapter, storage)
total = await fetcher.backfill_candles(
"BTCUSDT",
"1h",
start,
start + timedelta(hours=10),
batch_size=3,
)
# 3 + 3 + 1 = 7 candles
assert total == 7
assert adapter.get_candles.call_count == 3
class TestDataFetcherSync:
"""Tests for sync_candles functionality."""
async def test_sync_from_latest_timestamp(self) -> None:
"""Sync starts from last stored candle."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
adapter = MagicMock()
adapter.get_candles = AsyncMock(return_value=[])
with DataStorage(db_path) as storage:
storage.initialize_schema()
# Pre-populate with some candles
base_ts = datetime(2024, 1, 1, 0, 0, 0)
existing = [make_candle(base_ts + timedelta(hours=i)) for i in range(5)]
storage.insert_candles(existing, "BTCUSDT", "1h")
fetcher = DataFetcher(adapter, storage)
await fetcher.sync_candles("BTCUSDT", "1h")
# Verify sync started from after the last candle
call_kwargs = adapter.get_candles.call_args.kwargs
start_time = call_kwargs.get("start_time")
# Should be after hour 4 (the last existing candle)
expected_start_ms = int((base_ts + timedelta(hours=5)).timestamp() * 1000)
assert start_time >= expected_start_ms
async def test_sync_with_no_existing_data_uses_lookback(self) -> None:
"""Sync uses lookback when no existing data."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
adapter = MagicMock()
adapter.get_candles = AsyncMock(return_value=[])
with DataStorage(db_path) as storage:
storage.initialize_schema()
fetcher = DataFetcher(adapter, storage)
await fetcher.sync_candles("BTCUSDT", "1h", lookback_days=7)
# Should have called get_candles
adapter.get_candles.assert_called()
class TestDataFetcherLatest:
"""Tests for fetch_latest_candles functionality."""
async def test_fetch_latest_stores_candles(self) -> None:
"""Latest candles are fetched and stored."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
adapter = MagicMock()
now = datetime.now()
candles = [make_candle(now - timedelta(hours=i)) for i in range(5)]
adapter.get_candles = AsyncMock(return_value=candles)
with DataStorage(db_path) as storage:
storage.initialize_schema()
fetcher = DataFetcher(adapter, storage)
count = await fetcher.fetch_latest_candles("BTCUSDT", "1h", limit=5)
assert count == 5
assert storage.get_candle_count("BTCUSDT", "1h") == 5
async def test_fetch_latest_with_empty_response(self) -> None:
"""Empty response returns 0."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
adapter = MagicMock()
adapter.get_candles = AsyncMock(return_value=[])
with DataStorage(db_path) as storage:
storage.initialize_schema()
fetcher = DataFetcher(adapter, storage)
count = await fetcher.fetch_latest_candles("BTCUSDT", "1h")
assert count == 0
async def test_fetch_latest_respects_limit(self) -> None:
"""Limit parameter is passed to adapter."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
adapter = MagicMock()
adapter.get_candles = AsyncMock(return_value=[])
with DataStorage(db_path) as storage:
storage.initialize_schema()
fetcher = DataFetcher(adapter, storage)
await fetcher.fetch_latest_candles("BTCUSDT", "1h", limit=50)
call_kwargs = adapter.get_candles.call_args.kwargs
assert call_kwargs["limit"] == 50

293
tests/test_data_storage.py Normal file
View File

@@ -0,0 +1,293 @@
"""Unit tests for DataStorage (DuckDB operations)."""
import tempfile
from datetime import datetime, timedelta
from decimal import Decimal
from pathlib import Path
import pytest
from tradefinder.adapters.types import Candle, FundingRate
from tradefinder.data.storage import DataStorage
class TestDataStorageConnection:
"""Tests for connection lifecycle."""
def test_connect_creates_database_file(self) -> None:
"""Database file is created on connect."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
storage = DataStorage(db_path)
storage.connect()
assert db_path.exists()
storage.disconnect()
def test_connect_creates_parent_directories(self) -> None:
"""Parent directories are created if they don't exist."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "nested" / "dir" / "test.duckdb"
storage = DataStorage(db_path)
storage.connect()
assert db_path.parent.exists()
storage.disconnect()
def test_disconnect_closes_connection(self) -> None:
"""Disconnect properly closes the connection."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
storage = DataStorage(db_path)
storage.connect()
storage.disconnect()
assert storage._conn is None
def test_context_manager_lifecycle(self) -> None:
"""Context manager properly opens and closes connection."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
with DataStorage(db_path) as storage:
assert storage._conn is not None
assert storage._conn is None
def test_conn_property_raises_when_not_connected(self) -> None:
"""Accessing conn property raises when not connected."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
storage = DataStorage(db_path)
with pytest.raises(RuntimeError, match="Not connected"):
_ = storage.conn
class TestDataStorageSchema:
"""Tests for schema initialization."""
def test_initialize_schema_creates_tables(self) -> None:
"""All tables are created by initialize_schema."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
with DataStorage(db_path) as storage:
storage.initialize_schema()
# Verify tables exist
result = storage.conn.execute(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'main'"
).fetchall()
tables = {row[0] for row in result}
assert "candles" in tables
assert "trades" in tables
assert "funding_rates" in tables
def test_initialize_schema_is_idempotent(self) -> None:
"""Calling initialize_schema multiple times is safe."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
with DataStorage(db_path) as storage:
storage.initialize_schema()
storage.initialize_schema() # Should not raise
class TestDataStorageCandles:
"""Tests for candle CRUD operations."""
def _make_candle(self, timestamp: datetime) -> Candle:
"""Create a test candle."""
return Candle(
timestamp=timestamp,
open=Decimal("50000.00"),
high=Decimal("51000.00"),
low=Decimal("49000.00"),
close=Decimal("50500.00"),
volume=Decimal("1000.50"),
)
def test_insert_candles_stores_data(self) -> None:
"""Candles are stored correctly."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
with DataStorage(db_path) as storage:
storage.initialize_schema()
ts = datetime(2024, 1, 1, 12, 0, 0)
candles = [self._make_candle(ts)]
inserted = storage.insert_candles(candles, "BTCUSDT", "1h")
assert inserted == 1
def test_insert_candles_empty_list_returns_zero(self) -> None:
"""Inserting empty list returns 0."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
with DataStorage(db_path) as storage:
storage.initialize_schema()
inserted = storage.insert_candles([], "BTCUSDT", "1h")
assert inserted == 0
def test_insert_candles_handles_duplicates(self) -> None:
"""Duplicate candles are replaced (upsert behavior)."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
with DataStorage(db_path) as storage:
storage.initialize_schema()
ts = datetime(2024, 1, 1, 12, 0, 0)
candles = [self._make_candle(ts)]
storage.insert_candles(candles, "BTCUSDT", "1h")
storage.insert_candles(candles, "BTCUSDT", "1h") # Duplicate
count = storage.get_candle_count("BTCUSDT", "1h")
assert count == 1 # Not 2
def test_get_candles_retrieves_data(self) -> None:
"""Candles can be retrieved correctly."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
with DataStorage(db_path) as storage:
storage.initialize_schema()
ts = datetime(2024, 1, 1, 12, 0, 0)
candles = [self._make_candle(ts)]
storage.insert_candles(candles, "BTCUSDT", "1h")
result = storage.get_candles("BTCUSDT", "1h")
assert len(result) == 1
assert result[0].open == Decimal("50000.00")
assert result[0].close == Decimal("50500.00")
def test_get_candles_respects_time_range(self) -> None:
"""Candles are filtered by start/end time."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
with DataStorage(db_path) as storage:
storage.initialize_schema()
base_ts = datetime(2024, 1, 1, 0, 0, 0)
candles = [self._make_candle(base_ts + timedelta(hours=i)) for i in range(10)]
storage.insert_candles(candles, "BTCUSDT", "1h")
# Query middle range
start = base_ts + timedelta(hours=3)
end = base_ts + timedelta(hours=6)
result = storage.get_candles("BTCUSDT", "1h", start=start, end=end)
assert len(result) == 4 # hours 3, 4, 5, 6
def test_get_candles_respects_limit(self) -> None:
"""Candle count is limited correctly."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
with DataStorage(db_path) as storage:
storage.initialize_schema()
base_ts = datetime(2024, 1, 1, 0, 0, 0)
candles = [self._make_candle(base_ts + timedelta(hours=i)) for i in range(10)]
storage.insert_candles(candles, "BTCUSDT", "1h")
result = storage.get_candles("BTCUSDT", "1h", limit=5)
assert len(result) == 5
def test_get_candles_returns_ascending_order(self) -> None:
"""Candles are returned oldest first."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
with DataStorage(db_path) as storage:
storage.initialize_schema()
base_ts = datetime(2024, 1, 1, 0, 0, 0)
# Insert in reverse order
candles = [self._make_candle(base_ts + timedelta(hours=i)) for i in range(5)]
storage.insert_candles(candles[::-1], "BTCUSDT", "1h")
result = storage.get_candles("BTCUSDT", "1h")
timestamps = [c.timestamp for c in result]
assert timestamps == sorted(timestamps)
def test_get_candles_filters_by_symbol(self) -> None:
"""Candles are filtered by symbol."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
with DataStorage(db_path) as storage:
storage.initialize_schema()
ts = datetime(2024, 1, 1, 12, 0, 0)
candles = [self._make_candle(ts)]
storage.insert_candles(candles, "BTCUSDT", "1h")
storage.insert_candles(candles, "ETHUSDT", "1h")
btc_result = storage.get_candles("BTCUSDT", "1h")
eth_result = storage.get_candles("ETHUSDT", "1h")
assert len(btc_result) == 1
assert len(eth_result) == 1
def test_get_latest_candle_timestamp(self) -> None:
"""Latest timestamp is returned correctly."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
with DataStorage(db_path) as storage:
storage.initialize_schema()
base_ts = datetime(2024, 1, 1, 0, 0, 0)
candles = [self._make_candle(base_ts + timedelta(hours=i)) for i in range(5)]
storage.insert_candles(candles, "BTCUSDT", "1h")
latest = storage.get_latest_candle_timestamp("BTCUSDT", "1h")
assert latest == base_ts + timedelta(hours=4)
def test_get_latest_candle_timestamp_returns_none_when_empty(self) -> None:
"""None is returned when no candles exist."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
with DataStorage(db_path) as storage:
storage.initialize_schema()
latest = storage.get_latest_candle_timestamp("BTCUSDT", "1h")
assert latest is None
def test_get_candle_count(self) -> None:
"""Candle count is accurate."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
with DataStorage(db_path) as storage:
storage.initialize_schema()
base_ts = datetime(2024, 1, 1, 0, 0, 0)
candles = [self._make_candle(base_ts + timedelta(hours=i)) for i in range(7)]
storage.insert_candles(candles, "BTCUSDT", "1h")
count = storage.get_candle_count("BTCUSDT", "1h")
assert count == 7
class TestDataStorageFundingRates:
"""Tests for funding rate operations."""
def test_insert_funding_rate(self) -> None:
"""Funding rate is stored correctly."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.duckdb"
with DataStorage(db_path) as storage:
storage.initialize_schema()
rate = FundingRate(
symbol="BTCUSDT",
funding_rate=Decimal("0.0001"),
funding_time=datetime(2024, 1, 1, 8, 0, 0),
mark_price=Decimal("50000.00"),
)
storage.insert_funding_rate(rate)
# Verify stored
result = storage.conn.execute(
"SELECT symbol, funding_rate FROM funding_rates"
).fetchone()
assert result is not None
assert result[0] == "BTCUSDT"

295
tests/test_spot_adapter.py Normal file
View 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 == []