From 843df1d6f1a642282039a57d0de4ca85204d6bee Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 14 Aug 2026 15:29:31 +0900 Subject: [PATCH] fix(execution): fail closed on unresolved orders --- tests/adapters/broker/test_kis.py | 16 +++-- tests/adapters/broker/test_kis_cancel.py | 24 +++++-- .../test_order_timeout_reconciliation.py | 71 +++++++++++++++++++ trex_core/adapters/broker/kis.py | 10 ++- trex_core/application/execution.py | 13 ++++ 5 files changed, 123 insertions(+), 11 deletions(-) diff --git a/tests/adapters/broker/test_kis.py b/tests/adapters/broker/test_kis.py index f4d63de..7c9d0fb 100644 --- a/tests/adapters/broker/test_kis.py +++ b/tests/adapters/broker/test_kis.py @@ -143,7 +143,9 @@ def test_poll_fill_uses_current_all_market_contract(monkeypatch): monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fake_get) - status = broker.poll_fill("91258:1234", side="buy", symbol="005930") + status = broker.poll_fill( + "91258:1234", side="buy", symbol="005930", order_date="20260608" + ) assert status.state == "accepted" assert seen["tr_id"] == "VTTC0081R" @@ -578,8 +580,12 @@ def test_fill_poll_transport_failure_uses_backoff(monkeypatch): monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fail) - first = b.poll_fill("91258:1234", side="buy", symbol="005930") - immediate = b.poll_fill("91258:1234", side="buy", symbol="005930") + first = b.poll_fill( + "91258:1234", side="buy", symbol="005930", order_date="20260608" + ) + immediate = b.poll_fill( + "91258:1234", side="buy", symbol="005930", order_date="20260608" + ) assert first.state == "unknown" assert immediate.state == "accepted" @@ -587,6 +593,8 @@ def test_fill_poll_transport_failure_uses_backoff(monkeypatch): assert len(calls) == 1 clock[0] += 31 - again = b.poll_fill("91258:1234", side="buy", symbol="005930") + again = b.poll_fill( + "91258:1234", side="buy", symbol="005930", order_date="20260608" + ) assert again.state == "unknown" assert len(calls) == 2 diff --git a/tests/adapters/broker/test_kis_cancel.py b/tests/adapters/broker/test_kis_cancel.py index 16bb3ea..b18d074 100644 --- a/tests/adapters/broker/test_kis_cancel.py +++ b/tests/adapters/broker/test_kis_cancel.py @@ -59,13 +59,17 @@ def _row(**kw): def test_full_external_cancel_maps_to_cancelled(monkeypatch): monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(_row(tot_ccld_qty="0", cncl_yn="Y"))) - fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930") + fs = _broker().poll_fill( + f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608" + ) assert fs.state == "cancelled" and fs.is_terminal_reject and not fs.is_partial_cancel def test_zero_fill_not_cancelled_stays_accepted(monkeypatch): monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(_row(tot_ccld_qty="0", cncl_yn="N"))) - fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930") + fs = _broker().poll_fill( + f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608" + ) assert fs.state == "accepted" and not fs.is_terminal_reject @@ -83,7 +87,9 @@ def test_zero_fill_full_rejection_maps_to_rejected(monkeypatch): ) ), ) - fs = _broker().poll_fill(f"06010:{ODNO}", side="buy", symbol="005930") + fs = _broker().poll_fill( + f"06010:{ODNO}", side="buy", symbol="005930", order_date="20260608" + ) assert fs.state == "rejected" and fs.is_terminal_reject @@ -93,7 +99,9 @@ def test_zero_fill_partial_rejection_does_not_infer_terminal(monkeypatch): "get", _fake_get(_row(ord_qty="10", tot_ccld_qty="0", rjct_qty="4", cncl_yn="")), ) - fs = _broker().poll_fill(f"06010:{ODNO}", side="buy", symbol="005930") + fs = _broker().poll_fill( + f"06010:{ODNO}", side="buy", symbol="005930", order_date="20260608" + ) assert fs.state == "accepted" and not fs.is_terminal_reject @@ -103,7 +111,9 @@ def test_partial_fill_then_cancel_flags_partial_cancel(monkeypatch): "get", _fake_get(_row(ord_qty="10", tot_ccld_qty="4", avg_prvs="70000", cncl_yn="Y")), ) - fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930") + fs = _broker().poll_fill( + f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608" + ) assert fs.state == "partially_filled" assert fs.cancelled and fs.is_partial_cancel assert fs.filled_qty == 4 and fs.avg_price == 70000.0 @@ -116,5 +126,7 @@ def test_full_fill_unaffected_by_cancel_flag(monkeypatch): "get", _fake_get(_row(ord_qty="10", tot_ccld_qty="10", avg_prvs="70000", cncl_yn="N")), ) - fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930") + fs = _broker().poll_fill( + f"06010:{ODNO}", side="sell", symbol="005930", order_date="20260608" + ) assert fs.is_filled and fs.filled_qty == 10 and not fs.is_partial_cancel diff --git a/tests/application/test_order_timeout_reconciliation.py b/tests/application/test_order_timeout_reconciliation.py index 24a3ee9..017f811 100644 --- a/tests/application/test_order_timeout_reconciliation.py +++ b/tests/application/test_order_timeout_reconciliation.py @@ -218,6 +218,12 @@ def test_legacy_pending_without_authoritative_metadata_is_fail_closed_and_alarm_ order_date=None, updated_at=datetime.now(tz=timezone.utc) - timedelta(days=2), ) + broker.responder = lambda _side: FillStatus( + state="filled", + filled_qty=1, + avg_price=1000, + execution_id="wrong-current-date-order", + ) execution.run_once() execution.run_once() @@ -225,6 +231,7 @@ def test_legacy_pending_without_authoritative_metadata_is_fail_closed_and_alarm_ persisted = repo.fetch_slot(slot.slot_id) assert persisted.status is SlotState.PENDING_BUY assert broker.cancel_calls == [] + metadata_alarms = [ call for call in alarms.calls if call[0] == "order_reconciliation_metadata_unknown" ] @@ -232,6 +239,49 @@ def test_legacy_pending_without_authoritative_metadata_is_fail_closed_and_alarm_ assert set(metadata_alarms[0][2]) == {"slot_id", "symbol"} +def test_requested_cancel_terminal_unresolved_alarm_is_deduped_per_process_and_reemitted_after_restart(): + broker = _RecordingBroker() + repo, service, intents = _wire() + alarms = _AlarmRecorder() + execution = ExecutionService(service, repo, broker, alarm_emitter=alarms) + decision = intents.submit_buy( + BuyIntent(intent_id="requested", portfolio_id="PF-1", qty=1, timeout_sec=1) + ) + broker.responder = lambda _side: FillStatus(state="accepted") + execution.run_once() + slot = repo.fetch_slot(decision.slot_id) + repo._slots[slot.slot_id] = replace( + slot, + order_submitted_at=datetime.now(tz=timezone.utc) - timedelta(minutes=1), + ) + execution.run_once() + assert repo.fetch_slot(slot.slot_id).cancel_state == "requested" + assert len(broker.cancel_calls) == 1 + + broker.responder = lambda _side: FillStatus( + state="unknown", + raw={ + "reason": "order_not_visible_unresolved", + "raw_payload": {"authorization": "secret"}, + }, + ) + execution.run_once() + execution.run_once() + + persisted = repo.fetch_slot(slot.slot_id) + assert persisted.status is SlotState.PENDING_BUY + assert len(broker.cancel_calls) == 1 + unresolved = [call for call in alarms.calls if call[0] == "order_cancel_unresolved"] + assert len(unresolved) == 1 + assert unresolved[0][1] == "critical" + assert set(unresolved[0][2]) == {"slot_id", "symbol", "cancel_state", "order_date"} + assert "secret" not in repr(unresolved[0]) + + ExecutionService(service, repo, broker, alarm_emitter=alarms).run_once() + assert len(broker.cancel_calls) == 1 + assert len([call for call in alarms.calls if call[0] == "order_cancel_unresolved"]) == 2 + + def test_cancel_rejected_is_fail_closed_alarm_and_authoritative_bounded_retry(): broker = _RecordingBroker() broker.cancel_outcome = CancelOutcome(CancelState.REJECTED, "closed_session") @@ -340,3 +390,24 @@ def test_kis_recent_missing_row_stays_in_bounded_visibility_grace(monkeypatch): assert status.state == "accepted" assert status.raw["reason"] == "order_visibility_grace" assert status.raw["order_visible"] is False + + +def test_kis_missing_authoritative_order_date_fails_closed_without_network(monkeypatch): + now = datetime(2026, 8, 15, 9, 0, tzinfo=_KST) + + def forbidden_get(*_args, **_kwargs): + raise AssertionError("legacy order without original date must not query KIS") + + monkeypatch.setattr(kis_mod.httpx, "get", forbidden_get) + status = _kis(lambda: now).poll_fill( + "06010:0000123456", + side="buy", + symbol="005930", + order_date=None, + submitted_at=None, + ) + + assert status.state == "unknown" + assert not status.is_filled + assert not status.is_terminal_reject + assert status.raw == {"reason": "order_reconciliation_metadata_unknown"} diff --git a/trex_core/adapters/broker/kis.py b/trex_core/adapters/broker/kis.py index a2a1d25..8ec1d5f 100644 --- a/trex_core/adapters/broker/kis.py +++ b/trex_core/adapters/broker/kis.py @@ -376,6 +376,14 @@ class KISBroker: ) -> FillStatus: if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX): return self._poll_unknown_submission(broker_order_id, side=side, symbol=symbol) + if order_date is None: + # A legacy pending row has no authoritative date anchor. ODNO is + # reusable, so querying today's rows could attach another order's + # terminal evidence to this slot. Never invent an inquiry date. + return FillStatus( + state="unknown", + raw={"reason": "order_reconciliation_metadata_unknown"}, + ) now = monotonic() if now < self._fill_poll_not_before.get(broker_order_id, 0.0): return FillStatus( @@ -386,7 +394,7 @@ class KISBroker: # open duplicate KIS inquiries for the same order. self._fill_poll_not_before[broker_order_id] = now + _FILL_POLL_MIN_INTERVAL_SECONDS odno = broker_order_id.split(":")[-1] - inquiry_date = order_date or self._clock().astimezone(_KST).strftime("%Y%m%d") + inquiry_date = order_date tr_id, params = self._daily_fill_request(inquiry_date, order_number=odno) try: resp = httpx.get( diff --git a/trex_core/application/execution.py b/trex_core/application/execution.py index b7f8f87..a3792a3 100644 --- a/trex_core/application/execution.py +++ b/trex_core/application/execution.py @@ -270,6 +270,14 @@ class ExecutionService: for slot in self._repo.list_dispatched_pending(self._instance_key): side = "buy" if slot.status is SlotState.PENDING_BUY else "sell" set_correlation_id(str(slot.slot_id)) # REQ-084: same key as submit + if not str(slot.broker_order_id or "").startswith("submission-unknown:") and ( + slot.order_submitted_at is None or slot.order_date is None + ): + # Legacy rows cannot be tied safely to an authoritative KIS + # inquiry date. Do not poll: a reused ODNO on today's date + # could otherwise drive a false terminal transition. + self._emit_order_metadata_unknown_alarm(slot) + continue fs = self._poll_fill(slot, side) resolved_order_id = ( fs.raw.get("resolved_broker_order_id") if isinstance(fs.raw, dict) else None @@ -589,6 +597,11 @@ class ExecutionService: CancelState.REJECTED.value, CancelState.UNKNOWN.value, } + if slot.cancel_state == CancelState.REQUESTED.value: + reason = fs.raw.get("reason") if isinstance(fs.raw, dict) else None + if fs.state == "unknown" or reason == "order_not_visible_unresolved": + self._emit_unresolved_cancel_alarm(slot) + return if slot.cancel_state is not None and not is_retry: return if is_retry: