| name | cme-futures-time-buckets |
| description | Use when resampling intraday futures bars (e.g. 4h, 8h) or weekly/monthly bars to a coarser timeframe. CME globex futures sessions run Sunday 18:00 ET → Friday 17:00 ET, NOT calendar weeks; intraday buckets a futures trader expects align to the 18:00 ET session open, NOT UTC midnight. Naive resampling produces bars that look right but are wrong by 4-6 hours. |
| metadata | {"category":"charting"} |
CME futures time-bucket alignment
Most data tools default to UTC-aligned buckets ("a 4-hour bar starts at 00:00, 04:00, 08:00, …"). For a futures trader looking at a 4h NQ chart, that's wrong — they expect the 4h bar at 18:00 ET to mark the start of the new trading session, the same way it's drawn on TradingView and every other futures-aware platform. Same for weekly: a futures week is Sunday 18:00 → Friday 17:00 ET, not ISO Monday → Sunday.
When to use
Reach for this skill if you're:
- Building a resampler that turns 1m or 1h bars into 4h, 8h, weekly, or monthly bars
- Shipping a charting UI where the operator can pick any timeframe
- Hearing "the bars don't line up with TradingView" from a futures trader
- Implementing
group_by_dynamic or any equivalent time-bucketing in Polars / pandas / DuckDB
The pattern — Polars group_by_dynamic with NY-local timezone + offset
The trick is convert to NY-local time, group with the right offset argument, convert back to UTC. NY-local handles DST transitions automatically; the offset shifts the bucket origin so 18:00 ET lands on a bucket boundary.
import polars as pl
def resample_intraday(bars: pl.DataFrame, every: str) -> pl.DataFrame:
return (
bars.with_columns(
pl.col("window_start").dt.convert_time_zone("America/New_York")
)
.group_by_dynamic(
"window_start",
every=every,
offset="2h",
closed="left",
label="left",
)
.agg(
pl.col("open").first().alias("open"),
pl.col("high").max().alias("high"),
pl.col("low").min().alias("low"),
pl.col("close").last().alias("close"),
pl.col("volume").sum().alias("volume"),
)
.with_columns(
pl.col("window_start").dt.convert_time_zone("UTC")
)
)
WEEK_OFFSET_NY = "-6h"
Why the magic numbers
- 4h,
offset="2h": default 4h origin is 00:00. Adding 2h shifts to {02, 06, 10, 14, 18, 22} NY-local — 18:00 included.
- 8h,
offset="2h": default 8h origin is 00:00. Adding 2h shifts to {02, 10, 18} NY-local — 18:00 included.
- 2h,
offset="0h": default already at every even hour → 18:00 included naturally.
- 30m, no shift needed: trader convention is :00/:30 boundaries.
- 1w,
offset="-6h" on NY-local: shifts Monday 00:00 → previous Sunday 18:00.
What about 1d?
A daily bar on CME spans 18:00 ET (prev day) → 17:00 ET (current day). Most data providers already key daily bars by the session date with the right boundary baked in. If yours doesn't, apply the same NY-local + offset trick with every="1d" and offset="-6h" (NY-local 00:00 − 6h = previous day 18:00). Verify against the data provider's docs.
Verifying alignment
Build a test that constructs a known-aligned bar series, resamples, and asserts the output bucket starts at 18:00 ET on a specific date:
def test_4h_aligns_to_cme_session_start_18et() -> None:
bars = _mk_bars(datetime(2026, 1, 5, 23, 0), n=8, step_minutes=60)
out = resample_bars(bars, "4hour").sort("window_start")
assert out.height == 2
row0 = out.row(0, named=True)
assert row0["open"] == 0.0
assert row0["close"] == 3.25
This kind of test catches the "off by N hours" bugs that visual inspection misses.
Gotchas
- Polars
every="1w" defaults to Monday-aligned ISO week. Use the -6h NY-local offset trick above for CME-week, OR document the deviation as a known limitation if ISO-week is acceptable.
- DST. Don't try to bake "18:00 ET = 23:00 UTC" as a fixed offset — that's only true in EST. America/New_York handles DST automatically via
convert_time_zone. Don't shortcut it.
offset is a string token, not seconds. Polars wants "2h", "-6h", "30m". Numeric seconds throw.
- The
closed="left", label="left" combo matters. Default closed="right" shifts which bar each timestamp falls into and produces off-by-one bucket counts. Always specify both.
- Test data needs to live in a single CME session. Constructing bars spanning a DST transition or a weekend gap will produce surprising bucket counts that aren't bugs but test-data issues.
Reference implementation
This pattern was extracted from a working resampler module that translates 1m → {30m, 2h, 4h, 8h, weekly, monthly} for a CME futures charting platform. The structure looked like:
- A
resampler.py exposing a single resample_bars(df, target) function
- An
_INTRADAY_OFFSETS_NY dict mapping each intraday granularity to its NY-local offset string
- A
WEEK_OFFSET_NY constant for the CME-week alignment
- A test file with ~12 parity tests covering every granularity with explicit "first bucket starts at X" assertions
Related skills
partial-bar-graft — once your resampler works, the in-progress bar still needs special handling
lightweight-charts-integration — the chart side is unaware of timezones; the resampler is the right place for alignment logic