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