""" Tests for LiveBroker implementation. These tests use mocked exchange responses to verify LiveBroker behavior without requiring actual exchange connectivity. """ import pytest from unittest.mock import Mock, MagicMock, patch from datetime import datetime, timezone import json import sys import os sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) from brokers.live_broker import ( LiveBroker, LiveOrder, RateLimiter, retry_on_network_error ) from brokers.base_broker import OrderSide, OrderType, OrderStatus, Position import ccxt class TestRateLimiter: """Tests for the RateLimiter class.""" def test_rate_limiter_creation(self): """Test rate limiter initialization.""" limiter = RateLimiter(calls_per_second=2.0) assert limiter.min_interval == 0.5 def test_rate_limiter_wait(self): """Test that rate limiter enforces delays.""" limiter = RateLimiter(calls_per_second=100.0) # Fast for testing limiter.wait() assert limiter.last_call > 0 class TestRetryDecorator: """Tests for the retry_on_network_error decorator.""" def test_retry_succeeds_on_first_attempt(self): """Test function returns immediately when no error.""" @retry_on_network_error(max_retries=3, delay=0.01) def always_works(): return "success" assert always_works() == "success" def test_retry_on_network_error(self): """Test function retries on network error.""" call_count = 0 @retry_on_network_error(max_retries=3, delay=0.01) def fails_then_works(): nonlocal call_count call_count += 1 if call_count < 3: raise ccxt.NetworkError("Connection failed") return "success" result = fails_then_works() assert result == "success" assert call_count == 3 def test_retry_exhausted(self): """Test exception raised when retries exhausted.""" @retry_on_network_error(max_retries=2, delay=0.01) def always_fails(): raise ccxt.NetworkError("Connection failed") with pytest.raises(ccxt.NetworkError): always_fails() class TestLiveOrder: """Tests for the LiveOrder class.""" def test_live_order_creation(self): """Test creating a live order.""" order = LiveOrder( order_id="test123", exchange_order_id="EX123", symbol="BTC/USDT", side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1 ) assert order.order_id == "test123" assert order.symbol == "BTC/USDT" assert order.side == OrderSide.BUY assert order.status == OrderStatus.PENDING def test_live_order_to_dict(self): """Test serializing order to dict.""" order = LiveOrder( order_id="test123", exchange_order_id="EX123", symbol="BTC/USDT", side=OrderSide.BUY, order_type=OrderType.LIMIT, size=0.1, price=50000.0 ) order.status = OrderStatus.FILLED order.filled_qty = 0.1 order.filled_price = 50000.0 data = order.to_dict() assert data['order_id'] == "test123" assert data['symbol'] == "BTC/USDT" assert data['side'] == "buy" assert data['status'] == "filled" assert data['filled_qty'] == 0.1 def test_live_order_from_dict(self): """Test deserializing order from dict.""" data = { 'order_id': 'test123', 'exchange_order_id': 'EX123', 'symbol': 'BTC/USDT', 'side': 'buy', 'order_type': 'limit', 'size': 0.1, 'price': 50000.0, 'status': 'filled', 'filled_qty': 0.1, 'filled_price': 50000.0, 'commission': 5.0, 'created_at': datetime.now(timezone.utc).isoformat() } order = LiveOrder.from_dict(data) assert order.order_id == 'test123' assert order.side == OrderSide.BUY assert order.status == OrderStatus.FILLED class TestLiveBroker: """Tests for the LiveBroker class.""" def _create_mock_exchange(self, configured=True, testnet=True): """Create a mock exchange for testing.""" exchange = Mock() exchange.configured = configured exchange.testnet = testnet exchange.client = Mock() exchange.client.fetch_balance = Mock(return_value={ 'USDT': {'total': 10000, 'free': 9000, 'used': 1000}, 'BTC': {'total': 0.5, 'free': 0.5, 'used': 0} }) exchange.get_active_trades = Mock(return_value=[]) exchange.get_open_orders = Mock(return_value=[]) exchange.get_price = Mock(return_value=50000.0) exchange.place_order = Mock(return_value=( 'Success', {'id': 'EX123', 'status': 'open', 'filled': 0, 'average': 0} )) exchange.get_order = Mock(return_value={ 'id': 'EX123', 'status': 'closed', 'filled': 0.1, 'average': 50000.0, 'fee': {'cost': 5.0} }) return exchange def test_live_broker_creation_testnet(self): """Test creating a live broker in testnet mode.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) assert broker._testnet is True assert broker._connected is False def test_live_broker_creation_production_warning(self, caplog): """Test that production mode logs a warning.""" exchange = self._create_mock_exchange() with caplog.at_level('WARNING'): broker = LiveBroker(exchange=exchange, testnet=False) assert "PRODUCTION trading" in caplog.text def test_live_broker_connect(self): """Test connecting to exchange.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) result = broker.connect() assert result is True assert broker._connected is True assert 'USDT' in broker._balances assert broker._balances['USDT'] == 10000 def test_live_broker_connect_no_exchange(self): """Test connect fails without exchange.""" broker = LiveBroker(exchange=None, testnet=True) result = broker.connect() assert result is False assert broker._connected is False def test_live_broker_connect_not_configured(self): """Test connect fails when exchange not configured.""" exchange = self._create_mock_exchange(configured=False) broker = LiveBroker(exchange=exchange, testnet=True) result = broker.connect() assert result is False def test_live_broker_sync_balance(self): """Test syncing balance from exchange.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) balances = broker.sync_balance() assert 'USDT' in balances assert balances['USDT'] == 10000 def test_live_broker_get_balance(self): """Test getting balance.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) broker.connect() balance = broker.get_balance() assert balance == 10000 # USDT balance def test_live_broker_get_available_balance(self): """Test getting available balance.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) broker.connect() available = broker.get_available_balance() # 10000 total - 1000 locked = 9000 assert available == 9000 def test_live_broker_place_market_order(self): """Test placing a market order.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) broker.connect() result = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1 ) assert result.success assert result.order_id is not None exchange.place_order.assert_called_once() def test_live_broker_place_limit_order(self): """Test placing a limit order.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) broker.connect() result = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.LIMIT, size=0.1, price=49000.0 ) assert result.success assert result.status == OrderStatus.OPEN def test_live_broker_auto_client_id_reused_within_retry_window(self): """Auto-generated client IDs should be reused briefly for retry idempotency.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) broker.connect() first = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1 ) second = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1 ) assert first.success is True assert second.success is True assert "duplicate" in (second.message or "").lower() # Only one actual exchange call due to duplicate detection. assert exchange.place_order.call_count == 1 def test_live_broker_auto_client_id_expires_after_window(self): """After retry window expiry, identical orders should get a fresh client ID.""" import time exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) broker.connect() broker._auto_client_id_window_seconds = 0.01 first = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1 ) time.sleep(0.02) second = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0.1 ) assert first.success is True assert second.success is True # Two exchange calls because the auto ID rotated after expiry. assert exchange.place_order.call_count == 2 def test_live_broker_place_order_invalid_size(self): """Test that invalid order size is rejected.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) broker.connect() result = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.MARKET, size=0 # Invalid ) assert not result.success assert "positive" in result.message def test_live_broker_place_order_limit_no_price(self): """Test that limit order without price is rejected.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) broker.connect() result = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.LIMIT, size=0.1, price=None ) assert not result.success assert "price" in result.message.lower() def test_live_broker_cancel_order(self): """Test cancelling an order.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) broker.connect() # Place an order first result = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.LIMIT, size=0.1, price=49000.0 ) # Cancel it cancelled = broker.cancel_order(result.order_id) assert cancelled # Check order status order = broker._orders[result.order_id] assert order.status == OrderStatus.CANCELLED def test_live_broker_get_open_orders(self): """Test getting open orders.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) broker.connect() # Place a limit order (stays open) broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.LIMIT, size=0.1, price=49000.0 ) open_orders = broker.get_open_orders() assert len(open_orders) == 1 def test_live_broker_update_detects_fills(self): """Test that update() detects order fills.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) broker.connect() # Place a limit order result = broker.place_order( symbol='BTC/USDT', side=OrderSide.BUY, order_type=OrderType.LIMIT, size=0.1, price=49000.0 ) # Exchange returns filled order exchange.get_order.return_value = { 'id': 'EX123', 'status': 'closed', 'filled': 0.1, 'average': 49000.0, 'fee': {'cost': 4.9} } # Update should detect fill events = broker.update() assert len(events) == 1 assert events[0]['type'] == 'fill' assert events[0]['filled_qty'] == 0.1 assert events[0]['filled_price'] == 49000.0 def test_live_broker_get_current_price(self): """Test getting current price.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) broker._connected = True price = broker.get_current_price('BTC/USDT') assert price == 50000.0 exchange.get_price.assert_called_with('BTC/USDT') class TestLiveBrokerPersistence: """Tests for LiveBroker state persistence.""" def _create_mock_exchange(self): """Create a mock exchange for testing.""" exchange = Mock() exchange.configured = True exchange.client = Mock() exchange.client.fetch_balance = Mock(return_value={ 'USDT': {'total': 10000, 'free': 10000, 'used': 0} }) exchange.get_active_trades = Mock(return_value=[]) exchange.get_open_orders = Mock(return_value=[]) exchange.get_price = Mock(return_value=50000.0) return exchange def _create_mock_data_cache(self): """Create a mock data cache for testing.""" data_cache = Mock() data_cache.db = Mock() data_cache.db.execute_sql = Mock() data_cache.create_cache = Mock() # Empty result by default import pandas as pd data_cache.get_rows_from_datacache = Mock(return_value=pd.DataFrame()) data_cache.insert_row_into_datacache = Mock() data_cache.modify_datacache_item = Mock() return data_cache def test_live_broker_to_state_dict(self): """Test serializing broker state.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) broker.connect() # Add some state broker._balances = {'USDT': 10000} broker._current_prices = {'BTC/USDT': 50000} state = broker.to_state_dict() assert state['testnet'] is True assert 'USDT' in state['balances'] assert 'BTC/USDT' in state['current_prices'] def test_live_broker_from_state_dict(self): """Test restoring broker state.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) state = { 'testnet': True, 'balances': {'USDT': 9500}, 'locked_balances': {'USDT': 500}, 'orders': {}, 'positions': {}, 'current_prices': {'BTC/USDT': 51000} } broker.from_state_dict(state) assert broker._balances['USDT'] == 9500 assert broker._current_prices['BTC/USDT'] == 51000 def test_live_broker_save_state(self): """Test saving state to data cache.""" exchange = self._create_mock_exchange() data_cache = self._create_mock_data_cache() broker = LiveBroker(exchange=exchange, testnet=True, data_cache=data_cache) broker.connect() result = broker.save_state('test-strategy-123') assert result is True data_cache.insert_row_into_datacache.assert_called() def test_live_broker_load_state(self): """Test loading state from data cache.""" exchange = self._create_mock_exchange() data_cache = self._create_mock_data_cache() # Set up mock to return saved state import pandas as pd state_dict = { 'testnet': True, 'balances': {'USDT': 9500}, 'locked_balances': {}, 'orders': {}, 'positions': {}, 'current_prices': {} } data_cache.get_rows_from_datacache.return_value = pd.DataFrame([{ 'broker_state': json.dumps(state_dict) }]) broker = LiveBroker(exchange=exchange, testnet=True, data_cache=data_cache) result = broker.load_state('test-strategy-123') assert result is True assert broker._balances['USDT'] == 9500 class TestLiveBrokerReconciliation: """Tests for exchange reconciliation.""" def _create_mock_exchange(self): """Create a mock exchange for testing.""" exchange = Mock() exchange.configured = True exchange.client = Mock() exchange.client.fetch_balance = Mock(return_value={ 'USDT': {'total': 10000, 'free': 9000, 'used': 1000} }) exchange.get_active_trades = Mock(return_value=[]) exchange.get_open_orders = Mock(return_value=[]) exchange.get_price = Mock(return_value=50000.0) exchange.get_order = Mock(return_value={ 'id': 'EX123', 'status': 'closed', 'filled': 0.1, 'average': 50000.0 }) return exchange def test_reconcile_detects_balance_changes(self): """Test that reconciliation detects balance changes.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) broker.connect() # Manually set a different balance broker._balances['USDT'] = 8000 # Reconcile results = broker.reconcile_with_exchange() assert results['success'] is True assert len(results['balance_changes']) > 0 # Balance should now be updated to 10000 assert broker._balances['USDT'] == 10000 def test_reconcile_not_connected(self): """Test that reconciliation fails when not connected.""" exchange = self._create_mock_exchange() broker = LiveBroker(exchange=exchange, testnet=True) results = broker.reconcile_with_exchange() assert results['success'] is False assert 'error' in results