brighter-trading/tests/test_live_broker.py

600 lines
19 KiB
Python

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