600 lines
19 KiB
Python
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
|