brighter-trading/tests/test_live_strategy_instance.py

453 lines
15 KiB
Python

"""
Tests for LiveStrategyInstance.
These tests verify circuit breaker, position limits, and live trading
integration using mocked exchange and broker instances.
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from datetime import datetime, timezone
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from brokers.base_broker import OrderSide, OrderType, OrderStatus, OrderResult, Position
class MockLiveBroker:
"""Mock LiveBroker for testing LiveStrategyInstance."""
def __init__(self, initial_balance=10000):
self._connected = False
self._balances = {'USDT': initial_balance}
self._locked_balances = {}
self._positions = {}
self._orders = {}
self._current_prices = {'BTC/USDT': 50000}
self._testnet = True
self.commission = 0.001
def connect(self):
self._connected = True
return True
def disconnect(self):
self._connected = False
def sync_balance(self):
return self._balances
def get_balance(self, asset=None):
if asset:
return self._balances.get(asset, 0)
return self._balances.get('USDT', 0)
def get_available_balance(self, asset=None):
total = self.get_balance(asset)
locked = self._locked_balances.get(asset or 'USDT', 0)
return total - locked
def get_current_price(self, symbol):
return self._current_prices.get(symbol, 0)
def get_position(self, symbol):
return self._positions.get(symbol)
def get_all_positions(self):
return list(self._positions.values())
def get_open_orders(self, symbol=None):
return []
def place_order(self, symbol, side, order_type, size, price=None, **kwargs):
return OrderResult(
success=True,
order_id='TEST123',
status=OrderStatus.FILLED,
filled_qty=size,
filled_price=price or self._current_prices.get(symbol, 50000),
commission=size * (price or 50000) * self.commission
)
def update(self):
return []
def save_state(self, strategy_instance_id):
return True
def load_state(self, strategy_instance_id):
return False
def reconcile_with_exchange(self):
return {'success': True}
class TestLiveStrategyInstanceCircuitBreaker:
"""Tests for circuit breaker functionality."""
def _create_mock_instance(self, starting_balance=10000, current_balance=10000,
circuit_breaker_pct=-0.10):
"""Create a mock instance with circuit breaker."""
# We'll test the circuit breaker logic directly
class MockInstance:
def __init__(self):
self.starting_balance = starting_balance
self.current_balance = current_balance
self._circuit_breaker_pct = circuit_breaker_pct
self._circuit_breaker_tripped = False
self._circuit_breaker_reason = ""
def _check_circuit_breaker(self):
if self._circuit_breaker_tripped:
return True
if self.starting_balance <= 0:
return False
drawdown_pct = (self.current_balance - self.starting_balance) / self.starting_balance
if drawdown_pct < self._circuit_breaker_pct:
self._circuit_breaker_tripped = True
self._circuit_breaker_reason = (
f"Drawdown {drawdown_pct:.2%} exceeded threshold {self._circuit_breaker_pct:.2%}"
)
return True
return False
return MockInstance()
def test_circuit_breaker_not_tripped_within_threshold(self):
"""Test that circuit breaker doesn't trip within threshold."""
# 5% loss is within -10% threshold
instance = self._create_mock_instance(
starting_balance=10000,
current_balance=9500
)
result = instance._check_circuit_breaker()
assert result is False
assert instance._circuit_breaker_tripped is False
def test_circuit_breaker_trips_on_excessive_drawdown(self):
"""Test that circuit breaker trips on excessive drawdown."""
# 15% loss exceeds -10% threshold
instance = self._create_mock_instance(
starting_balance=10000,
current_balance=8500
)
result = instance._check_circuit_breaker()
assert result is True
assert instance._circuit_breaker_tripped is True
assert "-15" in instance._circuit_breaker_reason or "15" in instance._circuit_breaker_reason
def test_circuit_breaker_stays_tripped(self):
"""Test that circuit breaker stays tripped once triggered."""
instance = self._create_mock_instance(
starting_balance=10000,
current_balance=8500
)
# Trip the breaker
instance._check_circuit_breaker()
# Even if balance recovers, breaker stays tripped
instance.current_balance = 11000
result = instance._check_circuit_breaker()
assert result is True # Still tripped
def test_circuit_breaker_custom_threshold(self):
"""Test circuit breaker with custom threshold."""
# 3% loss with -5% threshold should not trip
instance = self._create_mock_instance(
starting_balance=10000,
current_balance=9700,
circuit_breaker_pct=-0.05
)
result = instance._check_circuit_breaker()
assert result is False
# 6% loss should trip with -5% threshold
instance.current_balance = 9400
result = instance._check_circuit_breaker()
assert result is True
class TestLiveStrategyInstancePositionLimits:
"""Tests for position limit functionality."""
def _create_mock_instance(self, starting_balance=10000, max_position_pct=0.5):
"""Create a mock instance with position limits."""
class MockInstance:
def __init__(self):
self.starting_balance = starting_balance
self._max_position_pct = max_position_pct
self.live_broker = Mock()
self.live_broker.get_available_balance.return_value = starting_balance
def _check_position_limit(self, size, price, symbol):
if self.starting_balance <= 0:
return True
order_value = size * price
max_order_value = self.starting_balance * self._max_position_pct
if order_value > max_order_value:
return False
available = self.live_broker.get_available_balance()
if order_value > available:
return False
return True
return MockInstance()
def test_position_limit_allows_valid_order(self):
"""Test that orders within limit are allowed."""
instance = self._create_mock_instance(
starting_balance=10000,
max_position_pct=0.5
)
# Order value = 0.05 * 50000 = 2500, which is < 5000 (50% of 10000)
result = instance._check_position_limit(0.05, 50000, 'BTC/USDT')
assert result is True
def test_position_limit_rejects_oversized_order(self):
"""Test that oversized orders are rejected."""
instance = self._create_mock_instance(
starting_balance=10000,
max_position_pct=0.5
)
# Order value = 0.15 * 50000 = 7500, which is > 5000 (50% of 10000)
result = instance._check_position_limit(0.15, 50000, 'BTC/USDT')
assert result is False
def test_position_limit_rejects_insufficient_balance(self):
"""Test that orders exceeding available balance are rejected."""
instance = self._create_mock_instance(
starting_balance=10000,
max_position_pct=0.5
)
# Available balance is less than order value
instance.live_broker.get_available_balance.return_value = 2000
# Order value = 0.05 * 50000 = 2500, but only 2000 available
result = instance._check_position_limit(0.05, 50000, 'BTC/USDT')
assert result is False
class TestLiveStrategyInstanceTick:
"""Tests for tick processing."""
def test_tick_returns_circuit_breaker_event(self):
"""Test that tick returns circuit breaker event when tripped."""
# Simulating the tick behavior
class MockInstance:
def __init__(self):
self.strategy_id = 'test-strategy'
self._circuit_breaker_tripped = True
self._circuit_breaker_reason = "Test reason"
def tick(self, candle_data=None):
if self._circuit_breaker_tripped:
return [{
'type': 'circuit_breaker',
'strategy_id': self.strategy_id,
'reason': self._circuit_breaker_reason
}]
return []
instance = MockInstance()
events = instance.tick()
assert len(events) == 1
assert events[0]['type'] == 'circuit_breaker'
assert events[0]['strategy_id'] == 'test-strategy'
def test_tick_processes_broker_fill_events(self):
"""Test that tick processes fill events from broker."""
fill_event = {
'type': 'fill',
'order_id': 'TEST123',
'symbol': 'BTC/USDT',
'side': 'buy',
'filled_qty': 0.1,
'filled_price': 50000
}
broker = Mock()
broker.update.return_value = [fill_event]
# The tick method should capture fill events
assert fill_event['type'] == 'fill'
class TestLiveStrategyInstanceSafetyFeatures:
"""Tests for overall safety features."""
def test_testnet_mode_default(self):
"""Test that testnet mode is default."""
broker = MockLiveBroker()
assert broker._testnet is True
def test_circuit_breaker_status_property(self):
"""Test circuit breaker status property."""
class MockInstance:
def __init__(self):
self._circuit_breaker_tripped = False
self._circuit_breaker_reason = ""
self._circuit_breaker_pct = -0.10
self.starting_balance = 10000
self.current_balance = 9500
@property
def circuit_breaker_status(self):
return {
'tripped': self._circuit_breaker_tripped,
'reason': self._circuit_breaker_reason,
'threshold': self._circuit_breaker_pct,
'current_drawdown': (
(self.current_balance - self.starting_balance) / self.starting_balance
if self.starting_balance > 0 else 0
)
}
instance = MockInstance()
status = instance.circuit_breaker_status
assert status['tripped'] is False
assert status['threshold'] == -0.10
assert status['current_drawdown'] == pytest.approx(-0.05)
def test_reset_circuit_breaker(self):
"""Test that circuit breaker can be manually reset."""
class MockInstance:
def __init__(self):
self._circuit_breaker_tripped = True
self._circuit_breaker_reason = "Previous issue"
def reset_circuit_breaker(self):
self._circuit_breaker_tripped = False
self._circuit_breaker_reason = ""
instance = MockInstance()
assert instance._circuit_breaker_tripped is True
instance.reset_circuit_breaker()
assert instance._circuit_breaker_tripped is False
assert instance._circuit_breaker_reason == ""
class TestLiveStrategyInstanceIntegration:
"""Integration tests for LiveStrategyInstance behavior."""
def test_order_rejected_when_circuit_breaker_tripped(self):
"""Test that orders are rejected when circuit breaker is tripped."""
class MockInstance:
def __init__(self):
self._circuit_breaker_tripped = True
self._max_position_pct = 0.5
self.starting_balance = 10000
self.live_broker = MockLiveBroker()
def _check_circuit_breaker(self):
return self._circuit_breaker_tripped
def trade_order(self, trade_type, size, order_type, **kwargs):
if self._check_circuit_breaker():
return None
return self.live_broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY if trade_type.lower() == 'buy' else OrderSide.SELL,
order_type=OrderType.MARKET,
size=size
)
instance = MockInstance()
result = instance.trade_order('buy', 0.1, 'MARKET')
assert result is None
def test_order_succeeds_when_safety_checks_pass(self):
"""Test that orders succeed when all safety checks pass."""
class MockInstance:
def __init__(self):
self._circuit_breaker_tripped = False
self._max_position_pct = 0.5
self.starting_balance = 10000
self.live_broker = MockLiveBroker()
def _check_circuit_breaker(self):
return self._circuit_breaker_tripped
def _check_position_limit(self, size, price, symbol):
return True
def get_current_price(self, **kwargs):
return 50000
def trade_order(self, trade_type, size, order_type, **kwargs):
if self._check_circuit_breaker():
return None
price = self.get_current_price()
if not self._check_position_limit(size, price, 'BTC/USDT'):
return None
return self.live_broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY if trade_type.lower() == 'buy' else OrderSide.SELL,
order_type=OrderType.MARKET,
size=size
)
instance = MockInstance()
result = instance.trade_order('buy', 0.05, 'MARKET')
assert result is not None
assert result.success is True
class TestLiveStrategyInstanceState:
"""Tests for state management."""
def test_save_context_includes_broker_state(self):
"""Test that save_context saves broker state."""
broker = Mock()
broker.save_state = Mock(return_value=True)
# Simulate save_context behavior
broker.save_state('test-strategy-123')
broker.save_state.assert_called_once_with('test-strategy-123')
def test_testnet_property(self):
"""Test is_testnet property."""
class MockInstance:
def __init__(self, testnet=True):
self._testnet = testnet
@property
def is_testnet(self):
return self._testnet
testnet_instance = MockInstance(testnet=True)
assert testnet_instance.is_testnet is True
production_instance = MockInstance(testnet=False)
assert production_instance.is_testnet is False