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(),
|
||||||
|
}
|
||||||
128
signal_v2/tests/test_kis_client.py
Normal file
128
signal_v2/tests/test_kis_client.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user