520 lines
17 KiB
Python
520 lines
17 KiB
Python
"""Tests for ExchangeOrderManager.
|
|
|
|
This module contains tests for the ExchangeOrderManager class which provides
|
|
a unified interface for order management with support for both paper trading
|
|
and live exchange trading.
|
|
"""
|
|
|
|
import asyncio
|
|
import pytest
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from openclaw.exchange.order_manager import (
|
|
ExchangeOrderManager,
|
|
CreateOrderRequest,
|
|
ClosePositionRequest,
|
|
OrderSummary,
|
|
PositionSummary,
|
|
TradingModeEnum,
|
|
TradingModeStatus,
|
|
)
|
|
from openclaw.exchange.models import OrderSide, OrderStatus, OrderType
|
|
|
|
|
|
class TestExchangeOrderManager:
|
|
"""Tests for ExchangeOrderManager class."""
|
|
|
|
@pytest.fixture
|
|
async def manager(self):
|
|
"""Fixture to create an ExchangeOrderManager instance."""
|
|
manager = ExchangeOrderManager(paper_trading=True, fee_rate=0.001)
|
|
await manager.start()
|
|
yield manager
|
|
await manager.stop()
|
|
|
|
def test_initialization_paper_mode(self):
|
|
"""Test ExchangeOrderManager initialization in paper mode."""
|
|
manager = ExchangeOrderManager(paper_trading=True)
|
|
assert manager.paper_trading is True
|
|
assert manager.is_paper_trading is True
|
|
assert manager.is_live_trading is False
|
|
assert manager.trading_mode == TradingModeEnum.PAPER
|
|
|
|
def test_initialization_live_mode(self):
|
|
"""Test ExchangeOrderManager initialization in live mode."""
|
|
manager = ExchangeOrderManager(paper_trading=False)
|
|
assert manager.paper_trading is False
|
|
assert manager.is_live_trading is True
|
|
assert manager.trading_mode == TradingModeEnum.LIVE
|
|
|
|
def test_fee_rate_configuration(self):
|
|
"""Test custom fee rate configuration."""
|
|
manager = ExchangeOrderManager(fee_rate=0.002)
|
|
assert manager.fee_rate == 0.002
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_market_buy_order(self, manager):
|
|
"""Test creating a market buy order."""
|
|
request = CreateOrderRequest(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.MARKET,
|
|
amount=0.1,
|
|
)
|
|
|
|
order = await manager.create_order(request)
|
|
|
|
assert order.symbol == "BTC/USDT"
|
|
assert order.side == OrderSide.BUY
|
|
assert order.order_type == OrderType.MARKET
|
|
assert order.amount == 0.1
|
|
assert order.status in [OrderStatus.FILLED, OrderStatus.PENDING]
|
|
assert order.order_id is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_market_sell_order(self, manager):
|
|
"""Test creating a market sell order."""
|
|
request = CreateOrderRequest(
|
|
symbol="ETH/USDT",
|
|
side=OrderSide.SELL,
|
|
order_type=OrderType.MARKET,
|
|
amount=1.0,
|
|
)
|
|
|
|
order = await manager.create_order(request)
|
|
|
|
assert order.symbol == "ETH/USDT"
|
|
assert order.side == OrderSide.SELL
|
|
assert order.order_type == OrderType.MARKET
|
|
assert order.amount == 1.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_limit_order(self, manager):
|
|
"""Test creating a limit order."""
|
|
request = CreateOrderRequest(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.LIMIT,
|
|
amount=0.1,
|
|
price=50000.0,
|
|
)
|
|
|
|
order = await manager.create_order(request)
|
|
|
|
assert order.order_type == OrderType.LIMIT
|
|
assert order.price == 50000.0
|
|
assert order.status in [OrderStatus.OPEN, OrderStatus.PENDING]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_order(self, manager):
|
|
"""Test getting an order by ID."""
|
|
request = CreateOrderRequest(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.MARKET,
|
|
amount=0.1,
|
|
)
|
|
|
|
created = await manager.create_order(request)
|
|
retrieved = await manager.get_order(created.order_id)
|
|
|
|
assert retrieved is not None
|
|
assert retrieved.order_id == created.order_id
|
|
assert retrieved.symbol == created.symbol
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_order_not_found(self, manager):
|
|
"""Test getting a non-existent order."""
|
|
order = await manager.get_order("non_existent_order_id")
|
|
assert order is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_orders(self, manager):
|
|
"""Test listing orders."""
|
|
# Create a few orders
|
|
for i in range(3):
|
|
request = CreateOrderRequest(
|
|
symbol=f"SYM{i}/USDT",
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.MARKET,
|
|
amount=0.1,
|
|
)
|
|
await manager.create_order(request)
|
|
|
|
orders = await manager.list_orders()
|
|
assert len(orders) >= 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_orders_with_filter(self, manager):
|
|
"""Test listing orders with filters."""
|
|
# Create orders with different symbols
|
|
request1 = CreateOrderRequest(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.MARKET,
|
|
amount=0.1,
|
|
)
|
|
request2 = CreateOrderRequest(
|
|
symbol="ETH/USDT",
|
|
side=OrderSide.SELL,
|
|
order_type=OrderType.MARKET,
|
|
amount=1.0,
|
|
)
|
|
await manager.create_order(request1)
|
|
await manager.create_order(request2)
|
|
|
|
# Filter by symbol
|
|
btc_orders = await manager.list_orders(symbol="BTC/USDT")
|
|
assert all(o.symbol == "BTC/USDT" for o in btc_orders)
|
|
|
|
# Filter by side
|
|
buy_orders = await manager.list_orders(side=OrderSide.BUY)
|
|
assert all(o.side == OrderSide.BUY for o in buy_orders)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_order(self, manager):
|
|
"""Test cancelling an order."""
|
|
# Create a limit order (market orders are filled immediately)
|
|
request = CreateOrderRequest(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.LIMIT,
|
|
amount=0.1,
|
|
price=10000.0, # Low price so it doesn't fill immediately
|
|
)
|
|
|
|
order = await manager.create_order(request)
|
|
cancelled = await manager.cancel_order(order.order_id)
|
|
|
|
assert cancelled is not None
|
|
assert cancelled.status == OrderStatus.CANCELLED
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_order_not_found(self, manager):
|
|
"""Test cancelling a non-existent order."""
|
|
result = await manager.cancel_order("non_existent_id")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_already_filled_order(self, manager):
|
|
"""Test cancelling an already filled order."""
|
|
request = CreateOrderRequest(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.MARKET,
|
|
amount=0.1,
|
|
)
|
|
|
|
order = await manager.create_order(request)
|
|
# Try to cancel the filled order
|
|
cancelled = await manager.cancel_order(order.order_id)
|
|
assert cancelled is None # Can't cancel filled orders
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_position(self, manager):
|
|
"""Test getting a position."""
|
|
# First create an order to establish a position
|
|
request = CreateOrderRequest(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.MARKET,
|
|
amount=0.1,
|
|
)
|
|
await manager.create_order(request)
|
|
|
|
position = await manager.get_position("BTC/USDT")
|
|
assert position is not None
|
|
assert position.symbol == "BTC/USDT"
|
|
assert position.side == OrderSide.BUY
|
|
assert position.amount == 0.1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_position_not_found(self, manager):
|
|
"""Test getting a non-existent position."""
|
|
position = await manager.get_position("NONEXISTENT/USD")
|
|
assert position is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_positions(self, manager):
|
|
"""Test listing all positions."""
|
|
# Create positions in different symbols
|
|
symbols = ["BTC/USDT", "ETH/USDT"]
|
|
for symbol in symbols:
|
|
request = CreateOrderRequest(
|
|
symbol=symbol,
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.MARKET,
|
|
amount=0.1,
|
|
)
|
|
await manager.create_order(request)
|
|
|
|
positions = await manager.list_positions()
|
|
assert len(positions) == len(symbols)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_position(self, manager):
|
|
"""Test closing a position."""
|
|
# First create a position
|
|
request = CreateOrderRequest(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.MARKET,
|
|
amount=0.1,
|
|
)
|
|
await manager.create_order(request)
|
|
|
|
# Close the position
|
|
close_request = ClosePositionRequest(symbol="BTC/USDT")
|
|
close_order = await manager.close_position(close_request)
|
|
|
|
assert close_order is not None
|
|
assert close_order.side == OrderSide.SELL # Opposite of BUY
|
|
assert close_order.amount == 0.1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_position_not_found(self, manager):
|
|
"""Test closing a non-existent position."""
|
|
close_request = ClosePositionRequest(symbol="NONEXISTENT/USD")
|
|
result = await manager.close_position(close_request)
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_partial_position(self, manager):
|
|
"""Test closing a partial position."""
|
|
# Create a position
|
|
request = CreateOrderRequest(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.MARKET,
|
|
amount=1.0,
|
|
)
|
|
await manager.create_order(request)
|
|
|
|
# Close half the position
|
|
close_request = ClosePositionRequest(symbol="BTC/USDT", amount=0.5)
|
|
close_order = await manager.close_position(close_request)
|
|
|
|
assert close_order is not None
|
|
assert close_order.amount == 0.5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_order_summary(self, manager):
|
|
"""Test getting order summary."""
|
|
# Create some orders
|
|
for _ in range(3):
|
|
request = CreateOrderRequest(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.MARKET,
|
|
amount=0.1,
|
|
)
|
|
await manager.create_order(request)
|
|
|
|
summary = await manager.get_order_summary()
|
|
assert isinstance(summary, OrderSummary)
|
|
assert summary.total_orders >= 3
|
|
assert summary.filled_orders >= 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_position_summaries(self, manager):
|
|
"""Test getting position summaries."""
|
|
# Create a position
|
|
request = CreateOrderRequest(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.MARKET,
|
|
amount=0.1,
|
|
)
|
|
await manager.create_order(request)
|
|
|
|
summaries = await manager.get_position_summaries()
|
|
assert len(summaries) >= 1
|
|
assert isinstance(summaries[0], PositionSummary)
|
|
assert summaries[0].symbol == "BTC/USDT"
|
|
|
|
def test_get_trading_mode_status(self, manager):
|
|
"""Test getting trading mode status."""
|
|
status = manager.get_trading_mode_status()
|
|
assert isinstance(status, TradingModeStatus)
|
|
assert status.mode in ["simulated", "live"]
|
|
assert isinstance(status.is_live, bool)
|
|
assert isinstance(status.daily_limit_usd, float)
|
|
assert isinstance(status.trade_count_today, int)
|
|
|
|
def test_set_trading_mode(self, manager):
|
|
"""Test setting trading mode."""
|
|
# Start in paper mode
|
|
assert manager.is_paper_trading is True
|
|
|
|
# Switch to live mode (may fail if not configured)
|
|
result = manager.set_trading_mode(TradingModeEnum.LIVE)
|
|
# Result depends on configuration
|
|
|
|
# Switch back to paper
|
|
result = manager.set_trading_mode(TradingModeEnum.PAPER)
|
|
assert result is True
|
|
assert manager.is_paper_trading is True
|
|
|
|
def test_toggle_trading_mode(self, manager):
|
|
"""Test toggling trading mode."""
|
|
initial_mode = manager.trading_mode
|
|
status = manager.toggle_trading_mode()
|
|
assert isinstance(status, TradingModeStatus)
|
|
# Mode should have changed
|
|
assert status.mode != initial_mode.value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_account_summary(self, manager):
|
|
"""Test getting complete account summary."""
|
|
# Create an order to have some data
|
|
request = CreateOrderRequest(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.MARKET,
|
|
amount=0.1,
|
|
)
|
|
await manager.create_order(request)
|
|
|
|
summary = await manager.get_account_summary()
|
|
assert "orders" in summary
|
|
assert "positions" in summary
|
|
assert "trading_mode" in summary
|
|
assert isinstance(summary["total_position_value"], float)
|
|
assert isinstance(summary["total_unrealized_pnl"], float)
|
|
assert isinstance(summary["positions_count"], int)
|
|
|
|
|
|
class TestCreateOrderRequest:
|
|
"""Tests for CreateOrderRequest model."""
|
|
|
|
def test_create_request(self):
|
|
"""Test creating a valid order request."""
|
|
request = CreateOrderRequest(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.LIMIT,
|
|
amount=0.1,
|
|
price=50000.0,
|
|
)
|
|
assert request.symbol == "BTC/USDT"
|
|
assert request.side == OrderSide.BUY
|
|
assert request.order_type == OrderType.LIMIT
|
|
assert request.amount == 0.1
|
|
assert request.price == 50000.0
|
|
|
|
def test_create_market_request_without_price(self):
|
|
"""Test creating a market order request without price."""
|
|
request = CreateOrderRequest(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
order_type=OrderType.MARKET,
|
|
amount=0.1,
|
|
)
|
|
assert request.price is None
|
|
|
|
|
|
class TestClosePositionRequest:
|
|
"""Tests for ClosePositionRequest model."""
|
|
|
|
def test_close_request_full(self):
|
|
"""Test creating a full close request."""
|
|
request = ClosePositionRequest(symbol="BTC/USDT")
|
|
assert request.symbol == "BTC/USDT"
|
|
assert request.amount is None # Full close
|
|
assert request.order_type == OrderType.MARKET
|
|
|
|
def test_close_request_partial(self):
|
|
"""Test creating a partial close request."""
|
|
request = ClosePositionRequest(
|
|
symbol="BTC/USDT",
|
|
amount=0.5,
|
|
order_type=OrderType.LIMIT,
|
|
price=60000.0,
|
|
)
|
|
assert request.symbol == "BTC/USDT"
|
|
assert request.amount == 0.5
|
|
assert request.order_type == OrderType.LIMIT
|
|
assert request.price == 60000.0
|
|
|
|
|
|
class TestOrderSummary:
|
|
"""Tests for OrderSummary model."""
|
|
|
|
def test_default_values(self):
|
|
"""Test default values."""
|
|
summary = OrderSummary()
|
|
assert summary.total_orders == 0
|
|
assert summary.open_orders == 0
|
|
assert summary.filled_orders == 0
|
|
assert summary.cancelled_orders == 0
|
|
assert summary.total_volume == 0.0
|
|
assert summary.total_fees == 0.0
|
|
|
|
def test_custom_values(self):
|
|
"""Test custom values."""
|
|
summary = OrderSummary(
|
|
total_orders=10,
|
|
open_orders=2,
|
|
filled_orders=7,
|
|
cancelled_orders=1,
|
|
total_volume=100000.0,
|
|
total_fees=100.0,
|
|
)
|
|
assert summary.total_orders == 10
|
|
assert summary.open_orders == 2
|
|
assert summary.filled_orders == 7
|
|
assert summary.cancelled_orders == 1
|
|
assert summary.total_volume == 100000.0
|
|
assert summary.total_fees == 100.0
|
|
|
|
|
|
class TestPositionSummary:
|
|
"""Tests for PositionSummary model."""
|
|
|
|
def test_position_summary(self):
|
|
"""Test position summary creation."""
|
|
summary = PositionSummary(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
amount=0.5,
|
|
entry_price=50000.0,
|
|
current_price=55000.0,
|
|
unrealized_pnl=2500.0,
|
|
unrealized_pnl_pct=10.0,
|
|
market_value=27500.0,
|
|
)
|
|
assert summary.symbol == "BTC/USDT"
|
|
assert summary.side == OrderSide.BUY
|
|
assert summary.amount == 0.5
|
|
assert summary.entry_price == 50000.0
|
|
assert summary.current_price == 55000.0
|
|
assert summary.unrealized_pnl == 2500.0
|
|
assert summary.unrealized_pnl_pct == 10.0
|
|
assert summary.market_value == 27500.0
|
|
|
|
def test_position_summary_optional_price(self):
|
|
"""Test position summary without current price."""
|
|
summary = PositionSummary(
|
|
symbol="BTC/USDT",
|
|
side=OrderSide.BUY,
|
|
amount=0.5,
|
|
entry_price=50000.0,
|
|
)
|
|
assert summary.current_price is None
|
|
|
|
|
|
class TestTradingModeEnum:
|
|
"""Tests for TradingModeEnum."""
|
|
|
|
def test_enum_values(self):
|
|
"""Test enum values."""
|
|
assert TradingModeEnum.PAPER == "paper"
|
|
assert TradingModeEnum.LIVE == "live"
|
|
|
|
def test_enum_comparison(self):
|
|
"""Test enum comparison."""
|
|
assert TradingModeEnum.PAPER != TradingModeEnum.LIVE
|
|
mode = TradingModeEnum.PAPER
|
|
assert mode == TradingModeEnum.PAPER
|