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:
286
tests/test_data_fetcher.py
Normal file
286
tests/test_data_fetcher.py
Normal 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
293
tests/test_data_storage.py
Normal 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
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