fix: reconcile KIS correction order chains
This commit is contained in:
+389
-140
@@ -19,6 +19,7 @@ from dataclasses import replace
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -39,6 +40,7 @@ _SUBMISSION_UNKNOWN_PREFIX = "submission-unknown:"
|
||||
_MOCK_HINTS = ("openapivts", "virtual", "mock")
|
||||
_FILL_POLL_MIN_INTERVAL_SECONDS = 5.0
|
||||
_FILL_POLL_TRANSPORT_BACKOFF_SECONDS = 30.0
|
||||
_FILL_POLL_MAX_BACKOFF_SECONDS = 300.0
|
||||
_SUBMISSION_LOOKUP_GRACE_SECONDS = 90.0
|
||||
_SUPPORTED_ORDER_VENUES = frozenset({"KRX", "NXT", "SOR"})
|
||||
|
||||
@@ -150,6 +152,7 @@ class KISBroker:
|
||||
self._token_cache = token_cache # optional TokenCache (L2, REQ-016)
|
||||
self._clock = clock or (lambda: datetime.now(tz=_KST))
|
||||
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)
|
||||
if not is_mock and not allow_live:
|
||||
raise RuntimeError(
|
||||
@@ -358,6 +361,118 @@ class KISBroker:
|
||||
"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(
|
||||
self,
|
||||
broker_order_id: str,
|
||||
@@ -379,9 +494,18 @@ class KISBroker:
|
||||
)
|
||||
now = monotonic()
|
||||
if now < self._fill_poll_not_before.get(broker_order_id, 0.0):
|
||||
stats = self._fill_poll_stats.get(broker_order_id, {})
|
||||
return FillStatus(
|
||||
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
|
||||
# open duplicate KIS inquiries for the same order.
|
||||
@@ -397,20 +521,20 @@ class KISBroker:
|
||||
timeout=self._timeout,
|
||||
)
|
||||
payload = resp.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
self._fill_poll_not_before[broker_order_id] = (
|
||||
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return self._record_fill_poll(
|
||||
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":
|
||||
self._fill_poll_not_before[broker_order_id] = (
|
||||
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
|
||||
return self._record_fill_poll(
|
||||
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 []
|
||||
normalized_odno = odno.lstrip("0") or "0"
|
||||
# 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
|
||||
# matches they all failed identity validation → unknown (not grace).
|
||||
if odno_matches:
|
||||
return FillStatus(
|
||||
state="unknown",
|
||||
raw={
|
||||
"reason": "parent_identity_mismatch",
|
||||
"order_visible": True,
|
||||
"inquiry_date": inquiry_date,
|
||||
},
|
||||
return self._record_fill_poll(
|
||||
broker_order_id,
|
||||
FillStatus(
|
||||
state="unknown",
|
||||
raw={
|
||||
"reason": "parent_identity_mismatch",
|
||||
"order_visible": True,
|
||||
"inquiry_date": inquiry_date,
|
||||
},
|
||||
),
|
||||
submitted_at=submitted_at,
|
||||
)
|
||||
# Truly invisible — apply the submission visibility grace window.
|
||||
visible_grace = (
|
||||
@@ -469,118 +597,210 @@ class KISBroker:
|
||||
.total_seconds()
|
||||
< _SUBMISSION_LOOKUP_GRACE_SECONDS
|
||||
)
|
||||
return FillStatus(
|
||||
state="accepted" if visible_grace else "unknown",
|
||||
raw={
|
||||
"reason": (
|
||||
"order_visibility_grace"
|
||||
if visible_grace
|
||||
else "order_not_visible_unresolved"
|
||||
),
|
||||
"order_visible": False,
|
||||
"inquiry_date": inquiry_date,
|
||||
},
|
||||
return self._record_fill_poll(
|
||||
broker_order_id,
|
||||
FillStatus(
|
||||
state="accepted" if visible_grace else "unknown",
|
||||
raw={
|
||||
"reason": (
|
||||
"order_visibility_grace"
|
||||
if visible_grace
|
||||
else "order_not_visible_unresolved"
|
||||
),
|
||||
"order_visible": False,
|
||||
"inquiry_date": inquiry_date,
|
||||
},
|
||||
),
|
||||
submitted_at=submitted_at,
|
||||
)
|
||||
|
||||
if len(exact_parent_matches) > 1:
|
||||
# More than one row claims the same exact identity — ambiguous.
|
||||
return FillStatus(
|
||||
state="unknown",
|
||||
raw={
|
||||
"reason": "parent_identity_ambiguous",
|
||||
"order_visible": True,
|
||||
"inquiry_date": inquiry_date,
|
||||
},
|
||||
return self._record_fill_poll(
|
||||
broker_order_id,
|
||||
FillStatus(
|
||||
state="unknown",
|
||||
raw={
|
||||
"reason": "parent_identity_ambiguous",
|
||||
"order_visible": True,
|
||||
"inquiry_date": inquiry_date,
|
||||
},
|
||||
),
|
||||
submitted_at=submitted_at,
|
||||
)
|
||||
|
||||
row = exact_parent_matches[0]
|
||||
|
||||
# dcy-013 ext: KIS sometimes records an external cancel as a separate
|
||||
# child row with orgn_odno=<original> and cncl_yn="Y" rather than
|
||||
# setting cncl_yn on the original row. Scan sibling rows to detect
|
||||
# this case.
|
||||
#
|
||||
# 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
|
||||
# Follow only response rows bound to the immutable request identity.
|
||||
# KIS may represent cancel/modify as a chain where each child points to
|
||||
# its immediate predecessor through orgn_odno. Row order and numerical
|
||||
# proximity are never evidence of parentage.
|
||||
identity_rows = [r for r in rows if _row_matches_request_identity(r)]
|
||||
|
||||
child_cancel_candidates = [
|
||||
r
|
||||
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)
|
||||
]
|
||||
def _row_parent_odno_normalized(r: dict) -> str:
|
||||
return str(r.get("orgn_odno") or "").strip().lstrip("0") or "0"
|
||||
|
||||
# Correction children: same orgn_odno + matching identity but cncl_yn != "Y".
|
||||
correction_candidates = [
|
||||
r
|
||||
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)
|
||||
]
|
||||
chain = [row]
|
||||
visited = {normalized_odno}
|
||||
current_odno = normalized_odno
|
||||
ambiguous_reason: str | None = None
|
||||
while True:
|
||||
children = [
|
||||
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:
|
||||
# More than one matching cancel child — identity is ambiguous.
|
||||
return FillStatus(
|
||||
state="unknown",
|
||||
raw={
|
||||
"reason": "child_cancel_ambiguous",
|
||||
"order_visible": True,
|
||||
"inquiry_date": inquiry_date,
|
||||
},
|
||||
if ambiguous_reason is not None:
|
||||
return self._record_fill_poll(
|
||||
broker_order_id,
|
||||
FillStatus(
|
||||
state="unknown",
|
||||
raw={
|
||||
"reason": ambiguous_reason,
|
||||
"order_visible": True,
|
||||
"inquiry_date": inquiry_date,
|
||||
},
|
||||
),
|
||||
submitted_at=submitted_at,
|
||||
)
|
||||
|
||||
if correction_candidates:
|
||||
# A correction/replacement child is present; the original order is
|
||||
# superseded but we cannot safely infer terminal status without
|
||||
# explicit broker confirmation of the replacement identity.
|
||||
return FillStatus(
|
||||
state="unknown",
|
||||
raw={
|
||||
"reason": "correction_child_present_unresolved",
|
||||
"order_visible": True,
|
||||
"inquiry_date": inquiry_date,
|
||||
},
|
||||
terminal_row = chain[-1]
|
||||
terminal_order_qty = _safe_int(terminal_row.get("ord_qty")) or 0
|
||||
terminal_filled_qty = _safe_int(terminal_row.get("tot_ccld_qty")) or 0
|
||||
terminal_cancelled_qty = _safe_int(terminal_row.get("cnc_cfrm_qty")) or 0
|
||||
terminal_cancel = (
|
||||
str(terminal_row.get("cncl_yn") or "").strip().upper() == "Y"
|
||||
or (
|
||||
terminal_order_qty > 0
|
||||
and terminal_cancelled_qty > 0
|
||||
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,
|
||||
raw={
|
||||
**(status.raw if isinstance(status.raw, dict) else {}),
|
||||
"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,
|
||||
},
|
||||
)
|
||||
return self._record_fill_poll(
|
||||
broker_order_id,
|
||||
status,
|
||||
submitted_at=submitted_at,
|
||||
)
|
||||
|
||||
def _poll_unknown_submission(
|
||||
self,
|
||||
@@ -595,16 +815,33 @@ class KISBroker:
|
||||
state="unknown",
|
||||
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()
|
||||
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(
|
||||
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] = (
|
||||
now_monotonic + _FILL_POLL_MIN_INTERVAL_SECONDS
|
||||
)
|
||||
submitted_at = datetime.fromisoformat(str(metadata["submitted_at"])).astimezone(_KST)
|
||||
today = submitted_at.strftime("%Y%m%d")
|
||||
tr_id, params = self._daily_fill_request(
|
||||
today,
|
||||
@@ -619,20 +856,18 @@ class KISBroker:
|
||||
)
|
||||
payload = resp.json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
self._fill_poll_not_before[broker_order_id] = (
|
||||
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
|
||||
)
|
||||
return FillStatus(
|
||||
state="unknown",
|
||||
raw={"reason": "submission_lookup_transport"},
|
||||
return self._record_fill_poll(
|
||||
broker_order_id,
|
||||
FillStatus(state="unknown", raw={"reason": "submission_lookup_transport"}),
|
||||
submitted_at=submitted_at,
|
||||
transport_failure=True,
|
||||
)
|
||||
if getattr(resp, "status_code", 200) >= 400 or str(payload.get("rt_cd")) != "0":
|
||||
self._fill_poll_not_before[broker_order_id] = (
|
||||
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
|
||||
)
|
||||
return FillStatus(
|
||||
state="unknown",
|
||||
raw={"reason": "submission_lookup_rejected"},
|
||||
return self._record_fill_poll(
|
||||
broker_order_id,
|
||||
FillStatus(state="unknown", raw={"reason": "submission_lookup_rejected"}),
|
||||
submitted_at=submitted_at,
|
||||
transport_failure=True,
|
||||
)
|
||||
matches = [
|
||||
row
|
||||
@@ -640,19 +875,29 @@ class KISBroker:
|
||||
if self._matches_unknown_submission(row, metadata, submitted_at)
|
||||
]
|
||||
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:
|
||||
return FillStatus(
|
||||
state="rejected",
|
||||
raw={"reason": "submission_confirmed_absent"},
|
||||
return self._record_fill_poll(
|
||||
broker_order_id,
|
||||
FillStatus(
|
||||
state="rejected",
|
||||
raw={"reason": "submission_confirmed_absent"},
|
||||
),
|
||||
submitted_at=submitted_at,
|
||||
)
|
||||
return FillStatus(
|
||||
state="unknown",
|
||||
raw={
|
||||
"reason": (
|
||||
"submission_lookup_ambiguous" if matches else "submission_lookup_pending"
|
||||
)
|
||||
},
|
||||
return self._record_fill_poll(
|
||||
broker_order_id,
|
||||
FillStatus(
|
||||
state="unknown",
|
||||
raw={
|
||||
"reason": (
|
||||
"submission_lookup_ambiguous"
|
||||
if matches
|
||||
else "submission_lookup_pending"
|
||||
)
|
||||
},
|
||||
),
|
||||
submitted_at=submitted_at,
|
||||
)
|
||||
row = matches[0]
|
||||
resolved_order_id = self._broker_order_id_from_row(row)
|
||||
@@ -662,12 +907,16 @@ class KISBroker:
|
||||
today=today,
|
||||
fallback_odno=resolved_order_id.split(":")[-1],
|
||||
)
|
||||
return replace(
|
||||
status,
|
||||
raw={
|
||||
**(status.raw if isinstance(status.raw, dict) else {}),
|
||||
"resolved_broker_order_id": resolved_order_id,
|
||||
},
|
||||
return self._record_fill_poll(
|
||||
broker_order_id,
|
||||
replace(
|
||||
status,
|
||||
raw={
|
||||
**(status.raw if isinstance(status.raw, dict) else {}),
|
||||
"resolved_broker_order_id": resolved_order_id,
|
||||
},
|
||||
),
|
||||
submitted_at=submitted_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -62,6 +62,24 @@ SlotEventEmitter = Callable[
|
||||
] # (event_type, slot, routing_strategy_id) -> None
|
||||
|
||||
_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
|
||||
@@ -115,6 +133,7 @@ class ExecutionService:
|
||||
# A restart intentionally emits once again, covering a crash between a
|
||||
# durable cancel reservation and best-effort alarm publication.
|
||||
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))
|
||||
# dcy-041 / GitHub #45: pending-sell quantity may use a bounded
|
||||
# account snapshot only through this injected reader. Reconciliation
|
||||
@@ -282,6 +301,7 @@ class ExecutionService:
|
||||
self._emit_order_metadata_unknown_alarm(slot)
|
||||
continue
|
||||
fs = self._poll_fill(slot, side)
|
||||
self._observe_order_poll(slot, side, fs)
|
||||
resolved_order_id = (
|
||||
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
|
||||
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:
|
||||
"""Current broker balance as {symbol: hldg_qty} (REQ-062 ground truth)."""
|
||||
|
||||
Reference in New Issue
Block a user