"""Polling scheduler — 시간대별 분기 + 휴장일 처리.""" from __future__ import annotations import json import logging from datetime import datetime, timedelta, time from pathlib import Path from zoneinfo import ZoneInfo logger = logging.getLogger(__name__) KST = ZoneInfo("Asia/Seoul") _HOLIDAYS_PATH = Path(__file__).parent / "holidays.json" _HOLIDAYS: set[str] = set(json.loads(_HOLIDAYS_PATH.read_text(encoding="utf-8"))) # Market windows (정규장) _PRE_OPEN = time(7, 0) _OPEN = time(9, 0) _CLOSE = time(15, 30) _POST_END = time(20, 0) # NXT windows (시간외) _NXT_PRE_END = time(23, 30) _NXT_POST_OPEN = time(4, 30) # 23:30 - 04:30 (dead zone) skip def _is_market_day(now: datetime) -> bool: """평일 + 휴장일 아닌 날.""" if now.weekday() >= 5: # Sat/Sun return False return now.strftime("%Y-%m-%d") not in _HOLIDAYS def _is_polling_window(now: datetime) -> bool: """폴링 윈도우: 07:00-23:30 + 04:30-07:00.""" t = now.time() return ( (_PRE_OPEN <= t < _NXT_PRE_END) or (_NXT_POST_OPEN <= t < _PRE_OPEN) ) def _next_interval(now: datetime) -> float: """다음 폴링까지 sleep 초수.""" if not _is_market_day(now): return _seconds_until_next_market_open(now) t = now.time() if _PRE_OPEN <= t < _OPEN: return 300.0 # 장전 5분 elif _OPEN <= t < _CLOSE: return 60.0 # 장중 1분 elif _CLOSE <= t < _POST_END: return 300.0 # 장후 5분 elif _POST_END <= t < _NXT_PRE_END: return 300.0 # NXT 야간 5분 elif _NXT_POST_OPEN <= t < _PRE_OPEN: return 300.0 # NXT 새벽 5분 else: # Dead zone (23:30-04:30) — wait until next 04:30 return _seconds_until_nxt_or_market_open(now) def _seconds_until_nxt_or_market_open(now: datetime) -> float: """다음 04:30 (NXT 새벽 start) 까지 초수. 휴장일은 다음 영업일 07:00.""" candidate = now.replace(hour=4, minute=30, second=0, microsecond=0) if candidate <= now: candidate += timedelta(days=1) for _ in range(14): if _is_market_day(candidate): return (candidate - now).total_seconds() candidate += timedelta(days=1) logger.warning("could not find next market day within 14 days") return 86400.0 def _is_post_close_trigger(now: datetime, last_post_close_date) -> bool: """F3 — 16:00 KST 이후 오늘 아직 post-close cycle 안 돌렸으면 True (상태기반). 이전엔 16:00:00-16:00:59 1분 윈도우라 5분 sleep + 비결정적 cycle 시작시각 조합으로 영영 못 잡는 경우 발생 (예: cycle이 15:31에 시작되면 16:01에 깸). Args: now: 현재 KST datetime. last_post_close_date: 마지막 post-close 실행 영업일 date (None=미실행). """ if not _is_market_day(now): return False if now.time() < time(16, 0): return False return last_post_close_date != now.date() def _seconds_until_next_market_open(now: datetime) -> float: """다음 영업일의 07:00 KST 까지 초수 (휴장일/주말용).""" candidate = now.replace(hour=7, minute=0, second=0, microsecond=0) if candidate <= now: candidate += timedelta(days=1) for _ in range(14): # safety bound (max 2 weeks of holidays) if _is_market_day(candidate): return (candidate - now).total_seconds() candidate += timedelta(days=1) logger.warning("could not find next market day within 14 days") return 86400.0