"""[5회차 2026-10-02] 최종 게이트 결함 회귀 — H-3 / M-1(MDD) / M-2(주문 락) / L-6. (H-1 시장가 미취소·지정가 롤백, L-5 는 tests/test_round4_fixes_20261002.py) 재현: scratchpad/r5/test_r5_repro.py 의 MDD·스윙 재시도 시나리오를 뒤집어 고정한다. """ import json from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from app.core.config import settings from app.services import mdd_manager as mm from app.services import trade_executor as te async def _nosleep(*a, **k): return None # ═════════════════════════════════════════════════════════════════════════════ # H-3: 전송 후 결과 불명은 재전송 금지 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.parametrize("resp,unknown,retry", [ ({"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN"}, True, False), ({"rt_cd": "-1", "msg1": "x"}, True, False), # msg_cd 없음 ({}, True, False), # 응답 없음 ({"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CONNECT"}, False, True), ({"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CIRCUIT_OPEN"}, False, True), ({"rt_cd": "-1", "msg_cd": "KIS_PRESEND_EXCEPTION"}, False, True), ({"rt_cd": "-1", "msg_cd": "TOKEN_UNAVAILABLE"}, False, True), ({"rt_cd": "-1", "msg_cd": "ORDER_LOCK_BUSY"}, False, True), ({"rt_cd": "1", "msg_cd": "EGW00201"}, False, True), ({"rt_cd": "1", "msg_cd": "EGW00123"}, False, True), ({"rt_cd": "1", "msg_cd": "APBK0013"}, False, False), # 업무 거부 ]) def test_h3_classification(resp, unknown, retry): assert te._is_order_outcome_unknown_v2(resp) is unknown assert te._is_retryable_order_failure(resp) is retry @pytest.mark.asyncio async def test_h3_repro_transport_unknown_not_resent(monkeypatch): """재현 뒤집기: KIS_TRANSPORT_UNKNOWN + 주문내역 조회 [] → 재전송하지 않는다(sent == [10]).""" sent = [] async def _po(code, side, qty, price): sent.append(qty) if len(sent) == 1: return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "ReadTimeout"} return {"rt_cd": "0", "output": {"ODNO": "9"}} monkeypatch.setattr(te.korea_investment_api, "place_order", _po) monkeypatch.setattr(te.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[])) monkeypatch.setattr(te.asyncio, "sleep", _nosleep) r = await te._place_order_with_retry(True, "005930", "BUY", 10, 70000.0) assert sent == [10], sent assert te._is_order_outcome_unknown_v2(r) @pytest.mark.asyncio async def test_h3_unknown_found_in_orders_returns_lookup_marked(monkeypatch): async def _po(*a): return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN"} monkeypatch.setattr(te.korea_investment_api, "place_order", _po) from datetime import datetime now = datetime.now().strftime("%H%M%S") monkeypatch.setattr(te.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[ {"pdno": "005930", "sll_buy_dvsn_cd": "01", "ord_qty": "3", "ord_tmd": now, "odno": "77"}, ])) r = await te._place_order_with_retry(True, "005930", "SELL", 3, None) assert r["rt_cd"] == "0" and r["_matched_by_lookup"] is True @pytest.mark.asyncio async def test_h3_sell_unknown_recorded_pending_not_failed(monkeypatch): """결과 불명 매도는 PENDING — 30초 중복매도 차단·당일 매도 판정이 이 주문을 센다.""" import app.services.db_worker as dbw rows = [] async def _th(p): rows.append(p) async def _po(**kw): return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "timeout"} 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()) monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) monkeypatch.setattr(dbw, "enqueue_trade_history", _th) monkeypatch.setattr(te.korea_investment_api, "get_current_price", AsyncMock(side_effect=AssertionError)) class _DB: async def execute(self, stmt): return SimpleNamespace(scalars=lambda: SimpleNamespace(first=lambda: None, all=lambda: [])) from app.services.daytrading.position_manager import day_trading_state day_trading_state.positions.pop("005930", None) h = SimpleNamespace(quantity=10, average_price=60000, profit_loss_rate=0, strategy_type="swing", stock_name="삼성", current_price=70000) r = await te._execute_sell({"stock_code": "005930", "strategy_type": "swing", "remaining_pct": 1.0}, _DB(), h, 70000, ["x"], True, "KRX", "005930", "삼성", sell_pct=1.0) assert r["success"] is False assert rows and rows[-1]["status"] == "PENDING" def test_h3_buy_unknown_is_pending_and_counted(): import inspect src = inspect.getsource(te._execute_buy) assert 'status = "COMPLETED" if success else ("PENDING" if _outcome_unknown else "FAILED")' in src i = src.index("elif _outcome_unknown:") blk = src[i:i + 900] assert "note_system_buy(stock_code, quantity)" in blk and "_note_cycle_buy_v2(" in blk # 거부 쿨다운은 결과 불명에 걸지 않는다 assert "not _outcome_unknown and not _is_retryable_order_failure(order_result)" in src # ═════════════════════════════════════════════════════════════════════════════ # M-1: MDD 축소 — 결과 불명은 잔고 확인 전 재매도 금지, 일시 실패는 횟수 미산정 # ═════════════════════════════════════════════════════════════════════════════ class _Acct: def __init__(self, qty, with_psbl=True): self.qty = qty self.open_sell = 0 # KIS 에 살아 있는 미체결 매도 self.with_psbl = with_psbl self.sent = [] self.script = [] # safe_place_order 응답 순서 async def get_balance(self, *a, **k): row = {"pdno": "005930", "prdt_name": "S", "hldg_qty": str(self.qty), "prpr": "70000", "pchs_avg_pric": "80000"} if self.with_psbl: row["ord_psbl_qty"] = str(self.qty - self.open_sell) return {"rt_cd": "0", "output1": [row]} async def order(self, stock_code, order_type, quantity, price=None, **kw): self.sent.append(quantity) r = self.script.pop(0) if self.script else {"rt_cd": "0", "output": {"ODNO": "1"}} if r.get("rt_cd") == "0": self.qty -= quantity return r @pytest.fixture def mdd_env(monkeypatch): import app.services.db_worker as dbw import app.core.market_schedule as ms async def _noop(*a, **k): return None monkeypatch.setattr(mm, "_sync_active_trade_after_reduction", _noop) monkeypatch.setattr(mm, "_reflect_reduction", _noop) monkeypatch.setattr(dbw, "enqueue_trade_history", _noop) monkeypatch.setattr(ms, "is_market_open", lambda: True) monkeypatch.setattr(mm.asyncio, "sleep", _nosleep) def _make(acct): # mdd_manager 는 함수 안에서 모듈 싱글톤을 import 한다 — 모듈 재적재로 te 쪽 참조와 # 다를 수 있으므로 둘 다 덮는다. import app.services.korea_investment_api as kmod monkeypatch.setattr(kmod.korea_investment_api, "get_balance", acct.get_balance) monkeypatch.setattr(te.korea_investment_api, "get_balance", acct.get_balance) monkeypatch.setattr(te, "safe_place_order", acct.order) m = mm.MDDManager() m.save_to_redis = AsyncMock() return m return _make _ACT = {"action": "REDUCE_AND_PAUSE", "reduce_pct": 0.3} _UNKNOWN = {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "ReadTimeout"} async def _armed(m): await m.update(100.0) await m.update(89.0) @pytest.mark.asyncio async def test_m1_repro_unknown_not_resold_without_confirmation(mdd_env): """재현 뒤집기: 잔고에 주문가능 수량이 없어 확인 불가 → 재매도하지 않는다(sent == [30]).""" a = _Acct(100, with_psbl=False) a.script = [dict(_UNKNOWN)] m = mdd_env(a) await _armed(m) await m.execute_reduction(dict(_ACT)) await m.execute_reduction(dict(_ACT)) assert a.sent == [30], a.sent @pytest.mark.asyncio async def test_m1_unknown_but_sold_marks_done(mdd_env): a = _Acct(100) a.script = [dict(_UNKNOWN)] m = mdd_env(a) await _armed(m) await m.execute_reduction(dict(_ACT)) a.qty = 70 # 실제로는 체결됨 await m.execute_reduction(dict(_ACT)) assert a.sent == [30] assert "005930" in m._reduced_codes["10pct"] and m._reduction_executed_10pct is True @pytest.mark.asyncio async def test_m1_unknown_open_order_waits_then_not_sent_reorders(mdd_env): a = _Acct(100) a.script = [dict(_UNKNOWN)] m = mdd_env(a) await _armed(m) await m.execute_reduction(dict(_ACT)) a.open_sell = 30 # 미체결 매도가 살아 있음 → 대기 await m.execute_reduction(dict(_ACT)) assert a.sent == [30] a.open_sell = 0 # 미접수로 판명(보유·주문가능 불변) → 정상 재주문 await m.execute_reduction(dict(_ACT)) assert a.sent == [30, 30] and a.qty == 70 @pytest.mark.asyncio async def test_m1_transient_failures_not_counted(mdd_env): cap = settings.MDD_REDUCTION_MAX_RETRIES a = _Acct(100) a.script = [{"rt_cd": "-1", "msg_cd": "TOKEN_UNAVAILABLE"}] * (cap + 2) m = mdd_env(a) await _armed(m) for _ in range(cap + 2): await m.execute_reduction(dict(_ACT)) assert "005930" not in m._reduction_gave_up.get("10pct", set()), "일시 장애로 당일 포기" assert m._reduction_attempts.get("10pct", {}).get("005930", 0) == 0 await m.execute_reduction(dict(_ACT)) assert a.qty == 70 def test_m1_unconfirmed_persisted(): m = mm.MDDManager() m._reduction_unconfirmed = {"10pct": {"005930": {"qty_before": 100, "sell_qty": 30, "cycles": 1}}} st = json.loads(json.dumps(m._reduction_progress_state())) m2 = mm.MDDManager() m2._restore_reduction_progress(st) assert m2._reduction_unconfirmed == m._reduction_unconfirmed # ═════════════════════════════════════════════════════════════════════════════ # M-2: 프로세스 내 in-flight 주문 락 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio @pytest.mark.parametrize("redis_mode", ["none", "error"]) async def test_m2_inflight_blocks_duplicate_when_redis_unavailable(monkeypatch, redis_mode): import app.core.redis as r def _gr(): if redis_mode == "error": raise TimeoutError("redis timeout") return None monkeypatch.setattr(r, "get_redis", _gr) monkeypatch.setattr(r, "delete_key", AsyncMock()) assert await te._acquire_order_lock("005930", "SELL") is True assert await te._acquire_order_lock("005930", "SELL") is False, "같은 종목 SELL 중복 허용" assert await te._acquire_order_lock("005930", "BUY") is True # 방향이 다르면 독립 await te._release_order_lock("005930", "SELL") assert await te._acquire_order_lock("005930", "SELL") is True @pytest.mark.asyncio async def test_m2_redis_held_elsewhere_does_not_leave_local_entry(monkeypatch): import app.core.redis as r client = SimpleNamespace(set=AsyncMock(return_value=None)) monkeypatch.setattr(r, "get_redis", lambda: client) assert await te._acquire_order_lock("005930", "SELL") is False assert ("005930", "SELL") not in te._inflight_order_locks def test_m2_local_entry_expires_after_ttl(monkeypatch): te._inflight_order_locks[("005930", "SELL")] = te.time.monotonic() - te._ORDER_LOCK_TTL - 1 assert te._local_order_lock_held_v2(("005930", "SELL")) is False # ═════════════════════════════════════════════════════════════════════════════ # L-6: MDD Redis 쓰기는 Lock 밖 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_l6_redis_write_outside_lock(monkeypatch): import app.core.redis as r m = mm.MDDManager() held = [] async def _set(key, val, **kw): held.append(m._lock.locked()) return True monkeypatch.setattr(r, "get_redis", lambda: SimpleNamespace(set=_set)) await m.update(100.0) await m.update(89.0) # -11% 전이(이전엔 락 안에서 save_to_redis) await m.reset() assert held and not any(held), held # ═════════════════════════════════════════════════════════════════════════════ # H-1 + 인프라 cancel_order_v2: 원주문 org_no 전달, ORGNO_NOT_FOUND 는 롤백 없음 # ═════════════════════════════════════════════════════════════════════════════ @pytest.fixture def cancel_env(monkeypatch): import app.services.db_worker as dbw marks, ats = [], [] async def _upd(order_id, fields): marks.append((order_id, dict(fields))) async def _u(p): ats.append(dict(p)) monkeypatch.setattr(dbw, "enqueue_update_trade_history_by_order_id", _upd) monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _u) monkeypatch.setattr(te.asyncio, "sleep", _nosleep) monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) monkeypatch.setattr(te.korea_investment_api, "get_order_execution", AsyncMock(return_value={ "ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 5})) return SimpleNamespace(marks=marks, ats=ats) _SNAP5 = {"stock_code": "005930", "strategy_name": "swing", "remaining_pct": 1.0, "quantity": 10} @pytest.mark.asyncio async def test_h1_cancel_uses_v2_with_org_no(cancel_env, monkeypatch): cancel = AsyncMock(return_value={"rt_cd": "0"}) monkeypatch.setattr(te.korea_investment_api, "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(_SNAP5), order_org_no="06010", ) cancel.assert_awaited_once_with("0001", org_no="06010", quantity=5, stock_code="005930") assert cancel_env.marks and cancel_env.ats == [_SNAP5] @pytest.mark.asyncio async def test_h1_orgno_not_found_no_rollback(cancel_env, monkeypatch, caplog): monkeypatch.setattr(te.korea_investment_api, "cancel_order_v2", AsyncMock( return_value={"rt_cd": "-1", "msg_cd": "ORGNO_NOT_FOUND", "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(_SNAP5), ) assert cancel_env.marks == [] and cancel_env.ats == [], "취소 미확정인데 CANCELLED/롤백" assert any("수동 취소" in r.message for r in caplog.records) def test_h1_execute_sell_passes_org_no_from_order_response(): import inspect src = inspect.getsource(te._execute_sell) assert "order_org_no=_extract_org_no_v2(order_result)" in src