"""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 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 _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] = {} 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 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): return FillStatus( state="accepted", raw={"reason": "fill_poll_backoff"}, ) # 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) as exc: self._fill_poll_not_before[broker_order_id] = ( monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS ) 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 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 # 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 row = next( ( r for r in rows if (str(r.get("odno") or "").strip().lstrip("0") or "0") == normalized_odno ), None, ) if row is None: visible_grace = ( submitted_at is None or (self._clock().astimezone(UTC) - submitted_at.astimezone(UTC)) .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, }, ) # Validate that the matched parent row's identity agrees with the # authoritative request identity. KIS can return stale rows for # reused odno values; a branch, date, or symbol mismatch means this # row does not belong to our order → fail-closed as unknown. row_brno = str(row.get("ord_gno_brno") or "").strip() row_dt = str(row.get("ord_dt") or "").strip() row_pdno = str(row.get("pdno") or "").strip() if request_brno and row_brno and row_brno != request_brno: return FillStatus( state="unknown", raw={ "reason": "parent_identity_mismatch_brno", "order_visible": True, "inquiry_date": inquiry_date, }, ) if row_dt and row_dt != request_date: return FillStatus( state="unknown", raw={ "reason": "parent_identity_mismatch_date", "order_visible": True, "inquiry_date": inquiry_date, }, ) if row_pdno and row_pdno != request_symbol: return FillStatus( state="unknown", raw={ "reason": "parent_identity_mismatch_symbol", "order_visible": True, "inquiry_date": inquiry_date, }, ) # dcy-013 ext: KIS sometimes records an external cancel as a separate # child row with orgn_odno= 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), NOT the row values. Using row values as # ground truth would let a mismatched row pass its own wrong values # back as the filter reference. # - 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 that only share orgn_odno but differ in branch/date/ # symbol are excluded from both candidate lists. def _child_matches_request(r: dict) -> bool: """True when a child row is bound to the same order identity as the request.""" 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 child_brno and child_brno != request_brno: return False if child_dt and child_dt != request_date: return False return not (child_pdno and child_pdno != request_symbol) 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) ] # 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) ] 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 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, }, ) 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( status, raw={ **(status.raw if isinstance(status.raw, dict) else {}), "order_visible": True, "order_open_confirmed": status.state in {"accepted", "partially_filled"}, "inquiry_date": inquiry_date, }, ) 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"}, ) now_monotonic = monotonic() if now_monotonic < self._fill_poll_not_before.get(broker_order_id, 0.0): return FillStatus( state="accepted", raw={"reason": "submission_lookup_backoff"}, ) 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, 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): self._fill_poll_not_before[broker_order_id] = ( monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS ) return FillStatus( state="unknown", raw={"reason": "submission_lookup_transport"}, ) 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"}, ) matches = [ row for row in (payload.get("output1") or []) if self._matches_unknown_submission(row, metadata, submitted_at) ] if len(matches) != 1: age = (datetime.now(tz=_KST) - submitted_at).total_seconds() if not matches and age >= _SUBMISSION_LOOKUP_GRACE_SECONDS: return FillStatus( state="rejected", raw={"reason": "submission_confirmed_absent"}, ) return FillStatus( state="unknown", raw={ "reason": ( "submission_lookup_ambiguous" if matches else "submission_lookup_pending" ) }, ) 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 replace( status, raw={ **(status.raw if isinstance(status.raw, dict) else {}), "resolved_broker_order_id": resolved_order_id, }, ) @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"), )