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
This commit is contained in:
459
backend/tests/test_dt_scheduler_exit_priority_20261009.py
Normal file
459
backend/tests/test_dt_scheduler_exit_priority_20261009.py
Normal file
@@ -0,0 +1,459 @@
|
||||
"""데이트레이딩 스케줄러 — 청산 우선 회귀 테스트 (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
|
||||
Reference in New Issue
Block a user