Compare commits

..

9 Commits

Author SHA1 Message Date
agentson
988a56c07c fix: KR 등락률순위 API 파라미터 오류 수정 — 스캐너 미동작 해결 (#240)
Some checks failed
CI / test (pull_request) Has been cancelled
실전 API가 fid_rank_sort_cls_code='0000'(4자리)를 거부함.
'0'(1자리)으로 수정하고, 실전 응답의 종목코드 키가
mksc_shrn_iscd 대신 stck_shrn_iscd임을 반영하여 파싱 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 09:15:40 +09:00
c9f1345e3c Merge pull request 'fix: 대시보드 mode 배지 os.getenv 대신 settings.MODE 사용 (#237)' (#239) from feature/issue-237-dashboard-mode-badge-fix into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #239
2026-02-24 06:52:29 +09:00
agentson
8c492eae3a fix: 대시보드 mode 배지 os.getenv 대신 settings.MODE 사용 (#237)
Some checks failed
CI / test (pull_request) Has been cancelled
os.getenv("MODE")는 .env 파일을 읽지 못해 항상 paper를 반환함.
create_dashboard_app에 mode 파라미터 추가 후 main.py에서
settings.MODE를 직접 전달하도록 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 06:52:10 +09:00
271c592a46 Merge pull request 'feat: 대시보드 헤더에 모의투자/실전투자 모드 배지 표시 (#237)' (#238) from feature/issue-237-dashboard-mode-badge into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #238
2026-02-24 06:49:21 +09:00
agentson
a063bd9d10 feat: 대시보드 헤더에 모의투자/실전투자 모드 배지 표시 (#237)
Some checks failed
CI / test (pull_request) Has been cancelled
- /api/status 응답에 MODE 환경변수 기반 mode 필드 추가
- 대시보드 헤더에 모드 배지 표시 (live=빨간색 깜빡임, paper=노란색)
- 모드 관련 테스트 3개 추가 (total 26 passed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 06:48:22 +09:00
847456e0af Merge pull request 'fix: 해외잔고 ord_psbl_qty 우선 적용 및 ghost position SELL 반복 방지 (#235)' (#236) from feature/issue-235-overseas-balance-ord-psbl-qty into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #236
2026-02-24 06:08:31 +09:00
agentson
a3a9fd1f24 docs: requirements-log에 #235 ghost position 수정 기록 추가
Some checks failed
CI / test (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 05:59:58 +09:00
agentson
f34117bc81 fix: 해외잔고 ord_psbl_qty 우선 적용 및 ghost position SELL 반복 방지 (#235)
Some checks failed
CI / test (pull_request) Has been cancelled
- _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 <noreply@anthropic.com>
2026-02-24 05:59:06 +09:00
17e012cd04 Merge pull request 'feat: 국내주식 지정가 전환 및 미체결 처리 (#232)' (#234) from feature/issue-232-domestic-limit-order-pending into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #234
2026-02-23 22:03:40 +09:00
8 changed files with 393 additions and 10 deletions

View File

@@ -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

View File

@@ -430,7 +430,7 @@ class KISBroker:
"fid_cond_mrkt_div_code": "J",
"fid_cond_scr_div_code": "20170",
"fid_input_iscd": "0000",
"fid_rank_sort_cls_code": "0000",
"fid_rank_sort_cls_code": "0",
"fid_input_cnt_1": str(limit),
"fid_prc_cls_code": "0",
"fid_input_price_1": "0",
@@ -466,7 +466,7 @@ class KISBroker:
rankings = []
for item in data.get("output", [])[:limit]:
rankings.append({
"stock_code": item.get("mksc_shrn_iscd", ""),
"stock_code": item.get("stck_shrn_iscd") or item.get("mksc_shrn_iscd", ""),
"name": item.get("hts_kor_isnm", ""),
"price": _safe_float(item.get("stck_prpr", "0")),
"volume": _safe_float(item.get("acml_vol", "0")),

View File

@@ -13,10 +13,11 @@ from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse
def create_dashboard_app(db_path: str) -> FastAPI:
def create_dashboard_app(db_path: str, mode: str = "paper") -> FastAPI:
"""Create dashboard FastAPI app bound to a SQLite database path."""
app = FastAPI(title="The Ouroboros Dashboard", version="1.0.0")
app.state.db_path = db_path
app.state.mode = mode
@app.get("/")
def index() -> FileResponse:
@@ -111,6 +112,7 @@ def create_dashboard_app(db_path: str) -> FastAPI:
return {
"date": today,
"mode": mode,
"markets": market_status,
"totals": {
"trade_count": total_trades,

View File

@@ -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>&#x1F40D; 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');

View File

@@ -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)
@@ -2003,7 +2045,7 @@ def _start_dashboard_server(settings: Settings) -> threading.Thread | None:
import uvicorn
from src.dashboard import create_dashboard_app
app = create_dashboard_app(settings.DB_PATH)
app = create_dashboard_app(settings.DB_PATH, mode=settings.MODE)
uvicorn.run(
app,
host=settings.DASHBOARD_HOST,

View File

@@ -354,6 +354,8 @@ class TestFetchMarketRankings:
assert "ranking/fluctuation" in url
assert headers.get("tr_id") == "FHPST01700000"
assert params.get("fid_cond_scr_div_code") == "20170"
# 실전 API는 4자리("0000") 거부 — 1자리("0")여야 한다 (#240)
assert params.get("fid_rank_sort_cls_code") == "0"
@pytest.mark.asyncio
async def test_volume_returns_parsed_rows(self, broker: KISBroker) -> None:
@@ -376,6 +378,27 @@ class TestFetchMarketRankings:
assert result[0]["price"] == 75000.0
assert result[0]["change_rate"] == 2.5
@pytest.mark.asyncio
async def test_fluctuation_parses_stck_shrn_iscd(self, broker: KISBroker) -> None:
"""실전 API는 mksc_shrn_iscd 대신 stck_shrn_iscd를 반환한다 (#240)."""
items = [
{
"stck_shrn_iscd": "015260",
"hts_kor_isnm": "에이엔피",
"stck_prpr": "794",
"acml_vol": "4896196",
"prdy_ctrt": "29.74",
"vol_inrt": "0",
}
]
mock_resp = _make_ranking_mock(items)
with patch("aiohttp.ClientSession.get", return_value=mock_resp):
result = await broker.fetch_market_rankings(ranking_type="fluctuation")
assert len(result) == 1
assert result[0]["stock_code"] == "015260"
assert result[0]["change_rate"] == 29.74
# ---------------------------------------------------------------------------
# KRX tick unit / round-down helpers (issue #157)

View File

@@ -413,3 +413,39 @@ 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) -> None:
"""mode=paper로 생성하면 status 응답에 mode=paper가 포함돼야 한다."""
db_path = tmp_path / "dashboard_test.db"
conn = init_db(str(db_path))
_seed_db(conn)
conn.close()
app = create_dashboard_app(str(db_path), mode="paper")
get_status = _endpoint(app, "/api/status")
body = get_status()
assert body["mode"] == "paper"
def test_status_mode_live(tmp_path: Path) -> None:
"""mode=live로 생성하면 status 응답에 mode=live가 포함돼야 한다."""
db_path = tmp_path / "dashboard_test.db"
conn = init_db(str(db_path))
_seed_db(conn)
conn.close()
app = create_dashboard_app(str(db_path), mode="live")
get_status = _endpoint(app, "/api/status")
body = get_status()
assert body["mode"] == "live"
def test_status_mode_default_paper(tmp_path: Path) -> None:
"""mode 파라미터 미전달 시 기본값은 paper여야 한다."""
db_path = tmp_path / "dashboard_test.db"
conn = init_db(str(db_path))
_seed_db(conn)
conn.close()
app = create_dashboard_app(str(db_path))
get_status = _endpoint(app, "/api/status")
body = get_status()
assert body["mode"] == "paper"

View File

@@ -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"