"""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 from datetime import datetime, timedelta, timezone from decimal import Decimal, InvalidOperation from typing import Optional import httpx from trex_core.application.broker_port import ( BalanceItem, FillStatus, OrderAck, OrderRequest, calculate_fee, ) _KST = timezone(timedelta(hours=9)) _MOCK_HINTS = ("openapivts", "virtual", "mock") def _safe_int(v: object) -> Optional[int]: try: return int(str(v).strip()) except (TypeError, ValueError): return None 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, ) -> 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: Optional[str] = None self._token_exp: datetime = datetime.now(tz=timezone.utc) self._token_cache = token_cache # optional TokenCache (L2, REQ-016) 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" # --- auth --- def _ensure_token(self) -> str: # L1: in-memory if self._token and datetime.now(tz=timezone.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=timezone.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, "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() body = { "CANO": cano, "ACNT_PRDT_CD": product, "PDNO": req.symbol, "ORD_DVSN": req.order_type, "ORD_QTY": str(req.qty), "ORD_UNPR": str(req.limit_price or 0), } try: resp = httpx.post( f"{self._base_url}/uapi/domestic-stock/v1/trading/order-cash", headers=self._headers(self._tr_submit(req.side)), json=body, timeout=self._timeout, ) payload = resp.json() except (httpx.HTTPError, ValueError) as exc: return OrderAck(accepted=False, reason=f"submit_network_error:{type(exc).__name__}", raw={"error": str(exc)}) 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, raw=payload) return OrderAck(accepted=False, reason=payload.get("msg1") or payload.get("msg_cd"), raw=payload) def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus: cano, product = self._account() odno = broker_order_id.split(":")[-1] today = datetime.now(tz=_KST).strftime("%Y%m%d") tr_id = "VTTC8001R" if self._mock else "TTTC8001R" params = { "CANO": cano, "ACNT_PRDT_CD": product, "INQR_STRT_DT": today, "INQR_END_DT": today, "SLL_BUY_DVSN_CD": "00", "INQR_DVSN": "00", "PDNO": "", "CCLD_DVSN": "00", "ORD_GNO_BRNO": "", "ODNO": odno, "INQR_DVSN_3": "00", "INQR_DVSN_1": "", "CTX_AREA_FK100": "", "CTX_AREA_NK100": "", } 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: return FillStatus(state="unknown", raw={"error": str(exc)}) rows = payload.get("output1") or [] row = next((r for r in rows if str(r.get("odno")) == odno), rows[0] if rows else None) if row is None: return FillStatus(state="accepted", raw=payload) ord_qty = _safe_int(row.get("ord_qty")) or 0 ccld_qty = _safe_int(row.get("tot_ccld_qty")) or 0 avg = _safe_int(row.get("avg_prvs")) or _safe_int(row.get("ccld_prvs")) if ccld_qty <= 0: return FillStatus(state="accepted", raw=row) if ccld_qty >= ord_qty and 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 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 fee = calculate_fee(side, ccld_qty * fill_price) return FillStatus( state="filled", filled_qty=ccld_qty, avg_price=fill_price or None, execution_id=execution_id, fee=fee, tax=0.0, raw=row, ) return FillStatus(state="partially_filled", filled_qty=ccld_qty, avg_price=float(avg) if avg else None, 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). Network/credential errors map to an empty list (caller treats as no broker positions). """ 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: resp = httpx.get( f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-balance", headers=self._headers(tr_id), params=params, timeout=self._timeout, ) payload = resp.json() except (httpx.HTTPError, ValueError): return [] if str(payload.get("rt_cd")) != "0": return [] rows = payload.get("output1") or [] out: list[BalanceItem] = [] for row in rows: symbol = str(row.get("pdno") or "").strip() qty = _safe_int(row.get("hldg_qty")) or 0 if not symbol or qty <= 0: continue out.append(BalanceItem(symbol=symbol, hldg_qty=qty)) return out def get_current_price(self, symbol: str) -> Optional[Decimal]: """Current traded price `stck_prpr` via KIS inquire-price (REQ-038). tr_id: `TTTC8397R` (live) / `VTTC8397R` (mock) per REQ-038 AC-7. 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": "J", "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) -> bool: 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", } 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, ) return str(resp.json().get("rt_cd")) == "0" except (httpx.HTTPError, ValueError): return False