651 lines
19 KiB
Python
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
|