4cb206a269
매수/매도 주문 및 계좌 잔고조회를 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)
213 lines
9.8 KiB
Python
213 lines
9.8 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
|
|
|
|
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},
|
|
)
|