"""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