Files
trex-core/trex_core/adapters/broker/kis.py
T
genichinandClaude Opus 4.8 4746f74aae fix(broker): dcy-013 KIS 외부 취소 감지 + 부분 체결 회계 + stale 워치독 (BAT-017)
KIS 계좌에서 직접 매도 주문을 취소하면 poll_fill 이 cncl_yn 을 파싱하지 않아
슬롯이 pending_sell 에 영구히 갇히고 죽은 주문을 무한 폴링하던 버그(issue #9)
정정.

거버넌스: 전부 change-req (신규 REQ 없음, dcy-013 OQ-3).
- REQ-012: KIS poll_fill cncl_yn 파싱 → 외부 취소 감지. FillStatus.cancelled
  /is_partial_cancel 추가. 체결 0 취소→cancelled, 부분 체결 후 취소→partial+cancelled
- REQ-013: 집행 _on_partial_cancel — 체결분 원장 1회 + qty 차감(매도 잔여/매수 체결분)
  + holding 복귀 (OQ-1 A안). 전량 취소는 기존 롤백 경로 재사용
- REQ-018: 부분 체결분 원장 1회 기록 (_record_ledger 재사용)
- REQ-031: 잔여 포지션 정합 연계 (slot.qty ↔ 실 잔고, 과매도 방지)
- REQ-014: pending stale 워치독 Alert-only (TREX_PENDING_STALE_THRESHOLD,
  WARN+카운터, 상태 미변경) — 취소 감지 누락 backstop
- REQ-015: interface-contract/runtime-flows 정합

전량 취소는 holding 자동 복구(limit_price clear→재매도 시장가, dcy-012 연계).
KIS cncl_yn 실 규약은 운영 검증(미지 값 보수 처리 + 워치독 backstop).

검증: 단위 294 passed/32 skipped, 인프라 게이트(PG+RabbitMQ) 326 passed, ruff clean.
신규 test_kis_cancel(httpx stub) + test_execution_cancel(programmable broker).
BAT-017 release-candidate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 14:11:49 +09:00

319 lines
13 KiB
Python

"""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"))
# 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:
if cancelled:
return FillStatus(state="cancelled", raw=row) # full external cancel
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,
)
# 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 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="partially_filled", filled_qty=ccld_qty,
avg_price=fill_price or None,
execution_id=execution_id, fee=fee, tax=0.0,
cancelled=True, 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