sifting/io
Economic Calendar
6 min readSiftingIO Team

Economic calendar API: pull macro events as JSON and join each release to its price bar

Pull US economic calendar events (CPI, NFP, FOMC) as JSON and join each release to the one-minute OHLCV bar that was open when it hit, using the real field names.

Economic calendar API: pull macro events as JSON and join each release to its price bar

How do you line up a CPI release with the exact one-minute bar that was open when it hit the wire? An economic calendar API gives you the schedule as JSON, a historical bars API gives you the prices, and the join between the two is where most scripts go wrong. The two feeds use different timestamp formats, and several calendar fields are nullable today. This post walks through the SiftingIO economic calendar endpoint as it actually responds, then joins each release to its price bar in Python.

What the economic calendar API returns#

The endpoint is GET /v1/fnd/economic-calendar, part of the fundamentals family. It returns a plain list. There is no cursor pagination on this endpoint. The response is an object with three keys: events, count, and filter. The filter object echoes the query the server applied, including any defaults it filled in, which is worth keeping in your logs.

Every query parameter is optional:

  • from: start of the window as an RFC 3339 timestamp. Defaults to now, UTC.
  • to: end of the window. Defaults to from plus 30 days. The maximum range is 365 days.
  • country: ISO 3166-1 alpha-2 code. Defaults to US.
  • impact: low, medium, or high.
  • agency: one of BLS, BEA, Census, Fed, DOL, EIA, ISM, ConferenceBoard, UMich, NAR, Treasury.
  • event_id: a single event identifier such as us_cpi.
  • limit: default 100, maximum 500.

Each event object carries exactly these fields: event_id, name, country, currency (ISO 4217), agency, impact, scheduled_at, actual, previous, consensus, and released_at. Both timestamps, scheduled_at and released_at, are RFC 3339 strings. released_at is null until the release goes out, and so is actual.

One honest note before you build on the numeric fields. As of this writing, actual, previous, and consensus come back null because that data hasn't been backfilled yet. Model them as nullable floats and don't coerce null to zero. There is no revision field, and revision handling isn't documented, so don't write logic that expects one.

A first call, restricted to high-impact releases from the Bureau of Labor Statistics:

curl --compressed -H "X-API-Key: $SIFTING_KEY" \
  "https://api.sifting.io/v1/fnd/economic-calendar?impact=high&agency=BLS&limit=50"

A trimmed response, with the numeric fields shown as they currently return and the date illustrative:

{
  "events": [
    {
      "event_id": "us_cpi",
      "name": "Consumer Price Index",
      "country": "US",
      "currency": "USD",
      "agency": "BLS",
      "impact": "high",
      "scheduled_at": "2026-09-11T12:30:00Z",
      "actual": null,
      "previous": null,
      "consensus": null,
      "released_at": null
    }
  ],
  "count": 1,
  "filter": { "country": "US", "impact": "high", "agency": "BLS", "limit": 50 }
}

Why the join breaks: RFC 3339 strings versus epoch milliseconds#

The bars endpoints under /v1/hist/{stocks,forex,crypto}/{symbol}/bars return {data, meta}. meta holds symbol, interval, as_of, and next_cursor. Each bar in data is {t, o, h, l, c, v}, and t is an int64 Unix epoch in milliseconds, UTC, marking the bar's open time. The calendar hands you "2026-09-11T12:30:00Z". Those two values never compare equal, so a naive merge produces zero rows or a dtype error.

The fix has three steps. Parse the RFC 3339 string into a timezone-aware datetime. Convert it to epoch milliseconds. Floor it to the interval boundary, which at 1m means the start of that minute and at 5m means the nearest five-minute mark below. The bar whose t equals that floored value is the bar that was open when the release landed. Prefer released_at for the join because it records when the release actually went out. Fall back to scheduled_at only when released_at is null.

Joining releases to bars in Python#

The script below pulls a month of high-impact US events, floors each release to a one-minute boundary, and left-joins onto EURUSD one-minute bars. Forex bars run from 1m to 1h and carry v as 0, so don't read volume from them. The bars are built from the cross-venue fair price rather than one venue's print, which matters for a release minute when individual venues can disagree or lag.

import os
from datetime import datetime, timezone

import pandas as pd
import requests

BASE = "https://api.sifting.io/v1"
HEADERS = {"X-API-Key": os.environ["SIFTING_KEY"], "Accept-Encoding": "gzip"}
INTERVAL_MS = {"1m": 60_000, "5m": 300_000, "15m": 900_000, "1h": 3_600_000}


def rfc3339_to_ms(s: str) -> int:
    # Python 3.9 and 3.10 reject a trailing Z in fromisoformat
    dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
    return int(dt.astimezone(timezone.utc).timestamp() * 1000)


def floor_to_interval(ms: int, interval: str) -> int:
    step = INTERVAL_MS[interval]
    return ms - (ms % step)


def get_events(**params) -> pd.DataFrame:
    r = requests.get(f"{BASE}/fnd/economic-calendar", headers=HEADERS, params=params)
    r.raise_for_status()
    body = r.json()
    print("server applied filter:", body["filter"], "count:", body["count"])
    return pd.DataFrame(body["events"])


def get_bars(asset_class: str, symbol: str, interval: str, **params) -> pd.DataFrame:
    frames, cursor = [], None
    while True:
        q = {"interval": interval, "limit": 200, **params}
        if cursor:
            q["cursor"] = cursor
        r = requests.get(f"{BASE}/hist/{asset_class}/{symbol}/bars", headers=HEADERS, params=q)
        r.raise_for_status()
        body = r.json()
        frames.append(pd.DataFrame(body["data"]))
        cursor = body["meta"].get("next_cursor")
        if not cursor:
            break
    return pd.concat(frames, ignore_index=True)


window = {"from": "2026-08-01T00:00:00Z", "to": "2026-09-01T00:00:00Z"}
events = get_events(country="US", impact="high", **window)

interval = "1m"
stamp = events["released_at"].fillna(events["scheduled_at"])
events["bar_t"] = stamp.map(rfc3339_to_ms).map(lambda ms: floor_to_interval(ms, interval))

# the bars endpoint requires a start; pass the same window to keep the pull small
bars = get_bars("forex", "EURUSD", interval, start=window["from"], end=window["to"])
bars["t"] = bars["t"].astype("int64")

joined = events.merge(bars, left_on="bar_t", right_on="t", how="left")
print(joined[["event_id", "name", "released_at", "bar_t", "o", "h", "l", "c"]])

The left join is deliberate. If no bar exists at the floored minute, the row survives with NaN prices, and you can see which releases fell outside the bar coverage instead of losing them silently. That happens with US stock bars for an 08:30 Eastern release, which is 12:30 or 13:30 UTC depending on daylight saving and lands before regular trading hours. Forex and crypto bars cover those minutes, which is why the example uses EURUSD.

The output is a table of releases with the open, high, low, and close of the minute they landed in. It's a research and validation artifact: you can measure how wide the release minute was, compare it against the minutes before and after, or check that a chart annotation sits on the right candle. None of that says which way to trade a release, and the data doesn't either.

Common pitfalls#

Merging the string against t. pandas raises "You are trying to merge on object and int64 columns" if the calendar column is still a string. If you converted to datetime64 instead of int64 milliseconds, the merge runs but matches nothing. Both columns must be int64 epoch milliseconds, floored to the same interval, before the merge.

Forgetting that from defaults to now. A call with no window returns upcoming events only, so released_at is null on every row and the join has nothing to work with. Pass an explicit past window, and keep it at 365 days or less, or the request is rejected as an invalid parameter.

Getting 406 gzip_required from the bars endpoint. Historical bars require gzip. The Python requests library negotiates it by default and the script sets the header explicitly, but a bare curl without --compressed or a hand-rolled http.client call gets the 406. The calendar endpoint negotiates gzip too but doesn't require it, so the same script can appear to work on one call and fail on the next.

Read the docs

Keep reading

Related posts