fix: reconcile KIS correction order chains

This commit is contained in:
Hermes Agent
2026-09-12 22:12:19 +09:00
parent 6017bde744
commit 06c3f06b5d
6 changed files with 864 additions and 149 deletions
+74 -2
View File
@@ -7,6 +7,7 @@ Unit tests run always; the sandbox integration test runs only when KIS
from __future__ import annotations from __future__ import annotations
import os import os
from datetime import UTC, datetime, timedelta
import httpx import httpx
import pytest import pytest
@@ -564,7 +565,9 @@ def test_submit_unknown_confirmed_absent_becomes_retryable_reject(monkeypatch):
status = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930") status = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
assert status.state == "rejected" assert status.state == "rejected"
assert status.raw == {"reason": "submission_confirmed_absent"} assert status.raw["reason"] == "submission_confirmed_absent"
assert status.raw["poll_performed"] is True
assert status.raw["poll_count"] == 1
def test_fill_poll_transport_failure_uses_backoff(monkeypatch): def test_fill_poll_transport_failure_uses_backoff(monkeypatch):
@@ -588,8 +591,12 @@ def test_fill_poll_transport_failure_uses_backoff(monkeypatch):
) )
assert first.state == "unknown" assert first.state == "unknown"
assert first.raw["reason"] == "fill_inquiry_transport"
assert "timed out" not in str(first.raw)
assert immediate.state == "accepted" assert immediate.state == "accepted"
assert immediate.raw == {"reason": "fill_poll_backoff"} assert immediate.raw["reason"] == "fill_poll_backoff"
assert immediate.raw["poll_performed"] is False
assert immediate.raw["poll_count"] == 1
assert len(calls) == 1 assert len(calls) == 1
clock[0] += 31 clock[0] += 31
@@ -598,3 +605,68 @@ def test_fill_poll_transport_failure_uses_backoff(monkeypatch):
) )
assert again.state == "unknown" assert again.state == "unknown"
assert len(calls) == 2 assert len(calls) == 2
def test_unresolved_fill_poll_uses_elapsed_bounded_backoff_and_converges(monkeypatch):
wall_now = datetime(2026, 9, 10, 12, 0, tzinfo=UTC)
b = KISBroker(
"12345678-01",
app_key="k",
app_secret="s",
base_url=_MOCK_URL,
clock=lambda: wall_now,
)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
mono = [100.0]
monkeypatch.setattr("trex_core.adapters.broker.kis.monotonic", lambda: mono[0])
calls = []
terminal = [False]
class Response:
status_code = 200
def json(self):
calls.append(1)
parent = {
"odno": "1234", "orgn_odno": "", "ord_qty": "10",
"tot_ccld_qty": "0", "avg_prvs": "0", "cncl_yn": "",
"rjct_qty": "0", "rmn_qty": "0", "ord_dt": "20260910",
"ord_gno_brno": "91258", "pdno": "005930",
}
child = {
**parent, "odno": "1235", "orgn_odno": "1234",
"cnc_cfrm_qty": "10" if terminal[0] else "0",
}
return {"rt_cd": "0", "output1": [parent, child]}
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
submitted_at = wall_now - timedelta(hours=7)
first = b.poll_fill(
"91258:1234", side="buy", symbol="005930",
order_date="20260910", submitted_at=submitted_at,
)
assert first.state == "unknown"
assert first.raw["poll_performed"] is True
assert first.raw["poll_count"] == 1
assert first.raw["backoff_sec"] == 300.0
assert first.raw["stuck_duration_sec"] == 7 * 3600
terminal[0] = True
mono[0] += 299
skipped = b.poll_fill(
"91258:1234", side="buy", symbol="005930",
order_date="20260910", submitted_at=submitted_at,
)
assert skipped.state == "accepted"
assert skipped.raw["last_result_state"] == "unknown"
assert len(calls) == 1
mono[0] += 1
resolved = b.poll_fill(
"91258:1234", side="buy", symbol="005930",
order_date="20260910", submitted_at=submitted_at,
)
assert resolved.state == "cancelled"
assert resolved.raw["poll_count"] == 2
assert len(calls) == 2
+203
View File
@@ -222,6 +222,209 @@ def test_correction_child_row_not_misclassified_as_cancel(monkeypatch):
assert not raw.get("order_open_confirmed", False) assert not raw.get("order_open_confirmed", False)
def test_correction_child_cancel_confirmed_quantity_maps_to_cancelled(monkeypatch):
"""KIS cancel-confirm quantity is terminal evidence even when cncl_yn is blank."""
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="31", symbol="053050")
parent, child = payload["output1"]
child.update(cncl_yn="", cnc_cfrm_qty="31")
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
)
assert fs.state == "cancelled"
assert fs.filled_qty == 0
assert fs.raw["reason"] == "replacement_chain_terminal_cancel"
assert "resolved_broker_order_id" not in fs.raw
def test_live_replacement_remains_fail_closed(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458")
_, child = payload["output1"]
child.update(cncl_yn="", cnc_cfrm_qty="0", rmn_qty="10", ord_qty="10")
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "unknown"
assert fs.raw["reason"] == "replacement_live_unresolved"
assert not fs.raw.get("order_open_confirmed", False)
def test_replacement_partial_fill_then_confirmed_cancel(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
_, child = payload["output1"]
child.update(
cncl_yn="",
cnc_cfrm_qty="6",
tot_ccld_qty="4",
rmn_qty="0",
avg_prvs="70100",
)
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "partially_filled"
assert fs.filled_qty == 4
assert fs.avg_price == 70100.0
assert fs.is_partial_cancel
assert fs.raw["reason"] == "replacement_chain_terminal_cancel"
def test_repeated_cumulative_fill_across_generations_is_not_ambiguous(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
parent, child = payload["output1"]
parent.update(tot_ccld_qty="4", rmn_qty="0", avg_prvs="70100")
child.update(
cncl_yn="",
cnc_cfrm_qty="6",
tot_ccld_qty="4",
rmn_qty="0",
avg_prvs="70100",
)
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "partially_filled"
assert fs.filled_qty == 4
assert fs.avg_price == 70100.0
assert fs.is_partial_cancel
def test_multi_generation_replacement_chain_terminal_cancel(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
parent, replacement = payload["output1"]
replacement.update(cncl_yn="", cnc_cfrm_qty="0", rmn_qty="0")
terminal = {
**replacement,
"odno": "0000123459",
"orgn_odno": replacement["odno"],
"cnc_cfrm_qty": "10",
}
payload["output1"] = [terminal, parent, replacement]
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "cancelled"
assert fs.raw["replacement_depth"] == 2
def test_replacement_chain_branching_is_ambiguous(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
parent, child = payload["output1"]
child.update(cncl_yn="", cnc_cfrm_qty="10")
sibling = {**child, "odno": "0000123459"}
payload["output1"] = [parent, child, sibling]
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "unknown"
assert fs.raw["reason"] == "replacement_chain_ambiguous"
def test_zero_remaining_without_cancel_confirmation_remains_unknown(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
_, child = payload["output1"]
child.update(cncl_yn="", cnc_cfrm_qty="0", rmn_qty="0")
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "unknown"
assert fs.raw["reason"] == "correction_child_present_unresolved"
def test_stable_zero_fill_replacement_chain_converges_after_grace(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="31", symbol="053050")
parent, child = payload["output1"]
parent.update(rmn_qty="0")
child.update(cncl_yn="", cnc_cfrm_qty="0", rmn_qty="0")
monotonic_now = [100.0]
monkeypatch.setattr(kis_mod, "monotonic", lambda: monotonic_now[0])
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
broker = _broker()
first = broker.poll_fill(
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
)
assert first.state == "unknown"
assert first.raw["reason"] == "correction_child_present_unresolved"
monotonic_now[0] += 90
confirmed = broker.poll_fill(
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
)
assert confirmed.state == "cancelled"
assert confirmed.raw["reason"] == "replacement_chain_stable_zero_terminal"
def test_zero_fill_terminal_grace_resets_when_chain_changes(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="31", symbol="053050")
parent, child = payload["output1"]
parent.update(rmn_qty="0")
child.update(cncl_yn="", cnc_cfrm_qty="0", rmn_qty="0")
monotonic_now = [100.0]
monkeypatch.setattr(kis_mod, "monotonic", lambda: monotonic_now[0])
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
broker = _broker()
first = broker.poll_fill(
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
)
assert first.state == "unknown"
monotonic_now[0] += 90
child["rmn_qty"] = "31"
changed = broker.poll_fill(
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
)
assert changed.raw["reason"] == "replacement_live_unresolved"
monotonic_now[0] += 90
child["rmn_qty"] = "0"
restarted_grace = broker.poll_fill(
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
)
assert restarted_grace.state == "unknown"
monotonic_now[0] += 90
confirmed = broker.poll_fill(
f"06010:{ODNO}", side="sell", symbol="053050", order_date="20260901"
)
assert confirmed.state == "cancelled"
def test_incomplete_cancel_confirmation_remains_unknown(monkeypatch):
payload = _payload_with_child_cancel(ODNO, "0000123458", ord_qty="10")
_, child = payload["output1"]
child.update(cncl_yn="", cnc_cfrm_qty="3", rmn_qty="0")
monkeypatch.setattr(kis_mod.httpx, "get", _fake_get(payload))
fs = _broker().poll_fill(
f"06010:{ODNO}", side="sell", symbol="225190", order_date="20260901"
)
assert fs.state == "unknown"
assert fs.raw["reason"] == "correction_child_present_unresolved"
def test_partial_fill_with_child_cancel_row(monkeypatch): def test_partial_fill_with_child_cancel_row(monkeypatch):
"""Partial fill on parent + cancel child → partial_cancel.""" """Partial fill on parent + cancel child → partial_cancel."""
parent = { parent = {
@@ -104,6 +104,58 @@ def test_full_external_cancel_returns_slot_to_holding():
assert r.rolled_back == 1 assert r.rolled_back == 1
def test_unresolved_poll_observability_is_sanitized_and_deduplicated(caplog):
broker = ProgrammableBroker()
repo, _svc, intents, execution, _ledger, _writer = _wire(broker)
sid = _buy_to_holding(intents, execution)
assert sid is not None
intents.submit_sell(SellIntent(intent_id="s-poll-observe", slot_id=sid))
samples = iter([
("correction_child_present_unresolved", 10),
("correction_child_present_unresolved", 10),
("correction_child_present_unresolved", 61),
("correction_child_present_unresolved", 61),
("parent_identity_mismatch", 61),
])
def unresolved(side):
del side
reason, age = next(samples)
return FillStatus(
state="unknown",
raw={
"poll_performed": True,
"poll_count": broker.polls,
"stuck_duration_sec": age,
"last_result_reason": reason,
"backoff_sec": 15.0,
"raw_payload": {"authorization": "secret-token"},
"broker_order_id": "sensitive-order-id",
},
)
broker.responder = unresolved
with caplog.at_level(logging.INFO):
for _ in range(5):
execution.run_once()
observations = [
record for record in caplog.records
if getattr(record, "event", None) == "order_poll"
]
warnings = [
record for record in caplog.records
if getattr(record, "event", None) == "order_poll_unresolved"
]
assert len(observations) == 5
assert len(warnings) == 3 # first, long-duration threshold, reason change
assert observations[-1].poll_count == 6
assert observations[-1].stuck_duration_sec == 61
assert observations[-1].result_reason == "parent_identity_mismatch"
assert "secret-token" not in caplog.text
assert "sensitive-order-id" not in caplog.text
def test_partial_fill_then_cancel_records_and_keeps_remainder(): def test_partial_fill_then_cancel_records_and_keeps_remainder():
broker = ProgrammableBroker() broker = ProgrammableBroker()
repo, svc, intents, execution, ledger, writer = _wire(broker) repo, svc, intents, execution, ledger, writer = _wire(broker)
@@ -12,7 +12,7 @@ from zoneinfo import ZoneInfo
from trex_core.adapters.broker.kis import KISBroker from trex_core.adapters.broker.kis import KISBroker
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.execution import ExecutionService from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.domain.slot import SlotService, SlotState from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
@@ -30,12 +30,15 @@ class _ExchangeState:
self.orders: list[dict[str, str]] = [] self.orders: list[dict[str, str]] = []
self.fill_orders = False self.fill_orders = False
self.reject_orders = False self.reject_orders = False
self.next_side = "02"
self.correction_cancel_sell = False
self.balance_qty: int | None = None
def submit(self, body: dict[str, str]) -> tuple[bool, dict[str, object]]: def submit(self, body: dict[str, str]) -> tuple[bool, dict[str, object]]:
self.submit_attempts += 1 self.submit_attempts += 1
order = { order = {
"pdno": body["PDNO"], "pdno": body["PDNO"],
"sll_buy_dvsn_cd": "02", "sll_buy_dvsn_cd": self.next_side,
"ord_qty": body["ORD_QTY"], "ord_qty": body["ORD_QTY"],
"ord_unpr": body["ORD_UNPR"], "ord_unpr": body["ORD_UNPR"],
"ord_dt": VIRTUAL_AT.strftime("%Y%m%d"), "ord_dt": VIRTUAL_AT.strftime("%Y%m%d"),
@@ -65,6 +68,20 @@ class _ExchangeState:
order["rjct_qty"] = order["ord_qty"] order["rjct_qty"] = order["ord_qty"]
order["rmn_qty"] = "0" order["rmn_qty"] = "0"
row = dict(order) row = dict(order)
if self.correction_cancel_sell and order["sll_buy_dvsn_cd"] == "01":
row["rmn_qty"] = "0"
rows.append(row)
rows.append(
{
**row,
"odno": f"{int(order['odno']) + 1:010d}",
"orgn_odno": order["odno"],
"cncl_yn": "",
"cnc_cfrm_qty": "0",
"rmn_qty": "0",
}
)
continue
if self.fill_orders and order["rjct_qty"] == "0": if self.fill_orders and order["rjct_qty"] == "0":
row["tot_ccld_qty"] = row["ord_qty"] row["tot_ccld_qty"] = row["ord_qty"]
row["avg_prvs"] = row["ord_unpr"] row["avg_prvs"] = row["ord_unpr"]
@@ -72,11 +89,13 @@ class _ExchangeState:
return {"rt_cd": "0", "output1": rows} return {"rt_cd": "0", "output1": rows}
def balance(self) -> dict[str, object]: def balance(self) -> dict[str, object]:
quantity = ( quantity = self.balance_qty
sum(int(order["ord_qty"]) for order in self.orders if order["rjct_qty"] == "0") if quantity is None:
if self.fill_orders quantity = (
else 0 sum(int(order["ord_qty"]) for order in self.orders if order["rjct_qty"] == "0")
) if self.fill_orders
else 0
)
rows = [{"pdno": SYMBOL, "hldg_qty": str(quantity)}] if quantity else [] rows = [{"pdno": SYMBOL, "hldg_qty": str(quantity)}] if quantity else []
return {"rt_cd": "0", "output1": rows} return {"rt_cd": "0", "output1": rows}
@@ -265,3 +284,54 @@ def test_exchange_reject_after_broker_ack_rolls_back_and_allows_one_retry():
finally: finally:
server.shutdown() server.shutdown()
server.server_close() server.server_close()
def test_stable_correction_chain_returns_pending_sell_to_holding(monkeypatch):
state = _ExchangeState(first_attempt_reaches_exchange=True)
server = _serve(state)
mono = [100.0]
monkeypatch.setattr("trex_core.adapters.broker.kis.monotonic", lambda: mono[0])
try:
repo, slots, intents, execution, slot_id = _wire(_broker(server))
assert slot_id is not None
execution.run_once()
state.fill_orders = True
execution = ExecutionService(
slots,
repo,
_broker(server),
instance_key=INSTANCE,
clock=lambda: VIRTUAL_AT,
)
execution.reconcile()
bought = repo.fetch_slot(slot_id)
assert bought is not None
assert bought.status is SlotState.HOLDING
state.fill_orders = False
state.balance_qty = 2
state.next_side = "01"
state.correction_cancel_sell = True
admitted = intents.submit_sell(
SellIntent(intent_id="virtual-sell-corrected", slot_id=slot_id)
)
assert admitted.status == "admitted"
execution.run_once()
unresolved = repo.fetch_slot(slot_id)
assert unresolved is not None
assert unresolved.status is SlotState.PENDING_SELL
assert state.submit_attempts == 2
mono[0] += 90
execution.run_once()
holding = repo.fetch_slot(slot_id)
assert holding is not None
assert holding.status is SlotState.HOLDING
assert holding.qty == 2
assert holding.broker_order_id is None
assert state.submit_attempts == 2
finally:
server.shutdown()
server.server_close()
+389 -140
View File
@@ -19,6 +19,7 @@ from dataclasses import replace
from datetime import UTC, datetime, timedelta, timezone from datetime import UTC, datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from time import monotonic from time import monotonic
from typing import Any
import httpx import httpx
@@ -39,6 +40,7 @@ _SUBMISSION_UNKNOWN_PREFIX = "submission-unknown:"
_MOCK_HINTS = ("openapivts", "virtual", "mock") _MOCK_HINTS = ("openapivts", "virtual", "mock")
_FILL_POLL_MIN_INTERVAL_SECONDS = 5.0 _FILL_POLL_MIN_INTERVAL_SECONDS = 5.0
_FILL_POLL_TRANSPORT_BACKOFF_SECONDS = 30.0 _FILL_POLL_TRANSPORT_BACKOFF_SECONDS = 30.0
_FILL_POLL_MAX_BACKOFF_SECONDS = 300.0
_SUBMISSION_LOOKUP_GRACE_SECONDS = 90.0 _SUBMISSION_LOOKUP_GRACE_SECONDS = 90.0
_SUPPORTED_ORDER_VENUES = frozenset({"KRX", "NXT", "SOR"}) _SUPPORTED_ORDER_VENUES = frozenset({"KRX", "NXT", "SOR"})
@@ -150,6 +152,7 @@ class KISBroker:
self._token_cache = token_cache # optional TokenCache (L2, REQ-016) self._token_cache = token_cache # optional TokenCache (L2, REQ-016)
self._clock = clock or (lambda: datetime.now(tz=_KST)) self._clock = clock or (lambda: datetime.now(tz=_KST))
self._fill_poll_not_before: dict[str, float] = {} self._fill_poll_not_before: dict[str, float] = {}
self._fill_poll_stats: dict[str, dict[str, Any]] = {}
is_mock = any(h in self._base_url for h in _MOCK_HINTS) is_mock = any(h in self._base_url for h in _MOCK_HINTS)
if not is_mock and not allow_live: if not is_mock and not allow_live:
raise RuntimeError( raise RuntimeError(
@@ -358,6 +361,118 @@ class KISBroker:
"EXCG_ID_DVSN_CD": "ALL", "EXCG_ID_DVSN_CD": "ALL",
} }
def _fill_poll_age_seconds(self, submitted_at: datetime | None) -> int:
if submitted_at is None:
return 0
return max(
0,
int(
(
self._clock().astimezone(UTC)
- submitted_at.astimezone(UTC)
).total_seconds()
),
)
@staticmethod
def _fill_poll_backoff_seconds(
status: FillStatus,
*,
age_seconds: int,
transport_failure: bool,
) -> float:
"""Return bounded state/age-based delay for the next authoritative poll."""
if status.state != "unknown":
delay = _FILL_POLL_MIN_INTERVAL_SECONDS
elif age_seconds < 60:
delay = _FILL_POLL_MIN_INTERVAL_SECONDS
elif age_seconds < 5 * 60:
delay = 15.0
elif age_seconds < 60 * 60:
delay = 30.0
elif age_seconds < 6 * 60 * 60:
delay = 120.0
else:
delay = _FILL_POLL_MAX_BACKOFF_SECONDS
if transport_failure:
delay = max(delay, _FILL_POLL_TRANSPORT_BACKOFF_SECONDS)
return min(delay, _FILL_POLL_MAX_BACKOFF_SECONDS)
def _record_fill_poll(
self,
broker_order_id: str,
status: FillStatus,
*,
submitted_at: datetime | None,
transport_failure: bool = False,
) -> FillStatus:
"""Attach allowlisted poll telemetry and schedule the next broker read."""
age_seconds = self._fill_poll_age_seconds(submitted_at)
backoff_seconds = self._fill_poll_backoff_seconds(
status,
age_seconds=age_seconds,
transport_failure=transport_failure,
)
raw = status.raw if isinstance(status.raw, dict) else {}
reason = raw.get("reason") if isinstance(raw.get("reason"), str) else status.state
previous = self._fill_poll_stats.get(broker_order_id, {})
if reason != "correction_child_present_unresolved":
previous.pop("zero_fill_chain_fingerprint", None)
previous.pop("zero_fill_chain_first_seen", None)
poll_count = int(previous.get("poll_count", 0)) + 1
self._fill_poll_not_before[broker_order_id] = monotonic() + backoff_seconds
self._fill_poll_stats[broker_order_id] = {
**previous,
"poll_count": poll_count,
"last_result_state": status.state,
"last_result_reason": reason,
"backoff_sec": backoff_seconds,
}
return replace(
status,
raw={
**raw,
"poll_performed": True,
"poll_count": poll_count,
"stuck_duration_sec": age_seconds,
"last_result_state": status.state,
"last_result_reason": reason,
"backoff_sec": backoff_seconds,
},
)
def _replacement_zero_fill_terminal_confirmed(
self,
broker_order_id: str,
chain: list[dict],
) -> bool:
"""Require a stable grace-period snapshot before terminal inference."""
fingerprint = tuple(
(
str(row.get("odno") or "").strip(),
str(row.get("orgn_odno") or "").strip(),
_safe_int(row.get("ord_qty")),
_safe_int(row.get("tot_ccld_qty")),
_safe_int(row.get("rmn_qty")),
)
for row in chain
)
stats = self._fill_poll_stats.setdefault(broker_order_id, {})
now = monotonic()
if stats.get("zero_fill_chain_fingerprint") != fingerprint:
stats["zero_fill_chain_fingerprint"] = fingerprint
stats["zero_fill_chain_first_seen"] = now
return False
first_seen = float(stats.get("zero_fill_chain_first_seen", now))
return now - first_seen >= _SUBMISSION_LOOKUP_GRACE_SECONDS
def _clear_replacement_zero_fill_candidate(self, broker_order_id: str) -> None:
stats = self._fill_poll_stats.get(broker_order_id)
if stats is None:
return
stats.pop("zero_fill_chain_fingerprint", None)
stats.pop("zero_fill_chain_first_seen", None)
def poll_fill( def poll_fill(
self, self,
broker_order_id: str, broker_order_id: str,
@@ -379,9 +494,18 @@ class KISBroker:
) )
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):
stats = self._fill_poll_stats.get(broker_order_id, {})
return FillStatus( return FillStatus(
state="accepted", state="accepted",
raw={"reason": "fill_poll_backoff"}, raw={
"reason": "fill_poll_backoff",
"poll_performed": False,
"poll_count": int(stats.get("poll_count", 0)),
"stuck_duration_sec": self._fill_poll_age_seconds(submitted_at),
"last_result_state": stats.get("last_result_state"),
"last_result_reason": stats.get("last_result_reason"),
"backoff_sec": float(stats.get("backoff_sec", 0.0)),
},
) )
# Set the normal gate before I/O so concurrent execution ticks cannot # Set the normal gate before I/O so concurrent execution ticks cannot
# open duplicate KIS inquiries for the same order. # open duplicate KIS inquiries for the same order.
@@ -397,20 +521,20 @@ class KISBroker:
timeout=self._timeout, timeout=self._timeout,
) )
payload = resp.json() payload = resp.json()
except (httpx.HTTPError, ValueError) as exc: except (httpx.HTTPError, ValueError):
self._fill_poll_not_before[broker_order_id] = ( return self._record_fill_poll(
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS broker_order_id,
FillStatus(state="unknown", raw={"reason": "fill_inquiry_transport"}),
submitted_at=submitted_at,
transport_failure=True,
) )
return FillStatus(state="unknown", raw={"error": str(exc)})
if getattr(resp, "status_code", 200) >= 400 or str(payload.get("rt_cd")) != "0": if getattr(resp, "status_code", 200) >= 400 or str(payload.get("rt_cd")) != "0":
self._fill_poll_not_before[broker_order_id] = ( return self._record_fill_poll(
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS broker_order_id,
FillStatus(state="unknown", raw={"reason": "fill_inquiry_rejected"}),
submitted_at=submitted_at,
transport_failure=True,
) )
return FillStatus(
state="unknown",
raw={"reason": "fill_inquiry_rejected"},
)
self._fill_poll_not_before[broker_order_id] = monotonic() + _FILL_POLL_MIN_INTERVAL_SECONDS
rows = payload.get("output1") or [] rows = payload.get("output1") or []
normalized_odno = odno.lstrip("0") or "0" normalized_odno = odno.lstrip("0") or "0"
# Canonical request identity from the caller — these are authoritative # Canonical request identity from the caller — these are authoritative
@@ -454,13 +578,17 @@ class KISBroker:
# No row with the exact request identity. If there are odno # No row with the exact request identity. If there are odno
# matches they all failed identity validation → unknown (not grace). # matches they all failed identity validation → unknown (not grace).
if odno_matches: if odno_matches:
return FillStatus( return self._record_fill_poll(
state="unknown", broker_order_id,
raw={ FillStatus(
"reason": "parent_identity_mismatch", state="unknown",
"order_visible": True, raw={
"inquiry_date": inquiry_date, "reason": "parent_identity_mismatch",
}, "order_visible": True,
"inquiry_date": inquiry_date,
},
),
submitted_at=submitted_at,
) )
# Truly invisible — apply the submission visibility grace window. # Truly invisible — apply the submission visibility grace window.
visible_grace = ( visible_grace = (
@@ -469,118 +597,210 @@ class KISBroker:
.total_seconds() .total_seconds()
< _SUBMISSION_LOOKUP_GRACE_SECONDS < _SUBMISSION_LOOKUP_GRACE_SECONDS
) )
return FillStatus( return self._record_fill_poll(
state="accepted" if visible_grace else "unknown", broker_order_id,
raw={ FillStatus(
"reason": ( state="accepted" if visible_grace else "unknown",
"order_visibility_grace" raw={
if visible_grace "reason": (
else "order_not_visible_unresolved" "order_visibility_grace"
), if visible_grace
"order_visible": False, else "order_not_visible_unresolved"
"inquiry_date": inquiry_date, ),
}, "order_visible": False,
"inquiry_date": inquiry_date,
},
),
submitted_at=submitted_at,
) )
if len(exact_parent_matches) > 1: if len(exact_parent_matches) > 1:
# More than one row claims the same exact identity — ambiguous. # More than one row claims the same exact identity — ambiguous.
return FillStatus( return self._record_fill_poll(
state="unknown", broker_order_id,
raw={ FillStatus(
"reason": "parent_identity_ambiguous", state="unknown",
"order_visible": True, raw={
"inquiry_date": inquiry_date, "reason": "parent_identity_ambiguous",
}, "order_visible": True,
"inquiry_date": inquiry_date,
},
),
submitted_at=submitted_at,
) )
row = exact_parent_matches[0] row = exact_parent_matches[0]
# dcy-013 ext: KIS sometimes records an external cancel as a separate # Follow only response rows bound to the immutable request identity.
# child row with orgn_odno=<original> and cncl_yn="Y" rather than # KIS may represent cancel/modify as a chain where each child points to
# setting cncl_yn on the original row. Scan sibling rows to detect # its immediate predecessor through orgn_odno. Row order and numerical
# this case. # proximity are never evidence of parentage.
# identity_rows = [r for r in rows if _row_matches_request_identity(r)]
# Safety requirements:
# - Child must share the AUTHORITATIVE request identity (branch, date,
# symbol from the caller). All identity fields must be present and
# equal — absent fields are NOT treated as wildcards (fail-closed).
# - Ambiguous (> 1 matching cancel) → fail-closed "unknown".
# - Correction (정정) children (cncl_yn != "Y") that match the same
# request identity are also fail-closed → "unknown".
# - Unrelated rows whose identity fields differ from the request are
# excluded from both candidate lists.
def _child_matches_request(r: dict) -> bool:
"""True when a child row's identity fields are all present and
equal to the authoritative request identity. Missing = mismatch."""
child_brno = str(r.get("ord_gno_brno") or "").strip()
child_dt = str(r.get("ord_dt") or "").strip()
child_pdno = str(r.get("pdno") or "").strip()
if request_brno and (not child_brno or child_brno != request_brno):
return False
if not child_dt or child_dt != request_date:
return False
return bool(child_pdno) and child_pdno == request_symbol
child_cancel_candidates = [ def _row_parent_odno_normalized(r: dict) -> str:
r return str(r.get("orgn_odno") or "").strip().lstrip("0") or "0"
for r in rows
if (str(r.get("orgn_odno") or "").strip().lstrip("0") or "0") == normalized_odno
and str(r.get("cncl_yn") or "").strip().upper() == "Y"
and _child_matches_request(r)
]
# Correction children: same orgn_odno + matching identity but cncl_yn != "Y". chain = [row]
correction_candidates = [ visited = {normalized_odno}
r current_odno = normalized_odno
for r in rows ambiguous_reason: str | None = None
if (str(r.get("orgn_odno") or "").strip().lstrip("0") or "0") == normalized_odno while True:
and str(r.get("cncl_yn") or "").strip().upper() != "Y" children = [
and _child_matches_request(r) candidate
] for candidate in identity_rows
if _row_parent_odno_normalized(candidate) == current_odno
]
if not children:
break
if len(children) > 1:
ambiguous_reason = (
"child_cancel_ambiguous"
if all(
str(child.get("cncl_yn") or "").strip().upper() == "Y"
for child in children
)
else "replacement_chain_ambiguous"
)
break
child = children[0]
child_odno = _row_odno_normalized(child)
if child_odno == "0" or child_odno in visited:
ambiguous_reason = "replacement_chain_ambiguous"
break
chain.append(child)
visited.add(child_odno)
current_odno = child_odno
if len(child_cancel_candidates) > 1: if ambiguous_reason is not None:
# More than one matching cancel child — identity is ambiguous. return self._record_fill_poll(
return FillStatus( broker_order_id,
state="unknown", FillStatus(
raw={ state="unknown",
"reason": "child_cancel_ambiguous", raw={
"order_visible": True, "reason": ambiguous_reason,
"inquiry_date": inquiry_date, "order_visible": True,
}, "inquiry_date": inquiry_date,
},
),
submitted_at=submitted_at,
) )
if correction_candidates: terminal_row = chain[-1]
# A correction/replacement child is present; the original order is terminal_order_qty = _safe_int(terminal_row.get("ord_qty")) or 0
# superseded but we cannot safely infer terminal status without terminal_filled_qty = _safe_int(terminal_row.get("tot_ccld_qty")) or 0
# explicit broker confirmation of the replacement identity. terminal_cancelled_qty = _safe_int(terminal_row.get("cnc_cfrm_qty")) or 0
return FillStatus( terminal_cancel = (
state="unknown", str(terminal_row.get("cncl_yn") or "").strip().upper() == "Y"
raw={ or (
"reason": "correction_child_present_unresolved", terminal_order_qty > 0
"order_visible": True, and terminal_cancelled_qty > 0
"inquiry_date": inquiry_date, and terminal_cancelled_qty + terminal_filled_qty >= terminal_order_qty
},
) )
if child_cancel_candidates and str(row.get("cncl_yn") or "").strip().upper() != "Y":
child_cancel_row = child_cancel_candidates[0]
# Inject the cancel signal from the child row into the parent row so
# _fill_status_from_row can treat it as cancelled, while keeping
# the original row's fill quantities authoritative.
row = {**row, "cncl_yn": "Y", "_child_cancel_odno": str(child_cancel_row.get("odno") or "")}
status = self._fill_status_from_row(
row, side=side, today=inquiry_date, fallback_odno=odno
) )
return replace( terminal_remaining = _safe_int(terminal_row.get("rmn_qty"))
positive_fill_rows = [
candidate
for candidate in chain
if (_safe_int(candidate.get("tot_ccld_qty")) or 0) > 0
]
positive_fill_signatures = {
(
_safe_int(candidate.get("tot_ccld_qty")),
str(candidate.get("avg_prvs") or "").strip(),
)
for candidate in positive_fill_rows
}
repeated_cumulative_fill = (
len(positive_fill_rows) > 1 and len(positive_fill_signatures) == 1
)
stable_zero_fill_candidate = (
len(chain) > 1
and not terminal_cancel
and not positive_fill_rows
and all((_safe_int(candidate.get("ord_qty")) or 0) > 0 for candidate in chain)
and all(_safe_int(candidate.get("rmn_qty")) == 0 for candidate in chain)
and all((_safe_int(candidate.get("rjct_qty")) or 0) == 0 for candidate in chain)
)
stable_zero_fill_terminal = False
if stable_zero_fill_candidate:
stable_zero_fill_terminal = self._replacement_zero_fill_terminal_confirmed(
broker_order_id,
chain,
)
else:
self._clear_replacement_zero_fill_candidate(broker_order_id)
if len(positive_fill_rows) > 1 and not repeated_cumulative_fill:
status = FillStatus(
state="unknown",
raw={"reason": "replacement_chain_fill_ambiguous"},
)
elif (terminal_cancel or stable_zero_fill_terminal) and terminal_remaining in {None, 0}:
# A child cancel row often carries no fills of its own. Use the one
# unambiguous filled generation when present; otherwise preserve the
# original quantity context and inject only the terminal cancel bit.
evidence_row = positive_fill_rows[-1] if positive_fill_rows else row
status = self._fill_status_from_row(
{**evidence_row, "cncl_yn": "Y"},
side=side,
today=inquiry_date,
fallback_odno=odno,
)
status = replace(
status,
raw={
**(status.raw if isinstance(status.raw, dict) else {}),
"reason": (
"replacement_chain_terminal_cancel"
if terminal_cancel
else "replacement_chain_stable_zero_terminal"
),
"replacement_depth": len(chain) - 1,
},
)
elif len(chain) > 1:
leaf_status = self._fill_status_from_row(
terminal_row,
side=side,
today=inquiry_date,
fallback_odno=odno,
)
if leaf_status.is_filled:
status = leaf_status
else:
status = FillStatus(
state="unknown",
raw={
"reason": (
"replacement_live_unresolved"
if (terminal_remaining or 0) > 0
else "correction_child_present_unresolved"
),
"replacement_depth": len(chain) - 1,
},
)
else:
status = self._fill_status_from_row(
row, side=side, today=inquiry_date, fallback_odno=odno
)
status = replace(
status, status,
raw={ raw={
**(status.raw if isinstance(status.raw, dict) else {}), **(status.raw if isinstance(status.raw, dict) else {}),
"order_visible": True, "order_visible": True,
"order_open_confirmed": status.state in {"accepted", "partially_filled"}, "order_open_confirmed": (
len(chain) == 1
and status.state in {"accepted", "partially_filled"}
),
"inquiry_date": inquiry_date, "inquiry_date": inquiry_date,
}, },
) )
return self._record_fill_poll(
broker_order_id,
status,
submitted_at=submitted_at,
)
def _poll_unknown_submission( def _poll_unknown_submission(
self, self,
@@ -595,16 +815,33 @@ class KISBroker:
state="unknown", state="unknown",
raw={"reason": "submission_unknown_metadata_invalid"}, raw={"reason": "submission_unknown_metadata_invalid"},
) )
try:
submitted_at = datetime.fromisoformat(str(metadata["submitted_at"])).astimezone(
_KST
)
except (KeyError, TypeError, ValueError):
return FillStatus(
state="unknown",
raw={"reason": "submission_unknown_metadata_invalid"},
)
now_monotonic = monotonic() now_monotonic = monotonic()
if now_monotonic < self._fill_poll_not_before.get(broker_order_id, 0.0): if now_monotonic < self._fill_poll_not_before.get(broker_order_id, 0.0):
stats = self._fill_poll_stats.get(broker_order_id, {})
return FillStatus( return FillStatus(
state="accepted", state="accepted",
raw={"reason": "submission_lookup_backoff"}, raw={
"reason": "submission_lookup_backoff",
"poll_performed": False,
"poll_count": int(stats.get("poll_count", 0)),
"stuck_duration_sec": self._fill_poll_age_seconds(submitted_at),
"last_result_state": stats.get("last_result_state"),
"last_result_reason": stats.get("last_result_reason"),
"backoff_sec": float(stats.get("backoff_sec", 0.0)),
},
) )
self._fill_poll_not_before[broker_order_id] = ( self._fill_poll_not_before[broker_order_id] = (
now_monotonic + _FILL_POLL_MIN_INTERVAL_SECONDS now_monotonic + _FILL_POLL_MIN_INTERVAL_SECONDS
) )
submitted_at = datetime.fromisoformat(str(metadata["submitted_at"])).astimezone(_KST)
today = submitted_at.strftime("%Y%m%d") today = submitted_at.strftime("%Y%m%d")
tr_id, params = self._daily_fill_request( tr_id, params = self._daily_fill_request(
today, today,
@@ -619,20 +856,18 @@ class KISBroker:
) )
payload = resp.json() payload = resp.json()
except (httpx.HTTPError, ValueError): except (httpx.HTTPError, ValueError):
self._fill_poll_not_before[broker_order_id] = ( return self._record_fill_poll(
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS broker_order_id,
) FillStatus(state="unknown", raw={"reason": "submission_lookup_transport"}),
return FillStatus( submitted_at=submitted_at,
state="unknown", transport_failure=True,
raw={"reason": "submission_lookup_transport"},
) )
if getattr(resp, "status_code", 200) >= 400 or str(payload.get("rt_cd")) != "0": if getattr(resp, "status_code", 200) >= 400 or str(payload.get("rt_cd")) != "0":
self._fill_poll_not_before[broker_order_id] = ( return self._record_fill_poll(
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS broker_order_id,
) FillStatus(state="unknown", raw={"reason": "submission_lookup_rejected"}),
return FillStatus( submitted_at=submitted_at,
state="unknown", transport_failure=True,
raw={"reason": "submission_lookup_rejected"},
) )
matches = [ matches = [
row row
@@ -640,19 +875,29 @@ class KISBroker:
if self._matches_unknown_submission(row, metadata, submitted_at) if self._matches_unknown_submission(row, metadata, submitted_at)
] ]
if len(matches) != 1: if len(matches) != 1:
age = (datetime.now(tz=_KST) - submitted_at).total_seconds() age = (self._clock().astimezone(_KST) - submitted_at).total_seconds()
if not matches and age >= _SUBMISSION_LOOKUP_GRACE_SECONDS: if not matches and age >= _SUBMISSION_LOOKUP_GRACE_SECONDS:
return FillStatus( return self._record_fill_poll(
state="rejected", broker_order_id,
raw={"reason": "submission_confirmed_absent"}, FillStatus(
state="rejected",
raw={"reason": "submission_confirmed_absent"},
),
submitted_at=submitted_at,
) )
return FillStatus( return self._record_fill_poll(
state="unknown", broker_order_id,
raw={ FillStatus(
"reason": ( state="unknown",
"submission_lookup_ambiguous" if matches else "submission_lookup_pending" raw={
) "reason": (
}, "submission_lookup_ambiguous"
if matches
else "submission_lookup_pending"
)
},
),
submitted_at=submitted_at,
) )
row = matches[0] row = matches[0]
resolved_order_id = self._broker_order_id_from_row(row) resolved_order_id = self._broker_order_id_from_row(row)
@@ -662,12 +907,16 @@ class KISBroker:
today=today, today=today,
fallback_odno=resolved_order_id.split(":")[-1], fallback_odno=resolved_order_id.split(":")[-1],
) )
return replace( return self._record_fill_poll(
status, broker_order_id,
raw={ replace(
**(status.raw if isinstance(status.raw, dict) else {}), status,
"resolved_broker_order_id": resolved_order_id, raw={
}, **(status.raw if isinstance(status.raw, dict) else {}),
"resolved_broker_order_id": resolved_order_id,
},
),
submitted_at=submitted_at,
) )
@staticmethod @staticmethod
+69
View File
@@ -62,6 +62,24 @@ SlotEventEmitter = Callable[
] # (event_type, slot, routing_strategy_id) -> None ] # (event_type, slot, routing_strategy_id) -> None
_MAX_DISPATCH_PER_TICK = 64 _MAX_DISPATCH_PER_TICK = 64
_ORDER_POLL_WARNING_THRESHOLDS_SEC = (60, 5 * 60, 15 * 60, 60 * 60, 6 * 60 * 60)
_ORDER_POLL_REASON_ALLOWLIST = frozenset({
"correction_child_present_unresolved",
"fill_inquiry_rejected",
"fill_inquiry_transport",
"order_not_visible_unresolved",
"parent_identity_ambiguous",
"parent_identity_mismatch",
"replacement_chain_ambiguous",
"replacement_chain_fill_ambiguous",
"replacement_chain_stable_zero_terminal",
"replacement_chain_terminal_cancel",
"replacement_live_unresolved",
"submission_lookup_ambiguous",
"submission_lookup_pending",
"submission_lookup_rejected",
"submission_lookup_transport",
})
@dataclass @dataclass
@@ -115,6 +133,7 @@ class ExecutionService:
# A restart intentionally emits once again, covering a crash between a # A restart intentionally emits once again, covering a crash between a
# durable cancel reservation and best-effort alarm publication. # durable cancel reservation and best-effort alarm publication.
self._order_alarm_keys: set[tuple[str, str, int]] = set() self._order_alarm_keys: set[tuple[str, str, int]] = set()
self._order_poll_warning_state: dict[str, tuple[str, str, int]] = {}
self._clock = clock or (lambda: datetime.now(tz=UTC)) self._clock = clock or (lambda: datetime.now(tz=UTC))
# dcy-041 / GitHub #45: pending-sell quantity may use a bounded # dcy-041 / GitHub #45: pending-sell quantity may use a bounded
# account snapshot only through this injected reader. Reconciliation # account snapshot only through this injected reader. Reconciliation
@@ -282,6 +301,7 @@ class ExecutionService:
self._emit_order_metadata_unknown_alarm(slot) self._emit_order_metadata_unknown_alarm(slot)
continue continue
fs = self._poll_fill(slot, side) fs = self._poll_fill(slot, side)
self._observe_order_poll(slot, side, fs)
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
) )
@@ -344,6 +364,55 @@ class ExecutionService:
kwargs["submitted_at"] = slot.order_submitted_at kwargs["submitted_at"] = slot.order_submitted_at
return poll(slot.broker_order_id, **kwargs) return poll(slot.broker_order_id, **kwargs)
def _observe_order_poll(self, slot, side: str, fs) -> None:
"""Emit sanitized poll telemetry and deduplicated unresolved warnings."""
raw = fs.raw if isinstance(fs.raw, dict) else {}
if raw.get("poll_performed") is not True:
return
def _metric_int(name: str) -> int:
try:
return max(0, int(raw.get(name, 0)))
except (TypeError, ValueError):
return 0
reason_value = raw.get("last_result_reason")
reason = (
str(reason_value)
if reason_value in _ORDER_POLL_REASON_ALLOWLIST
else "none"
)
poll_count = _metric_int("poll_count")
stuck_duration_sec = _metric_int("stuck_duration_sec")
try:
backoff_sec = max(0.0, float(raw.get("backoff_sec", 0.0)))
except (TypeError, ValueError):
backoff_sec = 0.0
fields = {
"event": "order_poll",
"side": side,
"result_state": fs.state,
"result_reason": reason,
"poll_count": poll_count,
"stuck_duration_sec": stuck_duration_sec,
"backoff_sec": backoff_sec,
}
_log.info("order poll observed", extra=fields)
slot_key = str(slot.slot_id)
if fs.state != "unknown":
self._order_poll_warning_state.pop(slot_key, None)
return
threshold_index = sum(
stuck_duration_sec >= threshold
for threshold in _ORDER_POLL_WARNING_THRESHOLDS_SEC
)
warning_key = (fs.state, reason, threshold_index)
if self._order_poll_warning_state.get(slot_key) == warning_key:
return
self._order_poll_warning_state[slot_key] = warning_key
_log.warning("order poll unresolved", extra={**fields, "event": "order_poll_unresolved"})
# ------------------------------------------------------------ # ------------------------------------------------------------
def _balance_map(self) -> dict: def _balance_map(self) -> dict:
"""Current broker balance as {symbol: hldg_qty} (REQ-062 ground truth).""" """Current broker balance as {symbol: hldg_qty} (REQ-062 ground truth)."""