stock/tests/unit/test_exchange.py
2026-02-27 03:17:12 +08:00

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)