644 lines
20 KiB
Python
644 lines
20 KiB
Python
"""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"])
|