"""데이트레이딩 스케줄러 — 청산 우선 회귀 테스트 (2026-10-09, 하드닝 루프 라운드 1). 결함 1: DAY_TRADE_ENABLED=False 면 루프가 sleep+continue 로 EOD·전일/고아·손절 청산까지 건너뛰었다(§8.1-11 위반). → 비활성은 신규 진입(ORB 수집·watchlist·스캔)만 막는다. 결함 2: 진입 스캔(최대 150초)·ORB 수집 동안 보유 포지션의 손절 판정이 멈췄다. → _run_with_stop_watch_impl 이 단계와 병행해 손절 선판정을 주기 실행한다. 목킹 원칙(§4.1): 호출되면 안 되는 경로는 BaseException 발생형, 매도 목은 공통 종단 (_execute_exit_impl)에 건다. 기대값은 settings·모듈 상수에서 계산한다. """ import asyncio import contextlib from unittest.mock import AsyncMock, patch import pytest from app.core.config import settings from app.services.daytrading import exit_service as es from app.services.daytrading import scheduler as sched from app.services.daytrading.position_manager import day_trading_state, dt_cycle_lock class MustNotBeCalled(BaseException): """호출되면 안 되는 경로 도달 (except Exception 에 삼켜지지 않게).""" class _StopLoop(BaseException): """스케줄러 무한 루프 탈출용.""" def _boom(name): async def _f(*a, **k): raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") return _f def _boom_coro_factory(name): """scan_and_enter() 처럼 '호출 결과 코루틴'을 넘기는 자리 — 호출 즉시 예외.""" def _f(*a, **k): raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") return _f def _pos(entry=10_000.0, qty=10, stop=9_700.0, entry_time=None): return { "stock_name": "테스트", "strategy": "VWAP", "entry_price": entry, "quantity": qty, "stop_loss": stop, "target_price": entry * 1.05, "highest_price": entry, "entry_time": entry_time or es._kst_now(), "reason": "", "order_id": "0000999", } @pytest.fixture(autouse=True) def _isolate_state(): saved = dict(day_trading_state.positions) saved_closed = set(day_trading_state.closed_today) saved_assets = day_trading_state.last_total_assets saved_pnl = day_trading_state.daily_pnl saved_streak = day_trading_state.loss_streak_today saved_orb = dict(day_trading_state.orb_cache) day_trading_state.positions.clear() day_trading_state.closed_today.clear() day_trading_state.daily_pnl = 0.0 day_trading_state.loss_streak_today = 0 day_trading_state.last_reset_date = es._kst_now().date() day_trading_state.exits_in_progress.clear() es._exit_tokens.clear() sched._last_disabled_orphan_check = 0.0 sched._orb_backfill_pending = False with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)): yield day_trading_state.positions.clear() day_trading_state.positions.update(saved) day_trading_state.closed_today.clear() day_trading_state.closed_today.update(saved_closed) day_trading_state.last_total_assets = saved_assets day_trading_state.daily_pnl = saved_pnl day_trading_state.loss_streak_today = saved_streak day_trading_state.orb_cache.clear() day_trading_state.orb_cache.update(saved_orb) day_trading_state.exits_in_progress.clear() sched._last_disabled_orphan_check = 0.0 sched._orb_backfill_pending = False class _FakeSession: async def __aenter__(self): return object() async def __aexit__(self, *a): return False @contextlib.contextmanager def _scheduler_env(*, enabled, eod=False, orb=False, entry_window=False, cycles=1, stale_mock=None, **overrides): """daytrading_scheduler 를 `cycles` 사이클만 돌리는 환경. 기본은 진입·청산 전부 금지(boom).""" from app.core import database sleeps = {"n": 0} async def _sleep(sec): sleeps["n"] += 1 if sleeps["n"] > cycles: # 첫 호출은 기동 대기(5초) raise _StopLoop() mocks = { "force_close_all": _boom("force_close_all"), "check_and_exit_positions": _boom("check_and_exit_positions"), "check_stops_only_v2": _boom("check_stops_only_v2"), "scan_and_enter": _boom_coro_factory("scan_and_enter"), "collect_opening_ranges": _boom_coro_factory("collect_opening_ranges"), "populate_daytrading_watchlist": _boom("populate_daytrading_watchlist"), } mocks.update(overrides) with contextlib.ExitStack() as st: st.enter_context(patch.object(sched, "_cycle_sleep", _sleep)) st.enter_context(patch.object(settings, "DAY_TRADE_ENABLED", enabled)) st.enter_context(patch.object(sched, "is_market_open", lambda: True)) st.enter_context(patch.object(sched, "is_eod_flat_time", lambda: eod)) st.enter_context(patch.object(sched, "is_opening_range_period", lambda: orb)) st.enter_context(patch.object(sched, "is_day_trade_entry_window", lambda: entry_window)) st.enter_context(patch.object(sched, "get_watchlist", lambda: {"005930": "삼성전자"})) st.enter_context(patch.object(sched, "is_watchlist_auto", lambda: False)) st.enter_context(patch.object(day_trading_state, "begin_restore", lambda: True)) st.enter_context(patch.object(day_trading_state, "load_daily_state", AsyncMock(return_value=True))) st.enter_context(patch.object(day_trading_state, "load_orb_cache", AsyncMock(return_value=0))) st.enter_context(patch.object(day_trading_state, "restore_from_db", AsyncMock(return_value=0))) st.enter_context(patch.object(database, "AsyncSessionLocal", lambda: _FakeSession())) st.enter_context(patch.object( es, "close_stale_positions", stale_mock or AsyncMock(return_value=[]))) if enabled: st.enter_context(patch.object( sched.mdd_manager, "is_short_term_strategy_enabled", AsyncMock(return_value=True))) else: # 비활성이면 진입 판단용 MDD 조회도 하지 않는다 st.enter_context(patch.object( sched.mdd_manager, "is_short_term_strategy_enabled", _boom("is_short_term_strategy_enabled"))) for name, m in mocks.items(): st.enter_context(patch.object(sched, name, m)) yield sleeps async def _run_scheduler(): with pytest.raises(_StopLoop): await asyncio.wait_for(sched.daytrading_scheduler(), timeout=5.0) # ═════════════════════════════════════════════════════════════════════════════ # 결함 1 — DAY_TRADE_ENABLED=False 여도 청산은 계속 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_disabled_still_runs_eod_force_close(): day_trading_state.positions["111111"] = _pos() fc = AsyncMock(return_value=[{"stock_code": "111111"}]) with _scheduler_env(enabled=False, eod=True, force_close_all=fc): await _run_scheduler() assert fc.await_count == 1 @pytest.mark.asyncio async def test_disabled_eod_without_positions_checks_orphans(): stale = AsyncMock(return_value=[]) with _scheduler_env(enabled=False, eod=True, stale_mock=stale): await _run_scheduler() # 기동 시 1회 + EOD 사이클 1회 assert stale.await_count == 2 @pytest.mark.asyncio async def test_disabled_entry_window_runs_exit_check_but_no_entry(): day_trading_state.positions["111111"] = _pos() ex = AsyncMock(return_value=[]) with _scheduler_env(enabled=False, entry_window=True, check_and_exit_positions=ex): await _run_scheduler() # scan/populate/MDD 는 boom assert ex.await_count == 1 @pytest.mark.asyncio async def test_disabled_orb_period_runs_stop_check_but_no_collection(): day_trading_state.positions["111111"] = _pos() stops = AsyncMock(return_value=[]) with _scheduler_env(enabled=False, orb=True, check_stops_only_v2=stops): await _run_scheduler() # collect_opening_ranges/populate 는 boom assert stops.await_count == 1 @pytest.mark.asyncio async def test_disabled_without_positions_throttles_orphan_check(): """보유 0 + 비활성이면 고아 점검은 DISABLED_ORPHAN_CHECK_INTERVAL_SEC 주기로만.""" assert sched.DISABLED_ORPHAN_CHECK_INTERVAL_SEC > 60 ex = AsyncMock(return_value=[]) with _scheduler_env(enabled=False, cycles=3, check_and_exit_positions=ex): await _run_scheduler() assert ex.await_count == 1 # 첫 사이클만, 이후 2사이클은 간격 안이라 생략 @pytest.mark.asyncio async def test_disabled_with_positions_checks_every_cycle(): day_trading_state.positions["111111"] = _pos() ex = AsyncMock(return_value=[]) with _scheduler_env(enabled=False, cycles=3, check_and_exit_positions=ex): await _run_scheduler() assert ex.await_count == 3 @pytest.mark.asyncio async def test_enabled_entry_window_scans_under_lock_with_stop_watch(): """활성 경로 회귀 — 스캔은 dt_cycle_lock 아래, 손절 감시 래퍼(lock_held=True)로 실행.""" seen = {} async def _scan(): seen["scan_locked"] = dt_cycle_lock.locked() return [] async def _wrapper(coro, step, timeout, *, lock_held, abort_on_entry_block=False): seen["args"] = (step, timeout, lock_held, abort_on_entry_block) return await coro ex = AsyncMock(return_value=[]) with _scheduler_env(enabled=True, entry_window=True, check_and_exit_positions=ex, scan_and_enter=_scan), \ patch.object(sched, "_run_with_stop_watch_impl", _wrapper): await _run_scheduler() assert ex.await_count == 1 assert seen["scan_locked"] is True assert seen["args"] == ("진입 스캔", sched.STEP_TIMEOUT, True, True) # ═════════════════════════════════════════════════════════════════════════════ # 결함 2 — 진입 스캔 중 손절 감시 # ═════════════════════════════════════════════════════════════════════════════ @pytest.fixture def fast_watch(monkeypatch): monkeypatch.setattr(sched, "SCAN_STOP_WATCH_INTERVAL_SEC", 0.03) monkeypatch.setattr(sched, "_SCAN_STOP_WATCH_MIN_SEC", 0.01) monkeypatch.setattr(settings, "DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC", 1.0) return 0.03 def test_stop_watch_interval_defaults_and_floor(monkeypatch): assert sched._stop_watch_interval() == max( sched._SCAN_STOP_WATCH_MIN_SEC, sched.SCAN_STOP_WATCH_INTERVAL_SEC) # 비정상 설정값은 기본값으로, 너무 작은 값은 하한으로 (config 에 키가 없어 대체 객체로 주입) from types import SimpleNamespace monkeypatch.setattr(sched, "settings", SimpleNamespace(DAY_TRADE_SCAN_STOP_WATCH_INTERVAL_SEC=-1)) assert sched._stop_watch_interval() == max( sched._SCAN_STOP_WATCH_MIN_SEC, sched.SCAN_STOP_WATCH_INTERVAL_SEC) monkeypatch.setattr(sched, "settings", SimpleNamespace(DAY_TRADE_SCAN_STOP_WATCH_INTERVAL_SEC=0.001)) assert sched._stop_watch_interval() == sched._SCAN_STOP_WATCH_MIN_SEC monkeypatch.setattr(sched, "settings", SimpleNamespace(DAY_TRADE_SCAN_STOP_WATCH_INTERVAL_SEC="x")) assert sched._stop_watch_interval() == max( sched._SCAN_STOP_WATCH_MIN_SEC, sched.SCAN_STOP_WATCH_INTERVAL_SEC) # 스캔 _guard(STEP_TIMEOUT) 보다 충분히 짧아야 의미가 있다 assert sched.SCAN_STOP_WATCH_INTERVAL_SEC < sched.STEP_TIMEOUT / 4 @pytest.mark.asyncio async def test_stop_loss_fires_during_long_scan(fast_watch): day_trading_state.positions["111111"] = _pos(stop=9_700.0) day_trading_state.positions["222222"] = _pos(stop=9_700.0) prices = {"111111": "9600", "222222": "10500"} state = {"scan_done": False} sold = [] async def _exec(code, pos, price, reason, sell_pct, is_stale): sold.append((code, state["scan_done"], is_stale)) day_trading_state.positions.pop(code, None) return {"stock_code": code} async def _slow_scan(): await asyncio.sleep(fast_watch * 8) state["scan_done"] = True return ["entry"] with patch.object(es.korea_investment_api, "get_current_price", AsyncMock(side_effect=lambda c: {"output": {"stck_prpr": prices[c]}})), \ patch.object(es.korea_investment_api, "get_minute_chart_full_day", _boom("get_minute_chart_full_day")), \ patch.object(es, "_execute_exit_impl", _exec): async with dt_cycle_lock: # 스케줄러처럼 락을 쥔 채 — 감시가 교착되면 안 된다 out = await asyncio.wait_for( sched._run_with_stop_watch_impl( _slow_scan(), "진입 스캔", 5, lock_held=True, abort_on_entry_block=True), timeout=3.0, ) assert out == ["entry"] assert sold == [("111111", False, False)], "스캔이 끝나기 전에 손절 이탈 종목만 매도" assert "222222" in day_trading_state.positions @pytest.mark.asyncio async def test_scan_aborted_when_watch_exit_triggers_cooldown(fast_watch): day_trading_state.positions["111111"] = _pos(stop=9_700.0) entered = [] async def _exec(code, pos, price, reason, sell_pct, is_stale): day_trading_state.positions.pop(code, None) day_trading_state.loss_streak_today = 3 # 이 청산으로 3연패 return {"stock_code": code} async def _slow_scan(): await asyncio.sleep(fast_watch * 20) entered.append("late-entry") return ["late-entry"] with patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "9600"}})), \ patch.object(es, "_execute_exit_impl", _exec): out = await asyncio.wait_for( sched._run_with_stop_watch_impl( _slow_scan(), "진입 스캔", 5, lock_held=True, abort_on_entry_block=True), timeout=3.0, ) assert out is None assert entered == [], "쿨다운이 걸린 뒤의 진입은 일어나면 안 된다" @pytest.mark.asyncio async def test_scan_aborted_when_watch_exit_breaches_daily_loss(fast_watch, monkeypatch): day_trading_state.positions["111111"] = _pos(stop=9_700.0) day_trading_state.last_total_assets = 10_000_000.0 limit = sched.is_daily_loss_exceeded # 실제 판정 함수(설정값 기반)를 그대로 쓴다 entered = [] async def _exec(code, pos, price, reason, sell_pct, is_stale): day_trading_state.positions.pop(code, None) day_trading_state.daily_pnl = -day_trading_state.last_total_assets # -100% 실현 return {"stock_code": code} async def _slow_scan(): await asyncio.sleep(fast_watch * 20) entered.append("late-entry") return [] with patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "9600"}})), \ patch.object(es, "_execute_exit_impl", _exec): out = await asyncio.wait_for( sched._run_with_stop_watch_impl( _slow_scan(), "진입 스캔", 5, lock_held=True, abort_on_entry_block=True), timeout=3.0, ) assert limit(day_trading_state.last_total_assets, day_trading_state.daily_pnl) assert out is None and entered == [] @pytest.mark.asyncio async def test_orb_collection_not_aborted_by_watch(fast_watch): """abort_on_entry_block=False(ORB 수집)는 감시 청산이 있어도 단계를 끝까지 수행.""" day_trading_state.positions["111111"] = _pos(stop=9_700.0) async def _exec(code, pos, price, reason, sell_pct, is_stale): day_trading_state.positions.pop(code, None) day_trading_state.loss_streak_today = 3 return {"stock_code": code} async def _collect(): await asyncio.sleep(fast_watch * 5) return "collected" with patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "9600"}})), \ patch.object(es, "_execute_exit_impl", _exec): out = await asyncio.wait_for( sched._run_with_stop_watch_impl(_collect(), "ORB 수집", 5, lock_held=False), timeout=3.0, ) assert out == "collected" assert "111111" not in day_trading_state.positions @pytest.mark.asyncio async def test_unlocked_watch_skips_when_other_cycle_holds_lock(fast_watch): """락 밖 단계(ORB 수집)의 감시는 /cycle 등 다른 사이클이 락을 쥐면 그 라운드를 건너뛴다.""" day_trading_state.positions["111111"] = _pos(stop=9_700.0) async def _collect(): await asyncio.sleep(fast_watch * 5) return "ok" with patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \ patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): await dt_cycle_lock.acquire() try: out = await asyncio.wait_for( sched._run_with_stop_watch_impl(_collect(), "ORB 수집", 5, lock_held=False), timeout=3.0, ) finally: dt_cycle_lock.release() assert out == "ok" @pytest.mark.asyncio async def test_watch_ignores_stale_positions(fast_watch): """전일 포지션은 count_daily_pnl=False 경로(close_stale_positions) 몫 — 감시가 당일 경로로 팔면 안 된다.""" from datetime import timedelta day_trading_state.positions["111111"] = _pos( stop=9_700.0, entry_time=es._kst_now() - timedelta(days=1)) with patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \ patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): assert await sched._stop_watch_round_impl(lock_held=True) == [] @pytest.mark.asyncio async def test_watch_error_does_not_break_scan(fast_watch): day_trading_state.positions["111111"] = _pos(stop=9_700.0) async def _scan(): await asyncio.sleep(fast_watch * 4) return ["e"] with patch.object(sched, "_stop_watch_round_impl", AsyncMock(side_effect=RuntimeError("시세 장애"))): out = await asyncio.wait_for( sched._run_with_stop_watch_impl(_scan(), "진입 스캔", 5, lock_held=True, abort_on_entry_block=True), timeout=3.0, ) assert out == ["e"] @pytest.mark.asyncio async def test_outer_cancellation_propagates(fast_watch): """스케줄러 종료(바깥 취소)는 흡수하지 않고 전파 — 단계와 감시도 함께 정리.""" scan_cancelled = asyncio.Event() async def _scan(): try: await asyncio.sleep(10) except asyncio.CancelledError: scan_cancelled.set() raise task = asyncio.ensure_future( sched._run_with_stop_watch_impl(_scan(), "진입 스캔", 30, lock_held=True, abort_on_entry_block=True)) await asyncio.sleep(fast_watch * 2) task.cancel() with pytest.raises(asyncio.CancelledError): await task assert scan_cancelled.is_set() @pytest.mark.asyncio async def test_guard_timeout_still_applies(fast_watch): async def _hang(): await asyncio.sleep(10) out = await asyncio.wait_for( sched._run_with_stop_watch_impl(_hang(), "진입 스캔", 0.1, lock_held=True), timeout=3.0, ) assert out is None