from datetime import datetime, time, timedelta from zoneinfo import ZoneInfo from app.bars.models import Timeframe UTC = ZoneInfo("UTC") EASTERN = ZoneInfo("America/New_York") SESSION_OPEN = time(18, 0) SESSION_CLOSE = time(17, 0) def _session_open_local(current: datetime) -> datetime: session_date = current.date() if current.timetz().replace(tzinfo=None) >= SESSION_OPEN else current.date() - timedelta(days=1) return datetime.combine(session_date, SESSION_OPEN, EASTERN) def bucket_start(t: int, tf: Timeframe) -> int: # Everything below a day divides the hour evenly, so UTC boundaries are # correct and session anchoring is unnecessary. Only the daily bar needs to # know that the CME trading day runs 18:00 to 17:00 ET. if tf is not Timeframe.D1: return (t // tf.seconds) * tf.seconds current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN) return int(_session_open_local(current).timestamp()) def bucket_duration(t: int, tf: Timeframe) -> int: """Wall-clock span of one logical bar bucket. Intraday buckets are fixed. A daily line advances only through the active 18:00-17:00 ET session, not the settlement halt; constructing the close in Eastern keeps DST transitions correct without shifting stored UTC times. """ if tf is not Timeframe.D1: return tf.seconds current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN) next_close = datetime.combine(current.date() + timedelta(days=1), SESSION_CLOSE, EASTERN) return int(next_close.timestamp()) - t