Compare commits

..

3 Commits

Author SHA1 Message Date
425218c3be Merge pull request 'feat: 국내주식 지정가 전환 및 미체결 처리 (#232)' (#233) from feature/issue-232-domestic-limit-order-pending into feature/issue-229-overseas-pending-order-handling
Reviewed-on: #233
2026-02-23 22:00:46 +09:00
agentson
b4b09a6d4c docs: requirements-log에 #232 국내주식 지정가 전환 기록
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:45:49 +09:00
agentson
66c35da7f1 feat: 국내주식 지정가 전환 및 미체결 처리 (#232)
- KISBroker에 get_domestic_pending_orders (TTTC0084R, 실전전용)
  및 cancel_domestic_order (실전 TTTC0013U / 모의 VTTC0013U) 추가
- main.py 국내 주문 price=0 → 지정가 전환 (2곳):
  · BUY +0.2% / SELL -0.2%, kr_round_down으로 KRX 틱 반올림 적용
- handle_domestic_pending_orders 함수 추가:
  · BUY 미체결 → 취소 + buy_cooldown 설정
  · SELL 미체결 → 취소 후 -0.4% 재주문 (최대 1회)
- daily/realtime 두 모드 market 루프 내 domestic pending 호출 추가
  (sell_resubmit_counts는 해외용과 공유, key prefix "KR:" vs 거래소코드)
- 테스트 14개 추가:
  · test_broker.py: TestGetDomesticPendingOrders 3개 + TestCancelDomesticOrder 5개
  · test_main.py: TestHandleDomesticPendingOrders 4개 + TestDomesticLimitOrderPrice 2개

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:44:58 +09:00
14 changed files with 16 additions and 417 deletions

View File

@@ -322,36 +322,3 @@ 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

@@ -346,10 +346,8 @@ class GeminiClient:
# Validate required fields
if not all(k in data for k in ("action", "confidence", "rationale")):
logger.warning("Missing fields in Gemini response — defaulting to HOLD")
# Preserve raw text in rationale so prompt_override callers (e.g. pre_market_planner)
# can extract their own JSON format from decision.rationale (#245)
return TradeDecision(
action="HOLD", confidence=0, rationale=raw
action="HOLD", confidence=0, rationale="Missing required fields"
)
action = str(data["action"]).upper()

View File

@@ -179,8 +179,8 @@ class PromptOptimizer:
# Minimal instructions
prompt = (
f"{market_name} trader. Analyze:\n{data_str}\n\n"
'Return JSON: {"action":"BUY"|"SELL"|"HOLD","confidence":<0-100>,"rationale":"<text>"}\n'
"Rules: action=BUY/SELL/HOLD, confidence=0-100, rationale=concise. No markdown."
'Return JSON: {"act":"BUY"|"SELL"|"HOLD","conf":<0-100>,"reason":"<text>"}\n'
"Rules: act=BUY/SELL/HOLD, conf=0-100, reason=concise. No markdown."
)
else:
# Data only (for cached contexts where instructions are known)

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": "0",
"fid_rank_sort_cls_code": "0000",
"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("stck_shrn_iscd") or item.get("mksc_shrn_iscd", ""),
"stock_code": 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

@@ -133,7 +133,7 @@ class OverseasBroker:
"AUTH": "",
"EXCD": ranking_excd,
"NDAY": "0",
"GUBN": "0", # 0=전체(상승+하락), 1=상승만 — 변동성 스캐너는 전체 필요
"GUBN": "1",
"VOL_RANG": "0",
}

View File

@@ -13,11 +13,10 @@ from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse
def create_dashboard_app(db_path: str, mode: str = "paper") -> FastAPI:
def create_dashboard_app(db_path: str) -> 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:
@@ -112,7 +111,6 @@ def create_dashboard_app(db_path: str, mode: str = "paper") -> FastAPI:
return {
"date": today,
"mode": mode,
"markets": market_status,
"totals": {
"trade_count": total_trades,

View File

@@ -43,19 +43,6 @@
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 {
@@ -238,7 +225,6 @@
<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>
@@ -526,22 +512,9 @@
}
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,15 +257,7 @@ def _extract_held_codes_from_balance(
if is_domestic:
qty = int(holding.get("ord_psbl_qty") or holding.get("hldg_qty") or 0)
else:
# 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
)
qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0)
if qty > 0:
codes.append(code)
return codes
@@ -288,12 +280,10 @@ def _extract_held_qty_from_balance(
ord_psbl_qty — 주문가능수량 (preferred: excludes unsettled)
hldg_qty — 보유수량 (fallback)
Overseas fields (VTTS3012R / TTTS3012R output1):
Overseas fields (output1):
ovrs_pdno — 종목코드
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)
ovrs_cblc_qty — 해외잔고수량 (preferred)
hldg_qty — 보유수량 (fallback)
"""
output1 = balance_data.get("output1", [])
if isinstance(output1, dict):
@@ -311,12 +301,7 @@ 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("ord_psbl_qty")
or holding.get("ovrs_cblc_qty")
or holding.get("hldg_qty")
or 0
)
qty = int(holding.get("ovrs_cblc_qty") or holding.get("hldg_qty") or 0)
return qty
return 0
@@ -923,33 +908,6 @@ 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)
@@ -2045,7 +2003,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, mode=settings.MODE)
app = create_dashboard_app(settings.DB_PATH)
uvicorn.run(
app,
host=settings.DASHBOARD_HOST,

View File

@@ -93,21 +93,9 @@ class TestMalformedJsonHandling:
def test_json_with_missing_fields_returns_hold(self, settings):
client = GeminiClient(settings)
raw = '{"action": "BUY"}'
decision = client.parse_response(raw)
decision = client.parse_response('{"action": "BUY"}')
assert decision.action == "HOLD"
assert decision.confidence == 0
# rationale preserves raw so prompt_override callers (e.g. pre_market_planner)
# can extract non-TradeDecision JSON from decision.rationale (#245)
assert decision.rationale == raw
def test_non_trade_decision_json_preserves_raw_in_rationale(self, settings):
"""Playbook JSON (no action/confidence/rationale) must be preserved for planner."""
client = GeminiClient(settings)
playbook_json = '{"market_outlook": "neutral", "stocks": []}'
decision = client.parse_response(playbook_json)
assert decision.action == "HOLD"
assert decision.rationale == playbook_json
def test_json_with_invalid_action_returns_hold(self, settings):
client = GeminiClient(settings)

View File

@@ -354,8 +354,6 @@ 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:
@@ -378,27 +376,6 @@ 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,39 +413,3 @@ 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,24 +101,10 @@ class TestExtractHeldQtyFromBalance:
balance = {"output1": [], "output2": [{}]}
assert _extract_held_qty_from_balance(balance, "005930", is_domestic=True) == 0
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:
def test_overseas_returns_ovrs_cblc_qty(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
@@ -161,26 +147,6 @@ 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."""
@@ -4412,189 +4378,3 @@ 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"

View File

@@ -124,7 +124,7 @@ class TestFetchOverseasRankings:
assert "/uapi/overseas-stock/v1/ranking/updown-rate" in url
assert params["EXCD"] == "NAS"
assert params["NDAY"] == "0"
assert params["GUBN"] == "0" # 0=전체(상승+하락), 변동성 스캐너에 필요
assert params["GUBN"] == "1"
assert params["VOL_RANG"] == "0"
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76290000")

View File

@@ -124,10 +124,6 @@ class TestPromptOptimizer:
assert len(prompt) < 300
assert "005930" in prompt
assert "75000" in prompt
# Keys must match parse_response expectations (#242)
assert '"action"' in prompt
assert '"confidence"' in prompt
assert '"rationale"' in prompt
def test_build_compressed_prompt_no_instructions(self):
"""Test compressed prompt without instructions."""