Add risk management features: implement KillSwitch and StopConditionsGuard; update settings and tests
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from arbitrade.risk.kill_switch import KillSwitch
|
||||
|
||||
|
||||
def test_kill_switch_can_activate_and_deactivate() -> None:
|
||||
kill_switch = KillSwitch()
|
||||
|
||||
assert not kill_switch.is_active
|
||||
assert kill_switch.reason is None
|
||||
|
||||
kill_switch.activate(reason="manual")
|
||||
|
||||
assert kill_switch.is_active
|
||||
assert kill_switch.reason == "manual"
|
||||
|
||||
kill_switch.deactivate()
|
||||
|
||||
assert not kill_switch.is_active
|
||||
assert kill_switch.reason is None
|
||||
|
||||
|
||||
def test_kill_switch_active_on_init_sets_reason() -> None:
|
||||
kill_switch = KillSwitch(active=True)
|
||||
|
||||
assert kill_switch.is_active
|
||||
assert kill_switch.reason == "manual"
|
||||
@@ -9,8 +9,10 @@ import pytest
|
||||
from arbitrade.detection.engine import OpportunityEvent
|
||||
from arbitrade.exchange.models import BookDelta, BookLevel
|
||||
from arbitrade.market_data.feed import ExecutionOutcome, MarketDataFeed
|
||||
from arbitrade.risk.kill_switch import KillSwitch
|
||||
from arbitrade.risk.loss_limits import LossLimitGuard
|
||||
from arbitrade.risk.pre_trade import PreTradeValidator
|
||||
from arbitrade.risk.stop_conditions import StopConditionsGuard
|
||||
from arbitrade.risk.trade_limits import TradeLimitsGuard
|
||||
|
||||
|
||||
@@ -72,6 +74,15 @@ class _FakeExecutor:
|
||||
return self.realized_pnls.pop(0)
|
||||
|
||||
|
||||
class _FakeFailingExecutor:
|
||||
def __init__(self) -> None:
|
||||
self.calls: int = 0
|
||||
|
||||
async def execute(self, _event: OpportunityEvent) -> None:
|
||||
self.calls += 1
|
||||
raise RuntimeError("executor failure")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _FakeWsClientTwoMessages:
|
||||
delta: BookDelta
|
||||
@@ -215,8 +226,7 @@ async def test_market_data_feed_enforces_max_concurrent_trades() -> None:
|
||||
event = _sample_event()
|
||||
detector = _FakeDetector(event)
|
||||
executor = _FakeExecutor()
|
||||
executor.outcomes = [ExecutionOutcome(
|
||||
realized_pnl=None, close_trade=False)]
|
||||
executor.outcomes = [ExecutionOutcome(realized_pnl=None, close_trade=False)]
|
||||
trade_guard = TradeLimitsGuard(max_concurrent_trades=1)
|
||||
feed = MarketDataFeed(
|
||||
ws_client=_FakeWsClientTwoMessages(_sample_delta()),
|
||||
@@ -322,3 +332,101 @@ async def test_market_data_feed_allows_when_pre_trade_validation_passes() -> Non
|
||||
await feed.run()
|
||||
|
||||
assert len(executor.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_market_data_feed_blocks_when_kill_switch_active() -> None:
|
||||
event = _sample_event(allocated_capital=75.0)
|
||||
detector = _FakeDetector(event)
|
||||
executor = _FakeExecutor()
|
||||
kill_switch = KillSwitch(active=True, reason="manual")
|
||||
feed = MarketDataFeed(
|
||||
ws_client=_FakeWsClient(_sample_delta()),
|
||||
snapshot_writer=_FakeSnapshotWriter(),
|
||||
detector=detector,
|
||||
opportunity_writer=_FakeOpportunityWriter(),
|
||||
paper_trading_mode=False,
|
||||
opportunity_executor=executor.execute,
|
||||
kill_switch=kill_switch,
|
||||
)
|
||||
|
||||
await feed.run()
|
||||
|
||||
assert len(executor.calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_market_data_feed_allows_when_kill_switch_inactive() -> None:
|
||||
event = _sample_event(allocated_capital=75.0)
|
||||
detector = _FakeDetector(event)
|
||||
executor = _FakeExecutor()
|
||||
kill_switch = KillSwitch(active=False)
|
||||
feed = MarketDataFeed(
|
||||
ws_client=_FakeWsClient(_sample_delta()),
|
||||
snapshot_writer=_FakeSnapshotWriter(),
|
||||
detector=detector,
|
||||
opportunity_writer=_FakeOpportunityWriter(),
|
||||
paper_trading_mode=False,
|
||||
opportunity_executor=executor.execute,
|
||||
kill_switch=kill_switch,
|
||||
)
|
||||
|
||||
await feed.run()
|
||||
|
||||
assert len(executor.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_market_data_feed_halts_on_abnormal_source_latency() -> None:
|
||||
event = _sample_event(allocated_capital=75.0)
|
||||
detector = _FakeDetector(event)
|
||||
executor = _FakeExecutor()
|
||||
kill_switch = KillSwitch(active=False)
|
||||
stop_guard = StopConditionsGuard(max_source_latency_ms=1.0)
|
||||
delta = _sample_delta()
|
||||
delta.source_timestamp_ms = 0
|
||||
feed = MarketDataFeed(
|
||||
ws_client=_FakeWsClient(delta),
|
||||
snapshot_writer=_FakeSnapshotWriter(),
|
||||
detector=detector,
|
||||
opportunity_writer=_FakeOpportunityWriter(),
|
||||
paper_trading_mode=False,
|
||||
opportunity_executor=executor.execute,
|
||||
kill_switch=kill_switch,
|
||||
stop_conditions_guard=stop_guard,
|
||||
)
|
||||
|
||||
await feed.run()
|
||||
|
||||
assert stop_guard.is_halted
|
||||
assert stop_guard.halted_reason == "source_latency_limit_breached"
|
||||
assert kill_switch.is_active
|
||||
assert kill_switch.reason == "source_latency_limit_breached"
|
||||
assert len(executor.calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_market_data_feed_halts_on_repeated_execution_failures() -> None:
|
||||
event = _sample_event(allocated_capital=75.0)
|
||||
detector = _FakeDetector(event)
|
||||
executor = _FakeFailingExecutor()
|
||||
kill_switch = KillSwitch(active=False)
|
||||
stop_guard = StopConditionsGuard(max_consecutive_failures=2)
|
||||
feed = MarketDataFeed(
|
||||
ws_client=_FakeWsClientTwoMessages(_sample_delta()),
|
||||
snapshot_writer=_FakeSnapshotWriter(),
|
||||
detector=detector,
|
||||
opportunity_writer=_FakeOpportunityWriter(),
|
||||
paper_trading_mode=False,
|
||||
opportunity_executor=executor.execute,
|
||||
kill_switch=kill_switch,
|
||||
stop_conditions_guard=stop_guard,
|
||||
)
|
||||
|
||||
await feed.run()
|
||||
|
||||
assert executor.calls == 2
|
||||
assert stop_guard.is_halted
|
||||
assert stop_guard.halted_reason == "consecutive_failures_limit_breached"
|
||||
assert kill_switch.is_active
|
||||
assert kill_switch.reason == "consecutive_failures_limit_breached"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from arbitrade.risk.stop_conditions import StopConditionsGuard
|
||||
|
||||
|
||||
def test_stop_conditions_guard_halts_on_source_latency_breach() -> None:
|
||||
guard = StopConditionsGuard(max_source_latency_ms=50.0)
|
||||
|
||||
guard.observe_latency(source_latency_ms=75.0, apply_latency_ms=1.0)
|
||||
|
||||
assert guard.is_halted
|
||||
assert guard.halted_reason == "source_latency_limit_breached"
|
||||
|
||||
|
||||
def test_stop_conditions_guard_halts_on_apply_latency_breach() -> None:
|
||||
guard = StopConditionsGuard(max_apply_latency_ms=2.0)
|
||||
|
||||
guard.observe_latency(source_latency_ms=None, apply_latency_ms=3.5)
|
||||
|
||||
assert guard.is_halted
|
||||
assert guard.halted_reason == "apply_latency_limit_breached"
|
||||
|
||||
|
||||
def test_stop_conditions_guard_halts_on_consecutive_failures() -> None:
|
||||
guard = StopConditionsGuard(max_consecutive_failures=2)
|
||||
|
||||
guard.register_failure()
|
||||
assert not guard.is_halted
|
||||
|
||||
guard.register_failure()
|
||||
|
||||
assert guard.is_halted
|
||||
assert guard.halted_reason == "consecutive_failures_limit_breached"
|
||||
|
||||
|
||||
def test_stop_conditions_guard_resets_failures_after_success() -> None:
|
||||
guard = StopConditionsGuard(max_consecutive_failures=3)
|
||||
|
||||
guard.register_failure()
|
||||
guard.register_success()
|
||||
guard.register_failure()
|
||||
|
||||
assert guard.consecutive_failures == 1
|
||||
assert not guard.is_halted
|
||||
|
||||
|
||||
def test_stop_conditions_guard_rejects_invalid_configuration() -> None:
|
||||
with pytest.raises(ValueError, match="max_source_latency_ms"):
|
||||
StopConditionsGuard(max_source_latency_ms=0.0)
|
||||
|
||||
with pytest.raises(ValueError, match="max_apply_latency_ms"):
|
||||
StopConditionsGuard(max_apply_latency_ms=-1.0)
|
||||
|
||||
with pytest.raises(ValueError, match="max_consecutive_failures"):
|
||||
StopConditionsGuard(max_consecutive_failures=0)
|
||||
Reference in New Issue
Block a user