From d761716e008a18c76a5d46114fcc2a3d11e20899 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 3 Jul 2026 01:46:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(trade-monitor):=20KIS=20=EC=9E=90=EC=B2=B4?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20+=20quote=20+=20=EC=9D=BC=EB=B4=89=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/trade-monitor/kis_client.py | 123 ++++++++++++++++++ .../trade-monitor/tests/test_kis_client.py | 54 ++++++++ 2 files changed, 177 insertions(+) create mode 100644 services/trade-monitor/kis_client.py create mode 100644 services/trade-monitor/tests/test_kis_client.py diff --git a/services/trade-monitor/kis_client.py b/services/trade-monitor/kis_client.py new file mode 100644 index 0000000..ed6a212 --- /dev/null +++ b/services/trade-monitor/kis_client.py @@ -0,0 +1,123 @@ +"""KIS REST client — 자체 OAuth 토큰(TM_KIS_*) + quote + 일봉 + throttle.""" +from __future__ import annotations + +import asyncio +import logging +import time +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +import httpx + +logger = logging.getLogger(__name__) +KST = ZoneInfo("Asia/Seoul") + +_MAX_ATTEMPTS = 3 +_THROTTLE_INTERVAL = 0.5 # 초당 2회 +_TOKEN_MARGIN = 600 # 만료 10분 전 재발급 + + +class KISClient: + def __init__(self, app_key, app_secret, account, is_virtual, timeout: float = 10.0): + self._app_key = app_key + self._app_secret = app_secret + self._account = account + 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: str | None = None + self._token_exp: float = 0.0 + self._last_throttle_at = 0.0 + self._throttle_lock = asyncio.Lock() + self._token_lock = asyncio.Lock() + + async def close(self) -> None: + await self._client.aclose() + + async def _issue_token(self) -> str: + async with self._token_lock: + now = time.time() + if self._token and now < self._token_exp - _TOKEN_MARGIN: + return self._token + r = await self._client.post( + f"{self._base_url}/oauth2/tokenP", + json={"grant_type": "client_credentials", + "appkey": self._app_key, "appsecret": self._app_secret}, + ) + r.raise_for_status() + data = r.json() + self._token = data["access_token"] + self._token_exp = now + int(data.get("expires_in", 86400)) + return self._token + + async def _throttle(self) -> None: + async with self._throttle_lock: + elapsed = time.monotonic() - self._last_throttle_at + if elapsed < _THROTTLE_INTERVAL: + await asyncio.sleep(_THROTTLE_INTERVAL - elapsed) + self._last_throttle_at = time.monotonic() + + async def _request(self, method: str, path: str, tr_id: str, **kwargs) -> dict: + token = await self._issue_token() + headers = { + "authorization": f"Bearer {token}", + "appkey": self._app_key, "appsecret": self._app_secret, + "tr_id": tr_id, "custtype": "P", + } + url = f"{self._base_url}{path}" + for attempt in range(_MAX_ATTEMPTS): + await self._throttle() + try: + resp = await self._client.request(method, url, headers=headers, **kwargs) + if resp.status_code == 429 and attempt < _MAX_ATTEMPTS - 1: + await asyncio.sleep(2 ** attempt) + continue + resp.raise_for_status() + return resp.json() + except httpx.TimeoutException: + if attempt < _MAX_ATTEMPTS - 1: + await asyncio.sleep(2 ** attempt) + continue + raise + raise RuntimeError("retry exhausted") + + async def get_quote(self, ticker: str) -> dict: + raw = await self._request( + "GET", "/uapi/domestic-stock/v1/quotations/inquire-price", + tr_id="FHKST01010100", + params={"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": ticker}, + ) + o = raw.get("output", {}) + return { + "price": int(o["stck_prpr"]), + "day_open": int(o["stck_oprc"]), + "today_volume": int(o["acml_vol"]), + "as_of": datetime.now(KST).isoformat(), + } + + async def get_daily_ohlcv(self, ticker: str, days: int = 250) -> list[dict]: + today = datetime.now(KST).strftime("%Y%m%d") + start = (datetime.now(KST) - timedelta(days=days * 2)).strftime("%Y%m%d") + raw = await self._request( + "GET", "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice", + tr_id="FHKST03010100", + params={"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": ticker, + "FID_INPUT_DATE_1": start, "FID_INPUT_DATE_2": today, + "FID_PERIOD_DIV_CODE": "D", "FID_ORG_ADJ_PRC": "1"}, + ) + bars = [] + for row in raw.get("output2", []): + try: + d = row["stck_bsop_date"] + bars.append({ + "datetime": f"{d[:4]}-{d[4:6]}-{d[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:] diff --git a/services/trade-monitor/tests/test_kis_client.py b/services/trade-monitor/tests/test_kis_client.py new file mode 100644 index 0000000..e91a7dc --- /dev/null +++ b/services/trade-monitor/tests/test_kis_client.py @@ -0,0 +1,54 @@ +"""KISClient — 토큰 발급/캐시 + quote/daily 파싱 (respx).""" +import httpx +import respx + +from kis_client import KISClient + +BASE = "https://openapi.koreainvestment.com:9443" + + +def _client(): + return KISClient("APPKEY", "APPSECRET", "12345678-01", is_virtual=False) + + +@respx.mock +async def test_issue_token_cached(): + route = respx.post(f"{BASE}/oauth2/tokenP").mock( + return_value=httpx.Response(200, json={"access_token": "TKN", "expires_in": 86400})) + c = _client() + t1 = await c._issue_token() + t2 = await c._issue_token() + assert t1 == "TKN" and t2 == "TKN" + assert route.call_count == 1 # 캐시 → 1회만 발급 + await c.close() + + +@respx.mock +async def test_get_quote_parses(): + respx.post(f"{BASE}/oauth2/tokenP").mock( + return_value=httpx.Response(200, json={"access_token": "TKN", "expires_in": 86400})) + respx.get(f"{BASE}/uapi/domestic-stock/v1/quotations/inquire-price").mock( + return_value=httpx.Response(200, json={"output": { + "stck_prpr": "71500", "stck_oprc": "71000", "acml_vol": "1234567"}})) + c = _client() + q = await c.get_quote("005930") + assert q["price"] == 71500 and q["day_open"] == 71000 and q["today_volume"] == 1234567 + await c.close() + + +@respx.mock +async def test_get_daily_ascending(): + respx.post(f"{BASE}/oauth2/tokenP").mock( + return_value=httpx.Response(200, json={"access_token": "TKN", "expires_in": 86400})) + # KIS는 내림차순 반환 → 오름차순으로 뒤집혀야 함 + respx.get(f"{BASE}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice").mock( + return_value=httpx.Response(200, json={"output2": [ + {"stck_bsop_date": "20260702", "stck_oprc": "100", "stck_hgpr": "110", + "stck_lwpr": "90", "stck_clpr": "105", "acml_vol": "5"}, + {"stck_bsop_date": "20260701", "stck_oprc": "95", "stck_hgpr": "102", + "stck_lwpr": "94", "stck_clpr": "100", "acml_vol": "4"}]})) + c = _client() + bars = await c.get_daily_ohlcv("005930", days=250) + assert bars[0]["datetime"] == "2026-07-01" + assert bars[-1]["close"] == 105 + await c.close()