From 9fd02932fa895e53c6159ebad442202c83884ac7 Mon Sep 17 00:00:00 2001 From: shuim Date: Wed, 23 Sep 2026 09:07:57 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B0=9C=EC=9E=A5=20=EC=8B=9C=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EC=A2=85=EB=AA=A9=20=EC=A7=80=EC=97=B0=20?= =?UTF-8?q?=E2=80=94=20=EB=B0=B0=EC=B9=98=20=EB=B6=84=EC=84=9D=20=EA=B0=9C?= =?UTF-8?q?=EC=9E=A5=20=EC=A0=84=20=EC=84=A0=ED=96=89=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=20+=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=9E=A5=20=EC=A0=84=20=EA=B8=B0=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모든 스케줄러가 장 마감 중 고정 1800초를 자서 09:00 개장을 최대 30분 건너뛰었고, 배치 분석은 개장 후에야 시작돼 추천 종목이 09:00에 비어 있었음. - market_schedule.is_pre_market_window() 추가 (08:30~09:00, 거래일만) - batch_analysis_scheduler: 개장 전 준비 구간에도 분석 실행 → stock_analysis·Redis 추천 캐시를 미리 채워 09:00에 추천 즉시 표시 - batch/auto_trade/swing 스케줄러: 마감 중 sleep = min(1800, 개장까지 남은 시간) - 테스트 1건 추가 (총 5건) Co-Authored-By: Claude Opus 5 (1M context) --- backend/app/core/market_schedule.py | 13 +++++++++++++ backend/app/services/schedulers.py | 19 ++++++++++++++----- backend/app/services/swing/scheduler.py | 7 +++++-- backend/tests/test_market_open_wakeup.py | 12 ++++++++++++ 4 files changed, 44 insertions(+), 7 deletions(-) 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