"""스윙 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) api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 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) sim.api.get_balance_all_v2 = sim.api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 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 from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지)