Refactored CadencePolicy.active_start_hour/active_end_hour into a tuple of (start, end) hour pairs so additional regional windows can be added without code changes. Default keeps EU/US-only behaviour identical. The Asia window (00:00-08:00 UTC — Tokyo + HK + Shanghai) is included as a commented-out tuple in the dataclass default. Uncomment one line to enable hourly AI cadence during the Asia session as well. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
72 lines
3 KiB
Python
72 lines
3 KiB
Python
"""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()
|