diff --git a/docs/requirements-log.md b/docs/requirements-log.md index a7c4ca2..3a1c0df 100644 --- a/docs/requirements-log.md +++ b/docs/requirements-log.md @@ -165,3 +165,22 @@ **효과:** - 국내/해외 스캐너 기준이 변동성 중심으로 일관화 - 고변동 구간에서 자동 익스포저 축소, 저변동 구간에서 과소진입 완화 + +## 2026-02-18 + +### KIS 해외 랭킹 API 404 에러 수정 + +**배경:** +- KIS 해외주식 랭킹 API(`fetch_overseas_rankings`)가 모든 거래소에서 HTTP 404를 반환 +- Smart Scanner가 해외 시장 후보 종목을 찾지 못해 거래가 전혀 실행되지 않음 + +**근본 원인:** +- TR_ID, API 경로, 거래소 코드가 모두 KIS 공식 문서와 불일치 + +**구현 결과:** +- `src/config.py`: TR_ID/Path 기본값을 KIS 공식 스펙으로 수정 +- `src/broker/overseas.py`: 랭킹 API 전용 거래소 코드 매핑 추가 (NASD→NAS, NYSE→NYS, AMEX→AMS), 올바른 API 파라미터 사용 +- `tests/test_overseas_broker.py`: 19개 단위 테스트 추가 + +**효과:** +- 해외 시장 랭킹 스캔이 정상 동작하여 Smart Scanner가 후보 종목 탐지 가능 diff --git a/src/broker/overseas.py b/src/broker/overseas.py index f1a3c43..bdf4bcc 100644 --- a/src/broker/overseas.py +++ b/src/broker/overseas.py @@ -12,6 +12,20 @@ from src.broker.kis_api import KISBroker logger = logging.getLogger(__name__) +# Ranking API uses different exchange codes than order/quote APIs. +_RANKING_EXCHANGE_MAP: dict[str, str] = { + "NASD": "NAS", + "NYSE": "NYS", + "AMEX": "AMS", + "SEHK": "HKS", + "SHAA": "SHS", + "SZAA": "SZS", + "HSX": "HSX", + "HNX": "HNX", + "TSE": "TSE", +} + + class OverseasBroker: """KIS Overseas Stock API wrapper that reuses KISBroker infrastructure.""" @@ -70,7 +84,7 @@ class OverseasBroker: ranking_type: str = "fluctuation", limit: int = 30, ) -> list[dict[str, Any]]: - """Fetch overseas rankings (price change or volume amount). + """Fetch overseas rankings (price change or volume surge). Ranking API specs may differ by account/product. Endpoint paths and TR_IDs are configurable via settings and can be overridden in .env. @@ -81,66 +95,63 @@ class OverseasBroker: await self._broker._rate_limiter.acquire() session = self._broker._get_session() + ranking_excd = _RANKING_EXCHANGE_MAP.get(exchange_code, exchange_code) + if ranking_type == "volume": - configured_tr_id = self._broker._settings.OVERSEAS_RANKING_VOLUME_TR_ID - configured_path = self._broker._settings.OVERSEAS_RANKING_VOLUME_PATH - default_tr_id = "HHDFS76200200" - default_path = "/uapi/overseas-price/v1/quotations/inquire-volume-rank" + tr_id = self._broker._settings.OVERSEAS_RANKING_VOLUME_TR_ID + path = self._broker._settings.OVERSEAS_RANKING_VOLUME_PATH + params: dict[str, str] = { + "AUTH": "", + "EXCD": ranking_excd, + "MIXN": "0", + "VOL_RANG": "0", + } else: - configured_tr_id = self._broker._settings.OVERSEAS_RANKING_FLUCT_TR_ID - configured_path = self._broker._settings.OVERSEAS_RANKING_FLUCT_PATH - default_tr_id = "HHDFS76200100" - default_path = "/uapi/overseas-price/v1/quotations/inquire-updown-rank" + tr_id = self._broker._settings.OVERSEAS_RANKING_FLUCT_TR_ID + path = self._broker._settings.OVERSEAS_RANKING_FLUCT_PATH + params = { + "AUTH": "", + "EXCD": ranking_excd, + "NDAY": "0", + "GUBN": "1", + "VOL_RANG": "0", + } - endpoint_specs: list[tuple[str, str]] = [(configured_tr_id, configured_path)] - if (configured_tr_id, configured_path) != (default_tr_id, default_path): - endpoint_specs.append((default_tr_id, default_path)) + headers = await self._broker._auth_headers(tr_id) + url = f"{self._broker._base_url}{path}" - # Try common param variants used by KIS overseas quotation APIs. - param_variants = [ - {"AUTH": "", "EXCD": exchange_code, "NREC": str(max(limit, 30))}, - {"AUTH": "", "OVRS_EXCG_CD": exchange_code, "NREC": str(max(limit, 30))}, - {"AUTH": "", "EXCD": exchange_code}, - {"AUTH": "", "OVRS_EXCG_CD": exchange_code}, - ] + try: + async with session.get(url, headers=headers, params=params) as resp: + if resp.status != 200: + text = await resp.text() + if resp.status == 404: + logger.warning( + "Overseas ranking endpoint unavailable (404) for %s/%s; " + "using symbol fallback scan", + exchange_code, + ranking_type, + ) + return [] + raise ConnectionError( + f"fetch_overseas_rankings failed ({resp.status}): {text}" + ) - last_error: str | None = None - saw_http_404 = False - for tr_id, path in endpoint_specs: - headers = await self._broker._auth_headers(tr_id) - url = f"{self._broker._base_url}{path}" - for params in param_variants: - try: - async with session.get(url, headers=headers, params=params) as resp: - text = await resp.text() - if resp.status != 200: - last_error = f"HTTP {resp.status}: {text}" - if resp.status == 404: - saw_http_404 = True - continue + data = await resp.json() + rows = self._extract_ranking_rows(data) + if rows: + return rows[:limit] - data = await resp.json() - rows = self._extract_ranking_rows(data) - if rows: - return rows[:limit] - - # keep trying another param variant if response has no usable rows - last_error = f"empty output (keys={list(data.keys())})" - except (TimeoutError, aiohttp.ClientError) as exc: - last_error = str(exc) - continue - - if saw_http_404: - logger.warning( - "Overseas ranking endpoint unavailable (404) for %s/%s; using symbol fallback scan", - exchange_code, - ranking_type, - ) - return [] - - raise ConnectionError( - f"fetch_overseas_rankings failed for {exchange_code}/{ranking_type}: {last_error}" - ) + logger.debug( + "Overseas ranking returned empty for %s/%s (keys=%s)", + exchange_code, + ranking_type, + list(data.keys()), + ) + return [] + except (TimeoutError, aiohttp.ClientError) as exc: + raise ConnectionError( + f"Network error fetching overseas rankings: {exc}" + ) from exc async def get_overseas_balance(self, exchange_code: str) -> dict[str, Any]: """ diff --git a/src/config.py b/src/config.py index 6d79119..1a9a5b6 100644 --- a/src/config.py +++ b/src/config.py @@ -91,13 +91,13 @@ class Settings(BaseSettings): # Overseas ranking API (KIS endpoint/TR_ID may vary by account/product) # Override these from .env if your account uses different specs. OVERSEAS_RANKING_ENABLED: bool = True - OVERSEAS_RANKING_FLUCT_TR_ID: str = "HHDFS76200100" - OVERSEAS_RANKING_VOLUME_TR_ID: str = "HHDFS76200200" + OVERSEAS_RANKING_FLUCT_TR_ID: str = "HHDFS76290000" + OVERSEAS_RANKING_VOLUME_TR_ID: str = "HHDFS76270000" OVERSEAS_RANKING_FLUCT_PATH: str = ( - "/uapi/overseas-price/v1/quotations/inquire-updown-rank" + "/uapi/overseas-stock/v1/ranking/updown-rate" ) OVERSEAS_RANKING_VOLUME_PATH: str = ( - "/uapi/overseas-price/v1/quotations/inquire-volume-rank" + "/uapi/overseas-stock/v1/ranking/volume-surge" ) # Dashboard (optional) diff --git a/tests/test_broker.py b/tests/test_broker.py index 37377e8..58f5587 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -90,12 +90,12 @@ class TestTokenManagement: await broker.close() @pytest.mark.asyncio - async def test_token_refresh_cooldown_prevents_rapid_retries(self, settings): - """Token refresh should enforce cooldown after failure (issue #54).""" + async def test_token_refresh_cooldown_waits_then_retries(self, settings): + """Token refresh should wait out cooldown then retry (issue #54).""" broker = KISBroker(settings) - broker._refresh_cooldown = 2.0 # Short cooldown for testing + broker._refresh_cooldown = 0.1 # Short cooldown for testing - # First refresh attempt fails with 403 (EGW00133) + # All attempts fail with 403 (EGW00133) mock_resp_403 = AsyncMock() mock_resp_403.status = 403 mock_resp_403.text = AsyncMock( @@ -109,8 +109,8 @@ class TestTokenManagement: with pytest.raises(ConnectionError, match="Token refresh failed"): await broker._ensure_token() - # Second attempt within cooldown should fail with cooldown error - with pytest.raises(ConnectionError, match="Token refresh on cooldown"): + # Second attempt within cooldown should wait then retry (and still get 403) + with pytest.raises(ConnectionError, match="Token refresh failed"): await broker._ensure_token() await broker.close() diff --git a/tests/test_overseas_broker.py b/tests/test_overseas_broker.py new file mode 100644 index 0000000..0a3eca9 --- /dev/null +++ b/tests/test_overseas_broker.py @@ -0,0 +1,521 @@ +"""Tests for OverseasBroker — rankings, price, balance, order, and helpers.""" + +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}" + + +class TestGetOverseasPrice: + """Test get_overseas_price method.""" + + @pytest.mark.asyncio + async def test_success(self, overseas_broker: OverseasBroker) -> None: + """Successful price fetch returns JSON data.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"output": {"last": "150.00"}}) + + 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 t"}) + + result = await overseas_broker.get_overseas_price("NASD", "AAPL") + assert result["output"]["last"] == "150.00" + + call_args = mock_session.get.call_args + params = call_args[1]["params"] + assert params["EXCD"] == "NASD" + assert params["SYMB"] == "AAPL" + + @pytest.mark.asyncio + async def test_http_error_raises(self, overseas_broker: OverseasBroker) -> None: + """Non-200 response should raise ConnectionError.""" + mock_resp = AsyncMock() + mock_resp.status = 400 + mock_resp.text = AsyncMock(return_value="Bad Request") + + 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="get_overseas_price failed"): + await overseas_broker.get_overseas_price("NASD", "AAPL") + + @pytest.mark.asyncio + async def test_network_error_raises(self, overseas_broker: OverseasBroker) -> None: + """Network error should raise ConnectionError.""" + cm = MagicMock() + cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("conn refused")) + 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.get_overseas_price("NASD", "AAPL") + + +class TestGetOverseasBalance: + """Test get_overseas_balance method.""" + + @pytest.mark.asyncio + async def test_success(self, overseas_broker: OverseasBroker) -> None: + """Successful balance fetch returns JSON data.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"output1": [{"pdno": "AAPL"}]}) + + 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.get_overseas_balance("NASD") + assert result["output1"][0]["pdno"] == "AAPL" + + @pytest.mark.asyncio + async def test_http_error_raises(self, overseas_broker: OverseasBroker) -> None: + """Non-200 should raise ConnectionError.""" + mock_resp = AsyncMock() + mock_resp.status = 500 + mock_resp.text = AsyncMock(return_value="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="get_overseas_balance failed"): + await overseas_broker.get_overseas_balance("NASD") + + @pytest.mark.asyncio + async def test_network_error_raises(self, overseas_broker: OverseasBroker) -> None: + """Network error should raise ConnectionError.""" + cm = MagicMock() + cm.__aenter__ = AsyncMock(side_effect=TimeoutError("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.get_overseas_balance("NYSE") + + +class TestSendOverseasOrder: + """Test send_overseas_order method.""" + + @pytest.mark.asyncio + async def test_buy_market_order(self, overseas_broker: OverseasBroker) -> None: + """Market buy order should use VTTT1002U and ORD_DVSN=01.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"rt_cd": "0"}) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp)) + + _setup_broker_mocks(overseas_broker, mock_session) + overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval") + + result = await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 10) + assert result["rt_cd"] == "0" + + # Verify BUY TR_ID + overseas_broker._broker._auth_headers.assert_called_with("VTTT1002U") + + call_args = mock_session.post.call_args + body = call_args[1]["json"] + assert body["ORD_DVSN"] == "01" # market order + assert body["OVRS_ORD_UNPR"] == "0" + + @pytest.mark.asyncio + async def test_sell_limit_order(self, overseas_broker: OverseasBroker) -> None: + """Limit sell order should use VTTT1006U and ORD_DVSN=00.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"rt_cd": "0"}) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp)) + + _setup_broker_mocks(overseas_broker, mock_session) + overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval") + + result = await overseas_broker.send_overseas_order("NYSE", "MSFT", "SELL", 5, price=350.0) + assert result["rt_cd"] == "0" + + overseas_broker._broker._auth_headers.assert_called_with("VTTT1006U") + + call_args = mock_session.post.call_args + body = call_args[1]["json"] + assert body["ORD_DVSN"] == "00" # limit order + assert body["OVRS_ORD_UNPR"] == "350.0" + + @pytest.mark.asyncio + async def test_order_http_error_raises(self, overseas_broker: OverseasBroker) -> None: + """Non-200 should raise ConnectionError.""" + mock_resp = AsyncMock() + mock_resp.status = 400 + mock_resp.text = AsyncMock(return_value="Bad Request") + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp)) + + _setup_broker_mocks(overseas_broker, mock_session) + overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval") + + with pytest.raises(ConnectionError, match="send_overseas_order failed"): + await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 1) + + @pytest.mark.asyncio + async def test_order_network_error_raises(self, overseas_broker: OverseasBroker) -> None: + """Network error should raise ConnectionError.""" + cm = MagicMock() + cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("conn reset")) + cm.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=cm) + + _setup_broker_mocks(overseas_broker, mock_session) + overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval") + + with pytest.raises(ConnectionError, match="Network error"): + await overseas_broker.send_overseas_order("NASD", "TSLA", "SELL", 2) + + +class TestGetCurrencyCode: + """Test _get_currency_code mapping.""" + + def test_us_exchanges(self, overseas_broker: OverseasBroker) -> None: + assert overseas_broker._get_currency_code("NASD") == "USD" + assert overseas_broker._get_currency_code("NYSE") == "USD" + assert overseas_broker._get_currency_code("AMEX") == "USD" + + def test_japan(self, overseas_broker: OverseasBroker) -> None: + assert overseas_broker._get_currency_code("TSE") == "JPY" + + def test_hong_kong(self, overseas_broker: OverseasBroker) -> None: + assert overseas_broker._get_currency_code("SEHK") == "HKD" + + def test_china(self, overseas_broker: OverseasBroker) -> None: + assert overseas_broker._get_currency_code("SHAA") == "CNY" + assert overseas_broker._get_currency_code("SZAA") == "CNY" + + def test_vietnam(self, overseas_broker: OverseasBroker) -> None: + assert overseas_broker._get_currency_code("HNX") == "VND" + assert overseas_broker._get_currency_code("HSX") == "VND" + + def test_unknown_defaults_usd(self, overseas_broker: OverseasBroker) -> None: + assert overseas_broker._get_currency_code("UNKNOWN") == "USD" + + +class TestExtractRankingRows: + """Test _extract_ranking_rows helper.""" + + def test_output_key(self, overseas_broker: OverseasBroker) -> None: + data = {"output": [{"a": 1}, {"b": 2}]} + assert overseas_broker._extract_ranking_rows(data) == [{"a": 1}, {"b": 2}] + + def test_output1_key(self, overseas_broker: OverseasBroker) -> None: + data = {"output1": [{"c": 3}]} + assert overseas_broker._extract_ranking_rows(data) == [{"c": 3}] + + def test_output2_key(self, overseas_broker: OverseasBroker) -> None: + data = {"output2": [{"d": 4}]} + assert overseas_broker._extract_ranking_rows(data) == [{"d": 4}] + + def test_no_list_returns_empty(self, overseas_broker: OverseasBroker) -> None: + data = {"output": "not a list"} + assert overseas_broker._extract_ranking_rows(data) == [] + + def test_empty_data(self, overseas_broker: OverseasBroker) -> None: + assert overseas_broker._extract_ranking_rows({}) == [] + + def test_filters_non_dict_rows(self, overseas_broker: OverseasBroker) -> None: + data = {"output": [{"a": 1}, "invalid", {"b": 2}]} + assert overseas_broker._extract_ranking_rows(data) == [{"a": 1}, {"b": 2}]