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>
This commit is contained in:
155
signal_v2/kis_client.py
Normal file
155
signal_v2/kis_client.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""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(),
|
||||
}
|
||||
Reference in New Issue
Block a user