"""Tests for CCXT exchange adapter. This module provides comprehensive tests for the CCXT adapter including: - Connection management - Market data retrieval (ticker, orderbook, OHLCV) - Account operations (balance, positions) - Order management (create, cancel, get) - Error handling and retry logic """ import pytest from datetime import datetime from typing import Any, Dict, List, Optional from unittest.mock import Mock, patch, AsyncMock # Skip all tests if CCXT is not installed pytest.importorskip("ccxt", reason="CCXT not installed") from openclaw.exchange.ccxt_adapter import ( CCXTAdapter, CCXTExchangeFactory, RateLimitConfig, RetryConfig, ExchangeFeature, RateLimiter, create_binance, create_okx, create_bybit, create_kucoin, ) from openclaw.exchange.base import ( ExchangeError, AuthenticationError, InsufficientFundsError, InvalidOrderError, OrderNotFoundError, ) from openclaw.exchange.models import OrderSide, OrderType, OrderStatus class TestRateLimiter: """Tests for RateLimiter class.""" @pytest.mark.asyncio async def test_acquire_token(self): """Test token acquisition.""" config = RateLimitConfig(requests_per_second=10, burst_limit=5) limiter = RateLimiter(config) # Should acquire token immediately result = await limiter.acquire() assert result is True assert limiter.tokens == 4 @pytest.mark.asyncio async def test_rate_limit_wait(self): """Test rate limit waiting.""" config = RateLimitConfig(requests_per_second=100, burst_limit=1) limiter = RateLimiter(config) # Acquire first token await limiter.acquire() assert limiter.tokens == 0 # Second acquisition should wait briefly result = await limiter.acquire() assert result is True class TestCCXTAdapterInitialization: """Tests for CCXTAdapter initialization.""" def test_init_with_valid_exchange(self): """Test initialization with valid exchange ID.""" adapter = CCXTAdapter("binance") assert adapter.exchange_id == "binance" assert adapter.name == "binance" assert adapter.is_simulated is False def test_init_with_invalid_exchange(self): """Test initialization with invalid exchange ID.""" with pytest.raises(ExchangeError, match="Unsupported exchange"): CCXTAdapter("invalid_exchange") def test_init_with_testnet(self): """Test initialization with testnet enabled.""" adapter = CCXTAdapter("binance", testnet=True) assert adapter.testnet is True def test_init_with_custom_config(self): """Test initialization with custom rate and retry configs.""" rate_config = RateLimitConfig(requests_per_second=5) retry_config = RetryConfig(max_retries=5) adapter = CCXTAdapter( "binance", rate_limit_config=rate_config, retry_config=retry_config, ) assert adapter.rate_limit_config.requests_per_second == 5 assert adapter.retry_config.max_retries == 5 class TestCCXTAdapterConnection: """Tests for CCXTAdapter connection management.""" @pytest.fixture def mock_ccxt_client(self): """Create a mock CCXT client.""" client = Mock() client.markets = {"BTC/USDT": {}, "ETH/USDT": {}} client.load_markets = AsyncMock() client.close = AsyncMock() return client @pytest.mark.asyncio async def test_connect_success(self, mock_ccxt_client): """Test successful connection.""" adapter = CCXTAdapter("binance") with patch.object(adapter, "_create_client", return_value=mock_ccxt_client): result = await adapter.connect() assert result is True assert adapter.is_connected is True assert adapter._markets == {"BTC/USDT": {}, "ETH/USDT": {}} @pytest.mark.asyncio async def test_connect_authentication_error(self): """Test connection with authentication failure.""" adapter = CCXTAdapter("binance") with patch.object( adapter, "_create_client", side_effect=Exception("authentication failed") ): with pytest.raises(AuthenticationError): await adapter.connect() @pytest.mark.asyncio async def test_disconnect(self, mock_ccxt_client): """Test disconnection.""" adapter = CCXTAdapter("binance") adapter._client = mock_ccxt_client adapter._connected = True await adapter.disconnect() assert adapter.is_connected is False class TestCCXTAdapterMarketData: """Tests for CCXTAdapter market data methods.""" @pytest.fixture def connected_adapter(self): """Create a connected adapter with mocked client.""" adapter = CCXTAdapter("binance") adapter._connected = True adapter._client = Mock() adapter._rate_limiter = Mock() adapter._rate_limiter.acquire = AsyncMock(return_value=True) return adapter @pytest.mark.asyncio async def test_get_ticker(self, connected_adapter): """Test getting ticker data.""" mock_ticker = { "symbol": "BTC/USDT", "bid": 50000.0, "ask": 50001.0, "last": 50000.5, "high": 51000.0, "low": 49000.0, "baseVolume": 1000.0, "timestamp": 1700000000000, } connected_adapter._client.fetch_ticker = AsyncMock(return_value=mock_ticker) ticker = await connected_adapter.get_ticker("BTC/USDT") assert ticker.symbol == "BTC/USDT" assert ticker.bid == 50000.0 assert ticker.ask == 50001.0 assert ticker.last == 50000.5 @pytest.mark.asyncio async def test_get_orderbook(self, connected_adapter): """Test getting order book.""" mock_orderbook = { "symbol": "BTC/USDT", "bids": [[50000.0, 1.5], [49999.0, 2.0]], "asks": [[50001.0, 1.0], [50002.0, 2.5]], "timestamp": 1700000000000, "nonce": 12345, "datetime": "2023-11-14T12:00:00.000Z", } connected_adapter._client.fetch_order_book = AsyncMock(return_value=mock_orderbook) orderbook = await connected_adapter.get_orderbook("BTC/USDT", limit=10) assert orderbook["symbol"] == "BTC/USDT" assert orderbook["bids"] == [[50000.0, 1.5], [49999.0, 2.0]] assert orderbook["asks"] == [[50001.0, 1.0], [50002.0, 2.5]] assert orderbook["nonce"] == 12345 @pytest.mark.asyncio async def test_get_ohlcv(self, connected_adapter): """Test getting OHLCV data.""" mock_ohlcv = [ [1700000000000, 50000.0, 51000.0, 49000.0, 50500.0, 100.0], [1700003600000, 50500.0, 51500.0, 50000.0, 51000.0, 150.0], ] connected_adapter._client.fetch_ohlcv = AsyncMock(return_value=mock_ohlcv) ohlcv = await connected_adapter.get_ohlcv("BTC/USDT", timeframe="1h", limit=2) assert len(ohlcv) == 2 assert ohlcv[0]["open"] == 50000.0 assert ohlcv[0]["high"] == 51000.0 assert ohlcv[0]["low"] == 49000.0 assert ohlcv[0]["close"] == 50500.0 assert ohlcv[0]["volume"] == 100.0 class TestCCXTAdapterAccount: """Tests for CCXTAdapter account operations.""" @pytest.fixture def connected_adapter(self): """Create a connected adapter with mocked client.""" adapter = CCXTAdapter("binance") adapter._connected = True adapter._client = Mock() adapter._rate_limiter = Mock() adapter._rate_limiter.acquire = AsyncMock(return_value=True) return adapter @pytest.mark.asyncio async def test_get_balance(self, connected_adapter): """Test getting account balance.""" mock_balance = { "free": {"BTC": 1.5, "USDT": 10000.0}, "used": {"BTC": 0.5, "USDT": 2000.0}, } connected_adapter._client.fetch_balance = AsyncMock(return_value=mock_balance) balances = await connected_adapter.get_balance() assert len(balances) == 2 btc_balance = next(b for b in balances if b.asset == "BTC") assert btc_balance.free == 1.5 assert btc_balance.locked == 0.5 assert btc_balance.total == 2.0 @pytest.mark.asyncio async def test_get_balance_with_filter(self, connected_adapter): """Test getting balance with asset filter.""" mock_balance = { "free": {"BTC": 1.5, "USDT": 10000.0}, "used": {"BTC": 0.5, "USDT": 2000.0}, } connected_adapter._client.fetch_balance = AsyncMock(return_value=mock_balance) balances = await connected_adapter.get_balance(asset="BTC") assert len(balances) == 1 assert balances[0].asset == "BTC" @pytest.mark.asyncio async def test_get_positions_futures(self, connected_adapter): """Test getting positions for futures trading.""" connected_adapter._client.has = {"future": True} mock_positions = [ { "symbol": "BTC/USDT:USDT", "contracts": 1.0, "side": "long", "entryPrice": 50000.0, "markPrice": 51000.0, "leverage": 10.0, } ] connected_adapter._client.fetch_positions = AsyncMock(return_value=mock_positions) positions = await connected_adapter.get_positions() assert len(positions) == 1 assert positions[0].symbol == "BTC/USDT:USDT" assert positions[0].amount == 1.0 assert positions[0].side == OrderSide.BUY assert positions[0].leverage == 10.0 class TestCCXTAdapterOrders: """Tests for CCXTAdapter order management.""" @pytest.fixture def connected_adapter(self): """Create a connected adapter with mocked client.""" adapter = CCXTAdapter("binance") adapter._connected = True adapter._client = Mock() adapter._rate_limiter = Mock() adapter._rate_limiter.acquire = AsyncMock(return_value=True) return adapter @pytest.mark.asyncio async def test_place_market_buy_order(self, connected_adapter): """Test placing a market buy order.""" mock_order = { "id": "12345", "symbol": "BTC/USDT", "side": "buy", "type": "market", "amount": 0.1, "price": None, "status": "closed", "filled": 0.1, "timestamp": 1700000000000, "clientOrderId": "my-order-1", } connected_adapter._client.create_market_buy_order = AsyncMock(return_value=mock_order) order = await connected_adapter.place_order( symbol="BTC/USDT", side=OrderSide.BUY, amount=0.1, order_type="market", ) assert order.order_id == "12345" assert order.symbol == "BTC/USDT" assert order.side == OrderSide.BUY assert order.order_type == OrderType.MARKET assert order.amount == 0.1 assert order.filled_amount == 0.1 @pytest.mark.asyncio async def test_place_limit_sell_order(self, connected_adapter): """Test placing a limit sell order.""" mock_order = { "id": "12346", "symbol": "BTC/USDT", "side": "sell", "type": "limit", "amount": 0.5, "price": 55000.0, "status": "open", "filled": 0.0, "timestamp": 1700000000000, } connected_adapter._client.create_limit_sell_order = AsyncMock(return_value=mock_order) order = await connected_adapter.place_order( symbol="BTC/USDT", side=OrderSide.SELL, amount=0.5, price=55000.0, order_type="limit", ) assert order.order_id == "12346" assert order.side == OrderSide.SELL assert order.order_type == OrderType.LIMIT assert order.price == 55000.0 assert order.status == OrderStatus.OPEN @pytest.mark.asyncio async def test_place_order_insufficient_funds(self, connected_adapter): """Test placing order with insufficient funds.""" connected_adapter._client.create_market_buy_order = AsyncMock( side_effect=Exception("insufficient balance") ) with pytest.raises(InsufficientFundsError): await connected_adapter.place_order( symbol="BTC/USDT", side=OrderSide.BUY, amount=100.0, order_type="market", ) @pytest.mark.asyncio async def test_cancel_order(self, connected_adapter): """Test cancelling an order.""" connected_adapter._client.cancel_order = AsyncMock(return_value={"id": "12345"}) result = await connected_adapter.cancel_order("12345") assert result is True @pytest.mark.asyncio async def test_cancel_order_not_found(self, connected_adapter): """Test cancelling a non-existent order.""" connected_adapter._client.cancel_order = AsyncMock( side_effect=Exception("Order not found") ) with pytest.raises(OrderNotFoundError): await connected_adapter.cancel_order("invalid-id") @pytest.mark.asyncio async def test_get_order(self, connected_adapter): """Test getting order by ID.""" mock_order = { "id": "12345", "symbol": "BTC/USDT", "side": "buy", "type": "market", "amount": 0.1, "price": None, "status": "closed", "filled": 0.1, "timestamp": 1700000000000, } connected_adapter._client.fetch_order = AsyncMock(return_value=mock_order) order = await connected_adapter.get_order("12345") assert order is not None assert order.order_id == "12345" assert order.symbol == "BTC/USDT" @pytest.mark.asyncio async def test_get_order_not_found(self, connected_adapter): """Test getting non-existent order.""" connected_adapter._client.fetch_order = AsyncMock( side_effect=Exception("Order not found") ) order = await connected_adapter.get_order("invalid-id") assert order is None @pytest.mark.asyncio async def test_get_open_orders(self, connected_adapter): """Test getting open orders.""" mock_orders = [ { "id": "12345", "symbol": "BTC/USDT", "side": "buy", "type": "limit", "amount": 0.1, "price": 50000.0, "status": "open", "filled": 0.0, "timestamp": 1700000000000, } ] connected_adapter._client.fetch_open_orders = AsyncMock(return_value=mock_orders) orders = await connected_adapter.get_open_orders() assert len(orders) == 1 assert orders[0].status == OrderStatus.OPEN class NetworkError(Exception): """Mock network error for testing.""" pass class TestCCXTAdapterRetry: """Tests for CCXTAdapter retry logic.""" @pytest.fixture def connected_adapter(self): """Create a connected adapter with mocked client.""" adapter = CCXTAdapter("binance", retry_config=RetryConfig(max_retries=2, base_delay=0.01)) adapter._connected = True adapter._client = Mock() adapter._rate_limiter = Mock() adapter._rate_limiter.acquire = AsyncMock(return_value=True) return adapter @pytest.mark.asyncio async def test_retry_on_network_error(self, connected_adapter): """Test retry on network error.""" mock_ticker = { "symbol": "BTC/USDT", "bid": 50000.0, "ask": 50001.0, "last": 50000.5, "timestamp": 1700000000000, } # Fail twice, then succeed (use actual NetworkError type) connected_adapter._client.fetch_ticker = AsyncMock( side_effect=[ NetworkError("Connection failed"), NetworkError("Connection failed"), mock_ticker, ] ) ticker = await connected_adapter.get_ticker("BTC/USDT") assert ticker.last == 50000.5 assert connected_adapter._client.fetch_ticker.call_count == 3 @pytest.mark.asyncio async def test_retry_exhausted(self, connected_adapter): """Test failure when all retries are exhausted.""" connected_adapter._client.fetch_ticker = AsyncMock( side_effect=NetworkError("Connection failed") ) with pytest.raises(ExchangeError, match="Request failed after"): await connected_adapter.get_ticker("BTC/USDT") assert connected_adapter._client.fetch_ticker.call_count == 3 # 1 + 2 retries class TestCCXTExchangeFactory: """Tests for CCXTExchangeFactory.""" def test_create_exchange(self): """Test creating an exchange adapter.""" adapter = CCXTExchangeFactory.create("binance", use_cached=False) assert isinstance(adapter, CCXTAdapter) assert adapter.exchange_id == "binance" def test_create_with_cache(self): """Test cached instance creation.""" CCXTExchangeFactory.clear_cache() adapter1 = CCXTExchangeFactory.create("binance") adapter2 = CCXTExchangeFactory.create("binance") # Should return the same instance assert adapter1 is adapter2 def test_configure_exchange(self): """Test configuring default exchange settings.""" CCXTExchangeFactory.configure_exchange( "binance", api_key="test_key", api_secret="test_secret", testnet=True, ) assert "binance" in CCXTExchangeFactory._default_configs config = CCXTExchangeFactory._default_configs["binance"] assert config["api_key"] == "test_key" assert config["api_secret"] == "test_secret" assert config["testnet"] is True def test_get_supported_exchanges(self): """Test getting list of supported exchanges.""" exchanges = CCXTExchangeFactory.get_supported_exchanges() assert "binance" in exchanges assert "okx" in exchanges assert "bybit" in exchanges assert "kucoin" in exchanges def test_get_exchange_info(self): """Test getting exchange information.""" info = CCXTExchangeFactory.get_exchange_info("binance") assert info["id"] == "binance" assert info["name"] == "Binance" def test_clear_cache(self): """Test clearing cache.""" CCXTExchangeFactory.clear_cache() assert len(CCXTExchangeFactory._instances) == 0 class TestConvenienceFunctions: """Tests for convenience creation functions.""" def test_create_binance(self): """Test create_binance function.""" CCXTExchangeFactory.clear_cache() adapter = create_binance(api_key="key", api_secret="secret", testnet=True) assert adapter.exchange_id == "binance" assert adapter.api_key == "key" assert adapter.api_secret == "secret" assert adapter.testnet is True def test_create_okx(self): """Test create_okx function.""" CCXTExchangeFactory.clear_cache() adapter = create_okx( api_key="key", api_secret="secret", passphrase="pass", testnet=True, ) assert adapter.exchange_id == "okx" assert adapter.passphrase == "pass" def test_create_bybit(self): """Test create_bybit function.""" CCXTExchangeFactory.clear_cache() adapter = create_bybit(api_key="key", api_secret="secret", testnet=True) assert adapter.exchange_id == "bybit" def test_create_kucoin(self): """Test create_kucoin function.""" CCXTExchangeFactory.clear_cache() adapter = create_kucoin( api_key="key", api_secret="secret", passphrase="pass", testnet=True, ) assert adapter.exchange_id == "kucoin" class TestExchangeFeature: """Tests for ExchangeFeature enum.""" def test_feature_values(self): """Test feature enum values.""" assert ExchangeFeature.SPOT_TRADING.value == "spot" assert ExchangeFeature.MARGIN_TRADING.value == "margin" assert ExchangeFeature.FUTURES_TRADING.value == "futures" assert ExchangeFeature.LEVERAGE.value == "leverage" if __name__ == "__main__": pytest.main([__file__, "-v"])