"""KIS (한국투자증권) Open API broker adapter (REQ-012, dcy-003). Implements BrokerPort against the KIS REST API, modeled on the trex-orbit reference (app/broker/kis.py). Slice 1 validates against the KIS 모의투자 (VTS sandbox) server; real-account go-live is config-gated and out of scope. Endpoints: /oauth2/tokenP (token), /uapi/domestic-stock/v1/trading/order-cash (submit), /uapi/domestic-stock/v1/trading/inquire-daily-ccld (fill poll), /uapi/domestic-stock/v1/trading/order-rvsecncl (cancel). """ from __future__ import annotations import base64 import hashlib import json from collections.abc import Callable 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 from trex_core.application.broker_port import ( BalanceItem, BrokerBalanceError, CancelOutcome, CancelState, FillStatus, OrderAck, OrderRequest, calculate_fee, calculate_tax, ) _KST = timezone(timedelta(hours=9)) _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"}) class KISEvidenceLookupError(RuntimeError): """Read-only daily-fill evidence lookup failed.""" def _balance_http_error_code(status: int | None) -> str: if status == 401: return "authentication_failed" if status == 403: return "authorization_failed" if status in {408, 425, 429} or (status is not None and status >= 500): return "transport_failed" return "broker_rejected" def resolve_order_venue(venue: str, at: datetime) -> str: """Validate and preserve an explicit KRX/NXT/SOR order venue. ``at`` remains in the signature for compatibility with existing callers; venue selection is deliberately independent of wall-clock time. """ normalized = str(venue).strip().upper() if normalized not in _SUPPORTED_ORDER_VENUES: raise ValueError("unsupported_order_venue") return normalized def _safe_int(v: object) -> int | None: try: return int(str(v).strip()) except (TypeError, ValueError): return None def _encode_submission_unknown(req: OrderRequest, submitted_at: datetime) -> str: payload = { "v": 1, "slot_id": req.slot_id, "side": req.side, "symbol": req.symbol, "qty": req.qty, "limit_price": req.limit_price, "venue": req.venue, "submitted_at": submitted_at.astimezone(_KST).isoformat(), } raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode() encoded = base64.urlsafe_b64encode(raw).decode().rstrip("=") return f"{_SUBMISSION_UNKNOWN_PREFIX}{encoded}" def _decode_submission_unknown(broker_order_id: str) -> dict | None: if not broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX): return None encoded = broker_order_id[len(_SUBMISSION_UNKNOWN_PREFIX) :] try: raw = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)) payload = json.loads(raw) except (ValueError, UnicodeDecodeError, json.JSONDecodeError): return None required = { "v", "slot_id", "side", "symbol", "qty", "venue", "submitted_at", } if ( not isinstance(payload, dict) or payload.get("v") != 1 or not required.issubset(payload) or payload.get("side") not in {"buy", "sell"} ): return None try: datetime.fromisoformat(str(payload["submitted_at"])) int(payload["qty"]) except (TypeError, ValueError): return None return payload class KISBroker: """KIS REST broker adapter. Live account is gated behind `allow_live`.""" def __init__( self, account_ref: str, *, app_key: str, app_secret: str, base_url: str, timeout: float = 5.0, allow_live: bool = False, token_cache=None, clock: Callable[[], datetime] | None = None, ) -> None: self._account_ref = account_ref self._app_key = app_key self._app_secret = app_secret self._base_url = base_url.rstrip("/") self._timeout = timeout self._token: str | None = None self._token_exp: datetime = datetime.now(tz=UTC) 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( "KIS live account base_url requires allow_live=True (config-gated). " "Use the 모의투자 (openapivts) endpoint for slice-1." ) self._mock = is_mock def name(self) -> str: return "kis-mock" if self._mock else "kis-live" def account_scope_hash(self) -> str: """Stable non-secret identity for account-scoped persisted state.""" cano, product = self._account() material = f"{self._base_url}|{cano}|{product}|{self._app_key}" return hashlib.sha256(material.encode()).hexdigest() # --- credential hot-swap (dcy-036 / REQ-113 AC-2 / REQ-115 AC-1) ---------- def reinitialize(self, credential) -> None: """Replace app_key/app_secret/account_ref with fetched day-start credential. Called once at the day-start checkpoint (07:30 KST) after a successful portfolio-hub fetch (REQ-113 AC-2 / REQ-115 AC-1). base_url / timeout / allow_live / _mock are env-sourced and remain unchanged. Thread-safety: CPython GIL makes attribute assignment atomic. This is safe because reinitialize() is called at day-start (before trading) and the "day-scoped fixed cache" principle (REQ-115) ensures no intraday re-fetch. If intraday reinitialize is ever needed a lock should be added at that time. Access-token invalidation: - L1 in-memory token is cleared so the next _ensure_token() call issues a fresh token with the new credentials. - L2 Redis cache: the new (account_ref, app_key) key will miss on first lookup, triggering a fresh token issue. The old key expires via TTL — no explicit delete needed for 1x-daily rotation. Args: credential: BrokerCredentialPayload (from trex_core.application.portfolio) providing account_ref, app_key, app_secret. """ self._account_ref = credential.account_ref self._app_key = credential.app_key self._app_secret = credential.app_secret # Invalidate L1 in-memory token so next call re-issues. self._token = None self._token_exp = datetime.min.replace(tzinfo=UTC) # --- auth --- def _ensure_token(self) -> str: # L1: in-memory if self._token and datetime.now(tz=UTC) < self._token_exp: return self._token # L2: shared/persistent cache (Redis) — survives restart / multi-process (REQ-016) if self._token_cache is not None: cached = self._token_cache.load(self._account_ref, self._app_key) if cached is not None: self._token, self._token_exp = cached return self._token # L3: issue from KIS resp = httpx.post( f"{self._base_url}/oauth2/tokenP", json={ "grant_type": "client_credentials", "appkey": self._app_key, "appsecret": self._app_secret, }, timeout=self._timeout, ) resp.raise_for_status() data = resp.json() self._token = data["access_token"] ttl = int(data.get("expires_in", 3600)) self._token_exp = datetime.now(tz=UTC) + timedelta(seconds=max(60, ttl - 300)) if self._token_cache is not None: self._token_cache.store(self._account_ref, self._app_key, self._token, self._token_exp) return self._token def _headers(self, tr_id: str) -> dict[str, str]: return { "authorization": f"Bearer {self._ensure_token()}", "appkey": self._app_key, "appsecret": self._app_secret, "tr_id": tr_id, "custtype": "P", "content-type": "application/json; charset=utf-8", } def _account(self) -> tuple[str, str]: raw = self._account_ref.strip() if "-" in raw: cano, product = raw.split("-", 1) return cano.strip(), product.strip() digits = "".join(c for c in raw if c.isdigit()) return digits[:8], (digits[8:] or "01") def _tr_submit(self, side: str) -> str: if self._mock: return "VTTC0012U" if side == "buy" else "VTTC0011U" return "TTTC0012U" if side == "buy" else "TTTC0011U" def _tr_price(self) -> str: """tr_id for current-price inquiry (REQ-038 AC-7, CHG-20260607-04). 주식현재가 시세 uses FHKST01010100 for both live and 모의투자 — KIS does not provide separate VTS quote tr_ids (the old TTTC8397R/ VTTC8397R were rejected LIVE as "없는 서비스 코드", rt_cd=1). """ return "FHKST01010100" # --- BrokerPort --- def submit_order(self, req: OrderRequest) -> OrderAck: cano, product = self._account() submitted_at = self._clock().astimezone(_KST) try: actual_venue = resolve_order_venue(req.venue, submitted_at) except ValueError as exc: return OrderAck(accepted=False, reason=str(exc)) resolved_req = replace(req, venue=actual_venue) body = { "CANO": cano, "ACNT_PRDT_CD": product, "PDNO": resolved_req.symbol, "ORD_DVSN": resolved_req.order_type, "ORD_QTY": str(resolved_req.qty), "ORD_UNPR": str(resolved_req.limit_price or 0), # The intent layer normalizes missing venue to SOR. The broker # adapter always forwards the resulting explicit order venue. # Real-account NXT order confirmed rt_cd=0 on 2026-07-06. "EXCG_ID_DVSN_CD": actual_venue, } try: resp = httpx.post( f"{self._base_url}/uapi/domestic-stock/v1/trading/order-cash", headers=self._headers(self._tr_submit(resolved_req.side)), json=body, timeout=self._timeout, ) payload = resp.json() except (httpx.HTTPError, ValueError) as exc: # A transport failure after POST does not prove that KIS rejected # the order. Keep the slot dispatched under a local sentinel so # the execution loop cannot submit the same order again. return OrderAck( accepted=True, broker_order_id=_encode_submission_unknown(resolved_req, submitted_at), reason=f"submit_outcome_unknown:{type(exc).__name__}", venue=actual_venue, raw={"error_class": type(exc).__name__}, submitted_at=submitted_at, ) if getattr(resp, "status_code", 200) >= 500: return OrderAck( accepted=True, broker_order_id=_encode_submission_unknown(resolved_req, submitted_at), reason=f"submit_outcome_unknown:http_{resp.status_code}", venue=actual_venue, raw={"http_status": resp.status_code}, submitted_at=submitted_at, ) if str(payload.get("rt_cd")) == "0": out = payload.get("output") or {} odno = out.get("ODNO") or out.get("odno") orgno = out.get("KRX_FWDG_ORD_ORGNO") or out.get("ord_gno_brno") or "" bid = f"{orgno}:{odno}" if orgno else str(odno) return OrderAck( accepted=True, broker_order_id=bid, venue=actual_venue, raw=payload, submitted_at=submitted_at, ) return OrderAck( accepted=False, reason=payload.get("msg1") or payload.get("msg_cd"), venue=actual_venue, raw=payload, ) def _daily_fill_request( self, trading_date: str, *, symbol: str = "", order_number: str = "", ) -> tuple[str, dict[str, str]]: """Build the current KIS daily-fill contract for every order venue.""" cano, product = self._account() tr_id = "VTTC0081R" if self._mock else "TTTC0081R" return tr_id, { "CANO": cano, "ACNT_PRDT_CD": product, "INQR_STRT_DT": trading_date, "INQR_END_DT": trading_date, "SLL_BUY_DVSN_CD": "00", "INQR_DVSN": "00", "PDNO": symbol, "CCLD_DVSN": "00", "ORD_GNO_BRNO": "", "ODNO": order_number, "INQR_DVSN_3": "00", "INQR_DVSN_1": "", "CTX_AREA_FK100": "", "CTX_AREA_NK100": "", "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, *, side: str, symbol: str, order_date: str | None = None, submitted_at: datetime | None = None, ) -> FillStatus: if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX): return self._poll_unknown_submission(broker_order_id, side=side, symbol=symbol) if order_date is None: # A legacy pending row has no authoritative date anchor. ODNO is # reusable, so querying today's rows could attach another order's # terminal evidence to this slot. Never invent an inquiry date. return FillStatus( state="unknown", raw={"reason": "order_reconciliation_metadata_unknown"}, ) now = monotonic() if now < self._fill_poll_not_before.get(broker_order_id, 0.0): stats = self._fill_poll_stats.get(broker_order_id, {}) return FillStatus( state="accepted", 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. self._fill_poll_not_before[broker_order_id] = now + _FILL_POLL_MIN_INTERVAL_SECONDS odno = broker_order_id.split(":")[-1] inquiry_date = order_date tr_id, params = self._daily_fill_request(inquiry_date, order_number=odno) try: resp = httpx.get( f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-daily-ccld", headers=self._headers(tr_id), params=params, timeout=self._timeout, ) payload = resp.json() 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, ) if getattr(resp, "status_code", 200) >= 400 or str(payload.get("rt_cd")) != "0": return self._record_fill_poll( broker_order_id, FillStatus(state="unknown", raw={"reason": "fill_inquiry_rejected"}), submitted_at=submitted_at, transport_failure=True, ) rows = payload.get("output1") or [] normalized_odno = odno.lstrip("0") or "0" # Canonical request identity from the caller — these are authoritative # and must not be overridden by whatever the response row contains. request_brno = broker_order_id.split(":")[0] if ":" in broker_order_id else "" request_date = inquiry_date # == order_date request_symbol = symbol def _row_odno_normalized(r: dict) -> str: return str(r.get("odno") or "").strip().lstrip("0") or "0" def _row_matches_request_identity(r: dict) -> bool: """True when a row's identity fields are present and agree with the authoritative request identity. An absent field in the row is treated as a mismatch (fail-closed), not as a wildcard.""" row_brno = str(r.get("ord_gno_brno") or "").strip() row_dt = str(r.get("ord_dt") or "").strip() row_pdno = str(r.get("pdno") or "").strip() # brno: required only when request carries one; if request has no # brno (legacy odno-only format) skip the check to preserve # backwards compatibility. if request_brno and (not row_brno or row_brno != request_brno): return False # date and symbol: always required — these never legitimately vary # for the same order on the same inquiry date. if not row_dt or row_dt != request_date: return False return bool(row_pdno) and row_pdno == request_symbol # Collect ALL rows whose odno matches. We must then require exactly # one that also passes the full identity check. Using next() here # would accept the first odno-match regardless of identity and stop # the search, missing authoritative rows that come later and ignoring # the ambiguous-parent edge case. odno_matches = [ r for r in rows if _row_odno_normalized(r) == normalized_odno ] exact_parent_matches = [r for r in odno_matches if _row_matches_request_identity(r)] if len(exact_parent_matches) == 0: # No row with the exact request identity. If there are odno # matches they all failed identity validation → unknown (not grace). if odno_matches: 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 = ( submitted_at is None or (self._clock().astimezone(UTC) - submitted_at.astimezone(UTC)) .total_seconds() < _SUBMISSION_LOOKUP_GRACE_SECONDS ) 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 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] # 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)] def _row_parent_odno_normalized(r: dict) -> str: return str(r.get("orgn_odno") or "").strip().lstrip("0") or "0" 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 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, ) 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 ) ) 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": ( 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, broker_order_id: str, *, side: str, symbol: str, ) -> FillStatus: metadata = _decode_submission_unknown(broker_order_id) if metadata is None: return FillStatus( 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", "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 ) today = submitted_at.strftime("%Y%m%d") tr_id, params = self._daily_fill_request( today, symbol=str(metadata["symbol"]), ) try: resp = httpx.get( f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-daily-ccld", headers=self._headers(tr_id), params=params, timeout=self._timeout, ) payload = resp.json() except (httpx.HTTPError, ValueError): 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": 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 for row in (payload.get("output1") or []) if self._matches_unknown_submission(row, metadata, submitted_at) ] if len(matches) != 1: age = (self._clock().astimezone(_KST) - submitted_at).total_seconds() if not matches and age >= _SUBMISSION_LOOKUP_GRACE_SECONDS: return self._record_fill_poll( broker_order_id, FillStatus( state="rejected", raw={"reason": "submission_confirmed_absent"}, ), submitted_at=submitted_at, ) 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) status = self._fill_status_from_row( row, side=side, today=today, fallback_odno=resolved_order_id.split(":")[-1], ) 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 def _broker_order_id_from_row(row: dict) -> str: odno = str(row.get("odno") or "").strip() orgno = str(row.get("ord_gno_brno") or "").strip() return f"{orgno}:{odno}" if orgno else odno @staticmethod def _matches_unknown_submission( row: dict, metadata: dict, submitted_at: datetime, ) -> bool: if str(row.get("pdno") or "").strip() != str(metadata["symbol"]): return False expected_side = "02" if metadata["side"] == "buy" else "01" if str(row.get("sll_buy_dvsn_cd") or "").strip() not in {"", expected_side}: return False if (_safe_int(row.get("ord_qty")) or 0) != int(metadata["qty"]): return False expected_price = int(metadata.get("limit_price") or 0) row_price = _safe_int(row.get("ord_unpr")) if row_price is not None and row_price != expected_price: return False order_date = str(row.get("ord_dt") or "").strip() order_time = str(row.get("ord_tmd") or "").strip()[:6] if len(order_date) != 8 or len(order_time) != 6: return False try: ordered_at = datetime.strptime(order_date + order_time, "%Y%m%d%H%M%S").replace( tzinfo=_KST ) except ValueError: return False delta = (ordered_at - submitted_at).total_seconds() return -5.0 <= delta <= 120.0 @staticmethod def _execution_venue_from_row(row: dict) -> str | None: """Parse broker-authoritative KRX/NXT without time inference.""" notice_code = str(row.get("ord_exg_gb") or row.get("ORD_EXG_GB") or "").strip() notice_map = {"1": "KRX", "2": "NXT", "3": "KRX", "4": "NXT"} if notice_code in notice_map: return notice_map[notice_code] for key in ( "excg_id_dvsn_cd", "excg_id_dvsn_Cd", "EXCG_ID_DVSN_CD", "execution_venue", ): value = str(row.get(key) or "").strip().upper() if value in {"KRX", "SOR-KRX"}: return "KRX" if value in {"NXT", "SOR-NXT"}: return "NXT" # SOR is routing provenance, not an execution exchange. return None @staticmethod def _order_venue_from_row(row: dict) -> str | None: """Parse original routing venue (KRX/NXT/SOR) without time inference.""" for key in ("excg_id_dvsn_cd", "excg_id_dvsn_Cd", "EXCG_ID_DVSN_CD", "order_venue"): value = str(row.get(key) or "").strip().upper() if value in _SUPPORTED_ORDER_VENUES: return value if value in {"SOR-KRX", "SOR-NXT"}: return "SOR" return None @staticmethod def _side_from_row(row: dict) -> str | None: code = str(row.get("sll_buy_dvsn_cd") or row.get("SLL_BUY_DVSN_CD") or "").strip() if code == "01": return "sell" if code == "02": return "buy" return None @staticmethod def _parse_daily_ccld_execution_id(execution_id: str) -> dict[str, str]: parts = str(execution_id or "").split(":") if len(parts) != 5 or parts[0] != "kis-daily-ccld": raise KISEvidenceLookupError("unsupported execution_id format") order_date, orgno, odno, qty = parts[1], parts[2], parts[3], parts[4] if len(order_date) != 8 or not order_date.isdigit(): raise KISEvidenceLookupError("execution_id missing order date") if not odno or not qty.isdigit() or int(qty) <= 0: raise KISEvidenceLookupError("execution_id missing order number or qty") return { "order_date": order_date, "orgno": orgno, "odno": odno, "qty": qty, } @staticmethod def _executed_at_from_row(row: dict, fallback_date: str) -> datetime: order_date = str(row.get("ord_dt") or fallback_date).strip() order_time = str(row.get("ord_tmd") or "").strip()[:6] year = int(order_date[0:4]) month = int(order_date[4:6]) day = int(order_date[6:8]) if len(order_time) == 6 and order_time.isdigit(): executed_at = datetime( year, month, day, int(order_time[0:2]), int(order_time[2:4]), int(order_time[4:6]), tzinfo=_KST, ) return executed_at.astimezone(UTC) return datetime(year, month, day, tzinfo=_KST).astimezone(UTC) def lookup_fill_evidence(self, *, trading_date: str, execution_id: str) -> dict: """Read one exact filled/partial-cancel evidence row for a historical execution.""" parsed = self._parse_daily_ccld_execution_id(execution_id) if trading_date != parsed["order_date"]: raise KISEvidenceLookupError("execution_id date does not match --date") tr_id, params = self._daily_fill_request(parsed["order_date"], order_number=parsed["odno"]) try: resp = httpx.get( f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-daily-ccld", headers=self._headers(tr_id), params=params, timeout=self._timeout, ) payload = resp.json() except (httpx.HTTPError, ValueError) as exc: raise KISEvidenceLookupError( f"daily fill evidence lookup failed: {exc.__class__.__name__}" ) from exc if getattr(resp, "status_code", 200) >= 400 or str(payload.get("rt_cd")) != "0": raise KISEvidenceLookupError("daily fill evidence lookup rejected") matches: list[dict] = [] normalized_odno = parsed["odno"].lstrip("0") or "0" for row in payload.get("output1") or []: row_odno = str(row.get("odno") or "").strip().lstrip("0") or "0" if row_odno != normalized_odno: continue side = self._side_from_row(row) if side is None: continue status = self._fill_status_from_row( row, side=side, today=parsed["order_date"], fallback_odno=parsed["odno"] ) if status.execution_id != execution_id: continue order_venue = self._order_venue_from_row(row) if order_venue is None: continue orgno = str(row.get("ord_gno_brno") or "").strip() odno = str(row.get("odno") or parsed["odno"]).strip() symbol = str(row.get("pdno") or "").strip() if not symbol: continue matches.append( { "symbol": symbol.zfill(6), "side": side, "qty": float(status.filled_qty), "price": float(status.avg_price or 0.0), "fee": float(status.fee), "tax": float(status.tax), "order_date": parsed["order_date"], "execution_id": execution_id, "order_id": f"{orgno}:{odno}" if orgno else odno, "order_venue": order_venue, "execution_venue": status.execution_venue, "executed_at": self._executed_at_from_row(row, parsed["order_date"]), "raw": dict(row), } ) if len(matches) != 1: raise KISEvidenceLookupError("daily fill evidence not found or ambiguous") return matches[0] @staticmethod def _fill_status_from_row( row: dict, *, side: str, today: str, fallback_odno: str, ) -> FillStatus: execution_venue = KISBroker._execution_venue_from_row(row) ord_qty = _safe_int(row.get("ord_qty")) or 0 ccld_qty = _safe_int(row.get("tot_ccld_qty")) or 0 rejected_qty = _safe_int(row.get("rjct_qty")) or 0 avg = _safe_int(row.get("avg_prvs")) or _safe_int(row.get("ccld_prvs")) # dcy-013 / REQ-012 (issue #9): external cancel detection. KIS # inquire-daily-ccld marks a cancelled order with cncl_yn="Y". Without # this, a zero-fill cancel looked like "accepted" and the slot was stuck # in pending_sell forever. Unknown/absent values are treated as not # cancelled (conservative — the stale watchdog is the backstop, R-1). cancelled = str(row.get("cncl_yn") or "").strip().upper() == "Y" if ccld_qty <= 0: # KIS can acknowledge an order submission and later report the # whole quantity as exchange-rejected without setting cncl_yn. # Treating that row as accepted leaves the slot pending forever # and suppresses the strategy's next valid signal. if rejected_qty > 0 and (ord_qty <= 0 or rejected_qty >= ord_qty): return FillStatus(state="rejected", raw=row) if cancelled: return FillStatus(state="cancelled", raw=row) # full external cancel return FillStatus(state="accepted", raw=row) if ccld_qty >= ord_qty > 0: # Deterministic execution_id (dcy-004 OQ-3, orbit kis.py rule): # kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty} ord_dt = str(row.get("ord_dt") or today) ord_gno_brno = str(row.get("ord_gno_brno") or "") row_odno = str(row.get("odno") or fallback_odno) execution_id = f"kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{row_odno}:{ccld_qty}" fill_price = float(avg) if avg else 0.0 # dcy-026: inquire-daily-ccld (output1) does not expose per-fill # fee/tax fields, so use the shared policy estimate (same as paper). # A future endpoint that returns broker-authoritative fee/tax would # be parsed here first, with this as fallback (FR-3). amount = ccld_qty * fill_price fee = calculate_fee(side, amount) tax = calculate_tax(side, amount) return FillStatus( state="filled", filled_qty=ccld_qty, avg_price=fill_price or None, execution_id=execution_id, fee=fee, tax=tax, execution_venue=execution_venue, executed_at=KISBroker._executed_at_from_row(row, ord_dt), raw=row, ) # 0 < ccld_qty < ord_qty if cancelled: # Partial fill then external cancel (dcy-013 FR-2): carry a # deterministic execution_id + fee so the filled portion is recorded # once, and flag cancelled so execution returns the remainder to # holding instead of waiting for a fill that will never come. ord_dt = str(row.get("ord_dt") or today) ord_gno_brno = str(row.get("ord_gno_brno") or "") row_odno = str(row.get("odno") or fallback_odno) execution_id = f"kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{row_odno}:{ccld_qty}" fill_price = float(avg) if avg else 0.0 amount = ccld_qty * fill_price fee = calculate_fee(side, amount) tax = calculate_tax(side, amount) return FillStatus( state="partially_filled", filled_qty=ccld_qty, avg_price=fill_price or None, execution_id=execution_id, fee=fee, tax=tax, execution_venue=execution_venue, executed_at=KISBroker._executed_at_from_row(row, ord_dt), cancelled=True, raw=row, ) return FillStatus( state="partially_filled", filled_qty=ccld_qty, avg_price=float(avg) if avg else None, execution_venue=execution_venue, raw=row, ) def get_balance(self) -> list[BalanceItem]: """KIS 주식잔고조회 (`TTTC8434R`/`VTTC8434R`). Aggregates `output1[*].pdno` + `hldg_qty`, drops zero-qty rows (D-2 leftover, Design Decision 7). Only a validated `rt_cd=0` response may return an empty list; token/HTTP/shape/KIS failures raise a sanitized :class:`BrokerBalanceError` so reconciliation cannot infer false zero. """ cano, product = self._account() tr_id = "VTTC8434R" if self._mock else "TTTC8434R" params = { "CANO": cano, "ACNT_PRDT_CD": product, "AFHR_FLPR_YN": "N", "OFL_YN": "", "INQR_DVSN": "02", "UNPR_DVSN": "01", "FUND_STTL_ICLD_YN": "N", "FNCG_AMT_AUTO_RDPT_YN": "N", "PRCS_DVSN": "01", "CTX_AREA_FK100": "", "CTX_AREA_NK100": "", } try: headers = self._headers(tr_id) except (httpx.HTTPError, KeyError, TypeError, ValueError): # Token issuance/header construction is distinct from a rejected # balance endpoint response and must never look like an empty book. raise BrokerBalanceError("token_issuance_failed") from None quantities: dict[str, int] = {} seen_continuations: set[tuple[str, str]] = set() continuation: tuple[str, str] | None = None while True: request_headers = headers if continuation is not None: params["CTX_AREA_FK100"], params["CTX_AREA_NK100"] = continuation request_headers = {**headers, "tr_cont": "N"} try: resp = httpx.get( f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-balance", headers=request_headers, params=params, timeout=self._timeout, ) resp.raise_for_status() payload = resp.json() except httpx.HTTPStatusError as exc: status = exc.response.status_code if exc.response is not None else None raise BrokerBalanceError(_balance_http_error_code(status)) from None except httpx.HTTPError: raise BrokerBalanceError("transport_failed") from None except (ValueError, TypeError): raise BrokerBalanceError("malformed_response") from None if not isinstance(payload, dict): raise BrokerBalanceError("malformed_response") if str(payload.get("rt_cd")) != "0": raise BrokerBalanceError("broker_rejected") rows = payload.get("output1") if not isinstance(rows, list): raise BrokerBalanceError("malformed_response") for row in rows: if not isinstance(row, dict): raise BrokerBalanceError("malformed_response") qty = _safe_int(row.get("hldg_qty")) if qty is None: raise BrokerBalanceError("malformed_response") if qty <= 0: continue symbol = str(row.get("pdno") or "").strip() if not symbol: raise BrokerBalanceError("malformed_response") quantities[symbol] = quantities.get(symbol, 0) + qty response_headers = getattr(resp, "headers", None) if response_headers is None: raise BrokerBalanceError("malformed_response") tr_cont = response_headers.get("tr_cont") if not isinstance(tr_cont, str): raise BrokerBalanceError("malformed_response") tr_cont = tr_cont.strip().upper() if tr_cont in {"D", "E"}: return [ BalanceItem(symbol=symbol, hldg_qty=qty) for symbol, qty in quantities.items() ] if tr_cont not in {"F", "M"}: raise BrokerBalanceError("malformed_response") next_continuation = ( str(payload.get("ctx_area_fk100") or "").strip(), str(payload.get("ctx_area_nk100") or "").strip(), ) if not all(next_continuation) or next_continuation in seen_continuations: raise BrokerBalanceError("malformed_response") seen_continuations.add(next_continuation) continuation = next_continuation def get_available_cash(self, *, price_check_code: str = "005930") -> int: """Return KIS cash-only buy capacity (`nrcvb_buy_amt`). This is intentionally separate from ``BrokerPort``. An account compatibility service may use it to refresh per-strategy portfolio budgets without teaching the domain/Core about mixed manual holdings. """ cano, product = self._account() params = { "CANO": cano, "ACNT_PRDT_CD": product, "PDNO": str(price_check_code).zfill(6), # KIS recommends market-order mode when checking cash-only buying # capacity so the symbol's margin ratio is reflected. "ORD_UNPR": "0", "ORD_DVSN": "01", "CMA_EVLU_AMT_ICLD_YN": "Y", "OVRS_ICLD_YN": "N", } try: headers = self._headers("VTTC8908R" if self._mock else "TTTC8908R") response = httpx.get( f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-psbl-order", headers=headers, params=params, timeout=self._timeout, ) response.raise_for_status() payload = response.json() except BrokerBalanceError: raise except httpx.HTTPStatusError as exc: status = exc.response.status_code if exc.response is not None else None raise BrokerBalanceError(_balance_http_error_code(status)) from None except httpx.HTTPError: raise BrokerBalanceError("transport_failed") from None except (KeyError, TypeError, ValueError): raise BrokerBalanceError("malformed_response") from None if not isinstance(payload, dict): raise BrokerBalanceError("malformed_response") if str(payload.get("rt_cd")) != "0": raise BrokerBalanceError("broker_rejected") output = payload.get("output") if not isinstance(output, dict): raise BrokerBalanceError("malformed_response") amount = _safe_int(output.get("nrcvb_buy_amt")) if amount is None or amount < 0: raise BrokerBalanceError("malformed_response") return amount def get_current_price(self, symbol: str, market: str = "J") -> Decimal | None: """Current traded price `stck_prpr` via KIS inquire-price (REQ-038, REQ-118). tr_id: `FHKST01010100` per REQ-038 AC-7 (live and mock share the same tr_id). market: KIS FID_COND_MRKT_DIV_CODE (REQ-118). Default "J" (KRX equities) preserves backward compatibility for existing safety-sell callers. Returns None on network/parse error or missing price so the safety-sell worker skips the slot this tick (no false trigger). """ params = {"FID_COND_MRKT_DIV_CODE": market, "FID_INPUT_ISCD": symbol} try: resp = httpx.get( f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-price", headers=self._headers(self._tr_price()), params=params, timeout=self._timeout, ) payload = resp.json() except (httpx.HTTPError, ValueError): return None if str(payload.get("rt_cd")) != "0": return None out = payload.get("output") or {} raw = out.get("stck_prpr") if raw is None or str(raw).strip() == "": return None try: return Decimal(str(raw).strip()) except (InvalidOperation, ValueError): return None def cancel_order( self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX" ) -> CancelOutcome: """Cancel a previously submitted order (REQ-118). venue: exchange venue of the original order. Must match the EXCG_ID_DVSN_CD used at submit time. Default "KRX" preserves backward compatibility. Real-account NXT cancel (EXCG=NXT) confirmed rt_cd=0 on 2026-07-06. """ if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX): return CancelOutcome( CancelState.UNKNOWN, "submission_outcome_unknown", ) cano, product = self._account() parts = broker_order_id.split(":") orgno, odno = (parts[0], parts[1]) if len(parts) == 2 else ("", parts[-1]) body = { "CANO": cano, "ACNT_PRDT_CD": product, "KRX_FWDG_ORD_ORGNO": orgno, "ORGN_ODNO": odno, "ORD_DVSN": "00", "RVSE_CNCL_DVSN_CD": "02", "ORD_QTY": str(qty), "ORD_UNPR": "0", "QTY_ALL_ORD_YN": "Y", # REQ-118: cancel must use the same EXCG_ID_DVSN_CD as the original submit. "EXCG_ID_DVSN_CD": venue, } tr_id = "VTTC0013U" if self._mock else "TTTC0013U" try: resp = httpx.post( f"{self._base_url}/uapi/domestic-stock/v1/trading/order-rvsecncl", headers=self._headers(tr_id), json=body, timeout=self._timeout, ) except httpx.HTTPError: return CancelOutcome(CancelState.UNKNOWN, "transport_failed") if getattr(resp, "status_code", 200) >= 500: return CancelOutcome(CancelState.UNKNOWN, "broker_unavailable") try: payload = resp.json() except ValueError: return CancelOutcome(CancelState.UNKNOWN, "malformed_response") rt_cd = payload.get("rt_cd") if str(rt_cd) == "0": return CancelOutcome(CancelState.REQUESTED) if rt_cd is None: if getattr(resp, "status_code", 200) >= 400: return CancelOutcome(CancelState.REJECTED, "http_rejected") return CancelOutcome(CancelState.UNKNOWN, "missing_result_code") return CancelOutcome( CancelState.REJECTED, str(payload.get("msg_cd") or "broker_rejected"), )