diff --git a/src/services/broker/live_broker_service.py b/src/services/broker/live_broker_service.py index 9040bb6..6dfa185 100644 --- a/src/services/broker/live_broker_service.py +++ b/src/services/broker/live_broker_service.py @@ -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) diff --git a/tests/unit/test_live_adapter_service.py b/tests/unit/test_live_adapter_service.py index 93e6703..8d43fce 100644 --- a/tests/unit/test_live_adapter_service.py +++ b/tests/unit/test_live_adapter_service.py @@ -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", ], )