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:
2026-05-16 05:12:45 +09:00
parent eafa73edb1
commit 27bf360b01
2 changed files with 283 additions and 0 deletions

View 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()