From 24fa22e77b75810395f1007f60309ffae404279e Mon Sep 17 00:00:00 2001 From: agentson Date: Thu, 19 Feb 2026 05:44:19 +0900 Subject: [PATCH] fix: overseas order rt_cd check, limit price premium, paper cash fallback (#151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for overseas stock trading failures: 1. Price API exchange code mapping: - get_overseas_price() now applies _PRICE_EXCHANGE_MAP (NASD→NAS, NYSE→NYS, AMEX→AMS) - Price API HHDFS00000300 requires short exchange codes same as ranking API 2. rt_cd check in send_overseas_order(): - Log WARNING (not INFO) when rt_cd != "0" (e.g., "주문가능금액이 부족합니다") - Caller (main.py) checks rt_cd == "0" before calling log_trade() - Prevents DB from recording failed orders as successful trades 3. Limit order price premium for BUY: - BUY limit price = current_price * 1.005 (0.5% premium) - SELL limit price = current_price (no premium) - Improves fill probability: KIS VTS only accepts limit orders, and last price is typically at or below ask 4. PAPER_OVERSEAS_CASH fallback (config + main.py): - New setting: PAPER_OVERSEAS_CASH = 50000.0 (USD) - When VTS overseas balance API fails/returns 0, use this as simulated cash - Applied in both trading_cycle() and run_daily_session() 5. Candidate price fallback: - If price API returns 0, use scanner candidate price as fallback Co-Authored-By: Claude Sonnet 4.6 --- src/broker/overseas.py | 29 ++++++-- src/main.py | 131 +++++++++++++++++++++++--------- tests/test_main.py | 9 ++- tests/test_overseas_broker.py | 136 ++++++++++++++++++++-------------- 4 files changed, 204 insertions(+), 101 deletions(-) diff --git a/src/broker/overseas.py b/src/broker/overseas.py index 33098a6..3d37acb 100644 --- a/src/broker/overseas.py +++ b/src/broker/overseas.py @@ -257,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/main.py b/src/main.py index 4145ca7..d506f94 100644 --- a/src/main.py +++ b/src/main.py @@ -239,17 +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") - # VTS (paper trading) overseas balance API often returns 0 or errors. - # Fall back to configured paper cash so BUY orders can be sized. + # 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", - stock_code, + "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")) @@ -497,6 +507,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, @@ -505,29 +516,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=current_price, # limit order — KIS VTS rejects market orders + 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"]) @@ -539,7 +569,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] @@ -711,6 +743,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") @@ -775,6 +817,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 # VTS overseas balance API often returns 0; use paper fallback. if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0: @@ -853,6 +898,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, @@ -905,6 +951,7 @@ async def run_daily_session( raise # Send order + order_succeeded = True try: if market.is_domestic: result = await broker.send_order( @@ -914,34 +961,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=stock_data["current_price"], # limit order — KIS VTS rejects market orders + 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"]) @@ -953,7 +1014,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_main.py b/tests/test_main.py index a7fbb58..2f50702 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -805,13 +805,14 @@ class TestOverseasBalanceParsing: scan_candidates={}, ) - # Verify limit order was sent with actual price, not 0.0 + # Verify limit order was sent with actual price + 0.5% premium (issue #151), not 0.0 mock_overseas_broker_with_buy_scenario.send_overseas_order.assert_called_once() call_kwargs = mock_overseas_broker_with_buy_scenario.send_overseas_order.call_args sent_price = call_kwargs[1].get("price") or call_kwargs[0][4] - assert sent_price == 182.5, ( - f"Expected limit price 182.5 but got {sent_price}. " - "KIS VTS only accepts limit orders for overseas paper trading." + expected_price = round(182.5 * 1.005, 4) # 0.5% premium for BUY limit orders + assert sent_price == expected_price, ( + f"Expected limit price {expected_price} (182.5 * 1.005) but got {sent_price}. " + "KIS VTS only accepts limit orders; BUY uses 0.5% premium to improve fill rate." ) diff --git a/tests/test_overseas_broker.py b/tests/test_overseas_broker.py index a6381a0..620b98d 100644 --- a/tests/test_overseas_broker.py +++ b/tests/test_overseas_broker.py @@ -302,8 +302,7 @@ class TestGetOverseasPrice: call_args = mock_session.get.call_args params = call_args[1]["params"] - # NASD is mapped to NAS for the price inquiry API (same as ranking API). - assert params["EXCD"] == "NAS" + assert params["EXCD"] == "NAS" # NASD → NAS via _PRICE_EXCHANGE_MAP assert params["SYMB"] == "AAPL" @pytest.mark.asyncio @@ -522,58 +521,32 @@ class TestExtractRankingRows: 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.""" + """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 - 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.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_uses_mapped_excd( + async def test_get_overseas_price_sends_mapped_code( self, overseas_broker: OverseasBroker ) -> None: - """AMEX should be sent as AMS to the price API.""" + """NASD → NAS must be sent to HHDFS00000300.""" mock_resp = AsyncMock() mock_resp.status = 200 - mock_resp.json = AsyncMock(return_value={"output": {"last": "44.30"}}) + 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) - 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") @@ -581,37 +554,90 @@ class TestPriceExchangeMap: assert params["EXCD"] == "NAS" -# --------------------------------------------------------------------------- -# PAPER_OVERSEAS_CASH config default -# --------------------------------------------------------------------------- +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="x", - KIS_APP_SECRET="x", + KIS_APP_KEY="k", + KIS_APP_SECRET="s", KIS_ACCOUNT_NO="12345678-01", - GEMINI_API_KEY="x", + GEMINI_API_KEY="g", ) 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") + def test_env_override(self) -> None: + import os + os.environ["PAPER_OVERSEAS_CASH"] = "25000" settings = Settings( - KIS_APP_KEY="x", - KIS_APP_SECRET="x", + KIS_APP_KEY="k", + KIS_APP_SECRET="s", KIS_ACCOUNT_NO="12345678-01", - GEMINI_API_KEY="x", + GEMINI_API_KEY="g", ) - assert settings.PAPER_OVERSEAS_CASH == 100000.0 + 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="x", - KIS_APP_SECRET="x", + KIS_APP_KEY="k", + KIS_APP_SECRET="s", KIS_ACCOUNT_NO="12345678-01", - GEMINI_API_KEY="x", - PAPER_OVERSEAS_CASH=0.0, + GEMINI_API_KEY="g", ) assert settings.PAPER_OVERSEAS_CASH == 0.0 + del os.environ["PAPER_OVERSEAS_CASH"]