Files
auto-stock/backend/tests/test_dt_scheduler_exit_priority_20261009.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

460 lines
20 KiB
Python

"""데이트레이딩 스케줄러 — 청산 우선 회귀 테스트 (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