Some checks failed
CI / test (pull_request) Has been cancelled
The overseas ranking API was returning 404 for all exchanges because the
TR_IDs, API paths, and exchange codes were all incorrect. Updated to match
KIS official API documentation:
- TR_ID: HHDFS76290000 (updown-rate), HHDFS76270000 (volume-surge)
- Path: /uapi/overseas-stock/v1/ranking/{updown-rate,volume-surge}
- Exchange codes: NASD→NAS, NYSE→NYS, AMEX→AMS via ranking-specific mapping
Fixes #141
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
282 lines
10 KiB
Python
282 lines
10 KiB
Python
"""Tests for OverseasBroker.fetch_overseas_rankings with correct KIS API specs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import aiohttp
|
|
import pytest
|
|
|
|
from src.broker.kis_api import KISBroker
|
|
from src.broker.overseas import OverseasBroker, _RANKING_EXCHANGE_MAP
|
|
from src.config import Settings
|
|
|
|
|
|
def _make_async_cm(mock_resp: AsyncMock) -> MagicMock:
|
|
"""Create an async context manager that returns mock_resp on __aenter__."""
|
|
cm = MagicMock()
|
|
cm.__aenter__ = AsyncMock(return_value=mock_resp)
|
|
cm.__aexit__ = AsyncMock(return_value=False)
|
|
return cm
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_settings() -> Settings:
|
|
"""Provide mock settings with correct default TR_IDs/paths."""
|
|
return Settings(
|
|
KIS_APP_KEY="test_key",
|
|
KIS_APP_SECRET="test_secret",
|
|
KIS_ACCOUNT_NO="12345678-01",
|
|
GEMINI_API_KEY="test_gemini_key",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_broker(mock_settings: Settings) -> KISBroker:
|
|
"""Provide a mock KIS broker."""
|
|
broker = KISBroker(mock_settings)
|
|
broker.get_orderbook = AsyncMock() # type: ignore[method-assign]
|
|
return broker
|
|
|
|
|
|
@pytest.fixture
|
|
def overseas_broker(mock_broker: KISBroker) -> OverseasBroker:
|
|
"""Provide an OverseasBroker wrapping a mock KISBroker."""
|
|
return OverseasBroker(mock_broker)
|
|
|
|
|
|
def _setup_broker_mocks(overseas_broker: OverseasBroker, mock_session: MagicMock) -> None:
|
|
"""Wire up common broker mocks."""
|
|
overseas_broker._broker._rate_limiter.acquire = AsyncMock()
|
|
overseas_broker._broker._get_session = MagicMock(return_value=mock_session)
|
|
overseas_broker._broker._auth_headers = AsyncMock(return_value={})
|
|
|
|
|
|
class TestRankingExchangeMap:
|
|
"""Test exchange code mapping for ranking API."""
|
|
|
|
def test_nasd_maps_to_nas(self) -> None:
|
|
assert _RANKING_EXCHANGE_MAP["NASD"] == "NAS"
|
|
|
|
def test_nyse_maps_to_nys(self) -> None:
|
|
assert _RANKING_EXCHANGE_MAP["NYSE"] == "NYS"
|
|
|
|
def test_amex_maps_to_ams(self) -> None:
|
|
assert _RANKING_EXCHANGE_MAP["AMEX"] == "AMS"
|
|
|
|
def test_sehk_maps_to_hks(self) -> None:
|
|
assert _RANKING_EXCHANGE_MAP["SEHK"] == "HKS"
|
|
|
|
def test_unmapped_exchange_passes_through(self) -> None:
|
|
assert _RANKING_EXCHANGE_MAP.get("UNKNOWN", "UNKNOWN") == "UNKNOWN"
|
|
|
|
def test_tse_unchanged(self) -> None:
|
|
assert _RANKING_EXCHANGE_MAP["TSE"] == "TSE"
|
|
|
|
|
|
class TestConfigDefaults:
|
|
"""Test that config defaults match KIS official API specs."""
|
|
|
|
def test_fluct_tr_id(self, mock_settings: Settings) -> None:
|
|
assert mock_settings.OVERSEAS_RANKING_FLUCT_TR_ID == "HHDFS76290000"
|
|
|
|
def test_volume_tr_id(self, mock_settings: Settings) -> None:
|
|
assert mock_settings.OVERSEAS_RANKING_VOLUME_TR_ID == "HHDFS76270000"
|
|
|
|
def test_fluct_path(self, mock_settings: Settings) -> None:
|
|
assert mock_settings.OVERSEAS_RANKING_FLUCT_PATH == "/uapi/overseas-stock/v1/ranking/updown-rate"
|
|
|
|
def test_volume_path(self, mock_settings: Settings) -> None:
|
|
assert mock_settings.OVERSEAS_RANKING_VOLUME_PATH == "/uapi/overseas-stock/v1/ranking/volume-surge"
|
|
|
|
|
|
class TestFetchOverseasRankings:
|
|
"""Test fetch_overseas_rankings method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fluctuation_uses_correct_params(
|
|
self, overseas_broker: OverseasBroker
|
|
) -> None:
|
|
"""Fluctuation ranking should use HHDFS76290000, updown-rate path, and correct params."""
|
|
mock_resp = AsyncMock()
|
|
mock_resp.status = 200
|
|
mock_resp.json = AsyncMock(
|
|
return_value={"output": [{"symb": "AAPL", "name": "Apple"}]}
|
|
)
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
|
|
|
_setup_broker_mocks(overseas_broker, mock_session)
|
|
overseas_broker._broker._auth_headers = AsyncMock(
|
|
return_value={"authorization": "Bearer test"}
|
|
)
|
|
|
|
result = await overseas_broker.fetch_overseas_rankings("NASD", "fluctuation")
|
|
|
|
assert len(result) == 1
|
|
assert result[0]["symb"] == "AAPL"
|
|
|
|
call_args = mock_session.get.call_args
|
|
url = call_args[0][0]
|
|
params = call_args[1]["params"]
|
|
|
|
assert "/uapi/overseas-stock/v1/ranking/updown-rate" in url
|
|
assert params["EXCD"] == "NAS"
|
|
assert params["NDAY"] == "0"
|
|
assert params["GUBN"] == "1"
|
|
assert params["VOL_RANG"] == "0"
|
|
|
|
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76290000")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_volume_uses_correct_params(
|
|
self, overseas_broker: OverseasBroker
|
|
) -> None:
|
|
"""Volume ranking should use HHDFS76270000, volume-surge path, and correct params."""
|
|
mock_resp = AsyncMock()
|
|
mock_resp.status = 200
|
|
mock_resp.json = AsyncMock(
|
|
return_value={"output": [{"symb": "TSLA", "name": "Tesla"}]}
|
|
)
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
|
|
|
_setup_broker_mocks(overseas_broker, mock_session)
|
|
overseas_broker._broker._auth_headers = AsyncMock(
|
|
return_value={"authorization": "Bearer test"}
|
|
)
|
|
|
|
result = await overseas_broker.fetch_overseas_rankings("NYSE", "volume")
|
|
|
|
assert len(result) == 1
|
|
|
|
call_args = mock_session.get.call_args
|
|
url = call_args[0][0]
|
|
params = call_args[1]["params"]
|
|
|
|
assert "/uapi/overseas-stock/v1/ranking/volume-surge" in url
|
|
assert params["EXCD"] == "NYS"
|
|
assert params["MIXN"] == "0"
|
|
assert params["VOL_RANG"] == "0"
|
|
assert "NDAY" not in params
|
|
assert "GUBN" not in params
|
|
|
|
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76270000")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_404_returns_empty_list(
|
|
self, overseas_broker: OverseasBroker
|
|
) -> None:
|
|
"""HTTP 404 should return empty list (fallback) instead of raising."""
|
|
mock_resp = AsyncMock()
|
|
mock_resp.status = 404
|
|
mock_resp.text = AsyncMock(return_value="Not Found")
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
|
|
|
_setup_broker_mocks(overseas_broker, mock_session)
|
|
|
|
result = await overseas_broker.fetch_overseas_rankings("AMEX", "fluctuation")
|
|
assert result == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_404_error_raises(
|
|
self, overseas_broker: OverseasBroker
|
|
) -> None:
|
|
"""Non-404 HTTP errors should raise ConnectionError."""
|
|
mock_resp = AsyncMock()
|
|
mock_resp.status = 500
|
|
mock_resp.text = AsyncMock(return_value="Internal Server Error")
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
|
|
|
_setup_broker_mocks(overseas_broker, mock_session)
|
|
|
|
with pytest.raises(ConnectionError, match="500"):
|
|
await overseas_broker.fetch_overseas_rankings("NASD")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_response_returns_empty(
|
|
self, overseas_broker: OverseasBroker
|
|
) -> None:
|
|
"""Empty output in response should return empty list."""
|
|
mock_resp = AsyncMock()
|
|
mock_resp.status = 200
|
|
mock_resp.json = AsyncMock(return_value={"output": []})
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
|
|
|
_setup_broker_mocks(overseas_broker, mock_session)
|
|
|
|
result = await overseas_broker.fetch_overseas_rankings("NASD")
|
|
assert result == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ranking_disabled_returns_empty(
|
|
self, overseas_broker: OverseasBroker
|
|
) -> None:
|
|
"""When OVERSEAS_RANKING_ENABLED=False, should return empty immediately."""
|
|
overseas_broker._broker._settings.OVERSEAS_RANKING_ENABLED = False
|
|
result = await overseas_broker.fetch_overseas_rankings("NASD")
|
|
assert result == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_limit_truncates_results(
|
|
self, overseas_broker: OverseasBroker
|
|
) -> None:
|
|
"""Results should be truncated to the specified limit."""
|
|
rows = [{"symb": f"SYM{i}"} for i in range(20)]
|
|
mock_resp = AsyncMock()
|
|
mock_resp.status = 200
|
|
mock_resp.json = AsyncMock(return_value={"output": rows})
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
|
|
|
_setup_broker_mocks(overseas_broker, mock_session)
|
|
|
|
result = await overseas_broker.fetch_overseas_rankings("NASD", limit=5)
|
|
assert len(result) == 5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_network_error_raises(
|
|
self, overseas_broker: OverseasBroker
|
|
) -> None:
|
|
"""Network errors should raise ConnectionError."""
|
|
cm = MagicMock()
|
|
cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("timeout"))
|
|
cm.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.get = MagicMock(return_value=cm)
|
|
|
|
_setup_broker_mocks(overseas_broker, mock_session)
|
|
|
|
with pytest.raises(ConnectionError, match="Network error"):
|
|
await overseas_broker.fetch_overseas_rankings("NASD")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exchange_code_mapping_applied(
|
|
self, overseas_broker: OverseasBroker
|
|
) -> None:
|
|
"""All major exchanges should use mapped codes in API params."""
|
|
for original, mapped in [("NASD", "NAS"), ("NYSE", "NYS"), ("AMEX", "AMS")]:
|
|
mock_resp = AsyncMock()
|
|
mock_resp.status = 200
|
|
mock_resp.json = AsyncMock(return_value={"output": [{"symb": "X"}]})
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
|
|
|
_setup_broker_mocks(overseas_broker, mock_session)
|
|
|
|
await overseas_broker.fetch_overseas_rankings(original)
|
|
|
|
call_params = mock_session.get.call_args[1]["params"]
|
|
assert call_params["EXCD"] == mapped, f"{original} should map to {mapped}"
|