feat: add live trading safety supervisor

This commit is contained in:
Leo
2026-09-15 15:50:31 +00:00
committed by GitHub
parent c1bd7d269d
commit a10365cae0
5 changed files with 281 additions and 1 deletions
+3
View File
@@ -13,6 +13,7 @@ from src.trading.capital_allocator import (
)
from src.trading.mt5_connector import MT5Connector
from src.trading.risk_engine import RiskDecision, RiskEngine
from src.trading.safety_supervisor import ReconciliationResult, SafetySupervisor
__all__ = [
"AllocationRequest",
@@ -24,6 +25,8 @@ __all__ = [
"RejectionCode",
"RiskDecision",
"RiskEngine",
"ReconciliationResult",
"SafetySupervisor",
"StrategyConfig",
"TradeSignal",
]
+14 -1
View File
@@ -48,6 +48,7 @@ from src.core.exceptions import (
from src.core.resilience import CircuitBreaker
from src.core.retry import with_retry
from src.core.schemas import TradeSignal
from src.trading.safety_supervisor import SafetySupervisor
# Apply nest_asyncio to allow nested loops (MetaAPI SDK uses asyncio)
nest_asyncio.apply()
@@ -93,7 +94,12 @@ class MT5Connector:
Supports both native Windows SDK and MetaAPI cloud fallback for cross-platform support.
"""
def __init__(self, config: TradingConfig, monitor: Optional["Monitor"] = None) -> None:
def __init__(
self,
config: TradingConfig,
monitor: Optional["Monitor"] = None,
safety_supervisor: Optional[SafetySupervisor] = None,
) -> None:
"""
Initialize the connector with configuration.
@@ -103,6 +109,7 @@ class MT5Connector:
"""
self.cfg = config
self.monitor = monitor
self.safety_supervisor = safety_supervisor
self.use_metaapi: bool = False
self.metaapi: Any | None = None
self.metaapi_account: Any | None = None
@@ -527,6 +534,12 @@ class MT5Connector:
def _place_order_logic(self, signal: TradeSignal) -> Optional[int]:
"""Internal order placement logic wrapped by circuit breaker."""
if self.safety_supervisor and not self.safety_supervisor.trading_allowed:
raise MT5ExecutionError(
"Order blocked by live trading safety supervisor",
details={"reasons": self.safety_supervisor.halt_reasons},
is_retriable=False,
)
if not self._is_initialized:
self.initialize()
+159
View File
@@ -0,0 +1,159 @@
"""Broker-state reconciliation and fail-closed live trading gate."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Protocol
logger = logging.getLogger(__name__)
class BrokerStateProvider(Protocol):
"""Minimum connector interface required by the safety supervisor."""
def get_account_info(self) -> dict[str, Any]: ...
def get_positions(self) -> list[dict[str, Any]]: ...
def get_terminal_status(self) -> dict[str, Any]: ...
@dataclass(frozen=True)
class ReconciliationResult:
"""Outcome of one broker-versus-strategy state check."""
is_safe: bool
reasons: tuple[str, ...] = ()
broker_position_count: int = 0
internal_position_count: int = 0
account_balance: float | None = None
account_equity: float | None = None
@dataclass
class SafetySupervisor:
"""Maintain a fail-closed gate for live order submission.
A failed or mismatched reconciliation latches the supervisor in a halted
state. An operator must acknowledge a subsequent healthy reconciliation
before trading can resume.
"""
volume_tolerance: float = 1e-6
halted: bool = False
halt_reasons: tuple[str, ...] = field(default_factory=tuple)
last_result: ReconciliationResult | None = None
@property
def trading_allowed(self) -> bool:
"""Return whether new orders may be submitted."""
return not self.halted
def reconcile(
self,
provider: BrokerStateProvider,
internal_positions: list[dict[str, Any]],
) -> ReconciliationResult:
"""Compare broker state with the strategy's current position view."""
try:
account = provider.get_account_info()
broker_positions = provider.get_positions()
terminal = provider.get_terminal_status()
except Exception as exc:
return self._halt((f"broker_state_unavailable:{type(exc).__name__}",))
reasons: list[str] = []
if terminal.get("algo_trading") is False:
reasons.append("terminal_algo_trading_disabled")
broker_by_ticket = self._index_positions(broker_positions)
internal_by_ticket = self._index_positions(internal_positions)
if len(broker_by_ticket) != len(broker_positions):
reasons.append("broker_position_missing_ticket")
if len(internal_by_ticket) != len(internal_positions):
reasons.append("internal_position_missing_ticket")
missing_tickets = sorted(set(internal_by_ticket) - set(broker_by_ticket))
orphan_tickets = sorted(set(broker_by_ticket) - set(internal_by_ticket))
if missing_tickets:
reasons.append(f"missing_broker_positions:{','.join(missing_tickets)}")
if orphan_tickets:
reasons.append(f"orphan_broker_positions:{','.join(orphan_tickets)}")
for ticket in sorted(set(internal_by_ticket) & set(broker_by_ticket)):
internal = internal_by_ticket[ticket]
broker = broker_by_ticket[ticket]
if not self._same_position(internal, broker):
reasons.append(f"position_mismatch:{ticket}")
result = ReconciliationResult(
is_safe=not reasons,
reasons=tuple(reasons),
broker_position_count=len(broker_positions),
internal_position_count=len(internal_positions),
account_balance=self._number(account.get("balance")),
account_equity=self._number(account.get("equity")),
)
self.last_result = result
if reasons:
self.halted = True
self.halt_reasons = result.reasons
logger.critical("Live trading halted: %s", "; ".join(result.reasons))
return result
def acknowledge_resume(self) -> bool:
"""Resume trading only after a healthy reconciliation is recorded."""
if self.last_result is None or not self.last_result.is_safe:
return False
self.halted = False
self.halt_reasons = ()
logger.warning("Live trading resume acknowledged")
return True
def _halt(self, reasons: tuple[str, ...]) -> ReconciliationResult:
result = ReconciliationResult(is_safe=False, reasons=reasons)
self.last_result = result
self.halted = True
self.halt_reasons = reasons
logger.critical("Live trading halted: %s", "; ".join(reasons))
return result
def _index_positions(self, positions: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
indexed: dict[str, dict[str, Any]] = {}
for position in positions:
ticket = position.get("ticket", position.get("id"))
if ticket is not None:
indexed[str(ticket)] = position
return indexed
def _same_position(self, left: dict[str, Any], right: dict[str, Any]) -> bool:
if left.get("symbol") != right.get("symbol"):
return False
left_direction = left.get("type", left.get("direction"))
right_direction = right.get("type", right.get("direction"))
if left_direction != right_direction:
return False
left_volume = self._number(left.get("volume", left.get("lot_size")))
right_volume = self._number(right.get("volume", right.get("lot_size")))
return (
left_volume is not None
and right_volume is not None
and abs(left_volume - right_volume) <= self.volume_tolerance
)
@staticmethod
def _number(value: Any) -> float | None:
try:
return None if value is None else float(value)
except (TypeError, ValueError):
return None
__all__ = ["BrokerStateProvider", "ReconciliationResult", "SafetySupervisor"]
+20
View File
@@ -3,8 +3,10 @@ from unittest.mock import MagicMock, patch
import pytest
from src.core.config import TradingConfig
from src.core.exceptions import MT5ExecutionError
from src.core.schemas import TradeSignal
from src.trading.mt5_connector import MT5Connector
from src.trading.safety_supervisor import SafetySupervisor
@pytest.fixture
@@ -18,6 +20,24 @@ def test_connector_init(mock_config):
assert not connector._is_initialized
def test_place_order_blocked_by_safety_supervisor(mock_config):
supervisor = SafetySupervisor(halted=True, halt_reasons=("orphan_position",))
connector = MT5Connector(mock_config, safety_supervisor=supervisor)
signal = TradeSignal(
symbol="XAUUSD",
direction=1,
entry_price=2300.0,
stop_loss=2290.0,
take_profit=2320.0,
lot_size=0.1,
algorithm="ppo",
confidence=0.8,
)
with pytest.raises(MT5ExecutionError, match="safety supervisor"):
connector.place_order(signal)
@patch("src.trading.mt5_connector.mt5")
@patch("src.trading.mt5_connector.MT5_AVAILABLE", True)
def test_native_initialization(mock_mt5, mock_config):
+85
View File
@@ -0,0 +1,85 @@
from dataclasses import dataclass
from src.trading.safety_supervisor import SafetySupervisor
@dataclass
class Provider:
positions: list[dict]
terminal: dict | None = None
error: Exception | None = None
def get_account_info(self):
if self.error:
raise self.error
return {"balance": 10000.0, "equity": 9950.0}
def get_positions(self):
return self.positions
def get_terminal_status(self):
return self.terminal or {"algo_trading": True}
def position(volume=0.2):
return {"ticket": 7, "symbol": "XAUUSD", "type": 0, "volume": volume}
def test_healthy_reconciliation_allows_trading():
supervisor = SafetySupervisor()
result = supervisor.reconcile(Provider([position()]), [position()])
assert result.is_safe
assert supervisor.trading_allowed
assert result.account_equity == 9950.0
def test_orphan_position_latches_halt_until_acknowledged():
supervisor = SafetySupervisor()
result = supervisor.reconcile(Provider([position()]), [])
assert not result.is_safe
assert "orphan_broker_positions:7" in result.reasons
assert not supervisor.trading_allowed
assert not supervisor.acknowledge_resume()
supervisor.reconcile(Provider([]), [])
assert supervisor.acknowledge_resume()
assert supervisor.trading_allowed
def test_volume_mismatch_and_disabled_terminal_are_unsafe():
supervisor = SafetySupervisor()
result = supervisor.reconcile(
Provider([position(0.3)], terminal={"algo_trading": False}),
[position(0.2)],
)
assert not result.is_safe
assert "terminal_algo_trading_disabled" in result.reasons
assert "position_mismatch:7" in result.reasons
def test_provider_failure_fails_closed():
supervisor = SafetySupervisor()
result = supervisor.reconcile(Provider([], error=TimeoutError()), [])
assert not result.is_safe
assert result.reasons == ("broker_state_unavailable:TimeoutError",)
assert not supervisor.trading_allowed
def test_position_without_ticket_is_unsafe():
supervisor = SafetySupervisor()
result = supervisor.reconcile(
Provider([{"symbol": "XAUUSD", "type": 0, "volume": 0.2}]),
[],
)
assert not result.is_safe
assert "broker_position_missing_ticket" in result.reasons