"""When should expensive AI jobs fire? Markets matter. The scheduler wakes every hour, but there's no point spending OpenRouter tokens at 03:00 UTC on a Sunday when nothing has moved. This module encodes a single policy: weekday active hours (LSE open through NYSE close, roughly 07:00-21:00 UTC) get the full hourly cadence; off-hours and weekends get throttled. Used by ai_log_job and indicator_summary_job to decide whether to run NOW or skip until enough time has passed since the last successful run. Market / news / portfolio ingestion jobs keep running hourly — they're cheap. """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone @dataclass(frozen=True) class CadencePolicy: # Active trading windows in UTC. A timestamp is "active" if its hour # falls in ANY listed window. Add or remove tuples to change coverage. # # LSE opens 07:00 BST → 07:00 UTC summer / 08:00 UTC winter. # NYSE closes 16:00 ET → 21:00 UTC summer / 21:00 UTC winter. # Tokyo trades 09:00-15:00 JST → 00:00-06:00 UTC. # HK/Shanghai trade 09:30-16:00 local → 01:30-08:00 UTC. active_windows: tuple[tuple[int, int], ...] = ( (7, 21), # EU/US (LSE open through NYSE close) # (0, 8), # Asia (Tokyo + HK/Shanghai) — uncomment to add ) # Minimum gap between successful runs outside the active window. off_hours_gap_h: float = 4.0 weekend_gap_h: float = 12.0 def is_active_window(self, now: datetime | None = None) -> bool: now = now or datetime.now(timezone.utc) if now.weekday() >= 5: # Saturday / Sunday return False return any(start <= now.hour < end for start, end in self.active_windows) def min_gap_hours(self, now: datetime | None = None) -> float: now = now or datetime.now(timezone.utc) if now.weekday() >= 5: return self.weekend_gap_h if self.is_active_window(now): return 0.0 # always run during the active window return self.off_hours_gap_h def should_run( self, last_success_at: datetime | None, now: datetime | None = None, ) -> tuple[bool, str]: """Returns (should_run, reason). The reason is human-readable for logs and the job_runs.error column when a run is skipped.""" now = now or datetime.now(timezone.utc) if self.is_active_window(now): return True, "active window" min_gap = self.min_gap_hours(now) if last_success_at is None: return True, "no prior successful run" # Normalise tz; DB returns naive but we treat it as UTC. if last_success_at.tzinfo is None: last_success_at = last_success_at.replace(tzinfo=timezone.utc) age_h = (now - last_success_at).total_seconds() / 3600.0 if age_h >= min_gap: return True, f"off-hours but last run {age_h:.1f}h ago (≥ {min_gap}h)" return False, f"off-hours throttled — last run {age_h:.1f}h ago (< {min_gap}h)" DEFAULT_POLICY = CadencePolicy()