Phase 7 live adapter safety closure
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user