Files
ai-trade/signal_v2/kis_client.py
gahusb 27bf360b01 feat(signal_v2-phase3a): kis_client REST + 4 integration tests
KISClient: 분봉 (FHKST03010200) + 호가 (FHKST01010200) async REST.
V1 토큰 파일 (signal_v1/data/kis_token.json) read-only 공유, mtime
캐시. 초당 2회 throttle. exponential retry (max 3, 1s/2s/4s).

4 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:12:45 +09:00

156 lines
5.7 KiB
Python

"""KIS REST API client — 분봉 + 호가. V1 토큰 read-only 공유."""
from __future__ import annotations
import asyncio
import json
import logging
import time
from datetime import datetime
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(),
}