"""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 window in UTC. 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. The # combined EU/US trading window is well covered by 07:00-21:00 UTC. active_start_hour: int = 7 active_end_hour: int = 21 # 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 self.active_start_hour <= now.hour < self.active_end_hour 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()