exchange-data-manager/tests/test_ccxt_connector.py

265 lines
9.2 KiB
Python

"""Tests for the generic CCXTConnector."""
import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from exchange_data_manager.exchanges import CCXTConnector, create_connector
from exchange_data_manager.candles.models import Candle
class TestCCXTConnectorInit:
"""Test CCXTConnector initialization."""
def test_valid_exchange(self):
"""Test creating connector for valid exchange."""
connector = CCXTConnector("binance")
assert connector.exchange_id == "binance"
assert connector.name == "binance"
def test_invalid_exchange_raises(self):
"""Test that invalid exchange raises ValueError."""
with pytest.raises(ValueError) as exc_info:
CCXTConnector("not_a_real_exchange")
assert "not supported by ccxt" in str(exc_info.value)
def test_exchange_case_insensitive(self):
"""Test that exchange names are case-insensitive."""
connector = CCXTConnector("BINANCE")
assert connector.exchange_id == "binance"
def test_with_credentials(self):
"""Test creating authenticated connector."""
connector = CCXTConnector(
"binance",
api_key="test_key",
api_secret="test_secret",
)
assert connector.is_authenticated
def test_with_password(self):
"""Test creating connector with passphrase (for KuCoin etc.)."""
connector = CCXTConnector(
"kucoin",
api_key="test_key",
api_secret="test_secret",
password="test_passphrase",
)
assert connector.password == "test_passphrase"
class TestCCXTConnectorStaticMethods:
"""Test static utility methods."""
def test_list_exchanges(self):
"""Test listing available exchanges."""
exchanges = CCXTConnector.list_exchanges()
assert "binance" in exchanges
assert "kraken" in exchanges
assert "kucoin" in exchanges
assert len(exchanges) > 50 # ccxt supports 100+ exchanges
def test_is_exchange_supported_true(self):
"""Test checking valid exchange."""
assert CCXTConnector.is_exchange_supported("binance") is True
assert CCXTConnector.is_exchange_supported("KRAKEN") is True
def test_is_exchange_supported_false(self):
"""Test checking invalid exchange."""
assert CCXTConnector.is_exchange_supported("not_real") is False
class TestCCXTConnectorFetchCandles:
"""Test candle fetching."""
@pytest.fixture
def connector(self):
"""Create a binance connector with mocked client."""
connector = CCXTConnector("binance")
return connector
@pytest.mark.asyncio
async def test_fetch_candles_basic(self, connector):
"""Test basic candle fetching."""
# Mock the ccxt client
connector.client.fetch_ohlcv = AsyncMock(return_value=[
[1709337600000, 50000.0, 50100.0, 49900.0, 50050.0, 100.0],
[1709337660000, 50050.0, 50150.0, 49950.0, 50100.0, 150.0],
])
connector.client.load_markets = AsyncMock()
connector.client.markets = {"BTC/USDT": {}}
candles = await connector.fetch_candles("BTC/USDT", "1m", limit=100)
assert len(candles) == 2
assert candles[0].time == 1709337600
assert candles[0].close == 50050.0
assert candles[1].time == 1709337660
@pytest.mark.asyncio
async def test_fetch_candles_with_time_range(self, connector):
"""Test fetching candles with start/end times."""
connector.client.fetch_ohlcv = AsyncMock(return_value=[
[1709337600000, 50000.0, 50100.0, 49900.0, 50050.0, 100.0],
])
connector.client.load_markets = AsyncMock()
connector.client.markets = {"BTC/USDT": {}}
candles = await connector.fetch_candles(
"BTC/USDT", "1m",
start=1709337600, # Seconds
end=1709337660,
)
# Verify start was converted to milliseconds
connector.client.fetch_ohlcv.assert_called_once()
call_kwargs = connector.client.fetch_ohlcv.call_args
assert call_kwargs.kwargs["since"] == 1709337600000 # Milliseconds
@pytest.mark.asyncio
async def test_fetch_candles_respects_end_time(self, connector):
"""Test that candles past end time are filtered."""
connector.client.fetch_ohlcv = AsyncMock(return_value=[
[1709337600000, 50000.0, 50100.0, 49900.0, 50050.0, 100.0],
[1709337660000, 50050.0, 50150.0, 49950.0, 50100.0, 150.0],
[1709337720000, 50100.0, 50200.0, 50000.0, 50150.0, 200.0],
])
connector.client.load_markets = AsyncMock()
connector.client.markets = {"BTC/USDT": {}}
candles = await connector.fetch_candles(
"BTC/USDT", "1m",
start=1709337600,
end=1709337660, # Should stop at second candle
)
# Should only return first 2 candles
assert len(candles) == 2
assert candles[-1].time == 1709337660
class TestCCXTConnectorTimeframes:
"""Test timeframe handling."""
def test_get_timeframes_binance(self):
"""Test getting timeframes for Binance."""
connector = CCXTConnector("binance")
# Binance client has timeframes defined
connector.client.timeframes = {
"1m": "1m", "5m": "5m", "1h": "1h", "1d": "1d"
}
timeframes = connector.get_timeframes()
assert "1m" in timeframes
assert "1d" in timeframes
def test_get_timeframes_fallback(self):
"""Test fallback timeframes when exchange doesn't provide them."""
connector = CCXTConnector("binance")
connector.client.timeframes = None
timeframes = connector.get_timeframes()
# Should return default common timeframes
assert "1m" in timeframes
assert "1h" in timeframes
class TestCCXTConnectorStreaming:
"""Test polling-based streaming."""
@pytest.mark.asyncio
async def test_subscribe_returns_subscription_id(self):
"""Test that subscribe returns a subscription ID."""
connector = CCXTConnector("binance")
connector.client.fetch_ohlcv = AsyncMock(return_value=[
[1709337600000, 50000.0, 50100.0, 49900.0, 50050.0, 100.0],
])
connector.client.load_markets = AsyncMock()
connector.client.markets = {"BTC/USDT": {}}
received = []
sub_id = await connector.subscribe(
"BTC/USDT", "1m",
callback=lambda c: received.append(c),
poll_interval=0.1, # Fast polling for test
)
assert sub_id.startswith("binance_BTC/USDT_1m_")
assert sub_id in connector._subscriptions
# Let it poll once
await asyncio.sleep(0.15)
# Unsubscribe
await connector.unsubscribe(sub_id)
assert sub_id not in connector._subscriptions
# Should have received at least one candle
assert len(received) >= 1
@pytest.mark.asyncio
async def test_unsubscribe_nonexistent_logs_warning(self):
"""Test that unsubscribing non-existent ID just logs warning."""
connector = CCXTConnector("binance")
# Should not raise, just log warning
await connector.unsubscribe("nonexistent_sub_id")
@pytest.mark.asyncio
async def test_close_stops_all_subscriptions(self):
"""Test that close() stops all active subscriptions."""
connector = CCXTConnector("binance")
connector.client.fetch_ohlcv = AsyncMock(return_value=[
[1709337600000, 50000.0, 50100.0, 49900.0, 50050.0, 100.0],
])
connector.client.load_markets = AsyncMock()
connector.client.markets = {"BTC/USDT": {}}
connector.client.close = AsyncMock()
# Create multiple subscriptions
await connector.subscribe("BTC/USDT", "1m", lambda c: None, poll_interval=1.0)
await connector.subscribe("ETH/USDT", "5m", lambda c: None, poll_interval=1.0)
assert len(connector._subscriptions) == 2
# Close should clean up all
await connector.close()
assert len(connector._subscriptions) == 0
connector.client.close.assert_called_once()
class TestCreateConnectorFactory:
"""Test the create_connector factory function."""
def test_create_basic_connector(self):
"""Test creating a basic connector."""
connector = create_connector("binance")
assert isinstance(connector, CCXTConnector)
assert connector.exchange_id == "binance"
def test_create_authenticated_connector(self):
"""Test creating an authenticated connector."""
connector = create_connector(
"kucoin",
api_key="key",
api_secret="secret",
password="passphrase",
)
assert connector.is_authenticated
assert connector.password == "passphrase"
class TestMultipleExchanges:
"""Test that various exchanges work."""
@pytest.mark.parametrize("exchange_id", [
"binance",
"kraken",
"kucoin",
"coinbase",
"bybit",
"okx",
])
def test_major_exchanges_supported(self, exchange_id):
"""Test that major exchanges can be instantiated."""
connector = CCXTConnector(exchange_id)
assert connector.exchange_id == exchange_id