"""[4회차 2026-10-02] 검증 결함 회귀 — H-1 / M-2 / M-3 / L-2 / L-3 / L-4. (M-1 reset 경합·L-1 장외 축소는 tests/test_mdd_reduction_retry_20261002.py) 주문·취소 종단은 trade_executor 가 실제로 참조하는 `te.korea_investment_api` 에 건다. """ from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from app.core.config import settings from app.services import trade_executor as te class _MustNotCall(BaseException): pass def _boom(name): async def _f(*a, **k): raise _MustNotCall(f"{name} 가 호출되면 안 된다") return _f async def _nosleep(*a, **k): return None # ═════════════════════════════════════════════════════════════════════════════ # H-1 (a)(b): 매도 가격 — 낡은 배치가 대신 시장가 / 주문 직전 재산정 # ═════════════════════════════════════════════════════════════════════════════ def test_h1_price_policy_pure(): # 전량 청산 → 시장가 assert te._resolve_sell_order_price_v2("005930", 70000, True, True, 5.0)[1:] == (None, "market") # 손실 청산(부분이어도) → 시장가 assert te._resolve_sell_order_price_v2("005930", 70000, True, False, -3.0)[1:] == (None, "market") # 부분 익절 → 재산정 assert te._resolve_sell_order_price_v2("005930", 70000, True, False, 4.0)[2] == "limit_refresh" # 해외 → 기존 지정가 유지 ref, px, kind = te._resolve_sell_order_price_v2("AAPL", 100.0, False, True, -5.0) assert kind == "limit_overseas" and px == te._adjust_order_price(100.0, "SELL", False) class _Scal: def first(self): return None def all(self): return [] class _SellDB: async def execute(self, stmt): return SimpleNamespace(scalars=lambda: _Scal(), scalar_one_or_none=lambda: None) @pytest.fixture def sell_env(monkeypatch): import app.services.db_worker as dbw from app.services.loss_tracker import loss_tracker class _L(list): pass orders = _L() async def _po(is_domestic, stock_code, order_type, quantity, price, exchange_code="NASD", max_retries=3): orders.append({"price": price, "quantity": quantity}) return {"rt_cd": "0", "output": {"ODNO": "0001", "ORD_TMD": "100000"}} monkeypatch.setattr(te, "_place_order_with_retry", _po) monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=True)) monkeypatch.setattr(te, "_release_order_lock", AsyncMock(return_value=None)) async def _noop(*a, **k): return None verifies = [] async def _cap_verify(*a, **k): verifies.append(k) monkeypatch.setattr(te, "_verify_and_sync_execution", _cap_verify) monkeypatch.setattr(te, "_verify_and_sync_execution_v2", _cap_verify) monkeypatch.setattr(dbw, "enqueue_trade_history", _noop) monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _noop) monkeypatch.setattr(dbw, "enqueue_close_active_trade", _noop) monkeypatch.setattr(dbw, "enqueue_db_write", _noop) monkeypatch.setattr(loss_tracker, "record_trade_result_safe", AsyncMock()) monkeypatch.setattr(loss_tracker, "save_to_db", AsyncMock()) from app.services.daytrading.position_manager import day_trading_state day_trading_state.positions.pop("005930", None) orders.verifies = verifies # type: ignore[attr-defined] return orders def _holding(qty=10): return SimpleNamespace(quantity=qty, average_price=60000, profit_loss_rate=0, strategy_type="swing", stock_name="삼성", current_price=70000) async def _sell(sell_pct, current_price=70000, avg=60000): h = _holding() h.average_price = avg stock = {"stock_code": "005930", "strategy_type": "swing", "strategy_name": "swing", "remaining_pct": 1.0} return await te._execute_sell( stock, _SellDB(), h, current_price, ["x"], True, "KRX", "005930", "삼성", sell_pct=sell_pct, ) @pytest.mark.asyncio async def test_h1_full_exit_is_market_order(sell_env, monkeypatch): monkeypatch.setattr(te.korea_investment_api, "get_current_price", _boom("get_current_price")) await _sell(1.0) assert sell_env[-1]["price"] is None, "전량 청산이 낡은 배치가 지정가로 나갔다" @pytest.mark.asyncio async def test_h1_partial_take_profit_reprices_before_order(sell_env, monkeypatch): monkeypatch.setattr(te.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "65000"}})) await _sell(0.5) assert sell_env[-1]["price"] == te._adjust_order_price(65000, "SELL", True), \ "부분 익절 지정가가 주문 직전 현재가로 재산정되지 않았다(배치가 70000 사용)" @pytest.mark.asyncio async def test_h1_partial_reprice_failure_falls_back_to_market(sell_env, monkeypatch): async def _fail(*a, **k): raise RuntimeError("시세 실패") monkeypatch.setattr(te.korea_investment_api, "get_current_price", _fail) await _sell(0.5) assert sell_env[-1]["price"] is None # ═════════════════════════════════════════════════════════════════════════════ # H-1 (c) / L-4: 체결 확인 — 미체결 매도 취소, 불성립 매수 CANCELLED # ═════════════════════════════════════════════════════════════════════════════ @pytest.fixture def verify_env(monkeypatch): import app.services.db_worker as dbw updates = [] async def _upd(order_id, fields): updates.append((order_id, dict(fields))) monkeypatch.setattr(dbw, "enqueue_update_trade_history_by_order_id", _upd) monkeypatch.setattr(te.asyncio, "sleep", _nosleep) async def _noop(*a, **k): return None monkeypatch.setattr(te, "_delayed_sync_portfolio", _noop) return updates def _exec(filled, remaining, found=True): return AsyncMock(return_value={"ok": True, "found": found, "filled_qty": filled, "filled_price": 70000, "remaining_qty": remaining}) async def _flush_tasks(): import asyncio as _aio await _aio.sleep(0) @pytest.mark.asyncio async def test_h1r5_market_full_exit_never_cancelled(sell_env, monkeypatch): """[5회차] 시장가(전량·손절) 매도는 미체결 취소 대상이 아니다(종가 단일가·VI·하한가).""" monkeypatch.setattr(te.korea_investment_api, "get_current_price", _boom("get_current_price")) await _sell(1.0) await _flush_tasks() assert sell_env.verifies and sell_env.verifies[-1]["cancel_unfilled_limit"] is False assert sell_env.verifies[-1]["rollback_active_trade"] is None @pytest.mark.asyncio async def test_h1r5_limit_partial_passes_cancel_and_rollback_snapshot(sell_env, monkeypatch): monkeypatch.setattr(te.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "65000"}})) await _sell(0.5) await _flush_tasks() k = sell_env.verifies[-1] assert k["cancel_unfilled_limit"] is True snap = k["rollback_active_trade"] assert snap["quantity"] == 10 and snap["remaining_pct"] == 1.0 assert snap.get("strategy_name") == "swing", "롤백이 다른 전략 행을 덮으면 안 된다" @pytest.mark.asyncio async def test_h1r5_v1_verify_never_cancels(verify_env, monkeypatch): """기존 시그니처(_verify_and_sync_execution) = 시장가·종류 불명 경로 → 잔량이 살아 있어도 취소 금지.""" k = te.korea_investment_api monkeypatch.setattr(k, "get_order_execution", _exec(0, 5)) monkeypatch.setattr(k, "cancel_order_v2", _boom("cancel_order(시장가)")) await te._verify_and_sync_execution("0001", "005930", "삼성", 5, 70000, "SELL", True, "KRX", 0, delay=0) assert verify_env == [] _SNAP = {"stock_code": "005930", "strategy_name": "swing", "remaining_pct": 1.0, "quantity": 10, "highest_since_entry": 72000.0} @pytest.fixture def at_capture(monkeypatch): import app.services.db_worker as dbw cap = [] async def _u(p): cap.append(dict(p)) monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _u) return cap @pytest.mark.asyncio async def test_h1r5_limit_unfilled_cancel_rolls_back_active_trade(verify_env, at_capture, monkeypatch): k = te.korea_investment_api monkeypatch.setattr(k, "get_order_execution", _exec(0, 5)) cancel = AsyncMock(return_value={"rt_cd": "0"}) monkeypatch.setattr(k, "cancel_order_v2", cancel) await te._verify_and_sync_execution_v2( "0001", "005930", "삼성", 5, 65000, "SELL", True, "KRX", 0, delay=0, cancel_unfilled_limit=True, rollback_active_trade=dict(_SNAP), ) cancel.assert_awaited_once_with("0001", org_no=None, quantity=5, stock_code="005930") assert verify_env == [("0001", {"status": "CANCELLED", "executed_quantity": 0})] assert at_capture == [_SNAP], "0체결 취소인데 ActiveTrade 가 주문 전 상태로 원복되지 않았다" @pytest.mark.asyncio async def test_h1r5_limit_partial_fill_cancel_fixes_quantity(verify_env, at_capture, monkeypatch): k = te.korea_investment_api monkeypatch.setattr(k, "get_order_execution", _exec(2, 3)) monkeypatch.setattr(k, "cancel_order_v2", AsyncMock(return_value={"rt_cd": "0"})) await te._verify_and_sync_execution_v2( "0001", "005930", "삼성", 5, 65000, "SELL", True, "KRX", 0, delay=0, cancel_unfilled_limit=True, rollback_active_trade=dict(_SNAP), ) assert verify_env and verify_env[-1][1]["status"] == "COMPLETED" assert verify_env[-1][1]["executed_quantity"] == 2 assert at_capture == [{"stock_code": "005930", "strategy_name": "swing", "quantity": 8}] @pytest.mark.asyncio async def test_h1r5_cancel_failure_logs_error_no_rollback(verify_env, at_capture, monkeypatch, caplog): k = te.korea_investment_api monkeypatch.setattr(k, "get_order_execution", _exec(0, 5)) monkeypatch.setattr(k, "cancel_order_v2", AsyncMock(return_value={"rt_cd": "1", "msg1": "취소불가"})) with caplog.at_level("ERROR"): await te._verify_and_sync_execution_v2( "0001", "005930", "삼성", 5, 65000, "SELL", True, "KRX", 0, delay=0, cancel_unfilled_limit=True, rollback_active_trade=dict(_SNAP), ) assert verify_env == [] and at_capture == [] assert any("취소 거부" in r.message for r in caplog.records) def test_l5_lookup_matched_order_is_never_cancel_target(): import inspect src = inspect.getsource(te._execute_sell) i = src.index("_cancel_unfilled_limit = (") assert 'not order_result.get("_matched_by_lookup")' in src[i:i + 300] assert "order_price is not None" in src[i:i + 300] and "not _full_exit" in src[i:i + 300] @pytest.mark.asyncio async def test_h1_buy_live_remainder_is_not_cancelled(verify_env, monkeypatch): k = te.korea_investment_api monkeypatch.setattr(k, "get_order_execution", _exec(0, 5)) monkeypatch.setattr(k, "cancel_order_v2", _boom("cancel_order(BUY)")) await te._verify_and_sync_execution("0001", "005930", "삼성", 5, 70000, "BUY", True, "KRX", 0, delay=0) assert verify_env == [] @pytest.mark.asyncio async def test_l4_dead_buy_marked_cancelled(verify_env, monkeypatch): k = te.korea_investment_api monkeypatch.setattr(k, "get_order_execution", _exec(0, 0)) monkeypatch.setattr(k, "cancel_order_v2", _boom("cancel_order")) await te._verify_and_sync_execution("0002", "005930", "삼성", 5, 70000, "BUY", True, "KRX", 0, delay=0) assert verify_env == [("0002", {"status": "CANCELLED", "executed_quantity": 0})] # ═════════════════════════════════════════════════════════════════════════════ # M-3: 일일 스윙 한도에 반영 지연 중인 사이클 매수 합산 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_m3_daily_count_includes_inflight_swing_buys(): class _DB: async def execute(self, stmt): return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: ["000001"])) te._recent_cycle_buys.clear() try: for code, st in (("000001", "swing"), ("000002", "swing"), ("000003", "manual"), ("AAPL", "swing")): te._note_cycle_buy_v2(code, code, 1, 1000) te._recent_cycle_buys[code]["strategy_type"] = st te._note_cycle_buy_v2("000004", "D", 1, 1000) # 태그 없음(레거시) = 스윙 소유로 셈 # DB 000001 ∪ 지연 중 스윙 000002·000004 (manual·해외 제외, 000001 중복 제외) assert await te._count_swing_buys_today_v2(_DB()) == 3 finally: te._recent_cycle_buys.clear() # ═════════════════════════════════════════════════════════════════════════════ # M-2 / L-2 / L-3: 루프 # ═════════════════════════════════════════════════════════════════════════════ class _LoopDB: def __init__(self, held): self.held = held async def execute(self, stmt): return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: list(self.held))) @pytest.fixture def loop_env(monkeypatch): monkeypatch.setattr(settings, "BEAR_MARKET_FILTER_ENABLED", False) monkeypatch.setattr(settings, "MAX_DAILY_TRADES", 2) monkeypatch.setattr(te, "_regime_limits_v2", lambda _r: (20, 1.0)) monkeypatch.setattr(te.asyncio, "sleep", _nosleep) te._order_reject_cooldown.clear() te._account_reject_at = None te._eval_rotation_offset = 0 seen = [] async def _eat(stock, db): seen.append((stock["stock_code"], bool(stock.get("_exit_only")))) return {"action": "HOLD", "reason": "x"} monkeypatch.setattr(te, "execute_auto_trade", _eat) yield seen te._order_reject_cooldown.clear() te._account_reject_at = None te._eval_rotation_offset = 0 def _recs(codes): return {"recommendations_by_rule": [{"rule_name": "r", "stocks": [ {"stock_code": c, "rule_priority": 1, "score": 100 - i} for i, c in enumerate(codes) ]}]} @pytest.mark.asyncio @pytest.mark.parametrize("count", [2, None]) async def test_m2_held_exit_eval_when_buys_blocked(loop_env, monkeypatch, count): """일일 한도 소진(2) / 건수 조회 실패(None) — 신규는 막고 보유는 _exit_only 로 평가.""" monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=count)) await te._execute_auto_trade_background_locked(_recs(["HELD", "NEW"]), _LoopDB(["HELD"])) assert loop_env == [("HELD", True)] @pytest.mark.asyncio async def test_m2_regime_gate_still_evaluates_held(loop_env, monkeypatch): monkeypatch.setattr(te, "_regime_limits_v2", lambda _r: (0, 0.0)) monkeypatch.setattr(te, "_count_swing_buys_today_v2", _boom("count(국면 게이트 시 불필요)")) await te._execute_auto_trade_background_locked(_recs(["HELD", "NEW"]), _LoopDB(["HELD"])) assert loop_env == [("HELD", True)] @pytest.mark.asyncio async def test_l2_account_reject_pauses_all_new_buys(loop_env, monkeypatch): monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=0)) te._note_order_reject_v2("X", {"rt_cd": "1", "msg_cd": settings.KIS_ACCOUNT_REJECT_MSG_CODES[0]}) assert te._account_reject_cooldown_remaining_v2() > 0 await te._execute_auto_trade_background_locked(_recs(["HELD", "N1", "N2"]), _LoopDB(["HELD"])) assert loop_env == [("HELD", True)] # 종목 단위 거부는 계좌 쿨다운을 걸지 않는다 te._account_reject_at = None te._note_order_reject_v2("Y", {"rt_cd": "1", "msg_cd": "APBK0013"}) assert te._account_reject_cooldown_remaining_v2() == 0.0 @pytest.mark.asyncio async def test_l3_rotation_offset_reaches_lower_candidates(loop_env, monkeypatch): monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=0)) monkeypatch.setattr(settings, "AUTO_TRADE_MAX_EVALUATIONS_PER_CYCLE", 2) cycles = [] for _ in range(3): loop_env.clear() await te._execute_auto_trade_background_locked(_recs(list("ABCDE")), _LoopDB([])) cycles.append([c for c, _ in loop_env]) assert cycles == [["A", "B"], ["C", "D"], ["E", "A"]], cycles # ═════════════════════════════════════════════════════════════════════════════ # safe_place_order(_v2) msg_cd — 전송 전/후 구분 (DT 재시도 판정용) # ═════════════════════════════════════════════════════════════════════════════ # conftest 가 테스트마다 te.safe_place_order 를 차단 스텁으로 바꾸므로, 수집 시점의 # 원본을 잡아 둔다(KIS 클래스 메서드 차단은 그대로 유지된다). from app.services.trade_executor import ( # noqa: E402 safe_place_order as _REAL_SAFE_PLACE_ORDER, safe_place_order_v2 as _REAL_SAFE_PLACE_ORDER_V2, ) @pytest.fixture def safe_env(monkeypatch): released = [] monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=True)) async def _rel(code, typ): released.append((code, typ)) monkeypatch.setattr(te, "_release_order_lock", _rel) return released def _ctx(kis): return SimpleNamespace(is_legacy=True, user_id=None, kis=kis) @pytest.mark.asyncio @pytest.mark.parametrize("variant", ["v1", "v2"]) async def test_safe_order_lock_busy_has_msg_cd(monkeypatch, variant): monkeypatch.setattr(te, "_acquire_order_lock", AsyncMock(return_value=False)) k = SimpleNamespace(place_order=_boom("place_order")) monkeypatch.setattr(te.korea_investment_api, "place_order", _boom("place_order")) r = (await _REAL_SAFE_PLACE_ORDER("005930", "SELL", 1) if variant == "v1" else await _REAL_SAFE_PLACE_ORDER_V2(_ctx(k), "005930", "SELL", 1)) assert r == {"rt_cd": "-1", "msg_cd": "ORDER_LOCK_BUSY", "msg1": "order_lock_busy"} @pytest.mark.asyncio @pytest.mark.parametrize("variant", ["v1", "v2"]) async def test_safe_order_presend_exception(safe_env, monkeypatch, variant): def _bad_sem(key): raise RuntimeError("semaphore broken") monkeypatch.setattr(te, "_get_order_semaphore", _bad_sem) k = SimpleNamespace(place_order=_boom("place_order")) monkeypatch.setattr(te.korea_investment_api, "place_order", _boom("place_order")) r = (await _REAL_SAFE_PLACE_ORDER("005930", "SELL", 1) if variant == "v1" else await _REAL_SAFE_PLACE_ORDER_V2(_ctx(k), "005930", "SELL", 1)) assert r["rt_cd"] == "-1" and r["msg_cd"] == "KIS_PRESEND_EXCEPTION" assert safe_env, "락이 해제되지 않았다" @pytest.mark.asyncio @pytest.mark.parametrize("variant", ["v1", "v2"]) @pytest.mark.parametrize("mode", ["raise", "none"]) async def test_safe_order_post_send_unknown(safe_env, monkeypatch, variant, mode): async def _po(**kw): if mode == "raise": raise RuntimeError("read timeout") return None k = SimpleNamespace(place_order=_po) monkeypatch.setattr(te.korea_investment_api, "place_order", _po) r = (await _REAL_SAFE_PLACE_ORDER("005930", "SELL", 1) if variant == "v1" else await _REAL_SAFE_PLACE_ORDER_V2(_ctx(k), "005930", "SELL", 1)) assert r["rt_cd"] == "-1" and r["msg_cd"] == "KIS_TRANSPORT_UNKNOWN" @pytest.mark.asyncio async def test_safe_order_success_passthrough(safe_env, monkeypatch): ok = {"rt_cd": "0", "output": {"ODNO": "1"}} monkeypatch.setattr(te.korea_investment_api, "place_order", AsyncMock(return_value=ok)) assert await _REAL_SAFE_PLACE_ORDER("005930", "BUY", 1) == ok