"""KIS REST API client — 분봉 + 호가. V1 토큰 read-only 공유.""" from __future__ import annotations import asyncio import json import logging import time from datetime import datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo import httpx logger = logging.getLogger(__name__) KST = ZoneInfo("Asia/Seoul") _MAX_ATTEMPTS = 3 _THROTTLE_INTERVAL = 0.5 # 초당 2회 제한 class KISClient: """KIS REST (분봉 + 호가). V1 토큰 파일 read-only.""" def __init__( self, app_key: str, app_secret: str, account: str, is_virtual: bool, v1_token_path: Path, timeout: float = 10.0, ): self._app_key = app_key self._app_secret = app_secret self._account = account self._is_virtual = is_virtual self._v1_token_path = Path(v1_token_path) self._base_url = ( "https://openapivts.koreainvestment.com:29443" if is_virtual else "https://openapi.koreainvestment.com:9443" ) self._client = httpx.AsyncClient(timeout=timeout) self._token_cache: tuple[str, float] | None = None # (token, file_mtime) self._last_throttle_at = 0.0 async def close(self) -> None: await self._client.aclose() def _read_v1_token(self) -> str: if not self._v1_token_path.exists(): raise RuntimeError(f"V1 token file missing: {self._v1_token_path}") mtime = self._v1_token_path.stat().st_mtime if self._token_cache and self._token_cache[1] == mtime: return self._token_cache[0] data = json.loads(self._v1_token_path.read_text(encoding="utf-8")) token = data.get("access_token", "") if not token: raise RuntimeError("V1 token file has no access_token") self._token_cache = (token, mtime) return token async def _throttle(self) -> None: elapsed = time.monotonic() - self._last_throttle_at if elapsed < _THROTTLE_INTERVAL: await asyncio.sleep(_THROTTLE_INTERVAL - elapsed) self._last_throttle_at = time.monotonic() def _common_headers(self, tr_id: str) -> dict[str, str]: token = self._read_v1_token() return { "authorization": f"Bearer {token}", "appkey": self._app_key, "appsecret": self._app_secret, "tr_id": tr_id, "custtype": "P", } async def _request_with_retry( self, method: str, path: str, tr_id: str, **kwargs, ) -> dict: url = f"{self._base_url}{path}" headers = self._common_headers(tr_id) for attempt in range(_MAX_ATTEMPTS): await self._throttle() try: response = await self._client.request( method, url, headers=headers, **kwargs ) if response.status_code == 429: if attempt < _MAX_ATTEMPTS - 1: await asyncio.sleep(2**attempt) continue response.raise_for_status() response.raise_for_status() return response.json() except httpx.TimeoutException: if attempt < _MAX_ATTEMPTS - 1: await asyncio.sleep(2**attempt) continue raise raise RuntimeError("retry exhausted") async def get_minute_ohlcv(self, ticker: str) -> list[dict]: """현재 시점 직전 30개 1분봉 OHLCV (TR_ID FHKST03010200).""" path = "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" params = { "FID_ETC_CLS_CODE": "", "FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": ticker, "FID_INPUT_HOUR_1": datetime.now(KST).strftime("%H%M%S"), "FID_PW_DATA_INCU_YN": "N", } raw = await self._request_with_retry( "GET", path, tr_id="FHKST03010200", params=params, ) output2 = raw.get("output2", []) bars = [] for row in output2: try: date = row["stck_bsop_date"] hhmmss = row["stck_cntg_hour"] dt = datetime.strptime(f"{date} {hhmmss}", "%Y%m%d %H%M%S").replace(tzinfo=KST) bars.append({ "datetime": dt.isoformat(), "open": int(row["stck_oprc"]), "high": int(row["stck_hgpr"]), "low": int(row["stck_lwpr"]), "close": int(row["stck_prpr"]), "volume": int(row["cntg_vol"]), }) except (KeyError, ValueError) as e: logger.warning("skip malformed bar for %s: %r", ticker, e) # KIS returns descending; reverse to ascending (most recent last) bars.reverse() return bars async def get_asking_price(self, ticker: str) -> dict: """현재 호가 + 매수/매도 잔량 (TR_ID FHKST01010200).""" path = "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn" params = { "FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": ticker, } raw = await self._request_with_retry( "GET", path, tr_id="FHKST01010200", params=params, ) output1 = raw.get("output1", {}) bid_total = int(output1.get("total_bidp_rsqn", 0)) ask_total = int(output1.get("total_askp_rsqn", 0)) total = bid_total + ask_total bid_ratio = bid_total / total if total > 0 else 0.0 current_price = int(output1.get("stck_prpr", 0)) return { "bid_total": bid_total, "ask_total": ask_total, "bid_ratio": bid_ratio, "current_price": current_price, "as_of": datetime.now(KST).isoformat(), } async def get_daily_ohlcv(self, ticker: str, days: int = 60) -> list[dict]: """KRX 일봉 OHLCV (TR_ID FHKST03010100). Returns: [{"datetime", "open", "high", "low", "close", "volume"}, ...] 시간 오름차순. """ path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" today = datetime.now(KST).strftime("%Y%m%d") start_date = (datetime.now(KST) - timedelta(days=days * 2)).strftime("%Y%m%d") params = { "FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": ticker, "FID_INPUT_DATE_1": start_date, "FID_INPUT_DATE_2": today, "FID_PERIOD_DIV_CODE": "D", "FID_ORG_ADJ_PRC": "1", } raw = await self._request_with_retry( "GET", path, tr_id="FHKST03010100", params=params, ) output2 = raw.get("output2", []) bars = [] for row in output2: try: date = row["stck_bsop_date"] bars.append({ "datetime": f"{date[:4]}-{date[4:6]}-{date[6:]}", "open": int(row["stck_oprc"]), "high": int(row["stck_hgpr"]), "low": int(row["stck_lwpr"]), "close": int(row["stck_clpr"]), "volume": int(row["acml_vol"]), }) except (KeyError, ValueError): continue bars.reverse() return bars[-days:]