fix(execution): fail closed on unresolved orders
This commit is contained in:
@@ -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)
|
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 status.state == "accepted"
|
||||||
assert seen["tr_id"] == "VTTC0081R"
|
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)
|
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fail)
|
||||||
|
|
||||||
first = b.poll_fill("91258:1234", side="buy", symbol="005930")
|
first = b.poll_fill(
|
||||||
immediate = b.poll_fill("91258:1234", side="buy", symbol="005930")
|
"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 first.state == "unknown"
|
||||||
assert immediate.state == "accepted"
|
assert immediate.state == "accepted"
|
||||||
@@ -587,6 +593,8 @@ def test_fill_poll_transport_failure_uses_backoff(monkeypatch):
|
|||||||
assert len(calls) == 1
|
assert len(calls) == 1
|
||||||
|
|
||||||
clock[0] += 31
|
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 again.state == "unknown"
|
||||||
assert len(calls) == 2
|
assert len(calls) == 2
|
||||||
|
|||||||
@@ -59,13 +59,17 @@ def _row(**kw):
|
|||||||
|
|
||||||
def test_full_external_cancel_maps_to_cancelled(monkeypatch):
|
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")))
|
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
|
assert fs.state == "cancelled" and fs.is_terminal_reject and not fs.is_partial_cancel
|
||||||
|
|
||||||
|
|
||||||
def test_zero_fill_not_cancelled_stays_accepted(monkeypatch):
|
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")))
|
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
|
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
|
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",
|
"get",
|
||||||
_fake_get(_row(ord_qty="10", tot_ccld_qty="0", rjct_qty="4", cncl_yn="")),
|
_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
|
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",
|
"get",
|
||||||
_fake_get(_row(ord_qty="10", tot_ccld_qty="4", avg_prvs="70000", cncl_yn="Y")),
|
_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.state == "partially_filled"
|
||||||
assert fs.cancelled and fs.is_partial_cancel
|
assert fs.cancelled and fs.is_partial_cancel
|
||||||
assert fs.filled_qty == 4 and fs.avg_price == 70000.0
|
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",
|
"get",
|
||||||
_fake_get(_row(ord_qty="10", tot_ccld_qty="10", avg_prvs="70000", cncl_yn="N")),
|
_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
|
assert fs.is_filled and fs.filled_qty == 10 and not fs.is_partial_cancel
|
||||||
|
|||||||
@@ -218,6 +218,12 @@ def test_legacy_pending_without_authoritative_metadata_is_fail_closed_and_alarm_
|
|||||||
order_date=None,
|
order_date=None,
|
||||||
updated_at=datetime.now(tz=timezone.utc) - timedelta(days=2),
|
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()
|
||||||
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)
|
persisted = repo.fetch_slot(slot.slot_id)
|
||||||
assert persisted.status is SlotState.PENDING_BUY
|
assert persisted.status is SlotState.PENDING_BUY
|
||||||
assert broker.cancel_calls == []
|
assert broker.cancel_calls == []
|
||||||
|
|
||||||
metadata_alarms = [
|
metadata_alarms = [
|
||||||
call for call in alarms.calls if call[0] == "order_reconciliation_metadata_unknown"
|
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"}
|
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():
|
def test_cancel_rejected_is_fail_closed_alarm_and_authoritative_bounded_retry():
|
||||||
broker = _RecordingBroker()
|
broker = _RecordingBroker()
|
||||||
broker.cancel_outcome = CancelOutcome(CancelState.REJECTED, "closed_session")
|
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.state == "accepted"
|
||||||
assert status.raw["reason"] == "order_visibility_grace"
|
assert status.raw["reason"] == "order_visibility_grace"
|
||||||
assert status.raw["order_visible"] is False
|
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"}
|
||||||
|
|||||||
@@ -376,6 +376,14 @@ class KISBroker:
|
|||||||
) -> FillStatus:
|
) -> FillStatus:
|
||||||
if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
|
if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
|
||||||
return self._poll_unknown_submission(broker_order_id, side=side, symbol=symbol)
|
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()
|
now = monotonic()
|
||||||
if now < self._fill_poll_not_before.get(broker_order_id, 0.0):
|
if now < self._fill_poll_not_before.get(broker_order_id, 0.0):
|
||||||
return FillStatus(
|
return FillStatus(
|
||||||
@@ -386,7 +394,7 @@ class KISBroker:
|
|||||||
# open duplicate KIS inquiries for the same order.
|
# open duplicate KIS inquiries for the same order.
|
||||||
self._fill_poll_not_before[broker_order_id] = now + _FILL_POLL_MIN_INTERVAL_SECONDS
|
self._fill_poll_not_before[broker_order_id] = now + _FILL_POLL_MIN_INTERVAL_SECONDS
|
||||||
odno = broker_order_id.split(":")[-1]
|
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)
|
tr_id, params = self._daily_fill_request(inquiry_date, order_number=odno)
|
||||||
try:
|
try:
|
||||||
resp = httpx.get(
|
resp = httpx.get(
|
||||||
|
|||||||
@@ -270,6 +270,14 @@ class ExecutionService:
|
|||||||
for slot in self._repo.list_dispatched_pending(self._instance_key):
|
for slot in self._repo.list_dispatched_pending(self._instance_key):
|
||||||
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
|
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
|
||||||
set_correlation_id(str(slot.slot_id)) # REQ-084: same key as submit
|
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)
|
fs = self._poll_fill(slot, side)
|
||||||
resolved_order_id = (
|
resolved_order_id = (
|
||||||
fs.raw.get("resolved_broker_order_id") if isinstance(fs.raw, dict) else None
|
fs.raw.get("resolved_broker_order_id") if isinstance(fs.raw, dict) else None
|
||||||
@@ -589,6 +597,11 @@ class ExecutionService:
|
|||||||
CancelState.REJECTED.value,
|
CancelState.REJECTED.value,
|
||||||
CancelState.UNKNOWN.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:
|
if slot.cancel_state is not None and not is_retry:
|
||||||
return
|
return
|
||||||
if is_retry:
|
if is_retry:
|
||||||
|
|||||||
Reference in New Issue
Block a user