Phase 7 live adapter safety closure

This commit is contained in:
Codex
2026-05-06 16:42:33 +08:00
parent d7a04975c7
commit 6e33a5e2d5
2 changed files with 148 additions and 7 deletions
+100 -6
View File
@@ -50,10 +50,31 @@ class LiveBrokerService:
) -> Optional[LiveOperationDecision]:
ticket = self.repository.fetch_execution_ticket_by_id(ticket_id)
if not ticket:
return None
return self._record_live_audit(
adapter_name=adapter_name,
operation_type="submit_bracket_order",
status="rejected",
requested_by=requested_by,
execution_ticket_id=ticket_id,
explicit_confirmation=explicit_live_confirmation,
payload={"ticket_id": ticket_id},
result={"rejected": True},
reason_codes=["execution_ticket_missing"],
)
signal = self.repository.fetch_signal_for_execution(int(ticket["signal_id"]))
if not signal:
return None
return self._record_live_audit(
adapter_name=adapter_name,
operation_type="submit_bracket_order",
status="rejected",
requested_by=requested_by,
execution_ticket_id=ticket_id,
signal_id=int(ticket["signal_id"]),
explicit_confirmation=explicit_live_confirmation,
payload={"ticket_id": ticket_id, "signal_id": int(ticket["signal_id"])},
result={"rejected": True},
reason_codes=["execution_ticket_signal_missing"],
)
reason_codes = validate_live_submit_request(
ticket=ticket,
live_trading_enabled=settings.live_trading_enabled,
@@ -78,7 +99,19 @@ class LiveBrokerService:
result={"rejected": True},
reason_codes=reason_codes,
)
adapter = build_broker_adapter(adapter_name)
adapter = self._load_adapter_or_reject(
adapter_name=adapter_name,
operation_type="submit_bracket_order",
requested_by=requested_by,
explicit_confirmation=explicit_live_confirmation,
payload=payload,
execution_ticket_id=ticket_id,
execution_id=ticket.get("execution_id"),
signal_id=int(ticket["signal_id"]),
instrument_id=int(signal["instrument_id"]),
)
if isinstance(adapter, LiveOperationDecision):
return adapter
snapshot = dict(ticket.get("ticket_snapshot") or {})
signal_snapshot = dict(snapshot.get("signal") or {})
decision_snapshot = dict(snapshot.get("decision") or {})
@@ -130,7 +163,16 @@ class LiveBrokerService:
result={"rejected": True},
reason_codes=["live_trading_disabled"],
)
adapter = build_broker_adapter(adapter_name)
adapter = self._load_adapter_or_reject(
adapter_name=adapter_name,
operation_type="reconcile_orders",
requested_by=requested_by,
explicit_confirmation=False,
payload={"instrument_id": instrument_id},
instrument_id=instrument_id,
)
if isinstance(adapter, LiveOperationDecision):
return adapter
request = ReconciliationRequest(instrument_id=instrument_id, requested_by=requested_by)
result = adapter.reconcile_orders(request)
return self._record_live_audit(
@@ -170,7 +212,16 @@ class LiveBrokerService:
result={"rejected": True},
reason_codes=reason_codes,
)
adapter = build_broker_adapter(adapter_name)
adapter = self._load_adapter_or_reject(
adapter_name=adapter_name,
operation_type="cancel_all",
requested_by=requested_by,
explicit_confirmation=explicit_live_confirmation,
payload=request.to_dict(),
instrument_id=instrument_id,
)
if isinstance(adapter, LiveOperationDecision):
return adapter
result = adapter.cancel_all(request)
return self._record_live_audit(
adapter_name=adapter_name,
@@ -208,7 +259,15 @@ class LiveBrokerService:
result={"rejected": True},
reason_codes=reason_codes,
)
adapter = build_broker_adapter(adapter_name)
adapter = self._load_adapter_or_reject(
adapter_name=adapter_name,
operation_type="activate_kill_switch",
requested_by=requested_by,
explicit_confirmation=explicit_live_confirmation,
payload=request.to_dict(),
)
if isinstance(adapter, LiveOperationDecision):
return adapter
result = adapter.activate_kill_switch(request)
return self._record_live_audit(
adapter_name=adapter_name,
@@ -265,6 +324,37 @@ class LiveBrokerService:
result=result,
)
def _load_adapter_or_reject(
self,
*,
adapter_name: str,
operation_type: str,
requested_by: Optional[str],
explicit_confirmation: bool,
payload: dict,
execution_ticket_id: Optional[int] = None,
execution_id: Optional[int] = None,
signal_id: Optional[int] = None,
instrument_id: Optional[int] = None,
):
try:
return build_broker_adapter(adapter_name)
except ValueError:
return self._record_live_audit(
adapter_name=adapter_name,
operation_type=operation_type,
status="rejected",
requested_by=requested_by,
execution_ticket_id=execution_ticket_id,
execution_id=execution_id,
signal_id=signal_id,
instrument_id=instrument_id,
explicit_confirmation=explicit_confirmation,
payload=payload,
result={"rejected": True},
reason_codes=["unsupported_broker_adapter"],
)
def build_broker_adapter(adapter_name: str):
if adapter_name == "okx":
@@ -283,10 +373,14 @@ def validate_live_submit_request(
reason_codes.append("live_trading_disabled")
if not explicit_live_confirmation:
reason_codes.append("explicit_live_confirmation_required")
if ticket.get("execution_mode") != "semi_auto":
reason_codes.append("execution_ticket_mode_not_live")
if ticket.get("status") != "confirmed":
reason_codes.append("execution_ticket_not_confirmed")
if not ticket.get("confirmed_by") or not ticket.get("confirmed_at"):
reason_codes.append("execution_ticket_missing_confirmation_metadata")
if not ticket.get("execution_id"):
reason_codes.append("execution_ticket_missing_execution_record")
return _dedupe(reason_codes)
+48 -1
View File
@@ -9,6 +9,21 @@ from src.services.broker import LiveBrokerService, validate_live_submit_request
class LiveBrokerServiceTests(unittest.TestCase):
def test_missing_ticket_is_rejected_and_audited(self) -> None:
repo = _LiveRepo()
service = LiveBrokerService(repository=repo)
with patch.object(settings, "live_trading_enabled", True):
result = service.submit_confirmed_ticket(
404,
requested_by="alice",
explicit_live_confirmation=True,
)
self.assertEqual(result.status, "rejected")
self.assertIn("execution_ticket_missing", result.reason_codes)
self.assertEqual(repo.live_audits[-1]["execution_ticket_id"], 404)
def test_submit_confirmed_ticket_rejects_when_live_disabled(self) -> None:
repo = _LiveRepo()
service = LiveBrokerService(repository=repo)
@@ -55,6 +70,36 @@ class LiveBrokerServiceTests(unittest.TestCase):
self.assertEqual(repo.live_audits[-1]["adapter_name"], "okx")
self.assertEqual(repo.live_audits[-1]["execution_ticket_id"], 10)
def test_submit_confirmed_ticket_rejects_paper_ticket(self) -> None:
repo = _LiveRepo()
repo.tickets[10]["execution_mode"] = "paper"
service = LiveBrokerService(repository=repo)
with patch.object(settings, "live_trading_enabled", True):
result = service.submit_confirmed_ticket(
10,
requested_by="alice",
explicit_live_confirmation=True,
)
self.assertEqual(result.status, "rejected")
self.assertIn("execution_ticket_mode_not_live", result.reason_codes)
def test_unsupported_adapter_is_audited(self) -> None:
repo = _LiveRepo()
service = LiveBrokerService(repository=repo)
with patch.object(settings, "live_trading_enabled", True):
result = service.cancel_all(
requested_by="ops",
instrument_id=1,
explicit_live_confirmation=True,
adapter_name="unknown",
)
self.assertEqual(result.status, "rejected")
self.assertIn("unsupported_broker_adapter", result.reason_codes)
def test_reconcile_is_audited(self) -> None:
repo = _LiveRepo()
service = LiveBrokerService(repository=repo)
@@ -93,7 +138,7 @@ class LiveBrokerServiceTests(unittest.TestCase):
class LiveValidationTests(unittest.TestCase):
def test_validate_live_submit_request_requires_confirmed_ticket(self) -> None:
reasons = validate_live_submit_request(
ticket={"status": "awaiting_confirmation", "confirmed_by": None, "confirmed_at": None},
ticket={"status": "awaiting_confirmation", "execution_mode": "paper", "confirmed_by": None, "confirmed_at": None, "execution_id": None},
live_trading_enabled=False,
explicit_live_confirmation=False,
)
@@ -103,8 +148,10 @@ class LiveValidationTests(unittest.TestCase):
[
"live_trading_disabled",
"explicit_live_confirmation_required",
"execution_ticket_mode_not_live",
"execution_ticket_not_confirmed",
"execution_ticket_missing_confirmation_metadata",
"execution_ticket_missing_execution_record",
],
)