scheduler._is_post_close_trigger: 16:00 KST ±1min detection (market day). pull_worker: - _run_post_close_cycle: daily fetch (60일) + chronos batch predict → state.chronos_predictions + state.daily_ohlcv. - update_minute_momentum_for_all: 매 cycle 마다 state.minute_momentum 갱신. - poll_loop signature 확장 (chronos optional). 45 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
100 lines
3.1 KiB
Python
100 lines
3.1 KiB
Python
"""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) -> bool:
|
|
"""16:00 KST ±1분 (post-close cycle 트리거). 평일/영업일만."""
|
|
if not _is_market_day(now):
|
|
return False
|
|
t = now.time()
|
|
return time(16, 0) <= t < time(16, 1)
|
|
|
|
|
|
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
|