From 27bf360b01a8fb5d934df2e5cd6ac16f31e86320 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 16 May 2026 05:12:45 +0900 Subject: [PATCH] feat(signal_v2-phase3a): kis_client REST + 4 integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- signal_v2/kis_client.py | 155 +++++++++++++++++++++++++++++ signal_v2/tests/test_kis_client.py | 128 ++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 signal_v2/kis_client.py create mode 100644 signal_v2/tests/test_kis_client.py diff --git a/signal_v2/kis_client.py b/signal_v2/kis_client.py new file mode 100644 index 0000000..1f5ec73 --- /dev/null +++ b/signal_v2/kis_client.py @@ -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(), + } diff --git a/signal_v2/tests/test_kis_client.py b/signal_v2/tests/test_kis_client.py new file mode 100644 index 0000000..952812f --- /dev/null +++ b/signal_v2/tests/test_kis_client.py @@ -0,0 +1,128 @@ +"""Tests for KISClient (REST).""" +import json +from pathlib import Path + +import httpx +import pytest +import respx + +from signal_v2.kis_client import KISClient + + +@pytest.fixture +def fake_v1_token(tmp_path): + """V1 토큰 파일 fixture.""" + token_file = tmp_path / "kis_token.json" + token_file.write_text(json.dumps({ + "access_token": "test-kis-token-abc123", + "token_expired": "2099-12-31 23:59:59", + })) + return token_file + + +@pytest.fixture +def kis_client_factory(fake_v1_token): + def _make(): + return KISClient( + app_key="test-app-key", + app_secret="test-app-secret", + account="50000000-01", + is_virtual=True, + v1_token_path=fake_v1_token, + ) + return _make + + +@respx.mock +async def test_get_minute_ohlcv_normal_returns_30_bars(kis_client_factory): + """정상 200 → 30개 분봉 list 반환.""" + sample_output2 = [ + { + "stck_bsop_date": "20260518", + "stck_cntg_hour": f"09{m:02d}00", + "stck_oprc": "78000", "stck_hgpr": "78500", + "stck_lwpr": "77800", "stck_prpr": "78300", + "cntg_vol": "12345", + } + for m in range(30) # 9:00-9:29 = 30 bars + ] + respx.get( + "https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" + ).mock( + return_value=httpx.Response(200, json={"output2": sample_output2}) + ) + + client = kis_client_factory() + try: + bars = await client.get_minute_ohlcv("005930") + assert len(bars) == 30 + assert bars[0]["close"] == 78300 + assert "datetime" in bars[0] + finally: + await client.close() + + +@respx.mock +async def test_get_minute_ohlcv_429_retry_then_success(kis_client_factory, monkeypatch): + """429 → exponential backoff → 200.""" + sleep_calls = [] + async def fake_sleep(s): sleep_calls.append(s) + monkeypatch.setattr("asyncio.sleep", fake_sleep) + + respx.get( + "https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" + ).mock(side_effect=[ + httpx.Response(429, text="rate limit"), + httpx.Response(200, json={"output2": []}), + ]) + client = kis_client_factory() + try: + result = await client.get_minute_ohlcv("005930") + assert result == [] + assert 1 in sleep_calls + finally: + await client.close() + + +@respx.mock +async def test_get_minute_ohlcv_uses_v1_token(kis_client_factory, fake_v1_token): + """KIS 호출 헤더에 V1 토큰 파일의 access_token 사용.""" + route = respx.get( + "https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" + ).mock(return_value=httpx.Response(200, json={"output2": []})) + + client = kis_client_factory() + try: + await client.get_minute_ohlcv("005930") + assert route.called + req = route.calls.last.request + # check authorization header contains the V1 token + auth = req.headers.get("authorization", "") + assert "test-kis-token-abc123" in auth + finally: + await client.close() + + +@respx.mock +async def test_get_asking_price_computes_bid_ratio(kis_client_factory): + """호가 응답 → bid_total/(bid+ask) bid_ratio 계산.""" + respx.get( + "https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn" + ).mock(return_value=httpx.Response(200, json={ + "output1": { + "total_bidp_rsqn": "600", + "total_askp_rsqn": "400", + "stck_prpr": "78500", + } + })) + + client = kis_client_factory() + try: + data = await client.get_asking_price("005930") + assert data["bid_total"] == 600 + assert data["ask_total"] == 400 + assert abs(data["bid_ratio"] - 0.6) < 1e-9 + assert data["current_price"] == 78500 + assert "as_of" in data + finally: + await client.close()