"""DT 청산 하드닝 라운드 4 — R4-3 청산 주문 연속 거부 경보 회귀 테스트 (2026-10-09). 손절선에 닿은 DT 포지션의 시장가 매도가 KIS 업무 거부(rt_cd≠0)로 계속 실패해도 logger.error 만 남고 청산 판정 헬스는 '성공'으로 리셋됐다(1단계 handled 종목은 2단계·헬스 실패 집합에서 빠짐). 이제: - 거부 1건마다 판정 실패 수집기에 기록 → 헬스 연속 실패로 센다(1단계 handled 포함) - 종목별 거부 연속 횟수가 N(settings.DAY_TRADE_EXIT_CHECK_FAIL_ALERT_CYCLES) 이면 DT_EXIT_REJECT 텔레그램 1회 - 접수(accepted) 1회면 리셋 - 전일(stale) 경로는 _stale_sell_failures 가 따로 세므로 제외 목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`safe_place_order`)에 건다. 거부 응답은 시나리오 자체(KIS 거부 재현)이고, 그 외 호출되면 안 되는 경로는 BaseException 발생형이다. """ import asyncio from unittest.mock import AsyncMock, patch import pytest from app.services.daytrading import exit_service as es from app.services.daytrading.position_manager import day_trading_state class MustNotBeCalled(BaseException): """호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException).""" def _boom(name): async def _f(*a, **k): raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") return _f _REJECT = {"rt_cd": "1", "msg_cd": "40910000", "msg1": "모의투자 주문처리가 안되었습니다"} _COLLS = ( "_stale_sell_attempted", "_stale_sell_failures", "_last_exit_order_outcome", "_last_exit_order_detail", "_unresolved_exit_unknown", "_exit_tokens", "_once_alerted", "_price_seen_mono", "_exit_check_failures", "_exit_stall_alerted", "_rest_price_seen", "_exit_reject_streak", "_exit_reject_alerted", "_alert_tasks", ) @pytest.fixture(autouse=True) def _clean(): from app.services.telegram_notifier import telegram_notifier saved = (dict(day_trading_state.positions), set(day_trading_state.closed_today), day_trading_state.daily_pnl) day_trading_state.reset_if_new_day() day_trading_state.positions.clear() day_trading_state.daily_pnl = 0.0 for name in _COLLS: getattr(es, name).clear() day_trading_state.exits_in_progress.clear() tg = AsyncMock() with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \ patch.object(telegram_notifier, "notify_system_warning", tg): yield tg day_trading_state.positions.clear() day_trading_state.positions.update(saved[0]) day_trading_state.closed_today.clear() day_trading_state.closed_today.update(saved[1]) day_trading_state.daily_pnl = saved[2] for name in _COLLS: getattr(es, name).clear() day_trading_state.exits_in_progress.clear() async def _drain_alerts(): await asyncio.gather(*list(es._alert_tasks), return_exceptions=True) def _tags(tg): return [c.args[0] for c in tg.await_args_list] def _pos(qty=4, entry=10_000.0, stop=9_700.0): return {"stock_name": "테스트", "strategy": "VWAP", "entry_price": entry, "quantity": qty, "stop_loss": stop, "target_price": entry * 1.045, "highest_price": entry, "entry_time": es._kst_now(), "reason": "", "order_id": "0000999"} def _cycle_patches(order_mock): """손절선 아래 현재가 → 1단계 손절 선판정이 매도 주문. 분봉 조회는 도달하면 안 된다.""" return ( patch.object(es, "is_eod_flat_time", return_value=False), patch.object(es, "close_stale_positions", AsyncMock(return_value=[])), patch.object(es, "_ensure_total_assets_for_exit_impl", AsyncMock(return_value=0.0)), patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "9500"}})), patch.object(es.korea_investment_api, "get_minute_chart_full_day", _boom("get_minute_chart_full_day")), patch("app.services.trade_executor.safe_place_order", order_mock), ) @pytest.mark.asyncio async def test_fast_stop_reject_counts_as_failure_and_alerts_after_n(_clean): code = "444444" day_trading_state.positions[code] = _pos() n = es._exit_check_fail_alert_cycles_impl() order = AsyncMock(return_value=dict(_REJECT)) p = _cycle_patches(order) with p[0], p[1], p[2], p[3], p[4], p[5]: for _ in range(n - 1): assert await es.check_and_exit_positions() == [] await _drain_alerts() assert "DT_EXIT_REJECT" not in _tags(_clean) # N-1 회까지는 경보 없음 # 1단계 handled 종목이라도 거부면 헬스가 '성공'으로 리셋되지 않는다 h = es.get_exit_check_health_v2() assert h["failing"] == {code: n - 1} assert h["order_rejects"] == {code: n - 1} await es.check_and_exit_positions() await es.check_and_exit_positions() # 같은 연속 구간 → 1회만 await _drain_alerts() tags = _tags(_clean) assert tags.count("DT_EXIT_REJECT") == 1 msg = next(c.args[1] for c in _clean.await_args_list if c.args[0] == "DT_EXIT_REJECT") assert "40910000" in msg and f"수량={_pos()['quantity']}주" in msg assert order.await_count == n + 1 # 매매 로직(매 사이클 재주문)은 그대로 assert code in day_trading_state.positions # 거부라 포지션 유지 assert es.get_exit_check_health_v2()["stalled"] == [code] @pytest.mark.asyncio async def test_accept_resets_reject_streak(_clean): code = "555555" day_trading_state.positions[code] = _pos() p = _cycle_patches(AsyncMock(return_value=dict(_REJECT))) with p[0], p[1], p[2], p[3], p[4], p[5]: await es.check_and_exit_positions() await es.check_and_exit_positions() assert es._exit_reject_streak[code] == 2 accepted = {"rt_cd": "0", "msg_cd": "", "msg1": "ok", "output": {"ODNO": "1"}} fin = AsyncMock(return_value=None) # 접수 이후 반영은 이 테스트 범위 밖 p = _cycle_patches(AsyncMock(return_value=accepted)) with p[0], p[1], p[2], p[3], p[4], p[5], patch.object(es, "_finalize_after_accept_impl", fin): await es.check_and_exit_positions() assert fin.await_count == 1 assert code not in es._exit_reject_streak assert code not in es._exit_reject_alerted assert es.get_exit_check_health_v2()["failing"] == {} @pytest.mark.asyncio async def test_stage2_reject_marks_bounded_failure_through_shield(_clean): """2단계(시간손절·트레일링 등)의 거부도 판정 실패로 수집된다(shield 태스크 경유 컨텍스트 전파).""" code = "666666" pos = _pos() day_trading_state.positions[code] = pos async def _fn(c): return await es._execute_exit(c, pos, 9_950.0, "시간손절 테스트", sell_pct=1.0) with patch("app.services.trade_executor.safe_place_order", AsyncMock(return_value=dict(_REJECT))): out, failed = await es._run_exit_checks_bounded_v2_impl([code], _fn, 5.0, "청산 판정") assert out == {code: None} assert failed == {code} assert es._exit_reject_streak[code] == 1 @pytest.mark.asyncio async def test_stale_path_reject_not_counted(_clean): """전일 경로 거부는 _stale_sell_failures 담당 — R4-3 카운터·경보에 넣지 않는다.""" code = "777777" pos = _pos() with patch("app.services.trade_executor.safe_place_order", AsyncMock(return_value=dict(_REJECT))): assert await es._execute_exit_stale(code, pos, 9_500.0, "전일 미청산") is None assert es._last_exit_order_outcome[code] == "rejected" assert code not in es._exit_reject_streak @pytest.mark.asyncio async def test_reject_counter_cleared_when_position_gone(_clean): code = "888888" day_trading_state.positions[code] = _pos() es._note_exit_order_rejected_impl(code, 4, "40910000", "x", "손절") assert es._exit_reject_streak[code] == 1 day_trading_state.positions.pop(code) es._update_exit_check_health_impl([code], set(), "청산 판정") assert code not in es._exit_reject_streak