feat(signal_v2-phase3a): scheduler NXT windows (20:00-23:30 / 04:30-07:00)
NXT 시간외 거래 시간대도 5분 cron 폴링 활성화. 23:30-04:30 dead zone (KIS 점검) → 04:30 까지 skip. 기존 _seconds_until_next_market_open (휴장일/주말용) 와 별개로 _seconds_until_nxt_or_market_open 신설. 3 new tests, scheduler suite 11 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,12 +12,17 @@ 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
|
||||
# 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:
|
||||
"""평일 + 휴장일 아닌 날."""
|
||||
@@ -27,8 +32,12 @@ def _is_market_day(now: datetime) -> bool:
|
||||
|
||||
|
||||
def _is_polling_window(now: datetime) -> bool:
|
||||
"""현재 시각이 폴링 윈도우 (07:00-20:00) 안인가."""
|
||||
return _PRE_OPEN <= now.time() < _POST_END
|
||||
"""폴링 윈도우: 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:
|
||||
@@ -38,17 +47,37 @@ def _next_interval(now: datetime) -> float:
|
||||
|
||||
t = now.time()
|
||||
if _PRE_OPEN <= t < _OPEN:
|
||||
return 300.0
|
||||
return 300.0 # 장전 5분
|
||||
elif _OPEN <= t < _CLOSE:
|
||||
return 60.0
|
||||
return 60.0 # 장중 1분
|
||||
elif _CLOSE <= t < _POST_END:
|
||||
return 300.0
|
||||
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:
|
||||
return _seconds_until_next_market_open(now)
|
||||
# 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 _seconds_until_next_market_open(now: datetime) -> float:
|
||||
"""다음 영업일의 07:00 KST 까지 초수."""
|
||||
"""다음 영업일의 07:00 KST 까지 초수 (휴장일/주말용)."""
|
||||
candidate = now.replace(hour=7, minute=0, second=0, microsecond=0)
|
||||
if candidate <= now:
|
||||
candidate += timedelta(days=1)
|
||||
@@ -58,5 +87,5 @@ def _seconds_until_next_market_open(now: datetime) -> float:
|
||||
return (candidate - now).total_seconds()
|
||||
candidate += timedelta(days=1)
|
||||
|
||||
logger.warning("could not find next market day within 14 days; using +1 day")
|
||||
logger.warning("could not find next market day within 14 days")
|
||||
return 86400.0
|
||||
|
||||
@@ -26,10 +26,10 @@ def test_next_interval_post_market_5min():
|
||||
|
||||
|
||||
def test_next_interval_overnight_skip_to_next_morning():
|
||||
now = _kst(2026, 5, 18, 22, 0) # Monday 22:00
|
||||
now = _kst(2026, 5, 18, 2, 30) # Monday 02:30 (dead zone, not NXT window)
|
||||
interval = _next_interval(now)
|
||||
# Next polling: Tuesday 07:00 (9 hours away)
|
||||
assert 9 * 3600 - 60 < interval < 9 * 3600 + 60
|
||||
# Dead zone 23:30-04:30 → next 04:30 is ~2h away
|
||||
assert 2 * 3600 - 60 < interval < 2 * 3600 + 60
|
||||
|
||||
|
||||
def test_next_interval_holiday_skip():
|
||||
@@ -54,8 +54,28 @@ def test_next_interval_at_market_close_boundary():
|
||||
|
||||
|
||||
def test_next_interval_at_polling_window_end_boundary():
|
||||
"""20:00:00 정확 second → overnight skip (다음 영업일 07:00 까지)."""
|
||||
now = _kst(2026, 5, 18, 20, 0) # Monday 20:00:00
|
||||
"""23:30:00 정확 second → dead zone skip (다음 04:30 까지)."""
|
||||
now = _kst(2026, 5, 18, 23, 30) # Monday 23:30:00 (NXT_PRE_END boundary)
|
||||
interval = _next_interval(now)
|
||||
# Next: Tuesday 07:00 — 11h away
|
||||
assert 11 * 3600 - 60 < interval < 11 * 3600 + 60
|
||||
# Dead zone 23:30-04:30 → next 04:30 is ~5h away
|
||||
assert 5 * 3600 - 60 < interval < 5 * 3600 + 60
|
||||
|
||||
|
||||
def test_next_interval_nxt_evening_5min():
|
||||
"""22:00 평일 (NXT 야간) → 300 (5분)."""
|
||||
now = _kst(2026, 5, 18, 22, 0)
|
||||
assert _next_interval(now) == 300
|
||||
|
||||
|
||||
def test_next_interval_nxt_dawn_5min():
|
||||
"""05:30 평일 (NXT 새벽) → 300 (5분)."""
|
||||
now = _kst(2026, 5, 18, 5, 30)
|
||||
assert _next_interval(now) == 300
|
||||
|
||||
|
||||
def test_next_interval_dead_zone_skip():
|
||||
"""02:00 평일 (dead zone 23:30-04:30) → 다음 04:30 까지 (~9000s)."""
|
||||
now = _kst(2026, 5, 18, 2, 0)
|
||||
interval = _next_interval(now)
|
||||
# 02:00 → 04:30 = 2.5h = 9000s
|
||||
assert 9000 - 60 < interval < 9000 + 60
|
||||
|
||||
Reference in New Issue
Block a user