Compare commits
8 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a063bd9d10 | ||
| 847456e0af | |||
|
|
a3a9fd1f24 | ||
|
|
f34117bc81 | ||
| 17e012cd04 | |||
|
|
a030dcc0dc | ||
|
|
d1698dee33 | ||
| 8a8ba3b0cb |
@@ -322,3 +322,36 @@ Order result: 모의투자 매수주문이 완료 되었습니다. ✓
|
||||
- `TestHandleDomesticPendingOrders` (4), `TestDomesticLimitOrderPrice` (2)
|
||||
|
||||
**이슈/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
|
||||
|
||||
@@ -111,6 +111,7 @@ def create_dashboard_app(db_path: str) -> FastAPI:
|
||||
|
||||
return {
|
||||
"date": today,
|
||||
"mode": os.getenv("MODE", "paper"),
|
||||
"markets": market_status,
|
||||
"totals": {
|
||||
"trade_count": total_trades,
|
||||
|
||||
@@ -43,6 +43,19 @@
|
||||
font-size: 12px; transition: border-color 0.2s;
|
||||
}
|
||||
.refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.mode-badge {
|
||||
padding: 3px 10px; border-radius: 5px; font-size: 12px; font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.mode-badge.live {
|
||||
background: rgba(224, 85, 85, 0.15); color: var(--red);
|
||||
border: 1px solid rgba(224, 85, 85, 0.4);
|
||||
animation: pulse-warn 2s ease-in-out infinite;
|
||||
}
|
||||
.mode-badge.paper {
|
||||
background: rgba(232, 160, 64, 0.15); color: var(--warn);
|
||||
border: 1px solid rgba(232, 160, 64, 0.4);
|
||||
}
|
||||
|
||||
/* CB Gauge */
|
||||
.cb-gauge-wrap {
|
||||
@@ -225,6 +238,7 @@
|
||||
<header>
|
||||
<h1>🐍 The Ouroboros</h1>
|
||||
<div class="header-right">
|
||||
<span class="mode-badge" id="mode-badge">--</span>
|
||||
<div class="cb-gauge-wrap" id="cb-gauge" title="Circuit Breaker">
|
||||
<span class="cb-dot unknown" id="cb-dot"></span>
|
||||
<span id="cb-label">CB --</span>
|
||||
@@ -512,9 +526,22 @@
|
||||
}
|
||||
document.getElementById('card-pnl-sub').textContent = `결정 ${t.decision_count ?? 0}건`;
|
||||
renderCbGauge(d.circuit_breaker);
|
||||
renderModeBadge(d.mode);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function renderModeBadge(mode) {
|
||||
const el = document.getElementById('mode-badge');
|
||||
if (!el) return;
|
||||
if (mode === 'live') {
|
||||
el.textContent = '🔴 실전투자';
|
||||
el.className = 'mode-badge live';
|
||||
} else {
|
||||
el.textContent = '🟡 모의투자';
|
||||
el.className = 'mode-badge paper';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPerformance() {
|
||||
try {
|
||||
const r = await fetch('/api/performance?market=all');
|
||||
|
||||
52
src/main.py
52
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)
|
||||
|
||||
@@ -413,3 +413,30 @@ def test_status_circuit_breaker_unknown_when_no_data(tmp_path: Path) -> None:
|
||||
cb = body["circuit_breaker"]
|
||||
assert cb["status"] == "unknown"
|
||||
assert cb["current_pnl_pct"] is None
|
||||
|
||||
|
||||
def test_status_mode_paper(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""MODE=paper일 때 status 응답에 mode=paper가 포함돼야 한다."""
|
||||
monkeypatch.setenv("MODE", "paper")
|
||||
app = _app(tmp_path)
|
||||
get_status = _endpoint(app, "/api/status")
|
||||
body = get_status()
|
||||
assert body["mode"] == "paper"
|
||||
|
||||
|
||||
def test_status_mode_live(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""MODE=live일 때 status 응답에 mode=live가 포함돼야 한다."""
|
||||
monkeypatch.setenv("MODE", "live")
|
||||
app = _app(tmp_path)
|
||||
get_status = _endpoint(app, "/api/status")
|
||||
body = get_status()
|
||||
assert body["mode"] == "live"
|
||||
|
||||
|
||||
def test_status_mode_default_paper(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""MODE 환경변수가 없으면 mode 기본값은 paper여야 한다."""
|
||||
monkeypatch.delenv("MODE", raising=False)
|
||||
app = _app(tmp_path)
|
||||
get_status = _endpoint(app, "/api/status")
|
||||
body = get_status()
|
||||
assert body["mode"] == "paper"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user