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) FUTURE_SLOT_COUNT = 180 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 def next_bucket_start(t: int, tf: Timeframe) -> int: """Next projected source-bar open, skipping known CME closures.""" if tf is not Timeframe.D1: candidate = datetime.fromtimestamp(t + tf.seconds, UTC).astimezone(EASTERN) weekday = candidate.weekday() wall_time = candidate.timetz().replace(tzinfo=None) if weekday == 5: # Saturday -> Sunday open. candidate = datetime.combine(candidate.date() + timedelta(days=1), SESSION_OPEN, EASTERN) elif weekday == 6 and wall_time < SESSION_OPEN: candidate = datetime.combine(candidate.date(), SESSION_OPEN, EASTERN) elif weekday in {0, 1, 2, 3} and SESSION_CLOSE <= wall_time < SESSION_OPEN: candidate = datetime.combine(candidate.date(), SESSION_OPEN, EASTERN) elif weekday == 4 and wall_time >= SESSION_CLOSE: candidate = datetime.combine(candidate.date() + timedelta(days=2), SESSION_OPEN, EASTERN) return int(candidate.timestamp()) current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN) candidate = current.date() + timedelta(days=1) # Daily futures sessions open Sunday through Thursday. while candidate.weekday() not in {6, 0, 1, 2, 3}: candidate += timedelta(days=1) return int(datetime.combine(candidate, SESSION_OPEN, EASTERN).timestamp()) def future_bucket_starts(t: int, tf: Timeframe, count: int = FUTURE_SLOT_COUNT) -> list[int]: values = [] for _ in range(count): t = next_bucket_start(t, tf) values.append(t) return values