mirror of
https://github.com/triqbit/mt5-ai-xauusd-trader.git
synced 2026-11-02 06:56:15 +00:00
feat: add live trading safety supervisor
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user