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
|
||||
|
||||
Reference in New Issue
Block a user