diff --git a/src/broker/overseas.py b/src/broker/overseas.py index bdf4bcc..33098a6 100644 --- a/src/broker/overseas.py +++ b/src/broker/overseas.py @@ -25,6 +25,10 @@ _RANKING_EXCHANGE_MAP: dict[str, str] = { "TSE": "TSE", } +# Price inquiry API (HHDFS00000300) uses the same short exchange codes as rankings. +# NASD → NAS, NYSE → NYS, AMEX → AMS (confirmed: AMEX returns empty, AMS returns price). +_PRICE_EXCHANGE_MAP: dict[str, str] = _RANKING_EXCHANGE_MAP + class OverseasBroker: """KIS Overseas Stock API wrapper that reuses KISBroker infrastructure.""" @@ -58,9 +62,11 @@ class OverseasBroker: session = self._broker._get_session() headers = await self._broker._auth_headers("HHDFS00000300") + # Map internal exchange codes to the short form expected by the price API. + price_excd = _PRICE_EXCHANGE_MAP.get(exchange_code, exchange_code) params = { "AUTH": "", - "EXCD": exchange_code, + "EXCD": price_excd, "SYMB": stock_code, } url = f"{self._broker._base_url}/uapi/overseas-price/v1/quotations/price" diff --git a/src/config.py b/src/config.py index 1a9a5b6..80e3203 100644 --- a/src/config.py +++ b/src/config.py @@ -55,6 +55,11 @@ class Settings(BaseSettings): # Trading mode MODE: str = Field(default="paper", pattern="^(paper|live)$") + # Simulated USD cash for VTS (paper) overseas trading. + # KIS VTS overseas balance API returns errors for most accounts. + # This value is used as a fallback when the balance API returns 0 in paper mode. + PAPER_OVERSEAS_CASH: float = Field(default=50000.0, ge=0.0) + # Trading frequency mode (daily = batch API calls, realtime = per-stock calls) TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$") DAILY_SESSIONS: int = Field(default=4, ge=1, le=10) diff --git a/src/main.py b/src/main.py index b4f147c..1542a0c 100644 --- a/src/main.py +++ b/src/main.py @@ -239,10 +239,33 @@ async def trading_cycle( total_cash = safe_float(balance_info.get("frcr_dncl_amt_2", "0") or "0") purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0") + # VTS (paper trading) overseas balance API often returns 0 or errors. + # Fall back to configured paper cash so BUY orders can be sized. + if total_cash <= 0 and settings and settings.PAPER_OVERSEAS_CASH > 0: + logger.debug( + "Overseas cash balance is 0 for %s; using paper fallback %.2f", + stock_code, + settings.PAPER_OVERSEAS_CASH, + ) + total_cash = settings.PAPER_OVERSEAS_CASH + current_price = safe_float(price_data.get("output", {}).get("last", "0")) foreigner_net = 0.0 # Not available for overseas price_change_pct = safe_float(price_data.get("output", {}).get("rate", "0")) + # Price API may return 0/empty for certain VTS exchange codes. + # Fall back to the scanner candidate's price so order sizing still works. + if current_price <= 0: + market_candidates_lookup = scan_candidates.get(market.code, {}) + cand_lookup = market_candidates_lookup.get(stock_code) + if cand_lookup and cand_lookup.price > 0: + current_price = cand_lookup.price + logger.debug( + "Price API returned 0 for %s; using scanner price %.4f", + stock_code, + current_price, + ) + # Calculate daily P&L % pnl_pct = ( ((total_eval - purchase_total) / purchase_total * 100) @@ -692,6 +715,16 @@ async def run_daily_session( price_change_pct = safe_float( price_data.get("output", {}).get("rate", "0") ) + # Fall back to scanner candidate price if API returns 0. + if current_price <= 0: + cand_lookup = candidate_map.get(stock_code) + if cand_lookup and cand_lookup.price > 0: + current_price = cand_lookup.price + logger.debug( + "Price API returned 0 for %s; using scanner price %.4f", + stock_code, + current_price, + ) stock_data: dict[str, Any] = { "stock_code": stock_code, @@ -743,6 +776,10 @@ async def run_daily_session( balance_info.get("frcr_buy_amt_smtl", "0") or "0" ) + # VTS overseas balance API often returns 0; use paper fallback. + if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0: + total_cash = settings.PAPER_OVERSEAS_CASH + # Calculate daily P&L % pnl_pct = ( ((total_eval - purchase_total) / purchase_total * 100) diff --git a/tests/test_overseas_broker.py b/tests/test_overseas_broker.py index 0a3eca9..a6381a0 100644 --- a/tests/test_overseas_broker.py +++ b/tests/test_overseas_broker.py @@ -8,7 +8,7 @@ import aiohttp import pytest from src.broker.kis_api import KISBroker -from src.broker.overseas import OverseasBroker, _RANKING_EXCHANGE_MAP +from src.broker.overseas import OverseasBroker, _PRICE_EXCHANGE_MAP, _RANKING_EXCHANGE_MAP from src.config import Settings @@ -302,7 +302,8 @@ class TestGetOverseasPrice: call_args = mock_session.get.call_args params = call_args[1]["params"] - assert params["EXCD"] == "NASD" + # NASD is mapped to NAS for the price inquiry API (same as ranking API). + assert params["EXCD"] == "NAS" assert params["SYMB"] == "AAPL" @pytest.mark.asyncio @@ -519,3 +520,98 @@ class TestExtractRankingRows: 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}] + + +# --------------------------------------------------------------------------- +# Price exchange code mapping +# --------------------------------------------------------------------------- + + +class TestPriceExchangeMap: + """Test that get_overseas_price uses the short exchange codes.""" + + def test_price_map_equals_ranking_map(self) -> None: + assert _PRICE_EXCHANGE_MAP is _RANKING_EXCHANGE_MAP + + def test_nasd_maps_to_nas(self) -> None: + assert _PRICE_EXCHANGE_MAP["NASD"] == "NAS" + + def test_amex_maps_to_ams(self) -> None: + assert _PRICE_EXCHANGE_MAP["AMEX"] == "AMS" + + def test_nyse_maps_to_nys(self) -> None: + assert _PRICE_EXCHANGE_MAP["NYSE"] == "NYS" + + @pytest.mark.asyncio + async def test_get_overseas_price_uses_mapped_excd( + self, overseas_broker: OverseasBroker + ) -> None: + """AMEX should be sent as AMS to the price API.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"output": {"last": "44.30"}}) + + 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={}) + + await overseas_broker.get_overseas_price("AMEX", "EWUS") + + params = mock_session.get.call_args[1]["params"] + assert params["EXCD"] == "AMS" # mapped, not raw "AMEX" + assert params["SYMB"] == "EWUS" + + @pytest.mark.asyncio + async def test_get_overseas_price_nasd_uses_nas( + self, overseas_broker: OverseasBroker + ) -> None: + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"output": {"last": "220.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={}) + + await overseas_broker.get_overseas_price("NASD", "AAPL") + + params = mock_session.get.call_args[1]["params"] + assert params["EXCD"] == "NAS" + + +# --------------------------------------------------------------------------- +# PAPER_OVERSEAS_CASH config default +# --------------------------------------------------------------------------- + + +class TestPaperOverseasCash: + def test_default_value(self) -> None: + settings = Settings( + KIS_APP_KEY="x", + KIS_APP_SECRET="x", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="x", + ) + assert settings.PAPER_OVERSEAS_CASH == 50000.0 + + def test_can_be_set_via_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PAPER_OVERSEAS_CASH", "100000.0") + settings = Settings( + KIS_APP_KEY="x", + KIS_APP_SECRET="x", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="x", + ) + assert settings.PAPER_OVERSEAS_CASH == 100000.0 + + def test_zero_disables_fallback(self) -> None: + settings = Settings( + KIS_APP_KEY="x", + KIS_APP_SECRET="x", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="x", + PAPER_OVERSEAS_CASH=0.0, + ) + assert settings.PAPER_OVERSEAS_CASH == 0.0