"""스윙 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"}} @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()) 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