KIS Open API 조회전용 연동 + 직접매매 절대금지 안전게이트
매수/매도 주문 및 계좌 잔고조회를 API로 직접 실행하지 않는다는 원칙을 코드 레벨에서 강제하는 안전게이트(governance/rules/06, 07)와 함께, 시세/호가/공매도거래비중 등 조회전용 KIS Open API 연동 및 SQLite 수집 파이프라인을 추가한다. - kis_api_client_v1: 모든 요청이 _assert_read_only를 통과해야 하며 /trading/ 경로·주문 TR_ID는 RuntimeError로 즉시 차단 - kis_data_collection_v1: KIS 우선 + Naver 폴백, 네트워크 실패는 개별 ticker 단위로 흡수(배치 전체 중단 없음) - data_collection_store_v1 / storage_backend_v1: SQLite 캐노니컬 저장소, PostgreSQL 전환 대비 백엔드 추상화 - Gitea 영업일 스케줄(2시간 간격) + CI 강제 게이트 (validate_no_direct_api_trading_v1, validate_kis_api_credentials_v1)
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
"""한국투자증권(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
|
||||
|
||||
import requests
|
||||
|
||||
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"
|
||||
|
||||
# ── [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)
|
||||
|
||||
|
||||
def _token_cache_path(creds: KisCredentials) -> Path:
|
||||
TOKEN_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return TOKEN_CACHE_DIR / f"kis_token_cache_{creds.account}.json"
|
||||
|
||||
|
||||
def _issue_or_reuse_token(creds: KisCredentials) -> str:
|
||||
"""KIS는 토큰 발급 빈도를 제한한다 — 만료 전까지 캐시 재사용 필수."""
|
||||
cache_path = _token_cache_path(creds)
|
||||
if cache_path.exists():
|
||||
try:
|
||||
cached = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
expires_at = dt.datetime.fromisoformat(cached["expires_at"])
|
||||
if dt.datetime.now(dt.timezone.utc) < expires_at - dt.timedelta(minutes=10):
|
||||
return cached["access_token"]
|
||||
except (json.JSONDecodeError, KeyError, ValueError):
|
||||
pass
|
||||
|
||||
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()
|
||||
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)
|
||||
cache_path.write_text(
|
||||
json.dumps({"access_token": access_token, "expires_at": expires_at.isoformat()}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
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",
|
||||
}
|
||||
resp = requests.get(f"{creds.domain}{path}", headers=headers, params=params, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ── 조회(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},
|
||||
)
|
||||
Reference in New Issue
Block a user