diff --git a/backend/app/core/market_schedule.py b/backend/app/core/market_schedule.py index 448ffe6..807a321 100644 --- a/backend/app/core/market_schedule.py +++ b/backend/app/core/market_schedule.py @@ -88,6 +88,19 @@ def seconds_until_market_open() -> float | None: return None +def is_pre_market_window(minutes_before: int = 30) -> bool: + """개장 직전 준비 구간(기본 08:30~09:00, 거래일만). + + 이 구간에 배치 분석을 미리 돌려 stock_analysis·추천 캐시를 채워두면 + 09:00 개장과 동시에 추천 종목이 보인다. (2026-09-23) + """ + now = datetime.now(KST) + if now.weekday() >= 5 or _is_holiday(now.date()): + return False + open_dt = now.replace(hour=9, minute=0, second=0, microsecond=0) + return (open_dt - timedelta(minutes=minutes_before)) <= now < open_dt + + def is_day_trade_entry_window() -> bool: """데이트레이딩 신규 진입 가능 시간 (09:30~14:59)""" now = datetime.now(KST) diff --git a/backend/app/services/schedulers.py b/backend/app/services/schedulers.py index 6bb7eb8..edcbcff 100644 --- a/backend/app/services/schedulers.py +++ b/backend/app/services/schedulers.py @@ -13,7 +13,9 @@ from datetime import datetime as _dt, date as _date, timedelta as _td from app.core.config import settings from app.core.database import AsyncSessionLocal -from app.core.market_schedule import is_market_open +from app.core.market_schedule import ( + is_market_open, seconds_until_market_open, is_pre_market_window, +) logger = logging.getLogger(__name__) @@ -102,8 +104,12 @@ async def batch_analysis_scheduler(): await asyncio.sleep(sleep_sec) continue - if not is_market_open(): - await asyncio.sleep(1800) + # [FIX 2026-09-23] 개장 전 준비 구간(08:30~09:00)에는 분석을 미리 돌려 + # stock_analysis·Redis 추천 캐시를 채워둔다 → 09:00에 추천 종목 즉시 표시. + # 그 외 마감 시간에는 개장을 넘겨 자지 않도록 sleep 상한을 둔다. + if not is_market_open() and not is_pre_market_window(): + _until_open = seconds_until_market_open() + await asyncio.sleep(1800.0 if _until_open is None else max(15.0, min(1800.0, _until_open))) continue # ── exponential backoff 재시도 (최대 3회) ──────────────────────── @@ -411,8 +417,11 @@ async def auto_trade_scheduler(): while True: try: if not is_market_open(): - logger.info("💤 Market is closed. Next check in 30 minutes.") - await asyncio.sleep(1800) + # 개장을 넘겨 자지 않도록 상한 (09:00부터 바로 매매 판단 시작) + _until_open = seconds_until_market_open() + _sleep = 1800.0 if _until_open is None else max(15.0, min(1800.0, _until_open)) + logger.info(f"💤 Market is closed. Next check in {_sleep / 60:.0f} minutes.") + await asyncio.sleep(_sleep) continue async with AsyncSessionLocal() as session: diff --git a/backend/app/services/swing/scheduler.py b/backend/app/services/swing/scheduler.py index f2bc1bf..fab120b 100644 --- a/backend/app/services/swing/scheduler.py +++ b/backend/app/services/swing/scheduler.py @@ -99,8 +99,11 @@ async def swing_scheduler() -> None: logger.info("[Swing] ── 사이클 시작 ──") if not is_market_open(): - logger.info("[Swing] 장 미오픈 — 30분 대기") - await asyncio.sleep(1800) + logger.info("[Swing] 장 미오픈 — 개장까지 대기") + # 개장을 넘겨 자지 않도록 상한 (2026-09-23) + from app.core.market_schedule import seconds_until_market_open + _until_open = seconds_until_market_open() + await asyncio.sleep(1800.0 if _until_open is None else max(15.0, min(1800.0, _until_open))) continue # ── Phase 1: 청산 체크 (별도 세션) ── diff --git a/backend/tests/test_market_open_wakeup.py b/backend/tests/test_market_open_wakeup.py index f2c63d9..5018e70 100644 --- a/backend/tests/test_market_open_wakeup.py +++ b/backend/tests/test_market_open_wakeup.py @@ -42,3 +42,15 @@ def test_sleep_never_overshoots_open(monkeypatch): 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 후)을 넘김" + + +def test_pre_market_window(monkeypatch): + """08:30~09:00은 개장 전 준비 구간 (배치 분석 선행 실행).""" + _at(monkeypatch, 2026, 9, 23, 8, 45) + assert ms.is_pre_market_window() is True + _at(monkeypatch, 2026, 9, 23, 8, 20) + assert ms.is_pre_market_window() is False + _at(monkeypatch, 2026, 9, 23, 9, 0) + assert ms.is_pre_market_window() is False # 개장 후는 정규 경로 + _at(monkeypatch, 2026, 9, 26, 8, 45) # 토요일 + assert ms.is_pre_market_window() is False