- OrderRequest.venue: str = "KRX" additive 추가 (기존 위치 인수 순서 불변) - BrokerPort.cancel_order: venue: str = "KRX" 파라미터 추가 - BrokerPort.get_current_price: market: str = "J" 파라미터 추가 - KISBroker.submit_order: EXCG_ID_DVSN_CD=req.venue 항상 명시 전송 - KISBroker.cancel_order: EXCG_ID_DVSN_CD=venue 항상 명시 전송 - KISBroker.get_current_price: FID_COND_MRKT_DIV_CODE=market 파라미터화 - PaperBroker: cancel_order/get_current_price 시그니처 정합 (venue/market 수신·무시) - poll_fill TR 유지 (TTTC8001R) — NXT 체결 포함 여부 미확인 open item (코드 주석 기록) - tests/adapters/broker/test_venue.py: REQ-117 AC-1~5 + REQ-118 AC-1~8 신규 17개 - docs: interface-contract.md OrderRequest/BrokerPort 섹션 + change log 추가 - docs: runtime-flows.md venue 전달 경로 명시 + change log 추가 결과: 794 passed, 47 skipped (기존 777+47 대비 신규 17개 추가 전부 통과) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
377 lines
17 KiB
Python
377 lines
17 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,
|
|
calculate_tax,
|
|
)
|
|
|
|
_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"
|
|
|
|
# --- 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=timezone.utc)
|
|
|
|
# --- 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),
|
|
# REQ-118: always send EXCG_ID_DVSN_CD from req.venue (default "KRX").
|
|
# Previously omitted (server defaulted to KRX); explicit is more robust.
|
|
# Real-account NXT order confirmed rt_cd=0 on 2026-07-06.
|
|
"EXCG_ID_DVSN_CD": req.venue,
|
|
}
|
|
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")
|
|
# REQ-118 AC-4 OPEN ITEM: TTTC8001R NXT 체결 포함 여부 미확인 (웹 접근 불가 환경,
|
|
# KIS 개발자 센터 문서 조회 불가). 보수 구현: 기존 TR 유지. KRX 경로 동작 절대 불변.
|
|
# 향후 NXT 체결 미포함 실측 확인 시 TTTC0081R(EXCG=ALL) 전환 별도 batch 처리.
|
|
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
|
|
# 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, 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
|
|
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,
|
|
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, market: str = "J") -> Optional[Decimal]:
|
|
"""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") -> bool:
|
|
"""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.
|
|
"""
|
|
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,
|
|
)
|
|
return str(resp.json().get("rt_cd")) == "0"
|
|
except (httpx.HTTPError, ValueError):
|
|
return False
|