155 lines
5.5 KiB
Python
155 lines
5.5 KiB
Python
|
|
"""Tests for CircuitBreakerManager."""
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from datetime import datetime, timedelta
|
||
|
|
from nautilus_dolphin.nautilus.circuit_breaker import (
|
||
|
|
CircuitBreakerManager,
|
||
|
|
CircuitBreakerReason
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestCircuitBreakerManager:
|
||
|
|
|
||
|
|
def test_can_open_position_within_limits(self):
|
||
|
|
"""Test position can be opened when within limits."""
|
||
|
|
cb = CircuitBreakerManager(max_concurrent_positions=3)
|
||
|
|
cb._day_start = datetime.now()
|
||
|
|
|
||
|
|
can_trade, reason = cb.can_open_position("BTCUSDT", 10000.0)
|
||
|
|
|
||
|
|
assert can_trade is True
|
||
|
|
assert reason == ""
|
||
|
|
|
||
|
|
def test_can_open_position_max_positions_reached(self):
|
||
|
|
"""Test position rejected when max positions reached."""
|
||
|
|
cb = CircuitBreakerManager(max_concurrent_positions=2)
|
||
|
|
cb._day_start = datetime.now()
|
||
|
|
cb._active_positions = {'pos1', 'pos2'}
|
||
|
|
|
||
|
|
can_trade, reason = cb.can_open_position("ETHUSDT", 10000.0)
|
||
|
|
|
||
|
|
assert can_trade is False
|
||
|
|
assert "max_positions_reached" in reason
|
||
|
|
|
||
|
|
def test_can_open_position_already_exists(self):
|
||
|
|
"""Test position rejected when asset already has position."""
|
||
|
|
cb = CircuitBreakerManager(max_concurrent_positions=10)
|
||
|
|
cb._day_start = datetime.now()
|
||
|
|
cb._asset_positions = {'BTCUSDT': 'pos1'}
|
||
|
|
|
||
|
|
can_trade, reason = cb.can_open_position("BTCUSDT", 10000.0)
|
||
|
|
|
||
|
|
assert can_trade is False
|
||
|
|
assert "position_already_exists" in reason
|
||
|
|
|
||
|
|
def test_daily_loss_limit_triggers_circuit_breaker(self):
|
||
|
|
"""Test circuit breaker trips on daily loss limit."""
|
||
|
|
cb = CircuitBreakerManager(daily_loss_limit_pct=10.0)
|
||
|
|
cb.on_trading_day_start(10000.0)
|
||
|
|
|
||
|
|
# Simulate losses that exceed 10%
|
||
|
|
cb.on_position_closed('pos1', 'BTCUSDT', -500.0)
|
||
|
|
cb.on_position_closed('pos2', 'ETHUSDT', -600.0)
|
||
|
|
|
||
|
|
assert cb.is_tripped() is True
|
||
|
|
assert cb._trip_reason == CircuitBreakerReason.DAILY_LOSS_LIMIT
|
||
|
|
|
||
|
|
def test_order_size_sanity_check(self):
|
||
|
|
"""Test circuit breaker trips on oversized order."""
|
||
|
|
cb = CircuitBreakerManager(max_order_size_pct=50.0)
|
||
|
|
cb._day_start = datetime.now()
|
||
|
|
|
||
|
|
# Try to submit order for 60% of balance
|
||
|
|
can_submit, reason = cb.can_submit_order(6000.0, 10000.0)
|
||
|
|
|
||
|
|
assert can_submit is False
|
||
|
|
assert "order_size_sanity_check_failed" in reason
|
||
|
|
assert cb.is_tripped() is True
|
||
|
|
assert cb._trip_reason == CircuitBreakerReason.ORDER_SIZE_SANITY
|
||
|
|
|
||
|
|
def test_api_failure_tracking(self):
|
||
|
|
"""Test circuit breaker trips after consecutive API failures."""
|
||
|
|
cb = CircuitBreakerManager(max_api_failures=3)
|
||
|
|
|
||
|
|
# Simulate 3 API failures
|
||
|
|
cb.on_api_failure("Connection timeout")
|
||
|
|
cb.on_api_failure("Connection timeout")
|
||
|
|
cb.on_api_failure("Connection timeout")
|
||
|
|
|
||
|
|
assert cb.is_tripped() is True
|
||
|
|
assert cb._trip_reason == CircuitBreakerReason.API_FAILURE
|
||
|
|
|
||
|
|
def test_manual_trip(self):
|
||
|
|
"""Test manual circuit breaker trip."""
|
||
|
|
cb = CircuitBreakerManager()
|
||
|
|
|
||
|
|
cb.manual_trip("Emergency stop")
|
||
|
|
|
||
|
|
assert cb.is_tripped() is True
|
||
|
|
assert cb._trip_reason == CircuitBreakerReason.MANUAL
|
||
|
|
|
||
|
|
def test_auto_reset_after_time(self):
|
||
|
|
"""Test circuit breaker auto-resets after configured time."""
|
||
|
|
cb = CircuitBreakerManager(auto_reset_hours=1.0)
|
||
|
|
|
||
|
|
cb.manual_trip("Test trip")
|
||
|
|
assert cb.is_tripped() is True
|
||
|
|
|
||
|
|
# Manually set trip time to past
|
||
|
|
cb._trip_time = datetime.now() - timedelta(hours=2)
|
||
|
|
|
||
|
|
# Should auto-reset on next check
|
||
|
|
assert cb.is_tripped() is False
|
||
|
|
|
||
|
|
def test_get_status(self):
|
||
|
|
"""Test status report."""
|
||
|
|
cb = CircuitBreakerManager()
|
||
|
|
cb.on_trading_day_start(10000.0)
|
||
|
|
|
||
|
|
status = cb.get_status()
|
||
|
|
|
||
|
|
assert 'is_tripped' in status
|
||
|
|
assert 'active_positions' in status
|
||
|
|
assert 'daily_pnl' in status
|
||
|
|
assert status['active_positions'] == 0
|
||
|
|
|
||
|
|
|
||
|
|
class TestCircuitBreakerIntegration:
|
||
|
|
|
||
|
|
def test_full_trading_scenario(self):
|
||
|
|
"""Test complete trading scenario with circuit breaker."""
|
||
|
|
cb = CircuitBreakerManager(
|
||
|
|
daily_loss_limit_pct=10.0,
|
||
|
|
max_concurrent_positions=2,
|
||
|
|
max_order_size_pct=50.0
|
||
|
|
)
|
||
|
|
|
||
|
|
# Start trading day
|
||
|
|
cb.on_trading_day_start(10000.0)
|
||
|
|
|
||
|
|
# Should allow first position
|
||
|
|
can_trade, _ = cb.can_open_position("BTCUSDT", 10000.0)
|
||
|
|
assert can_trade is True
|
||
|
|
|
||
|
|
# Open position
|
||
|
|
cb.on_position_opened("pos1", "BTCUSDT")
|
||
|
|
|
||
|
|
# Should allow second position
|
||
|
|
can_trade, _ = cb.can_open_position("ETHUSDT", 10000.0)
|
||
|
|
assert can_trade is True
|
||
|
|
|
||
|
|
# Open second position
|
||
|
|
cb.on_position_opened("pos2", "ETHUSDT")
|
||
|
|
|
||
|
|
# Should reject third position (max 2)
|
||
|
|
can_trade, reason = cb.can_open_position("SOLUSDT", 10000.0)
|
||
|
|
assert can_trade is False
|
||
|
|
assert "max_positions_reached" in reason
|
||
|
|
|
||
|
|
# Close first position with profit
|
||
|
|
cb.on_position_closed("pos1", "BTCUSDT", 500.0)
|
||
|
|
|
||
|
|
# Now should allow new position
|
||
|
|
can_trade, _ = cb.can_open_position("SOLUSDT", 10000.0)
|
||
|
|
assert can_trade is True
|