420 lines
12 KiB
Python
420 lines
12 KiB
Python
"""Unit tests for exchange module."""
|
|
|
|
import asyncio
|
|
import pytest
|
|
from datetime import datetime
|
|
|
|
from openclaw.exchange.models import (
|
|
Balance,
|
|
Order,
|
|
OrderSide,
|
|
OrderStatus,
|
|
OrderType,
|
|
Position,
|
|
Ticker,
|
|
)
|
|
from openclaw.exchange.base import Exchange, ExchangeError, InsufficientFundsError
|
|
from openclaw.exchange.mock import MockExchange
|
|
from openclaw.exchange.binance import BinanceExchange
|
|
|
|
|
|
class TestOrder:
|
|
"""Tests for Order model."""
|
|
|
|
def test_order_creation(self):
|
|
"""Test basic order creation."""
|
|
order = Order(
|
|
order_id="test-123",
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
amount=1.0,
|
|
price=50000.0,
|
|
)
|
|
assert order.order_id == "test-123"
|
|
assert order.symbol == "BTC/USDT"
|
|
assert order.side == OrderSide.BUY
|
|
assert order.amount == 1.0
|
|
assert order.price == 50000.0
|
|
assert order.status == OrderStatus.PENDING
|
|
|
|
def test_order_is_filled(self):
|
|
"""Test order fill detection."""
|
|
order = Order(
|
|
order_id="test-123",
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
amount=1.0,
|
|
status=OrderStatus.FILLED,
|
|
filled_amount=1.0,
|
|
)
|
|
assert order.is_filled is True
|
|
|
|
def test_order_remaining_amount(self):
|
|
"""Test remaining amount calculation."""
|
|
order = Order(
|
|
order_id="test-123",
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
amount=2.0,
|
|
filled_amount=1.5,
|
|
)
|
|
assert order.remaining_amount == 0.5
|
|
|
|
def test_order_fill_percentage(self):
|
|
"""Test fill percentage calculation."""
|
|
order = Order(
|
|
order_id="test-123",
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
amount=2.0,
|
|
filled_amount=1.0,
|
|
)
|
|
assert order.fill_percentage == 50.0
|
|
|
|
|
|
class TestBalance:
|
|
"""Tests for Balance model."""
|
|
|
|
def test_balance_total(self):
|
|
"""Test total balance calculation."""
|
|
balance = Balance(asset="BTC", free=1.0, locked=0.5)
|
|
assert balance.total == 1.5
|
|
|
|
def test_balance_zero(self):
|
|
"""Test zero balance."""
|
|
balance = Balance(asset="USDT", free=0.0, locked=0.0)
|
|
assert balance.total == 0.0
|
|
|
|
|
|
class TestPosition:
|
|
"""Tests for Position model."""
|
|
|
|
def test_position_long_pnl(self):
|
|
"""Test long position PnL calculation."""
|
|
position = Position(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
amount=1.0,
|
|
entry_price=50000.0,
|
|
current_price=55000.0,
|
|
)
|
|
assert position.unrealized_pnl == 5000.0
|
|
|
|
def test_position_short_pnl(self):
|
|
"""Test short position PnL calculation."""
|
|
position = Position(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.SELL,
|
|
amount=1.0,
|
|
entry_price=50000.0,
|
|
current_price=45000.0,
|
|
)
|
|
assert position.unrealized_pnl == 5000.0
|
|
|
|
def test_position_market_value(self):
|
|
"""Test market value calculation."""
|
|
position = Position(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
amount=2.0,
|
|
entry_price=50000.0,
|
|
current_price=55000.0,
|
|
)
|
|
assert position.market_value == 110000.0
|
|
|
|
|
|
class TestTicker:
|
|
"""Tests for Ticker model."""
|
|
|
|
def test_ticker_spread(self):
|
|
"""Test spread calculation."""
|
|
ticker = Ticker(
|
|
symbol="BTC/USDT",
|
|
bid=64000.0,
|
|
ask=64100.0,
|
|
last=64050.0,
|
|
)
|
|
assert ticker.spread == 100.0
|
|
|
|
def test_ticker_mid_price(self):
|
|
"""Test mid price calculation."""
|
|
ticker = Ticker(
|
|
symbol="BTC/USDT",
|
|
bid=64000.0,
|
|
ask=64100.0,
|
|
last=64050.0,
|
|
)
|
|
assert ticker.mid_price == 64050.0
|
|
|
|
|
|
class TestMockExchange:
|
|
"""Tests for MockExchange implementation."""
|
|
|
|
@pytest.fixture
|
|
async def exchange(self):
|
|
"""Create a mock exchange for testing."""
|
|
ex = MockExchange(
|
|
name="test_mock",
|
|
initial_balances={"USDT": 10000.0, "BTC": 1.0},
|
|
latency_ms=0, # No latency for tests
|
|
)
|
|
await ex.connect()
|
|
yield ex
|
|
await ex.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_disconnect(self):
|
|
"""Test connection lifecycle."""
|
|
ex = MockExchange()
|
|
result = await ex.connect()
|
|
assert result is True
|
|
assert ex.is_connected is True
|
|
await ex.disconnect()
|
|
assert ex.is_connected is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_balance(self):
|
|
"""Test balance retrieval."""
|
|
ex = MockExchange(initial_balances={"USDT": 10000.0})
|
|
await ex.connect()
|
|
|
|
balances = await ex.get_balance()
|
|
assert len(balances) == 1
|
|
assert balances[0].asset == "USDT"
|
|
assert balances[0].free == 10000.0
|
|
|
|
await ex.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_ticker(self):
|
|
"""Test ticker retrieval."""
|
|
ex = MockExchange()
|
|
await ex.connect()
|
|
|
|
ticker = await ex.get_ticker("BTC/USDT")
|
|
assert ticker.symbol == "BTC/USDT"
|
|
assert ticker.bid > 0
|
|
assert ticker.ask > 0
|
|
assert ticker.last > 0
|
|
|
|
await ex.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_place_market_order_buy(self):
|
|
"""Test placing a buy market order."""
|
|
ex = MockExchange(initial_balances={"USDT": 10000.0})
|
|
await ex.connect()
|
|
|
|
order = await ex.place_order(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
amount=0.1,
|
|
)
|
|
|
|
assert order.symbol == "BTC/USDT"
|
|
assert order.side == OrderSide.BUY
|
|
assert order.amount == 0.1
|
|
assert order.status == OrderStatus.FILLED
|
|
assert order.is_filled is True
|
|
|
|
# Check balance was deducted
|
|
balances = await ex.get_balance("USDT")
|
|
assert balances[0].free < 10000.0
|
|
|
|
await ex.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_place_market_order_sell(self):
|
|
"""Test placing a sell market order."""
|
|
ex = MockExchange(initial_balances={"BTC": 1.0, "USDT": 10000.0})
|
|
await ex.connect()
|
|
|
|
order = await ex.place_order(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.SELL,
|
|
amount=0.5,
|
|
)
|
|
|
|
assert order.side == OrderSide.SELL
|
|
assert order.amount == 0.5
|
|
assert order.status == OrderStatus.FILLED
|
|
|
|
await ex.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_insufficient_funds(self):
|
|
"""Test order with insufficient funds."""
|
|
ex = MockExchange(initial_balances={"USDT": 100.0})
|
|
await ex.connect()
|
|
|
|
with pytest.raises(InsufficientFundsError):
|
|
await ex.place_order(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
amount=1.0, # Too much for $100 balance
|
|
)
|
|
|
|
await ex.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_order(self):
|
|
"""Test order cancellation."""
|
|
ex = MockExchange(initial_balances={"USDT": 10000.0})
|
|
await ex.connect()
|
|
|
|
# Place an order
|
|
order = await ex.place_order(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
amount=0.1,
|
|
)
|
|
|
|
# Market orders fill immediately in mock, so can't cancel
|
|
# Test cancel returns False for already filled orders
|
|
result = await ex.cancel_order(order.order_id)
|
|
assert result is False
|
|
|
|
await ex.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_order(self):
|
|
"""Test retrieving order details."""
|
|
ex = MockExchange(initial_balances={"USDT": 10000.0})
|
|
await ex.connect()
|
|
|
|
order = await ex.place_order(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
amount=0.1,
|
|
)
|
|
|
|
retrieved = await ex.get_order(order.order_id)
|
|
assert retrieved is not None
|
|
assert retrieved.order_id == order.order_id
|
|
|
|
await ex.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_positions(self):
|
|
"""Test position retrieval."""
|
|
ex = MockExchange(initial_balances={"USDT": 10000.0})
|
|
await ex.connect()
|
|
|
|
# Initially no positions
|
|
positions = await ex.get_positions()
|
|
assert len(positions) == 0
|
|
|
|
# Place an order to create position
|
|
await ex.place_order(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
amount=0.1,
|
|
)
|
|
|
|
# Now should have a position
|
|
positions = await ex.get_positions()
|
|
assert len(positions) == 1
|
|
assert positions[0].symbol == "BTC/USDT"
|
|
|
|
await ex.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_ticker(self):
|
|
"""Test manual ticker update."""
|
|
ex = MockExchange()
|
|
await ex.connect()
|
|
|
|
ex.update_ticker("BTC/USDT", 70000.0)
|
|
ticker = await ex.get_ticker("BTC/USDT")
|
|
# Allow for small price movement simulation (within 1%)
|
|
assert abs(ticker.last - 70000.0) < 700.0
|
|
|
|
await ex.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_balance(self):
|
|
"""Test manual balance update."""
|
|
ex = MockExchange()
|
|
await ex.connect()
|
|
|
|
ex.set_balance("ETH", 10.0)
|
|
balances = await ex.get_balance("ETH")
|
|
assert len(balances) == 1
|
|
assert balances[0].free == 10.0
|
|
|
|
await ex.disconnect()
|
|
|
|
|
|
class TestBinanceExchange:
|
|
"""Tests for BinanceExchange implementation."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_simulated_mode(self):
|
|
"""Test that simulated mode uses mock."""
|
|
ex = BinanceExchange(is_simulated=True)
|
|
assert ex.is_simulated is True
|
|
assert ex._mock is not None
|
|
|
|
result = await ex.connect()
|
|
assert result is True
|
|
|
|
await ex.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_live_mode_requires_credentials(self):
|
|
"""Test that live mode requires API credentials."""
|
|
with pytest.raises(Exception): # AuthenticationError
|
|
BinanceExchange(is_simulated=False, api_key=None, api_secret=None)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_place_order_simulated(self):
|
|
"""Test placing order in simulated mode."""
|
|
ex = BinanceExchange(is_simulated=True)
|
|
await ex.connect()
|
|
|
|
order = await ex.place_order(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
amount=0.1,
|
|
)
|
|
|
|
assert order.symbol == "BTC/USDT"
|
|
assert order.side == OrderSide.BUY
|
|
|
|
await ex.disconnect()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_ticker_simulated(self):
|
|
"""Test getting ticker in simulated mode."""
|
|
ex = BinanceExchange(is_simulated=True)
|
|
await ex.connect()
|
|
|
|
ticker = await ex.get_ticker("ETH/USDT")
|
|
assert ticker.symbol == "ETH/USDT"
|
|
assert ticker.last > 0
|
|
|
|
await ex.disconnect()
|
|
|
|
|
|
class TestExchangeError:
|
|
"""Tests for exchange exceptions."""
|
|
|
|
def test_exchange_error_basic(self):
|
|
"""Test basic error creation."""
|
|
err = ExchangeError("Something went wrong")
|
|
assert str(err) == "Something went wrong"
|
|
assert err.message == "Something went wrong"
|
|
assert err.error_code is None
|
|
|
|
def test_exchange_error_with_code(self):
|
|
"""Test error with code."""
|
|
err = ExchangeError("Rate limit exceeded", error_code="RATE_LIMIT")
|
|
assert str(err) == "[RATE_LIMIT] Rate limit exceeded"
|
|
assert err.error_code == "RATE_LIMIT"
|
|
|
|
def test_insufficient_funds_error(self):
|
|
"""Test insufficient funds error."""
|
|
err = InsufficientFundsError("Not enough BTC")
|
|
assert isinstance(err, ExchangeError)
|
|
assert "BTC" in str(err)
|