273 lines
12 KiB
Python
273 lines
12 KiB
Python
"""한국투자증권(KIS) Open API 클라이언트 — 조회(read-only) 전용.
|
|
|
|
근거: https://apiportal.koreainvestment.com/apiservice-summary ,
|
|
https://github.com/koreainvestment/open-trading-api (2026-06-21 실측 확인된
|
|
api_url/tr_id만 사용 — 추정 금지).
|
|
|
|
══════════════════════════════════════════════════════════════════════════════
|
|
[CRITICAL] governance/rules/06_no_direct_api_trading.yaml — 절대 규칙
|
|
이 모듈은 매수/매도 주문을 어떤 경로로도 제출하지 않는다. 주문 제출/정정/취소
|
|
함수는 이 파일에 일체 작성하지 않으며, 공유 요청 함수(_send_request)는 주문
|
|
관련 경로("/trading/")나 TR_ID(TTTC08*/VTTC08* 등)를 만나면 즉시 RuntimeError로
|
|
요청을 차단한다(2차 방어). 이 원칙을 어기면 엔진 전체가 '제안 시스템'에서
|
|
'자동매매 시스템'으로 변질되어 프로젝트 핵심 전제가 깨진다(사용자 직접 지시).
|
|
══════════════════════════════════════════════════════════════════════════════
|
|
|
|
인증 정보는 Windows 환경변수에서 읽는다(실제계좌: KIS_APP_Key/KIS_APP_Secret,
|
|
모의계좌: KIS_APP_Key_TEST/KIS_APP_Secret_TEST). 방금 setx로 설정된 값은 현재
|
|
프로세스의 os.environ에 아직 반영되지 않을 수 있어, HKCU\\Environment 레지스트리
|
|
폴백을 둔다(읽기만 함, 값을 로그에 남기지 않음).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
REAL_DOMAIN = "https://openapi.koreainvestment.com:9443"
|
|
MOCK_DOMAIN = "https://openapivts.koreainvestment.com:29443"
|
|
TOKEN_CACHE_DIR = ROOT / "Temp"
|
|
TOKEN_CACHE_DB_NAME = "kis_tokens.db"
|
|
TOKEN_REFRESH_SKEW_MINUTES = 10
|
|
|
|
|
|
def _requests():
|
|
try:
|
|
import requests # type: ignore
|
|
except Exception as exc: # pragma: no cover - surfaced as runtime validation error
|
|
raise RuntimeError(f"import_error: {exc}") from exc
|
|
return requests
|
|
|
|
# ── [CRITICAL] 주문 차단 목록 — 절대 수정/완화 금지 (governance/rules/06_no_direct_api_trading.yaml) ──
|
|
# "/trading/" 하위 경로는 주문(order)뿐 아니라 계좌잔고조회(inquire-balance)도 포함한다.
|
|
# 계좌 보유종목/잔고는 governance/rules/07_no_kis_account_balance_query.yaml에 의해
|
|
# 별도로도 금지된다 — HTS 캡처가 유일한 출처(사용자 직접 지시).
|
|
FORBIDDEN_PATH_SUBSTRINGS: tuple[str, ...] = ("/trading/",)
|
|
FORBIDDEN_TR_ID_PREFIXES: tuple[str, ...] = (
|
|
"TTTC08", "VTTC08", "TTTC01", "VTTC01", # 현금/신용 매수·매도·정정·취소
|
|
"TTTC8434R", "VTTC8434R", # 주식잔고조회 — 계좌 보유종목 조회 금지(07번 규칙)
|
|
)
|
|
|
|
|
|
class OrderEndpointBlockedError(RuntimeError):
|
|
"""주문 제출/정정/취소 경로 호출 시도 — 절대 차단."""
|
|
|
|
|
|
def _assert_read_only(path: str, tr_id: str) -> None:
|
|
for forbidden in FORBIDDEN_PATH_SUBSTRINGS:
|
|
if forbidden in path:
|
|
raise OrderEndpointBlockedError(
|
|
f"BLOCKED: 주문 관련 경로 호출 시도 차단 — path={path!r}. "
|
|
"이 엔진은 매수/매도를 API로 직접 실행하지 않는다(governance/rules/06_no_direct_api_trading.yaml)."
|
|
)
|
|
for prefix in FORBIDDEN_TR_ID_PREFIXES:
|
|
if tr_id.upper().startswith(prefix):
|
|
raise OrderEndpointBlockedError(
|
|
f"BLOCKED: 주문 관련 TR_ID 호출 시도 차단 — tr_id={tr_id!r}. "
|
|
"이 엔진은 매수/매도를 API로 직접 실행하지 않는다(governance/rules/06_no_direct_api_trading.yaml)."
|
|
)
|
|
|
|
|
|
def _read_env_var(name: str) -> str | None:
|
|
import os
|
|
|
|
value = os.environ.get(name)
|
|
if value:
|
|
return value
|
|
if sys.platform != "win32":
|
|
return None
|
|
try:
|
|
import winreg
|
|
|
|
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key:
|
|
value, _ = winreg.QueryValueEx(key, name)
|
|
return value or None
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
class KisCredentials:
|
|
def __init__(self, app_key: str, app_secret: str, account: str):
|
|
self.app_key = app_key
|
|
self.app_secret = app_secret
|
|
self.account = account # "real" | "mock"
|
|
self.domain = REAL_DOMAIN if account == "real" else MOCK_DOMAIN
|
|
|
|
@classmethod
|
|
def load(cls, account: str = "mock") -> "KisCredentials":
|
|
if account == "real":
|
|
key_name, secret_name = "KIS_APP_Key", "KIS_APP_Secret"
|
|
elif account == "mock":
|
|
key_name, secret_name = "KIS_APP_Key_TEST", "KIS_APP_Secret_TEST"
|
|
else:
|
|
raise ValueError("account must be 'real' or 'mock'")
|
|
app_key = _read_env_var(key_name)
|
|
app_secret = _read_env_var(secret_name)
|
|
if not app_key or not app_secret:
|
|
raise RuntimeError(
|
|
f"{key_name}/{secret_name} 환경변수를 찾을 수 없음 — Windows 환경변수 설정 후 "
|
|
"새 셸에서 재시도하거나 HKCU\\Environment 레지스트리 반영을 확인하세요."
|
|
)
|
|
return cls(app_key=app_key, app_secret=app_secret, account=account)
|
|
|
|
|
|
import sqlite3
|
|
|
|
def _token_db_path() -> Path:
|
|
import os
|
|
|
|
override = os.environ.get("KIS_TOKEN_DB_PATH", "").strip()
|
|
if override:
|
|
return Path(override)
|
|
TOKEN_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
return TOKEN_CACHE_DIR / TOKEN_CACHE_DB_NAME
|
|
|
|
|
|
def _init_token_db(conn: sqlite3.Connection) -> None:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS kis_tokens (
|
|
account TEXT PRIMARY KEY,
|
|
access_token TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def _issue_or_reuse_token(creds: KisCredentials) -> str:
|
|
"""KIS는 토큰 발급 빈도를 제한한다 — 만료 전까지 DB 캐시 재사용 필수."""
|
|
db_path = _token_db_path()
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with sqlite3.connect(db_path, timeout=30) as conn:
|
|
_init_token_db(conn)
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
row = conn.execute(
|
|
"SELECT access_token, expires_at FROM kis_tokens WHERE account = ?",
|
|
(creds.account,),
|
|
).fetchone()
|
|
if row:
|
|
token, expires_at_str = row
|
|
try:
|
|
expires_at = dt.datetime.fromisoformat(expires_at_str)
|
|
# 만료 시간 10분 전까지 재사용 가능 여부 검사
|
|
if dt.datetime.now(dt.timezone.utc) < expires_at - dt.timedelta(minutes=TOKEN_REFRESH_SKEW_MINUTES):
|
|
conn.commit()
|
|
return token
|
|
except ValueError:
|
|
pass
|
|
|
|
# 2. 토큰이 만료되었거나 없을 시 KIS API로 새로 발급 요청
|
|
requests = _requests()
|
|
try:
|
|
resp = requests.post(
|
|
f"{creds.domain}/oauth2/tokenP",
|
|
json={"grant_type": "client_credentials", "appkey": creds.app_key, "appsecret": creds.app_secret},
|
|
timeout=15,
|
|
)
|
|
resp.raise_for_status()
|
|
body = resp.json()
|
|
except Exception as exc:
|
|
raise RuntimeError("KIS token refresh failed; check credentials and API availability.") from exc
|
|
access_token = body["access_token"]
|
|
expires_in_sec = int(body.get("expires_in", 86400))
|
|
expires_at = dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=expires_in_sec)
|
|
|
|
# 3. 새로운 토큰 정보를 DB에 안전하게 업서트
|
|
conn.execute(
|
|
"""
|
|
INSERT OR REPLACE INTO kis_tokens (account, access_token, expires_at, updated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
""",
|
|
(
|
|
creds.account,
|
|
access_token,
|
|
expires_at.isoformat(),
|
|
dt.datetime.now(dt.timezone.utc).isoformat(),
|
|
),
|
|
)
|
|
conn.commit()
|
|
return access_token
|
|
|
|
|
|
def _send_request(creds: KisCredentials, path: str, tr_id: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
"""모든 KIS REST 호출의 단일 진입점 — 여기서만 가드가 작동하면 충분하다."""
|
|
_assert_read_only(path, tr_id) # [CRITICAL] 절대 제거 금지
|
|
access_token = _issue_or_reuse_token(creds)
|
|
headers = {
|
|
"content-type": "application/json; charset=utf-8",
|
|
"authorization": f"Bearer {access_token}",
|
|
"appkey": creds.app_key,
|
|
"appsecret": creds.app_secret,
|
|
"tr_id": tr_id,
|
|
"custtype": "P",
|
|
}
|
|
requests = _requests()
|
|
try:
|
|
resp = requests.get(f"{creds.domain}{path}", headers=headers, params=params, timeout=15)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
except Exception as exc:
|
|
raise RuntimeError(f"KIS read-only request failed for {path} / {tr_id}.") from exc
|
|
|
|
|
|
# ── 조회(read-only) 함수 — 전부 GET, 전부 quotations/ranking 카테고리 (실측 확인) ──────────
|
|
|
|
def get_current_price(creds: KisCredentials, code: str) -> dict[str, Any]:
|
|
"""주식현재가 시세. api_url=/uapi/domestic-stock/v1/quotations/inquire-price, tr_id=FHKST01010100."""
|
|
return _send_request(
|
|
creds, "/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100",
|
|
{"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code},
|
|
)
|
|
|
|
|
|
def get_asking_price_10_level(creds: KisCredentials, code: str) -> dict[str, Any]:
|
|
"""주식현재가 호가/예상체결 — 10단계 매수/매도 호가.
|
|
api_url=/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn, tr_id=FHKST01010200.
|
|
"""
|
|
return _send_request(
|
|
creds, "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", "FHKST01010200",
|
|
{"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code},
|
|
)
|
|
|
|
|
|
def get_daily_short_sale(creds: KisCredentials, code: str, start_date: str, end_date: str) -> dict[str, Any]:
|
|
"""국내주식 공매도 일별추이. api_url=/uapi/domestic-stock/v1/quotations/daily-short-sale,
|
|
tr_id=FHPST04830000. start_date/end_date: YYYYMMDD."""
|
|
return _send_request(
|
|
creds, "/uapi/domestic-stock/v1/quotations/daily-short-sale", "FHPST04830000",
|
|
{"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code,
|
|
"FID_INPUT_DATE_1": start_date, "FID_INPUT_DATE_2": end_date},
|
|
)
|
|
|
|
|
|
def get_daily_item_chart_price(
|
|
creds: KisCredentials, code: str, start_date: str, end_date: str, period: str = "D",
|
|
) -> dict[str, Any]:
|
|
"""주식현재가 일자별. api_url=/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice,
|
|
tr_id=FHKST03010100."""
|
|
return _send_request(
|
|
creds, "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice", "FHKST03010100",
|
|
{"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code,
|
|
"FID_INPUT_DATE_1": start_date, "FID_INPUT_DATE_2": end_date,
|
|
"FID_PERIOD_DIV_CODE": period, "FID_ORG_ADJ_PRC": "0"},
|
|
)
|
|
|
|
|
|
def get_investor_trend(creds: KisCredentials, code: str) -> dict[str, Any]:
|
|
"""주식현재가 투자자(개인/외국인/기관) 매매동향.
|
|
api_url=/uapi/domestic-stock/v1/quotations/inquire-investor, tr_id=FHKST01010900."""
|
|
return _send_request(
|
|
creds, "/uapi/domestic-stock/v1/quotations/inquire-investor", "FHKST01010900",
|
|
{"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code},
|
|
)
|