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

651 lines
19 KiB
Python

"""Tests for exchange interface and mock exchange.
This module contains tests for MockExchange, Order, OrderType, and related models.
"""
import asyncio
import pytest
from openclaw.exchange.mock import MockExchange
from openclaw.exchange.models import (
Balance,
Order,
OrderSide,
OrderStatus,
OrderType,
Position,
Ticker,
)
from openclaw.exchange.base import ExchangeError, InsufficientFundsError
class TestMockExchange:
"""Tests for MockExchange class."""
@pytest.fixture
async def exchange(self):
"""Fixture to create a MockExchange instance."""
exchange = MockExchange(
name="test_exchange",
initial_balances={"USDT": 10000.0, "BTC": 1.0},
latency_ms=0, # No latency for faster tests
slippage_pct=0.1,
)
await exchange.connect()
yield exchange
await exchange.disconnect()
@pytest.mark.asyncio
async def test_exchange_initialization(self):
"""Test MockExchange initialization."""
exchange = MockExchange(
name="test_exchange",
initial_balances={"USDT": 10000.0},
)
assert exchange.name == "test_exchange"
assert exchange.latency_ms == 10.0 # Default
assert exchange.slippage_pct == 0.1 # Default
@pytest.mark.asyncio
async def test_default_initial_balances(self):
"""Test default initial balances."""
exchange = MockExchange()
balances = await exchange.get_balance()
assert len(balances) == 1
assert balances[0].asset == "USDT"
assert balances[0].free == 10000.0
@pytest.mark.asyncio
async def test_connect_disconnect(self):
"""Test connect and disconnect."""
exchange = MockExchange()
connected = await exchange.connect()
assert connected is True
await exchange.disconnect()
# No assertion needed, just verify no exception
@pytest.mark.asyncio
async def test_get_balance_all(self):
"""Test getting all balances."""
exchange = MockExchange(initial_balances={"USDT": 10000.0, "BTC": 1.0})
await exchange.connect()
balances = await exchange.get_balance()
assert len(balances) == 2
assets = {b.asset for b in balances}
assert assets == {"USDT", "BTC"}
@pytest.mark.asyncio
async def test_get_balance_specific(self):
"""Test getting specific asset balance."""
exchange = MockExchange(initial_balances={"USDT": 10000.0, "BTC": 1.0})
await exchange.connect()
balances = await exchange.get_balance("BTC")
assert len(balances) == 1
assert balances[0].asset == "BTC"
assert balances[0].free == 1.0
@pytest.mark.asyncio
async def test_get_balance_nonexistent(self):
"""Test getting nonexistent asset balance."""
exchange = MockExchange(initial_balances={"USDT": 10000.0})
await exchange.connect()
balances = await exchange.get_balance("ETH")
assert balances == []
@pytest.mark.asyncio
async def test_place_market_buy_order(self):
"""Test placing a market buy order."""
exchange = MockExchange(initial_balances={"USDT": 10000.0})
await exchange.connect()
order = await exchange.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.filled_amount == 0.1
@pytest.mark.asyncio
async def test_place_market_sell_order(self):
"""Test placing a market sell order."""
exchange = MockExchange(initial_balances={"USDT": 10000.0, "BTC": 1.0})
await exchange.connect()
order = await exchange.place_order(
symbol="BTC/USDT",
side=OrderSide.SELL,
amount=0.5,
)
assert order.symbol == "BTC/USDT"
assert order.side == OrderSide.SELL
assert order.amount == 0.5
assert order.status == OrderStatus.FILLED
@pytest.mark.asyncio
async def test_place_order_insufficient_funds_buy(self):
"""Test buy order fails with insufficient funds."""
exchange = MockExchange(initial_balances={"USDT": 100.0})
await exchange.connect()
with pytest.raises(InsufficientFundsError):
await exchange.place_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=1.0, # Costs ~$65k
)
@pytest.mark.asyncio
async def test_place_order_insufficient_funds_sell(self):
"""Test sell order fails with insufficient funds."""
exchange = MockExchange(initial_balances={"USDT": 10000.0, "BTC": 0.1})
await exchange.connect()
with pytest.raises(InsufficientFundsError):
await exchange.place_order(
symbol="BTC/USDT",
side=OrderSide.SELL,
amount=1.0, # Only have 0.1 BTC
)
@pytest.mark.asyncio
async def test_get_order(self):
"""Test getting order details."""
exchange = MockExchange(initial_balances={"USDT": 10000.0})
await exchange.connect()
order = await exchange.place_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
retrieved = await exchange.get_order(order.order_id)
assert retrieved is not None
assert retrieved.order_id == order.order_id
@pytest.mark.asyncio
async def test_get_order_nonexistent(self):
"""Test getting nonexistent order returns None."""
exchange = MockExchange()
await exchange.connect()
retrieved = await exchange.get_order("nonexistent")
assert retrieved is None
@pytest.mark.asyncio
async def test_cancel_order(self):
"""Test cancelling an order."""
# Note: In mock exchange, orders are filled immediately
# so cancellation typically won't work for market orders
exchange = MockExchange(initial_balances={"USDT": 10000.0})
await exchange.connect()
order = await exchange.place_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
# Already filled, so cancel should fail
cancelled = await exchange.cancel_order(order.order_id)
assert cancelled is False
@pytest.mark.asyncio
async def test_cancel_order_nonexistent(self):
"""Test cancelling nonexistent order returns False."""
exchange = MockExchange()
await exchange.connect()
cancelled = await exchange.cancel_order("nonexistent")
assert cancelled is False
@pytest.mark.asyncio
async def test_get_open_orders(self):
"""Test getting open orders."""
exchange = MockExchange(initial_balances={"USDT": 10000.0})
await exchange.connect()
# Place an order (fills immediately in mock)
await exchange.place_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
# Since orders fill immediately, there should be no open orders
open_orders = await exchange.get_open_orders()
assert len(open_orders) == 0
@pytest.mark.asyncio
async def test_get_open_orders_filtered(self):
"""Test getting open orders filtered by symbol."""
exchange = MockExchange(initial_balances={"USDT": 10000.0})
await exchange.connect()
# All orders fill immediately, so test the filter works
open_orders = await exchange.get_open_orders("BTC/USDT")
assert open_orders == []
@pytest.mark.asyncio
async def test_get_positions(self):
"""Test getting positions."""
exchange = MockExchange(
initial_balances={"USDT": 50000.0, "BTC": 1.0} # Need more USDT for BTC purchase
)
await exchange.connect()
# Initially no positions
positions = await exchange.get_positions()
assert len(positions) == 0
# After buy, should have position
await exchange.place_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1, # Smaller amount for test
)
positions = await exchange.get_positions()
assert len(positions) == 1
assert positions[0].symbol == "BTC/USDT"
@pytest.mark.asyncio
async def test_get_positions_filtered(self):
"""Test getting positions filtered by symbol."""
exchange = MockExchange(
initial_balances={"USDT": 50000.0, "BTC": 1.0, "ETH": 10.0} # More USDT
)
await exchange.connect()
await exchange.place_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
await exchange.place_order(
symbol="ETH/USDT",
side=OrderSide.BUY,
amount=2.0,
)
btc_positions = await exchange.get_positions("BTC/USDT")
assert len(btc_positions) == 1
assert btc_positions[0].symbol == "BTC/USDT"
@pytest.mark.asyncio
async def test_get_ticker(self):
"""Test getting ticker data."""
exchange = MockExchange()
await exchange.connect()
ticker = await exchange.get_ticker("BTC/USDT")
assert ticker.symbol == "BTC/USDT"
assert ticker.bid > 0
assert ticker.ask > 0
assert ticker.last > 0
assert ticker.ask > ticker.bid # Ask should be higher than bid
@pytest.mark.asyncio
async def test_get_ticker_unknown_symbol(self):
"""Test getting ticker for unknown symbol."""
exchange = MockExchange()
await exchange.connect()
ticker = await exchange.get_ticker("UNKNOWN/PAIR")
assert ticker.symbol == "UNKNOWN/PAIR"
assert ticker.last > 0 # Should generate a default price
@pytest.mark.asyncio
async def test_update_ticker(self):
"""Test manually updating ticker price."""
exchange = MockExchange()
await exchange.connect()
exchange.update_ticker("BTC/USDT", 70000.0)
ticker = await exchange.get_ticker("BTC/USDT")
# get_ticker applies random price movement, so check approximate values
assert abs(ticker.last - 70000.0) < 100 # Within $100 of expected
assert ticker.bid < ticker.last # Bid should be less than last
assert ticker.ask > ticker.last # Ask should be more than last
assert abs(ticker.bid - 70000.0 * 0.9995) < 100
assert abs(ticker.ask - 70000.0 * 1.0005) < 100
@pytest.mark.asyncio
async def test_set_balance(self):
"""Test manually setting balance."""
exchange = MockExchange()
await exchange.connect()
exchange.set_balance("ETH", 5.0)
balances = await exchange.get_balance("ETH")
assert len(balances) == 1
assert balances[0].asset == "ETH"
assert balances[0].free == 5.0
@pytest.mark.asyncio
async def test_buy_order_updates_balances(self):
"""Test that buy order correctly updates balances."""
exchange = MockExchange(initial_balances={"USDT": 10000.0})
await exchange.connect()
initial_usdt = (await exchange.get_balance("USDT"))[0].free
await exchange.place_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
final_usdt = (await exchange.get_balance("USDT"))[0].free
btc_balance = (await exchange.get_balance("BTC"))[0].free
assert final_usdt < initial_usdt # USDT decreased
assert btc_balance == 0.1 # BTC increased
@pytest.mark.asyncio
async def test_sell_order_updates_balances(self):
"""Test that sell order correctly updates balances."""
exchange = MockExchange(
initial_balances={"USDT": 10000.0, "BTC": 1.0}
)
await exchange.connect()
initial_btc = (await exchange.get_balance("BTC"))[0].free
await exchange.place_order(
symbol="BTC/USDT",
side=OrderSide.SELL,
amount=0.5,
)
final_btc = (await exchange.get_balance("BTC"))[0].free
usdt_balance = (await exchange.get_balance("USDT"))[0].free
assert final_btc == initial_btc - 0.5 # BTC decreased
assert usdt_balance > 10000.0 # USDT increased
class TestOrder:
"""Tests for Order model."""
def test_order_creation(self):
"""Test creating an Order."""
order = Order(
order_id="order123",
symbol="BTC/USDT",
side=OrderSide.BUY,
order_type=OrderType.MARKET,
amount=1.0,
price=50000.0,
status=OrderStatus.FILLED,
filled_amount=1.0,
)
assert order.order_id == "order123"
assert order.symbol == "BTC/USDT"
assert order.side == OrderSide.BUY
assert order.order_type == OrderType.MARKET
assert order.amount == 1.0
assert order.price == 50000.0
assert order.status == OrderStatus.FILLED
def test_order_is_filled(self):
"""Test is_filled property."""
filled_order = Order(
order_id="order1",
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=1.0,
status=OrderStatus.FILLED,
filled_amount=1.0,
)
pending_order = Order(
order_id="order2",
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=1.0,
status=OrderStatus.PENDING,
filled_amount=0.0,
)
assert filled_order.is_filled is True
assert pending_order.is_filled is False
def test_order_remaining_amount(self):
"""Test remaining_amount property."""
order = Order(
order_id="order1",
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=1.0,
status=OrderStatus.PARTIALLY_FILLED,
filled_amount=0.5,
)
assert order.remaining_amount == 0.5
def test_order_fill_percentage(self):
"""Test fill_percentage property."""
order = Order(
order_id="order1",
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=1.0,
status=OrderStatus.PARTIALLY_FILLED,
filled_amount=0.75,
)
assert order.fill_percentage == 75.0
class TestOrderType:
"""Tests for OrderType enum."""
def test_order_type_values(self):
"""Test OrderType enum values."""
assert OrderType.MARKET.value == "market"
assert OrderType.LIMIT.value == "limit"
assert OrderType.STOP_LOSS.value == "stop_loss"
assert OrderType.TAKE_PROFIT.value == "take_profit"
class TestOrderSide:
"""Tests for OrderSide enum."""
def test_order_side_values(self):
"""Test OrderSide enum values."""
assert OrderSide.BUY.value == "buy"
assert OrderSide.SELL.value == "sell"
class TestOrderStatus:
"""Tests for OrderStatus enum."""
def test_order_status_values(self):
"""Test OrderStatus enum values."""
assert OrderStatus.PENDING.value == "pending"
assert OrderStatus.OPEN.value == "open"
assert OrderStatus.PARTIALLY_FILLED.value == "partially_filled"
assert OrderStatus.FILLED.value == "filled"
assert OrderStatus.CANCELLED.value == "cancelled"
assert OrderStatus.REJECTED.value == "rejected"
assert OrderStatus.EXPIRED.value == "expired"
class TestBalance:
"""Tests for Balance model."""
def test_balance_creation(self):
"""Test creating a Balance."""
balance = Balance(
asset="BTC",
free=1.5,
locked=0.5,
)
assert balance.asset == "BTC"
assert balance.free == 1.5
assert balance.locked == 0.5
def test_balance_total(self):
"""Test balance total property."""
balance = Balance(
asset="BTC",
free=1.5,
locked=0.5,
)
assert balance.total == 2.0
class TestPosition:
"""Tests for Position model."""
def test_position_creation(self):
"""Test creating a Position."""
position = Position(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=1.0,
entry_price=50000.0,
current_price=55000.0,
)
assert position.symbol == "BTC/USDT"
assert position.side == OrderSide.BUY
assert position.amount == 1.0
assert position.entry_price == 50000.0
def test_position_unrealized_pnl_long(self):
"""Test unrealized PnL for long position."""
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_unrealized_pnl_short(self):
"""Test unrealized PnL for short position."""
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_unrealized_pnl_percentage(self):
"""Test unrealized PnL percentage."""
position = Position(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=1.0,
entry_price=50000.0,
current_price=55000.0,
)
assert position.unrealized_pnl_pct == 10.0 # 10% gain
def test_position_market_value(self):
"""Test position market value."""
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_creation(self):
"""Test creating a Ticker."""
ticker = Ticker(
symbol="BTC/USDT",
bid=64000.0,
ask=64100.0,
last=64050.0,
high=65000.0,
low=63000.0,
volume=1000000.0,
)
assert ticker.symbol == "BTC/USDT"
assert ticker.bid == 64000.0
assert ticker.ask == 64100.0
assert ticker.last == 64050.0
def test_ticker_spread(self):
"""Test ticker spread calculation."""
ticker = Ticker(
symbol="BTC/USDT",
bid=64000.0,
ask=64100.0,
last=64050.0,
)
assert ticker.spread == 100.0
def test_ticker_spread_percentage(self):
"""Test ticker spread percentage calculation."""
ticker = Ticker(
symbol="BTC/USDT",
bid=64000.0,
ask=64100.0,
last=64050.0,
)
expected_spread_pct = (100.0 / 64050.0) * 100
assert abs(ticker.spread_pct - expected_spread_pct) < 0.01
def test_ticker_mid_price(self):
"""Test ticker mid price calculation."""
ticker = Ticker(
symbol="BTC/USDT",
bid=64000.0,
ask=64100.0,
last=64050.0,
)
assert ticker.mid_price == 64050.0