"""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