265 lines
9.2 KiB
Python
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
|