fix: 해외잔고 ord_psbl_qty 우선 적용 및 ghost position SELL 반복 방지 (#235) #236
@@ -322,3 +322,36 @@ Order result: 모의투자 매수주문이 완료 되었습니다. ✓
|
|||||||
- `TestHandleDomesticPendingOrders` (4), `TestDomesticLimitOrderPrice` (2)
|
- `TestHandleDomesticPendingOrders` (4), `TestDomesticLimitOrderPrice` (2)
|
||||||
|
|
||||||
**이슈/PR:** #232, PR #233
|
**이슈/PR:** #232, PR #233
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-02-24
|
||||||
|
|
||||||
|
### 해외잔고 ghost position 수정 — '모의투자 잔고내역이 없습니다' 반복 방지 (#235)
|
||||||
|
|
||||||
|
**배경:**
|
||||||
|
- 모의투자 실행 시 MLECW, KNRX, NBY, SNSE 등 만료/정지된 종목에 대해
|
||||||
|
`모의투자 잔고내역이 없습니다` 오류가 매 사이클 반복됨
|
||||||
|
|
||||||
|
**근본 원인:**
|
||||||
|
1. `ovrs_cblc_qty` (해외잔고수량, 총 보유) vs `ord_psbl_qty` (주문가능수량, 실제 매도 가능)
|
||||||
|
- 기존 코드: `ovrs_cblc_qty` 우선 사용 → 만료 Warrant가 `ovrs_cblc_qty=289456`이지만 실제 `ord_psbl_qty=0`
|
||||||
|
- startup sync / build_overseas_symbol_universe가 이 종목들을 포지션으로 기록
|
||||||
|
2. SELL 실패 시 DB 포지션이 닫히지 않아 다음 사이클에서도 재시도 (무한 반복)
|
||||||
|
|
||||||
|
**구현 내용:**
|
||||||
|
|
||||||
|
1. `src/main.py` — `_extract_held_codes_from_balance`, `_extract_held_qty_from_balance`
|
||||||
|
- 해외 잔고 필드 우선순위 변경: `ord_psbl_qty` → `ovrs_cblc_qty` → `hldg_qty` (fallback 유지)
|
||||||
|
- KIS 공식 문서(VTTS3012R) 기준: `ord_psbl_qty`가 실제 매도 가능 수량
|
||||||
|
|
||||||
|
2. `src/main.py` — `trading_cycle` ghost-close 처리
|
||||||
|
- 해외 SELL이 `잔고내역이 없습니다`로 실패 시 DB 포지션을 `[ghost-close]` SELL로 종료
|
||||||
|
- exchange code 불일치 등 예외 상황에서 무한 반복 방지
|
||||||
|
|
||||||
|
3. 테스트 7개 추가:
|
||||||
|
- `TestExtractHeldQtyFromBalance` 3개: ord_psbl_qty 우선, 0이면 0 반환, fallback
|
||||||
|
- `TestExtractHeldCodesFromBalance` 2개: ord_psbl_qty=0인 종목 제외, fallback
|
||||||
|
- `TestOverseasGhostPositionClose` 2개: ghost-close 로그 확인, 일반 오류 무시
|
||||||
|
|
||||||
|
**이슈/PR:** #235, PR #236
|
||||||
|
|||||||
52
src/main.py
52
src/main.py
@@ -257,7 +257,15 @@ def _extract_held_codes_from_balance(
|
|||||||
if is_domestic:
|
if is_domestic:
|
||||||
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
|
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
|
||||||
else:
|
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:
|
if qty > 0:
|
||||||
codes.append(code)
|
codes.append(code)
|
||||||
return codes
|
return codes
|
||||||
@@ -280,10 +288,12 @@ def _extract_held_qty_from_balance(
|
|||||||
ord_psbl_qty — 주문가능수량 (preferred: excludes unsettled)
|
ord_psbl_qty — 주문가능수량 (preferred: excludes unsettled)
|
||||||
hldg_qty — 보유수량 (fallback)
|
hldg_qty — 보유수량 (fallback)
|
||||||
|
|
||||||
Overseas fields (output1):
|
Overseas fields (VTTS3012R / TTTS3012R output1):
|
||||||
ovrs_pdno — 종목코드
|
ovrs_pdno — 종목코드
|
||||||
ovrs_cblc_qty — 해외잔고수량 (preferred)
|
ord_psbl_qty — 주문가능수량 (preferred: actual sellable qty)
|
||||||
hldg_qty — 보유수량 (fallback)
|
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", [])
|
output1 = balance_data.get("output1", [])
|
||||||
if isinstance(output1, dict):
|
if isinstance(output1, dict):
|
||||||
@@ -301,7 +311,12 @@ def _extract_held_qty_from_balance(
|
|||||||
if is_domestic:
|
if is_domestic:
|
||||||
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
|
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
|
||||||
else:
|
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 qty
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -908,6 +923,33 @@ async def trading_cycle(
|
|||||||
stock_code,
|
stock_code,
|
||||||
_BUY_COOLDOWN_SECONDS,
|
_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"))
|
logger.info("Order result: %s", result.get("msg1", "OK"))
|
||||||
|
|
||||||
# 5.5. Notify trade execution (only on success)
|
# 5.5. Notify trade execution (only on success)
|
||||||
|
|||||||
@@ -101,10 +101,24 @@ class TestExtractHeldQtyFromBalance:
|
|||||||
balance = {"output1": [], "output2": [{}]}
|
balance = {"output1": [], "output2": [{}]}
|
||||||
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 0
|
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"}]}
|
balance = {"output1": [{"ovrs_pdno": "AAPL", "ovrs_cblc_qty": "10"}]}
|
||||||
assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 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:
|
def test_overseas_fallback_to_hldg_qty(self) -> None:
|
||||||
balance = {"output1": [{"ovrs_pdno": "AAPL", "hldg_qty": "4"}]}
|
balance = {"output1": [{"ovrs_pdno": "AAPL", "hldg_qty": "4"}]}
|
||||||
assert _extract_held_qty_from_balance(balance, "AAPL", is_domestic=False) == 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)
|
result = _extract_held_codes_from_balance(balance, is_domestic=False)
|
||||||
assert result == ["AAPL"]
|
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:
|
class TestDetermineOrderQuantity:
|
||||||
"""Test _determine_order_quantity() — SELL uses broker_held_qty."""
|
"""Test _determine_order_quantity() — SELL uses broker_held_qty."""
|
||||||
@@ -4378,3 +4412,189 @@ class TestDomesticLimitOrderPrice:
|
|||||||
expected_price = kr_round_down(current_price * 0.998)
|
expected_price = kr_round_down(current_price * 0.998)
|
||||||
assert call_kwargs["price"] == expected_price
|
assert call_kwargs["price"] == expected_price
|
||||||
assert call_kwargs["order_type"] == "SELL"
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user