"""Tests for KISClient (REST).""" import asyncio import json import time as time_module from pathlib import Path import httpx import pytest import respx from ai_trade.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() @respx.mock async def test_get_daily_ohlcv_returns_60_bars(kis_client_factory): """KIS daily endpoint returns 60 ascending bars after parsing.""" # Build 60 KIS-format daily bars (descending dates as KIS does) sample_output2 = [] for i in range(60): # Generate a fake date 60 days ago, descending day = 60 - i sample_output2.append({ "stck_bsop_date": f"2026{(((day-1)//30)+1):02d}{(((day-1)%30)+1):02d}", "stck_oprc": "78000", "stck_hgpr": "78500", "stck_lwpr": "77800", "stck_clpr": str(78000 + i), "acml_vol": "12345", }) respx.get( "https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" ).mock(return_value=httpx.Response(200, json={"output2": sample_output2})) client = kis_client_factory() try: bars = await client.get_daily_ohlcv("005930", days=60) # KIS returns descending; client reverses to ascending assert len(bars) == 60 # Ascending order: first item has smaller datetime than last assert bars[0]["datetime"] < bars[-1]["datetime"] assert isinstance(bars[0]["open"], int) assert isinstance(bars[0]["close"], int) assert "datetime" in bars[0] finally: await client.close() @respx.mock async def test_throttle_serializes_concurrent_gather(kis_client_factory): """F2 — 5개 동시 요청이 asyncio.gather로 들어와도 0.5초 간격으로 직렬화. 초당 2회 = 0.5초 간격. 5개 요청 시 최소 (5-1)*0.5 = 2.0초. Lock 없으면 race condition으로 거의 동시에 나가 0.5초대로 끝남. """ sample = {"output2": []} respx.get( "https://openapivts.koreainvestment.com:29443" "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" ).mock(return_value=httpx.Response(200, json=sample)) client = kis_client_factory() try: start = time_module.monotonic() await asyncio.gather(*[client.get_minute_ohlcv(f"00593{i}") for i in range(5)]) elapsed = time_module.monotonic() - start # 5 throttle = 최소 (5-1)*0.5 = 2.0s, tolerance 0.3s assert elapsed >= 1.7, ( f"throttle race condition: 5 concurrent calls took only {elapsed:.2f}s, " f"expected >=1.7s (0.5s * 4 inter-call gaps)" ) finally: await client.close()