From f34117bc812effc40e3885b5680ea9bc0c3358dc Mon Sep 17 00:00:00 2001 From: agentson Date: Tue, 24 Feb 2026 05:59:06 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=95=B4=EC=99=B8=EC=9E=94=EA=B3=A0=20o?= =?UTF-8?q?rd=5Fpsbl=5Fqty=20=EC=9A=B0=EC=84=A0=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20ghost=20position=20SELL=20=EB=B0=98=EB=B3=B5=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20(#235)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _extract_held_codes_from_balance / _extract_held_qty_from_balance: 해외 잔고 수량 필드를 ovrs_cblc_qty(총 보유수량) → ord_psbl_qty(주문가능수량) 우선으로 변경. KIS 공식 문서(VTTS3012R) 확인 결과 ord_psbl_qty가 실제 매도 가능 수량이며, ovrs_cblc_qty는 만료/결제 미완료 포지션을 포함함. MLECW 등 만료된 Warrant는 ovrs_cblc_qty=289456이지만 ord_psbl_qty=0이라 startup sync 대상에서 제외되고 SELL 수량도 0이 됨. - trading_cycle: 해외 SELL이 '잔고내역이 없습니다'로 실패할 때 DB 포지션을 ghost-close SELL 로그로 닫아 무한 재시도 방지. exchange code 불일치 등 예외 상황에서 DB가 계속 open 상태로 남는 문제 해소. - docstring: _extract_held_qty_from_balance 해외 필드 설명 업데이트 Co-Authored-By: Claude Sonnet 4.6 --- src/main.py | 52 ++++++++++- tests/test_main.py | 222 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 268 insertions(+), 6 deletions(-) diff --git a/src/main.py b/src/main.py index 4ef0e03..a424be2 100644 --- a/src/main.py +++ b/src/main.py @@ -257,7 +257,15 @@ def _extract_held_codes_from_balance( if is_domestic: qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0) else: - qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0) + # ord_psbl_qty (주문가능수량) is the actual sellable quantity. + # ovrs_cblc_qty (해외잔고수량) includes unsettled/expired holdings + # that cannot actually be sold (e.g. expired warrants). + qty = int( + holding.get("ord_psbl_qty") + or holding.get("ovrs_cblc_qty") + or holding.get("hldg_qty") + or 0 + ) if qty > 0: codes.append(code) return codes @@ -280,10 +288,12 @@ def _extract_held_qty_from_balance( ord_psbl_qty — 주문가능수량 (preferred: excludes unsettled) hldg_qty — 보유수량 (fallback) - Overseas fields (output1): + Overseas fields (VTTS3012R / TTTS3012R output1): ovrs_pdno — 종목코드 - ovrs_cblc_qty — 해외잔고수량 (preferred) - hldg_qty — 보유수량 (fallback) + ord_psbl_qty — 주문가능수량 (preferred: actual sellable qty) + ovrs_cblc_qty — 해외잔고수량 (fallback: total holding, may include + unsettled or expired positions with ord_psbl_qty=0) + hldg_qty — 보유수량 (last-resort fallback) """ output1 = balance_data.get("output1", []) if isinstance(output1, dict): @@ -301,7 +311,12 @@ def _extract_held_qty_from_balance( if is_domestic: qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0) else: - qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0) + qty = int( + holding.get("ord_psbl_qty") + or holding.get("ovrs_cblc_qty") + or holding.get("hldg_qty") + or 0 + ) return qty return 0 @@ -908,6 +923,33 @@ async def trading_cycle( stock_code, _BUY_COOLDOWN_SECONDS, ) + # Close ghost position when broker has no matching balance. + # This prevents infinite SELL retry cycles for positions that + # exist in the DB (from startup sync) but are no longer + # sellable at the broker (expired warrants, delisted stocks, etc.) + if decision.action == "SELL" and "잔고내역이 없습니다" in msg1: + logger.warning( + "Ghost position detected for %s (%s): broker reports no balance." + " Closing DB position to prevent infinite retry.", + stock_code, + market.exchange_code, + ) + log_trade( + conn=db_conn, + stock_code=stock_code, + action="SELL", + confidence=0, + rationale=( + "[ghost-close] Broker reported no balance;" + " position closed without fill" + ), + quantity=0, + price=0.0, + pnl=0.0, + market=market.code, + exchange_code=market.exchange_code, + mode=settings.MODE if settings else "paper", + ) logger.info("Order result: %s", result.get("msg1", "OK")) # 5.5. Notify trade execution (only on success) diff --git a/tests/test_main.py b/tests/test_main.py index 4cd61c9..4f0a848 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -101,10 +101,24 @@ class TestExtractHeldQtyFromBalance: balance = {"output1": [], "output2": [{}]} assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 0 - def test_overseas_returns_ovrs_cblc_qty(self) -> None: + def test_overseas_returns_ord_psbl_qty_first(self) -> None: + """ord_psbl_qty (주문가능수량) takes priority over ovrs_cblc_qty.""" + balance = { + "output1": [{"ovrs_pdno": "AAPL", "ord_psbl_qty": "8", "ovrs_cblc_qty": "10"}] + } + assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 8 + + def test_overseas_fallback_to_ovrs_cblc_qty_when_ord_psbl_qty_absent(self) -> None: balance = {"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10"}]} assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 10 + def test_overseas_returns_zero_when_ord_psbl_qty_zero(self) -> None: + """Expired/delisted securities: ovrs_cblc_qty large but ord_psbl_qty=0.""" + balance = { + "output1": [{"ovrs_pdno": "MLECW", "ord_psbl_qty": "0", "ovrs_cblc_qty": "289456"}] + } + assert _extract_held_qty_from_balance(balance, "MLECW", is_domestic=False) == 0 + def test_overseas_fallback_to_hldg_qty(self) -> None: balance = {"output1": [{"ovrs_pdno": "AAPL", "hldg_qty": "4"}]} assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 4 @@ -147,6 +161,26 @@ class TestExtractHeldCodesFromBalance: result = _extract_held_codes_from_balance(balance, is_domestic=False) assert result == ["AAPL"] + def test_overseas_uses_ord_psbl_qty_to_filter(self) -> None: + """ord_psbl_qty=0 should exclude stock even if ovrs_cblc_qty is large.""" + balance = { + "output1": [ + {"ovrs_pdno": "MLECW", "ord_psbl_qty": "0", "ovrs_cblc_qty": "289456"}, + {"ovrs_pdno": "AAPL", "ord_psbl_qty": "5", "ovrs_cblc_qty": "5"}, + ] + } + result = _extract_held_codes_from_balance(balance, is_domestic=False) + assert "MLECW" not in result + assert "AAPL" in result + + def test_overseas_includes_stock_when_ord_psbl_qty_absent_and_ovrs_cblc_qty_positive( + self, + ) -> None: + """Fallback to ovrs_cblc_qty when ord_psbl_qty field is missing.""" + balance = {"output1": [{"ovrs_pdno": "TSLA", "ovrs_cblc_qty": "3"}]} + result = _extract_held_codes_from_balance(balance, is_domestic=False) + assert "TSLA" in result + class TestDetermineOrderQuantity: """Test _determine_order_quantity() — SELL uses broker_held_qty.""" @@ -4378,3 +4412,189 @@ class TestDomesticLimitOrderPrice: expected_price = kr_round_down(current_price * 0.998) assert call_kwargs["price"] == expected_price assert call_kwargs["order_type"] == "SELL" + + +# --------------------------------------------------------------------------- +# Ghost position — overseas SELL "잔고내역이 없습니다" handling +# --------------------------------------------------------------------------- + + +class TestOverseasGhostPositionClose: + """trading_cycle must close ghost DB position when broker returns 잔고없음.""" + + def _make_overseas_market(self) -> MagicMock: + market = MagicMock() + market.name = "NASDAQ" + market.code = "US_NASDAQ" + market.exchange_code = "NASD" + market.is_domestic = False + return market + + def _make_overseas_broker( + self, + current_price: float, + balance_data: dict, + sell_result: dict, + ) -> MagicMock: + ob = MagicMock() + ob.get_overseas_price = AsyncMock( + return_value={"output": {"last": str(current_price), "rate": "0.0"}} + ) + ob.get_overseas_balance = AsyncMock(return_value=balance_data) + ob.send_overseas_order = AsyncMock(return_value=sell_result) + return ob + + @pytest.mark.asyncio + async def test_ghost_position_closes_db_on_no_balance_error(self) -> None: + """When SELL fails with '잔고내역이 없습니다', log_trade is called to close the ghost. + + This can happen when exchange code recorded at startup differs from the + exchange code used in the SELL cycle (e.g. KNRX recorded as NASD but + actually traded on AMEX), causing the broker to see no matching balance. + The position has ord_psbl_qty > 0 (so a SELL is attempted), but KIS + rejects it with '잔고내역이 없습니다'. + """ + from src.strategy.models import ScenarioAction + + stock_code = "KNRX" + current_price = 1.5 + # ord_psbl_qty=5 means the code passes the qty check and a SELL is sent + balance_data = { + "output1": [ + {"ovrs_pdno": stock_code, "ord_psbl_qty": "5", "ovrs_cblc_qty": "5"} + ], + "output2": [{"tot_evlu_amt": "10000", "frcr_dncl_amt_2": "10000"}], + } + sell_result = {"rt_cd": "1", "msg1": "모의투자 잔고내역이 없습니다"} + + domestic_broker = MagicMock() + domestic_broker.get_balance = AsyncMock(return_value={"output1": [], "output2": [{}]}) + overseas_broker = self._make_overseas_broker(current_price, balance_data, sell_result) + market = self._make_overseas_market() + + sell_match = ScenarioMatch( + stock_code=stock_code, + matched_scenario=None, + action=ScenarioAction.SELL, + confidence=85, + rationale="test ghost KNRX", + ) + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=sell_match) + + risk = MagicMock() + risk.validate_order = MagicMock() + risk.check_circuit_breaker = MagicMock() + telegram = MagicMock() + telegram.notify_trade_execution = AsyncMock() + telegram.notify_fat_finger = AsyncMock() + telegram.notify_circuit_breaker = AsyncMock() + telegram.notify_scenario_matched = AsyncMock() + + db_conn = MagicMock() + + settings = MagicMock(spec=Settings) + settings.MODE = "paper" + settings.POSITION_SIZING_ENABLED = False + settings.PAPER_OVERSEAS_CASH = 0 + + with patch("src.main.log_trade") as mock_log_trade, patch( + "src.main.get_open_position", return_value=None + ), patch("src.main.get_latest_buy_trade", return_value=None): + await trading_cycle( + broker=domestic_broker, + overseas_broker=overseas_broker, + scenario_engine=engine, + playbook=_make_playbook(market="US_NASDAQ"), + risk=risk, + db_conn=db_conn, + decision_logger=MagicMock(), + context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)), + criticality_assessor=MagicMock( + assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")), + get_timeout=MagicMock(return_value=5.0), + ), + telegram=telegram, + market=market, + stock_code=stock_code, + scan_candidates={}, + settings=settings, + ) + + # log_trade must be called with action="SELL" to close the ghost position + ghost_close_calls = [ + c + for c in mock_log_trade.call_args_list + if c.kwargs.get("action") == "SELL" + and "[ghost-close]" in (c.kwargs.get("rationale") or "") + ] + assert ghost_close_calls, "Expected ghost-close log_trade call was not made" + + @pytest.mark.asyncio + async def test_normal_sell_failure_does_not_close_db(self) -> None: + """Non-잔고없음 SELL failures must NOT close the DB position.""" + from src.strategy.models import ScenarioAction + + stock_code = "TSLA" + current_price = 250.0 + balance_data = { + "output1": [{"ovrs_pdno": stock_code, "ord_psbl_qty": "5", "ovrs_cblc_qty": "5"}], + "output2": [{"tot_evlu_amt": "100000", "frcr_dncl_amt_2": "100000"}], + } + sell_result = {"rt_cd": "1", "msg1": "일시적 오류가 발생했습니다"} + + domestic_broker = MagicMock() + domestic_broker.get_balance = AsyncMock(return_value={"output1": [], "output2": [{}]}) + overseas_broker = self._make_overseas_broker(current_price, balance_data, sell_result) + market = self._make_overseas_market() + + sell_match = ScenarioMatch( + stock_code=stock_code, + matched_scenario=None, + action=ScenarioAction.SELL, + confidence=85, + rationale="test", + ) + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=sell_match) + + risk = MagicMock() + risk.validate_order = MagicMock() + risk.check_circuit_breaker = MagicMock() + telegram = MagicMock() + telegram.notify_trade_execution = AsyncMock() + telegram.notify_fat_finger = AsyncMock() + telegram.notify_circuit_breaker = AsyncMock() + telegram.notify_scenario_matched = AsyncMock() + + db_conn = MagicMock() + + with patch("src.main.log_trade") as mock_log_trade, patch( + "src.main.get_open_position", return_value=None + ): + await trading_cycle( + broker=domestic_broker, + overseas_broker=overseas_broker, + scenario_engine=engine, + playbook=_make_playbook(market="US_NASDAQ"), + risk=risk, + db_conn=db_conn, + decision_logger=MagicMock(), + context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)), + criticality_assessor=MagicMock( + assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")), + get_timeout=MagicMock(return_value=5.0), + ), + telegram=telegram, + market=market, + stock_code=stock_code, + scan_candidates={}, + ) + + ghost_close_calls = [ + c + for c in mock_log_trade.call_args_list + if c.kwargs.get("action") == "SELL" + and "[ghost-close]" in (c.kwargs.get("rationale") or "") + ] + assert not ghost_close_calls, "Ghost-close must NOT be triggered for non-잔고없음 errors"