"""Daily slot preallocation scheduler (dcy-014 / REQ-005, issue #14). trading_date is computed in a configurable timezone (default Asia/Seoul) so the DB trading_date matches the local trading calendar, while all timestamps stay UTC (NFR-1). Modeled on SnapshotScheduler (REQ-030): a tick loop fires once per TZ-day at/after the configured trigger time, calling the idempotent `preallocate_daily_slots` so the next day's slots are created without a daemon restart (NFR-2/NFR-3). """ from __future__ import annotations import logging import threading from datetime import date, datetime, time, timezone from typing import Callable, Optional, Protocol _log = logging.getLogger(__name__) class _Preallocator(Protocol): def preallocate_daily_slots(self, trading_date: date, count: int) -> int: ... class DailyPreallocationScheduler: """Background-thread-friendly daily preallocation. Use `run_once()` for tests.""" def __init__( self, *, slot_service: _Preallocator, total_slots: int, tz: timezone, schedule_time: time = time(hour=0, minute=0), clock: Optional[Callable[[], datetime]] = None, ) -> None: self._svc = slot_service self._total = total_slots self._tz = tz # tzinfo used to derive trading_date (default Asia/Seoul) self._schedule = schedule_time self._clock = clock or (lambda: datetime.now(tz=timezone.utc)) self._last_fired_date: Optional[date] = None # ---- callable from a daemon thread loop -------------------------------- def run_forever(self, stop: threading.Event, *, tick_sec: float = 60.0) -> None: # Boot: ensure the current TZ-day's slots exist immediately (idempotent), # then enter the daily cadence for subsequent day boundaries. self.run_once() while not stop.wait(tick_sec): try: self._maybe_tick() except Exception: # noqa: BLE001 - loop must survive transient errors _log.exception("preallocation scheduler tick failed") def _maybe_tick(self) -> None: now_tz = self._clock().astimezone(self._tz) target = datetime.combine(now_tz.date(), self._schedule, tzinfo=self._tz) if now_tz < target: return # before today's trigger time if self._last_fired_date is not None and self._last_fired_date == now_tz.date(): return # already created today's slots self.run_once() # ---- the actual preallocation ----------------------------------------- def run_once(self) -> date: """Preallocate slots for the current TZ trading_date (idempotent). Returns it.""" trading_date = self._clock().astimezone(self._tz).date() created = self._svc.preallocate_daily_slots(trading_date, self._total) if created: _log.info("preallocated %d slots for trading_date=%s", created, trading_date) self._last_fired_date = trading_date return trading_date