From 546cf17edb0bf4fdd9518af0e60a469e7ecbee55 Mon Sep 17 00:00:00 2001 From: shuim Date: Wed, 23 Sep 2026 09:05:36 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9E=A5=20=EB=A7=88=EA=B0=90=20?= =?UTF-8?q?=EC=A4=91=2030=EB=B6=84=20sleep=EC=9D=B4=2009:00=20=EA=B0=9C?= =?UTF-8?q?=EC=9E=A5=EC=9D=84=20=EA=B1=B4=EB=84=88=EB=9B=B0=EC=96=B4=20ORB?= =?UTF-8?q?=20=EC=88=98=EC=A7=91=20=EB=88=84=EB=9D=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 데이트레이딩 스케줄러는 장 마감 시 무조건 1800초를 자서, 08:31~09:00 사이에 잠들면 09:01~09:30에 깨어나 ORB 수집 구간(09:00~09:30)을 통째로 놓쳤음(2026-09-23 감시종목 0 확인). - market_schedule.seconds_until_market_open() 추가 (주말·공휴일 건너뜀) - 스케줄러: 마감 중 sleep = min(1800, 개장까지 남은 시간), 개장 3분 전 watchlist 선구성 - 회귀 테스트 4건 Co-Authored-By: Claude Opus 5 (1M context) --- backend/app/core/market_schedule.py | 24 ++++++++++- backend/app/services/daytrading/scheduler.py | 15 ++++++- backend/tests/test_market_open_wakeup.py | 44 ++++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_market_open_wakeup.py diff --git a/backend/app/core/market_schedule.py b/backend/app/core/market_schedule.py index fe60a2a..448ffe6 100644 --- a/backend/app/core/market_schedule.py +++ b/backend/app/core/market_schedule.py @@ -4,7 +4,7 @@ - KST 기준 장 시작/종료 시간 판별 - 공휴일, 주말 휴장 체크 """ -from datetime import datetime, time, date as date_type +from datetime import datetime, time, date as date_type, timedelta try: import pytz @@ -66,6 +66,28 @@ def is_opening_range_period() -> bool: return time(9, 0) <= t < time(9, 30) +def seconds_until_market_open() -> float | None: + """다음 정규장 개장(09:00 KST)까지 남은 초. 이미 장중이면 None. + + 장 마감 중 긴 sleep이 개장 시각을 건너뛰어 ORB 수집(09:00~09:30)을 통째로 + 놓치는 문제를 막기 위해 스케줄러가 사용한다. (2026-09-23) + 최대 7일 앞까지 탐색(주말·공휴일 건너뜀), 찾지 못하면 None. + """ + if is_market_open(): + return None + now = datetime.now(KST) + for day_offset in range(8): + cand = (now + timedelta(days=day_offset)).replace( + hour=9, minute=0, second=0, microsecond=0 + ) + if cand <= now: + continue + if cand.weekday() >= 5 or _is_holiday(cand.date()): + continue + return (cand - now).total_seconds() + return None + + def is_day_trade_entry_window() -> bool: """데이트레이딩 신규 진입 가능 시간 (09:30~14:59)""" now = datetime.now(KST) diff --git a/backend/app/services/daytrading/scheduler.py b/backend/app/services/daytrading/scheduler.py index 3b7062d..0dd10cf 100644 --- a/backend/app/services/daytrading/scheduler.py +++ b/backend/app/services/daytrading/scheduler.py @@ -27,6 +27,7 @@ from app.core.config import settings from app.core.market_schedule import ( is_market_open, is_opening_range_period, is_day_trade_entry_window, is_eod_flat_time, + seconds_until_market_open, ) from app.services.mdd_manager import mdd_manager from app.services.daytrading.position_manager import day_trading_state @@ -75,7 +76,19 @@ async def daytrading_scheduler() -> None: day_trading_state.reset_if_new_day() if not is_market_open(): - await asyncio.sleep(1800) + # [FIX 2026-09-23] 고정 1800초 sleep은 09:00 개장을 건너뛰어 + # ORB 수집(09:00~09:30)을 통째로 놓칠 수 있다 → 개장까지 남은 시간만큼만 대기. + _until_open = seconds_until_market_open() + # 개장 3분 전이면 watchlist를 미리 채워 09:00부터 ORB 수집 가능 + if _until_open is not None and _until_open <= 180 and not get_watchlist(): + try: + count = await populate_daytrading_watchlist() + if count: + logger.info(f"[DayTrading] 개장 전 watchlist 선구성: {count}개") + except Exception as e: + logger.warning(f"[DayTrading] 개장 전 watchlist 구성 실패: {e}") + _sleep = 1800.0 if _until_open is None else max(15.0, min(1800.0, _until_open)) + await asyncio.sleep(_sleep) continue if not settings.DAY_TRADE_ENABLED: diff --git a/backend/tests/test_market_open_wakeup.py b/backend/tests/test_market_open_wakeup.py new file mode 100644 index 0000000..f2c63d9 --- /dev/null +++ b/backend/tests/test_market_open_wakeup.py @@ -0,0 +1,44 @@ +"""장 마감 중 sleep이 개장(09:00)을 건너뛰지 않는지 검증 — ORB 수집 누락 방지.""" +from datetime import datetime, timedelta + +import pytest + +from app.core import market_schedule as ms + + +def _at(monkeypatch, y, m, d, hh, mm): + fake = datetime(y, m, d, hh, mm, tzinfo=ms.KST) + + class _DT(datetime): + @classmethod + def now(cls, tz=None): + return fake if tz else fake.replace(tzinfo=None) + + monkeypatch.setattr(ms, "datetime", _DT) + return fake + + +def test_seconds_until_open_same_day(monkeypatch): + _at(monkeypatch, 2026, 9, 23, 8, 59) # 수요일 08:59 + assert ms.seconds_until_market_open() == pytest.approx(60, abs=1) + + +def test_none_during_market_hours(monkeypatch): + _at(monkeypatch, 2026, 9, 23, 10, 0) + assert ms.seconds_until_market_open() is None + + +def test_skips_weekend(monkeypatch): + _at(monkeypatch, 2026, 9, 25, 16, 0) # 금요일 폐장 후 + secs = ms.seconds_until_market_open() + nxt = datetime(2026, 9, 25, 16, 0, tzinfo=ms.KST) + timedelta(seconds=secs) + assert nxt.weekday() == 0 and (nxt.hour, nxt.minute) == (9, 0) # 다음 월요일 09:00 + + +def test_sleep_never_overshoots_open(monkeypatch): + """스케줄러가 쓰는 계산식이 개장을 넘겨 자지 않는다.""" + for hh, mm in [(8, 0), (8, 59), (7, 30), (6, 0)]: + _at(monkeypatch, 2026, 9, 23, hh, mm) + until = ms.seconds_until_market_open() + sleep = 1800.0 if until is None else max(15.0, min(1800.0, until)) + assert sleep <= until, f"{hh}:{mm} — sleep {sleep}s가 개장({until}s 후)을 넘김"