Files
auto-stock/backend/tests/test_swing_r5_pending_sell_20261002.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

475 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""스윙 5·6회차 — 결과 불명 매도·DT 물량 과다매도 방지 회귀 테스트 (2026-10-02).
모든 시나리오는 상태를 가진 KIS 시뮬레이터(보유/주문가능 수량이 주문에 따라 실제로 줄어듦)
위에서 **3주기 이상** 돌린다. 5회차 수정은 확인 대기를 1주기만 보호해 3주기째 DT 물량을 팔았다
(6회차 C-1 재현 ①②). 6회차 수정:
(c) 국내 매도 직전 항상 KIS 실보유 확인, 상한 = min(주문가능, 보유) − DT 인메모리
(a) 결과 불명 체결분을 KIS 보유 감소로 확인해 장부(TradeHistory·reflect_sell·ActiveTrade·
loss_tracker) 반영 후 그 주기 skip
(b) 결과 불명 직후 Portfolio sync 요청
규약(CLAUDE.md §4.1): 주문 목은 공통 종단(safe_place_order). 기대값은 settings.*.
"""
import asyncio
import logging
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
import app.services.mean_reversion_strategy as mr
from app.core.config import settings
from app.services.mean_reversion_strategy import ExitSignal
UNKNOWN = {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "ReadTimeout", "odno": None}
OK = {"rt_cd": "0", "output": {"ODNO": "9"}}
_FILL_CHECKS: list = []
async def _record_fill_check(order_id, stock_code, stock_name, quantity, price, side, *args, **kwargs):
"""체결 확인 종단 기록형 목 — 즉시 완료(대기·KIS 조회 없음)."""
_FILL_CHECKS.append({"order_id": order_id, "stock_code": stock_code, "quantity": quantity,
"side": side, "kwargs": kwargs})
@pytest.fixture(autouse=True)
def _reset(monkeypatch):
import app.services.swing.exit_service as es
monkeypatch.setattr(es, "_pending_unknown_sells", {})
monkeypatch.setattr(es, "_last_live_prices", {})
monkeypatch.setattr(es, "_last_atr", {})
monkeypatch.setattr(es, "_presell_check_failures", {})
monkeypatch.setattr(es, "_no_price_skips", {})
from app.core import log_throttle
log_throttle.reset_for_tests()
import app.services.adaptive_strategy as ad
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True))
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None)
import app.services.trade_executor as te
monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock())
# [하드닝 R2 2026-10-09] 성공한 시장가 매도는 _schedule_swing_sell_fill_check_v2 →
# spawn_tracked_task_v2(_verify_and_sync_execution_v3(..., delay=3)) 로 **실제** 체결 확인
# 코루틴을 띄운다. 목이 없으면 테스트 종료 후 pending 으로 남고(“Task was destroyed but it is
# pending”), 3초가 지나면 get_order_execution(KIS)·enqueue_update_trade_history 를 호출해
# conftest 차단에만 의존했다(§4.1 위반). 공통 종단(_verify_and_sync_execution_v3)에 기록형 목.
_FILL_CHECKS.clear()
monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _record_fill_check)
class _Sim:
"""KIS 계좌 + 앱 장부 시뮬레이터."""
def __init__(self, monkeypatch, *, swing, dt, price=8000, avg=10000, signal=None,
responses=None, fill=None, balance_fail=False):
import app.services.swing.exit_service as es
import app.services.trade_executor as te
import app.services.db_worker as dbw
import app.services.portfolio_cache_bridge as pcb
from app.core.timeutil import now_kst
from app.services.daytrading.position_manager import day_trading_state
self.es = es
self.kis = {"hldg": swing + dt, "psbl": swing + dt}
self.sent = [] # 브로커에 실제 도달한 주문 수량
self.history = [] # enqueue_trade_history payload
self.closed = 0
self.balance_fail = balance_fail
self.responses = list(responses or [])
self.fill = fill # 주문별 실제 체결 수량 함수(q, n) → filled
# 장부: Portfolio 행(sync 없음 — reflect_sell 이 장부 수량만 줄인다)
self.holding = SimpleNamespace(stock_code="005930", stock_name="T", quantity=swing + dt,
average_price=avg, current_price=price, market="KRX",
updated_at=now_kst())
self.at = SimpleNamespace(strategy_name="swing", remaining_pct=1.0, highest_since_entry=None,
created_at=now_kst() - timedelta(days=1), entry_reason=None)
monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(side_effect=lambda db: [self.holding]))
monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"}))
monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(side_effect=lambda db, c: self.at))
api = MagicMock()
api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(price)}})
bars = [{"stck_clpr": str(avg), "stck_hgpr": str(avg + 50), "stck_lwpr": str(avg - 50)}
for _ in range(25)]
api.get_daily_chart = AsyncMock(return_value={"output2": bars if signal else []})
api.get_balance = AsyncMock(side_effect=self._balance)
self.api = api
monkeypatch.setattr(es, "korea_investment_api", api)
if signal is not None:
monkeypatch.setattr(es, "check_exit_signal", lambda **kw: signal(kw["remaining_pct"]))
monkeypatch.setattr(day_trading_state, "positions", {"005930": {"quantity": dt}} if dt else {})
monkeypatch.setattr(day_trading_state, "save_daily_state", AsyncMock())
monkeypatch.setattr(te, "safe_place_order", self._order)
monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock(side_effect=lambda p: self.history.append(p)))
async def _close(code, strategy_name=None):
self.closed += 1
self.at = None
monkeypatch.setattr(es, "enqueue_close_active_trade", _close)
async def _upsert(p):
if self.at is not None and "remaining_pct" in p:
self.at.remaining_pct = p["remaining_pct"]
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _upsert)
async def _reflect(code, q, price, remaining_qty=None):
self.holding.quantity = max(0, self.holding.quantity - q)
monkeypatch.setattr(pcb, "reflect_sell", _reflect)
self.loss = AsyncMock()
monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", self.loss)
monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock())
async def _balance(self):
if self.balance_fail:
raise RuntimeError("KIS down")
return {"rt_cd": "0", "output1": [{"pdno": "005930", "hldg_qty": str(self.kis["hldg"]),
"ord_psbl_qty": str(self.kis["psbl"])}]}
async def _order(self, **kw):
q = kw["quantity"]
if q > self.kis["psbl"]:
return {"rt_cd": "1", "msg_cd": "APBK0013", "msg1": "주문가능수량 초과"}
n = len(self.sent)
filled = self.fill(q, n) if self.fill else q
self.kis["hldg"] -= filled
self.kis["psbl"] -= filled
self.sent.append(filled)
return self.responses[n] if n < len(self.responses) else OK
async def cycles(self, n):
for _ in range(n):
self.es._recent_exits.clear()
await self.es.check_and_execute_exit_v2(MagicMock())
def _partial_signal(remaining):
return ExitSignal("SELL_PARTIAL", "1차 익절", 0.5) if remaining > 0.5 else None
# ── C-1 재현 ① : 손절 전량 불명(체결) → 3주기째 DT 물량 매도 ────────────────
@pytest.mark.asyncio
async def test_c1_unknown_full_stop_never_sells_dt_shares_over_4_cycles(monkeypatch):
"""[재현 반전] 스윙 10 + DT 10, 손절 10주 결과 불명(실제 체결). 이전: 3주기째 DT 10주 매도
(합계 20). 이제: 2주기에 체결분 장부 반영(COMPLETED·close·loss_tracker), 이후 KIS 상한 0."""
sim = _Sim(monkeypatch, swing=10, dt=10, responses=[UNKNOWN])
await sim.cycles(4)
assert sum(sim.sent) == 10 and sim.kis["hldg"] == 10 # DT 10주 보존
done = [h for h in sim.history if h["status"] == "COMPLETED" and h["executed_quantity"] == 10]
assert len(done) == 1 and "체결 확인" in done[0]["exit_reason"]
assert sim.closed == 1
sim.loss.assert_awaited_once()
assert "005930" not in sim.es._pending_unknown_sells
# ── C-1 재현 ② : 부분익절 불명(체결) → 같은 단계 재발동 → 전량 매도 ────────
@pytest.mark.asyncio
async def test_c1_unknown_partial_does_not_refire_stage_over_4_cycles(monkeypatch):
"""[재현 반전] 스윙 20 + DT 5, 50% 익절 10주 불명(체결). 이전: remaining_pct 미갱신으로
같은 단계 재발동 → 25주 전량. 이제: 대조 시 remaining_pct=0.5 기록 → 재발동 없음."""
sim = _Sim(monkeypatch, swing=20, dt=5, price=11500, signal=_partial_signal, responses=[UNKNOWN])
await sim.cycles(4)
assert sum(sim.sent) == 10
assert sim.kis["hldg"] == 15
assert sim.at.remaining_pct == 0.5
assert sim.closed == 0
sim.loss.assert_not_awaited() # 분할매도는 loss_tracker 기록 대상 아님
@pytest.mark.asyncio
async def test_c1_unknown_not_filled_resells_once(monkeypatch):
sim = _Sim(monkeypatch, swing=10, dt=5, responses=[UNKNOWN], fill=lambda q, n: 0 if n == 0 else q)
await sim.cycles(3)
assert sim.sent == [0, 10] # 1주기 미체결 → 2주기 재매도(스윙 10주만)
assert sim.kis["hldg"] == 5
@pytest.mark.asyncio
async def test_c1_unknown_partial_fill_reconciled_then_remainder_sold(monkeypatch):
sim = _Sim(monkeypatch, swing=10, dt=10, responses=[UNKNOWN], fill=lambda q, n: 3 if n == 0 else q)
await sim.cycles(4)
assert sim.sent == [3, 7]
assert sim.kis["hldg"] == 10 # DT 10주 보존
assert any(h["executed_quantity"] == 3 and "체결 확인" in h["exit_reason"] for h in sim.history)
@pytest.mark.asyncio
async def test_c1_open_order_at_broker_blocks_resell(monkeypatch):
"""불명 주문이 브로커에 미체결로 남음(보유 불변·주문가능 감소) → 대기 유지, 상한 0."""
sim = _Sim(monkeypatch, swing=10, dt=10, responses=[UNKNOWN], fill=lambda q, n: 0)
orig = sim._order
async def _open(**kw):
r = await orig(**kw)
sim.kis["psbl"] -= kw["quantity"] # 미체결 주문분이 주문가능에서 빠짐
return r
import app.services.trade_executor as te
monkeypatch.setattr(te, "safe_place_order", _open)
await sim.cycles(3)
assert len(sim.sent) == 1
assert "005930" in sim.es._pending_unknown_sells
# ── (c) 불명 없이도: 장부가 부풀어 있으면 KIS 기준 상한 ─────────────────────
@pytest.mark.asyncio
async def test_c_inflated_ledger_capped_by_kis(monkeypatch):
sim = _Sim(monkeypatch, swing=10, dt=5)
sim.kis = {"hldg": 12, "psbl": 12} # 외부 매도 등으로 실보유 12 (장부 15)
await sim.cycles(3)
assert sim.sent == [7] # 12 − DT 5
assert sim.kis["hldg"] == 5
@pytest.mark.asyncio
async def test_c_balance_failure_holds_and_escalates(monkeypatch, caplog):
sim = _Sim(monkeypatch, swing=10, dt=5, balance_fail=True)
n = int(settings.SWING_PRESELL_HOLDING_CHECK_ERROR_AFTER)
with caplog.at_level(logging.WARNING, logger=sim.es.logger.name):
await sim.cycles(n)
assert sim.sent == []
msgs = [r for r in caplog.records if "KIS 잔고 확인 실패" in r.getMessage()]
assert [r.levelno for r in msgs] == [logging.WARNING] * (n - 1) + [logging.ERROR]
sim.balance_fail = False
await sim.cycles(1)
assert sim.sent == [10]
assert sim.es._presell_check_failures == {}
@pytest.mark.asyncio
async def test_c_balance_failure_while_pending_holds(monkeypatch):
sim = _Sim(monkeypatch, swing=10, dt=10, responses=[UNKNOWN])
await sim.cycles(1)
sim.balance_fail = True
await sim.cycles(3)
assert sum(sim.sent) == 10 and "005930" in sim.es._pending_unknown_sells
sim.balance_fail = False
await sim.cycles(2)
assert sum(sim.sent) == 10 and sim.closed == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("bal", [{"rt_cd": "1", "msg1": "err"}, {"rt_cd": "0"}, None])
async def test_c_bad_balance_response_holds(monkeypatch, bal):
sim = _Sim(monkeypatch, swing=10, dt=5)
sim.api.get_balance = AsyncMock(return_value=bal)
await sim.cycles(2)
assert sim.sent == []
@pytest.mark.asyncio
async def test_b_unknown_triggers_portfolio_sync(monkeypatch):
import app.services.trade_executor as te
sim = _Sim(monkeypatch, swing=10, dt=0, responses=[UNKNOWN])
await sim.cycles(1)
await asyncio.sleep(0)
te._delayed_sync_portfolio.assert_called()
# ── 분류: 전송 전 실패·업무 거부는 재시도, 불명은 대기 ───────────────────────
@pytest.mark.asyncio
@pytest.mark.parametrize("resp", [
{"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CONNECT", "msg1": "connect"},
{"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CIRCUIT_OPEN", "msg1": "cb"},
{"rt_cd": "-1", "msg_cd": "KIS_PRESEND_EXCEPTION", "msg1": "x"},
{"rt_cd": "-1", "msg_cd": "TOKEN_UNAVAILABLE", "msg1": "token"},
{"rt_cd": "-1", "msg_cd": "ORDER_LOCK_BUSY", "msg1": "order_lock_busy"},
{"rt_cd": "1", "msg_cd": "APBK0013", "msg1": "주문가능수량 초과"},
])
async def test_presend_and_business_reject_retry_next_cycle(monkeypatch, resp):
sim = _Sim(monkeypatch, swing=10, dt=5, responses=[resp], fill=lambda q, n: 0 if n == 0 else q)
await sim.cycles(1)
assert "005930" not in sim.es._pending_unknown_sells
await sim.cycles(2)
assert sim.sent == [0, 10] and sim.kis["hldg"] == 5
@pytest.mark.asyncio
async def test_msgcd_less_minus_one_is_pending(monkeypatch):
sim = _Sim(monkeypatch, swing=10, dt=5, responses=[{"rt_cd": "-1", "msg1": "wrapped"}])
await sim.cycles(1)
assert "005930" in sim.es._pending_unknown_sells
await sim.cycles(2)
assert sum(sim.sent) == 10 and sim.kis["hldg"] == 5
@pytest.mark.parametrize("resp,expected", [
({"rt_cd": "0"}, "accepted"),
(UNKNOWN, "unknown"),
({"rt_cd": "-1"}, "unknown"),
(None, "unknown"),
({}, "unknown"),
({"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CONNECT"}, "retry"),
({"rt_cd": "-1", "msg_cd": "TOKEN_UNAVAILABLE"}, "retry"),
({"rt_cd": "-1", "msg_cd": "ORDER_LOCK_BUSY"}, "retry"),
({"rt_cd": "-1", "msg1": "order_lock_busy"}, "retry"),
({"rt_cd": "1", "msg_cd": "APBK0013"}, "retry"),
])
def test_classify_swing_sell_response_v2(resp, expected):
import app.services.swing.exit_service as es
assert es._classify_swing_sell_response_v2(resp) == expected
# ── H-1(c) 전량 청산 시 캐시 폐기 / M-4 shield ─────────────────────────────
@pytest.mark.asyncio
async def test_full_close_drops_price_and_atr_cache(monkeypatch):
sim = _Sim(monkeypatch, swing=10, dt=0)
import time as _t
from app.core.timeutil import now_kst
sim.es._last_atr["005930"] = (100.0, _t.monotonic(), now_kst())
await sim.cycles(1)
assert sim.sent == [10] and sim.closed == 1
assert "005930" not in sim.es._last_live_prices
assert "005930" not in sim.es._last_atr
@pytest.mark.asyncio
async def test_m4_cancel_during_order_still_records(monkeypatch):
"""주문 대기 중 루프가 취소돼도 shield 된 작업이 TradeHistory·ActiveTrade 반영까지 완주."""
sim = _Sim(monkeypatch, swing=10, dt=0)
gate = asyncio.Event()
entered = asyncio.Event()
orig = sim._order
async def _slow(**kw):
entered.set()
await gate.wait()
return await orig(**kw)
import app.services.trade_executor as te
monkeypatch.setattr(te, "safe_place_order", _slow)
sim.es._recent_exits.clear()
task = asyncio.ensure_future(sim.es.check_and_execute_exit_v2(MagicMock()))
await asyncio.wait_for(entered.wait(), 2)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
gate.set()
for _ in range(50):
if sim.closed:
break
await asyncio.sleep(0)
assert len(sim.history) == 1 and sim.closed == 1
# ── [2026-10-09 R1] 연속손실 판정 = 비용 차감 순손익 (CLAUDE.md §5.1) ─────────
def _full_signal(remaining):
return ExitSignal("SELL_ALL", "전량 익절", 1.0)
def _expected_net(avg, price, qty):
from app.services.trade_helpers import calculate_trade_costs
costs = calculate_trade_costs(entry_price=avg, exit_price=price, quantity=qty,
is_domestic=True, market="KOSPI")
return (price - avg) * qty - costs["total_cost"], costs["total_cost"]
def test_r1_swing_net_pnl_matches_trade_helpers_and_flips_small_gain():
"""+0.1% 는 총손익 양수지만 왕복 비용(수수료×2+매도세) 차감 후 음수. 비용 정의는
trade_helpers.calculate_trade_costs 와 동일(KOSPI/KOSDAQ 세율 모두)."""
import app.services.swing.exit_service as es
from app.services.trade_helpers import calculate_trade_costs
for mkt, label in (("KRX", "KOSPI"), ("KOSDAQ", "KOSDAQ")):
r = es._swing_net_pnl_v2(10000, 10010, 10, mkt, domestic=True)
c = calculate_trade_costs(entry_price=10000, exit_price=10010, quantity=10,
is_domestic=True, market=label)
assert r["gross_profit_loss"] == 100
assert r["total_cost"] == c["total_cost"]
assert r["net_profit_loss"] == 100 - c["total_cost"]
assert r["net_profit_loss"] < 0
# 해외는 거래세 없음(trade_helpers 와 동일)
r = es._swing_net_pnl_v2(100, 101, 10, "NASD", domestic=False)
c = calculate_trade_costs(entry_price=100, exit_price=101, quantity=10, is_domestic=False)
assert r["total_cost"] == c["total_cost"]
@pytest.mark.asyncio
async def test_r1_full_exit_within_cost_band_counts_as_loss(monkeypatch):
"""[재현 반전] 스윙 10주 +0.1% 전량 청산. 이전: is_loss=False(총손익 +100원), net_profit_loss
에 총손익 기록. 이제: 순손익 기준 is_loss=True, net_profit_loss·commission 은 비용 차감값."""
avg, price = 10000, 10010
sim = _Sim(monkeypatch, swing=10, dt=0, price=price, avg=avg, signal=_full_signal)
await sim.cycles(1)
assert sim.sent == [10]
net, cost = _expected_net(avg, price, 10)
assert net < 0 < (price - avg) * 10
sim.loss.assert_awaited_once()
assert sim.loss.await_args.kwargs["is_loss"] is True
sell = [h for h in sim.history if h["order_type"] == "SELL"]
assert len(sell) == 1
assert sell[0]["net_profit_loss"] == net
assert sell[0]["commission"] == cost
@pytest.mark.asyncio
async def test_r1_full_exit_clear_gain_is_not_loss(monkeypatch):
"""비용을 넘는 이익(+5%)은 여전히 승 — 판정이 과도하게 뒤집히지 않음."""
avg, price = 10000, 10500
sim = _Sim(monkeypatch, swing=10, dt=0, price=price, avg=avg, signal=_full_signal)
await sim.cycles(1)
sim.loss.assert_awaited_once()
assert sim.loss.await_args.kwargs["is_loss"] is False
net, _ = _expected_net(avg, price, 10)
assert [h for h in sim.history if h["order_type"] == "SELL"][0]["net_profit_loss"] == net
@pytest.mark.asyncio
async def test_r1_unknown_sell_reconcile_uses_net_pnl(monkeypatch):
"""결과 불명 전량 매도가 KIS 잔고로 체결 확인되는 경로도 순손익 기준으로 판정·기록."""
avg, price = 10000, 10010
sim = _Sim(monkeypatch, swing=10, dt=0, price=price, avg=avg, signal=_full_signal,
responses=[UNKNOWN])
await sim.cycles(3)
assert sum(sim.sent) == 10
sim.loss.assert_awaited_once()
assert sim.loss.await_args.kwargs["is_loss"] is True
done = [h for h in sim.history if "체결 확인" in (h.get("exit_reason") or "")]
assert len(done) == 1
net, cost = _expected_net(avg, price, 10)
assert done[0]["net_profit_loss"] == net and done[0]["commission"] == cost
# ── 하드닝 R2 (2026-10-09): 체결 확인 백그라운드 태스크가 테스트 밖으로 새지 않는다 ─────
@pytest.mark.asyncio
async def test_r2_fill_check_terminal_is_mocked_and_task_completes(monkeypatch):
"""성공 매도 → 체결 확인 예약은 기록형 목 종단으로 수렴하고, swing_sell_fill 태스크가
테스트 종료 전에 끝난다(실 _verify_and_sync_execution_v3 미실행 → KIS 조회 0)."""
import app.services.trade_executor as te
assert te._verify_and_sync_execution_v3 is _record_fill_check
sim = _Sim(monkeypatch, swing=10, dt=0)
await sim.cycles(1)
assert sim.sent == [10] and sim.closed == 1
assert len(_FILL_CHECKS) == 1
fc = _FILL_CHECKS[0]
assert fc["stock_code"] == "005930" and fc["quantity"] == 10 and fc["side"] == "SELL"
assert fc["kwargs"]["sell_pnl_basis"]["strategy_type"] == "swing"
# 기록형 목은 즉시 완료 — 한 틱 양보 후 남은 swing_sell_fill 태스크가 없어야 한다
await asyncio.sleep(0)
await asyncio.sleep(0)
pending = [t for t in asyncio.all_tasks()
if (t.get_name() or "").startswith("swing_sell_fill:") and not t.done()]
assert pending == []
@pytest.mark.asyncio
async def test_r2_fill_check_real_coroutine_would_hit_kis_is_not_reached(monkeypatch):
"""종단 목이 빠지면 실 코루틴이 get_order_execution 을 부른다는 전제를 예외 발생형 목으로
고정 — 이 파일의 매도 경로가 그 메서드에 도달하지 않음을 증명한다."""
sim = _Sim(monkeypatch, swing=10, dt=0)
async def _boom(*a, **k):
raise AssertionError("get_order_execution 실호출 경로 도달")
sim.api.get_order_execution = AsyncMock(side_effect=_boom)
import app.services.trade_executor as te
monkeypatch.setattr(te, "korea_investment_api", sim.api, raising=False)
await sim.cycles(1)
await asyncio.sleep(0)
sim.api.get_order_execution.assert_not_called()
assert len(_FILL_CHECKS) == 1