diff --git a/src/broker/overseas.py b/src/broker/overseas.py index bdf4bcc..3d37acb 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" @@ -251,14 +257,27 @@ class OverseasBroker: f"send_overseas_order failed ({resp.status}): {text}" ) data = await resp.json() - logger.info( - "Overseas order submitted", - extra={ - "exchange": exchange_code, - "stock_code": stock_code, - "action": order_type, - }, - ) + rt_cd = data.get("rt_cd", "") + msg1 = data.get("msg1", "") + if rt_cd == "0": + logger.info( + "Overseas order submitted", + extra={ + "exchange": exchange_code, + "stock_code": stock_code, + "action": order_type, + }, + ) + else: + logger.warning( + "Overseas order rejected (rt_cd=%s): %s [%s %s %s qty=%d]", + rt_cd, + msg1, + order_type, + stock_code, + exchange_code, + quantity, + ) return data except (TimeoutError, aiohttp.ClientError) as exc: raise ConnectionError( 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..6bb77b6 100644 --- a/src/main.py +++ b/src/main.py @@ -239,7 +239,27 @@ 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") + # Paper mode fallback: VTS overseas balance API often fails for many accounts. + 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 USD", + market.exchange_code, + settings.PAPER_OVERSEAS_CASH, + ) + total_cash = settings.PAPER_OVERSEAS_CASH + current_price = safe_float(price_data.get("output", {}).get("last", "0")) + # Fallback: if price API returns 0, use scanner candidate price + 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: + logger.debug( + "Price API returned 0 for %s; using scanner candidate price %.4f", + stock_code, + cand_lookup.price, + ) + current_price = cand_lookup.price foreigner_net = 0.0 # Not available for overseas price_change_pct = safe_float(price_data.get("output", {}).get("rate", "0")) @@ -474,6 +494,7 @@ async def trading_cycle( raise # Re-raise to prevent trade # 5. Send order + order_succeeded = True if market.is_domestic: result = await broker.send_order( stock_code=stock_code, @@ -482,29 +503,48 @@ async def trading_cycle( price=0, # market order ) else: + # For overseas orders: + # - KIS VTS only accepts limit orders (지정가만 가능) + # - BUY: use 0.5% premium over last price to improve fill probability + # (ask price is typically slightly above last, and VTS won't fill below ask) + # - SELL: use last price as the limit + if decision.action == "BUY": + order_price = round(current_price * 1.005, 4) + else: + order_price = current_price result = await overseas_broker.send_overseas_order( exchange_code=market.exchange_code, stock_code=stock_code, order_type=decision.action, quantity=quantity, - price=0.0, # market order + price=order_price, # limit order — KIS VTS rejects market orders ) + # Check if KIS rejected the order (rt_cd != "0") + if result.get("rt_cd", "") != "0": + order_succeeded = False + logger.warning( + "Overseas order not accepted for %s: rt_cd=%s msg=%s", + stock_code, + result.get("rt_cd"), + result.get("msg1"), + ) logger.info("Order result: %s", result.get("msg1", "OK")) - # 5.5. Notify trade execution - try: - await telegram.notify_trade_execution( - stock_code=stock_code, - market=market.name, - action=decision.action, - quantity=quantity, - price=current_price, - confidence=decision.confidence, - ) - except Exception as exc: - logger.warning("Telegram notification failed: %s", exc) + # 5.5. Notify trade execution (only on success) + if order_succeeded: + try: + await telegram.notify_trade_execution( + stock_code=stock_code, + market=market.name, + action=decision.action, + quantity=quantity, + price=current_price, + confidence=decision.confidence, + ) + except Exception as exc: + logger.warning("Telegram notification failed: %s", exc) - if decision.action == "SELL": + if decision.action == "SELL" and order_succeeded: buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code) if buy_trade and buy_trade.get("price") is not None: buy_price = float(buy_trade["price"]) @@ -516,7 +556,9 @@ async def trading_cycle( accuracy=1 if trade_pnl > 0 else 0, ) - # 6. Log trade with selection context + # 6. Log trade with selection context (skip if order was rejected) + if decision.action in ("BUY", "SELL") and not order_succeeded: + return selection_context = None if stock_code in market_candidates: candidate = market_candidates[stock_code] @@ -688,6 +730,16 @@ async def run_daily_session( current_price = safe_float( price_data.get("output", {}).get("last", "0") ) + # Fallback: if price API returns 0, use scanner candidate price + if current_price <= 0: + cand_lookup = candidate_map.get(stock_code) + if cand_lookup and cand_lookup.price > 0: + logger.debug( + "Price API returned 0 for %s; using scanner candidate price %.4f", + stock_code, + cand_lookup.price, + ) + current_price = cand_lookup.price foreigner_net = 0.0 price_change_pct = safe_float( price_data.get("output", {}).get("rate", "0") @@ -742,6 +794,9 @@ async def run_daily_session( purchase_total = safe_float( balance_info.get("frcr_buy_amt_smtl", "0") or "0" ) + # Paper mode fallback: VTS overseas balance API often fails for many accounts. + if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0: + total_cash = settings.PAPER_OVERSEAS_CASH # Calculate daily P&L % pnl_pct = ( @@ -816,6 +871,7 @@ async def run_daily_session( quantity = 0 trade_price = stock_data["current_price"] trade_pnl = 0.0 + order_succeeded = True if decision.action in ("BUY", "SELL"): quantity = _determine_order_quantity( action=decision.action, @@ -868,6 +924,7 @@ async def run_daily_session( raise # Send order + order_succeeded = True try: if market.is_domestic: result = await broker.send_order( @@ -877,34 +934,48 @@ async def run_daily_session( price=0, # market order ) else: + # KIS VTS only accepts limit orders; use 0.5% premium for BUY + if decision.action == "BUY": + order_price = round(stock_data["current_price"] * 1.005, 4) + else: + order_price = stock_data["current_price"] result = await overseas_broker.send_overseas_order( exchange_code=market.exchange_code, stock_code=stock_code, order_type=decision.action, quantity=quantity, - price=0.0, # market order + price=order_price, # limit order ) + if result.get("rt_cd", "") != "0": + order_succeeded = False + logger.warning( + "Overseas order not accepted for %s: rt_cd=%s msg=%s", + stock_code, + result.get("rt_cd"), + result.get("msg1"), + ) logger.info("Order result: %s", result.get("msg1", "OK")) - # Notify trade execution - try: - await telegram.notify_trade_execution( - stock_code=stock_code, - market=market.name, - action=decision.action, - quantity=quantity, - price=stock_data["current_price"], - confidence=decision.confidence, - ) - except Exception as exc: - logger.warning("Telegram notification failed: %s", exc) + # Notify trade execution (only on success) + if order_succeeded: + try: + await telegram.notify_trade_execution( + stock_code=stock_code, + market=market.name, + action=decision.action, + quantity=quantity, + price=stock_data["current_price"], + confidence=decision.confidence, + ) + except Exception as exc: + logger.warning("Telegram notification failed: %s", exc) except Exception as exc: logger.error( "Order execution failed for %s: %s", stock_code, exc ) continue - if decision.action == "SELL": + if decision.action == "SELL" and order_succeeded: buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code) if buy_trade and buy_trade.get("price") is not None: buy_price = float(buy_trade["price"]) @@ -916,7 +987,9 @@ async def run_daily_session( accuracy=1 if trade_pnl > 0 else 0, ) - # Log trade + # Log trade (skip if order was rejected by API) + if decision.action in ("BUY", "SELL") and not order_succeeded: + continue log_trade( conn=db_conn, stock_code=stock_code, diff --git a/tests/test_overseas_broker.py b/tests/test_overseas_broker.py index 0a3eca9..620b98d 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,7 @@ class TestGetOverseasPrice: call_args = mock_session.get.call_args params = call_args[1]["params"] - assert params["EXCD"] == "NASD" + assert params["EXCD"] == "NAS" # NASD → NAS via _PRICE_EXCHANGE_MAP assert params["SYMB"] == "AAPL" @pytest.mark.asyncio @@ -519,3 +519,125 @@ 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}] + + +class TestPriceExchangeMap: + """Test _PRICE_EXCHANGE_MAP is applied in get_overseas_price (issue #151).""" + + def test_price_map_equals_ranking_map(self) -> None: + assert _PRICE_EXCHANGE_MAP is _RANKING_EXCHANGE_MAP + + @pytest.mark.parametrize("original,expected", [ + ("NASD", "NAS"), + ("NYSE", "NYS"), + ("AMEX", "AMS"), + ]) + def test_us_exchange_code_mapping(self, original: str, expected: str) -> None: + assert _PRICE_EXCHANGE_MAP[original] == expected + + @pytest.mark.asyncio + async def test_get_overseas_price_sends_mapped_code( + self, overseas_broker: OverseasBroker + ) -> None: + """NASD → NAS must be sent to HHDFS00000300.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"output": {"last": "200.00"}}) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp)) + _setup_broker_mocks(overseas_broker, mock_session) + + await overseas_broker.get_overseas_price("NASD", "AAPL") + + params = mock_session.get.call_args[1]["params"] + assert params["EXCD"] == "NAS" + + +class TestOrderRtCdCheck: + """Test that send_overseas_order checks rt_cd and logs accordingly (issue #151).""" + + @pytest.fixture + def overseas_broker(self, mock_settings: Settings) -> OverseasBroker: + broker = MagicMock(spec=KISBroker) + broker._settings = mock_settings + broker._account_no = "12345678" + broker._product_cd = "01" + broker._base_url = "https://openapivts.koreainvestment.com:9443" + broker._rate_limiter = AsyncMock() + broker._rate_limiter.acquire = AsyncMock() + broker._auth_headers = AsyncMock(return_value={"authorization": "Bearer t"}) + broker._get_hash_key = AsyncMock(return_value="hashval") + return OverseasBroker(broker) + + @pytest.mark.asyncio + async def test_success_rt_cd_returns_data( + self, overseas_broker: OverseasBroker + ) -> None: + """rt_cd='0' → order accepted, data returned.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "완료"}) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp)) + overseas_broker._broker._get_session = MagicMock(return_value=mock_session) + + result = await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 10, price=150.0) + assert result["rt_cd"] == "0" + + @pytest.mark.asyncio + async def test_error_rt_cd_returns_data_with_msg( + self, overseas_broker: OverseasBroker + ) -> None: + """rt_cd != '0' → order rejected, data still returned (caller checks rt_cd).""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock( + return_value={"rt_cd": "1", "msg1": "주문가능금액이 부족합니다."} + ) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp)) + overseas_broker._broker._get_session = MagicMock(return_value=mock_session) + + result = await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 10, price=150.0) + assert result["rt_cd"] == "1" + assert "부족" in result["msg1"] + + +class TestPaperOverseasCash: + """Test PAPER_OVERSEAS_CASH config setting (issue #151).""" + + def test_default_value(self) -> None: + settings = Settings( + KIS_APP_KEY="k", + KIS_APP_SECRET="s", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="g", + ) + assert settings.PAPER_OVERSEAS_CASH == 50000.0 + + def test_env_override(self) -> None: + import os + os.environ["PAPER_OVERSEAS_CASH"] = "25000" + settings = Settings( + KIS_APP_KEY="k", + KIS_APP_SECRET="s", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="g", + ) + assert settings.PAPER_OVERSEAS_CASH == 25000.0 + del os.environ["PAPER_OVERSEAS_CASH"] + + def test_zero_disables_fallback(self) -> None: + import os + os.environ["PAPER_OVERSEAS_CASH"] = "0" + settings = Settings( + KIS_APP_KEY="k", + KIS_APP_SECRET="s", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="g", + ) + assert settings.PAPER_OVERSEAS_CASH == 0.0 + del os.environ["PAPER_OVERSEAS_CASH"]