stock/tests/test_ccxt_adapter.py
ZhangPeng 9aecdd036c Initial commit: OpenClaw Trading - AI多智能体量化交易系统
- 添加项目核心代码和配置
- 添加前端界面 (Next.js)
- 添加单元测试
- 更新 .gitignore 排除缓存和依赖
2026-02-27 03:47:40 +08:00

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