A strategy that looks profitable across a year of minute bars can fall apart the moment you check what was happening on the days it made most of its money. Often the answer is a handful of scheduled releases: an inflation print, a jobs report, a rate decision. If your backtest treats those bars like any other, you're measuring a strategy that doesn't exist, because in production you'd never trade blind into a known announcement.
The fix is to give the backtest a calendar. You need a list of when each event is scheduled, joined to the same timeline your bars live on, so the harness can flag, mask, or specifically target those windows. SiftingIO exposes the schedule through a single fundamentals endpoint, and the bars through the historical endpoint, both under one credential and one timestamp convention. That shared convention is what makes the join clean.
Pulling the schedule#
The economic calendar lives under the fundamentals family:
curl -H "X-API-Key: $SIFTING_KEY" \
"https://api.sifting.io/v1/fnd/economic-calendar?limit=200"
The endpoint returns upcoming US economic events. Each row carries the event name, its scheduled date, and pagination metadata in the response envelope. Because the feed is forward-looking, a backtest over past data needs a record you've been accumulating, or whatever historical range the docs describe for your tier. Log the calendar on a schedule and you build your own event history over time; check /docs for the exact field names and any historical coverage before you assume a deep back-record exists.
The important detail is the timestamp format. Date-only fields come back as ISO 8601 (YYYY-MM-DD), and that is the seam where most calendar joins go wrong. Your bars are timestamped to the minute in RFC 3339 UTC, but a calendar date is just a day. A release scheduled for a given date does not land at midnight; it lands at a specific time of day that the date field alone doesn't tell you. So treat the calendar date as a coarse key and decide your own intraday window around it rather than assuming the event timestamp is exact to the second.
Joining events to bars#
Here's a minimal pattern in Python. It pulls daily bars for a placeholder ticker, pulls the calendar, and tags each bar's date with whether a scheduled event falls on it. The same shape works for an FX pair or a crypto symbol; only the bar source changes.
import requests, gzip, io, json
import pandas as pd
KEY = os.environ["SIFTING_KEY"]
HEADERS = {"X-API-Key": KEY, "Accept-Encoding": "gzip"}
BASE = "https://api.sifting.io/v1"
def get_bars(ticker, interval="1d"):
r = requests.get(
f"{BASE}/hist/stocks/{ticker}/bars",
headers=HEADERS,
params={"interval": interval, "limit": 200},
)
r.raise_for_status()
bars = r.json()["data"]
df = pd.DataFrame(bars)
df["date"] = pd.to_datetime(df["t"], utc=True).dt.date
return df
def get_calendar():
rows, cursor = [], None
while True:
params = {"limit": 200}
if cursor:
params["cursor"] = cursor
r = requests.get(f"{BASE}/fnd/economic-calendar",
headers=HEADERS, params=params)
r.raise_for_status()
body = r.json()
rows.extend(body["data"])
cursor = body["meta"]["next_cursor"]
if cursor is None:
break
cal = pd.DataFrame(rows)
cal["date"] = pd.to_datetime(cal["date"]).dt.date
return cal
bars = get_bars("{ticker}")
cal = get_calendar()
event_days = set(cal["date"])
bars["has_event"] = bars["date"].isin(event_days)
The requests call requests gzip explicitly. The historical bars endpoint requires it: without Accept-Encoding: gzip it returns 406 with the body gzip_required, and requests decodes the gzip response for you transparently. The calendar endpoint under /v1/fnd/* negotiates gzip but doesn't force it, so the same header is harmless there.
With has_event attached, the backtest can do whatever the strategy demands. A mean-reversion model might drop event days from its training window so it isn't fitting on volatility it can't reproduce. An event-driven model might do the opposite and trade only the bars in a window around each release. Either way, the decision is now explicit in the data rather than hidden in the equity curve.
Widening the window past a single day#
A same-day flag is the floor, not the ceiling. Markets often move the session before a release and the session after, so a one-day mask undercounts the real exposure. Build the window in pandas instead of trying to express it in the query:
cal_dates = pd.to_datetime(sorted(event_days))
bar_dates = pd.to_datetime(bars["date"])
# nearest scheduled event, in days, for every bar
nearest = bar_dates.apply(
lambda d: (cal_dates - d).abs().min().days
)
bars["event_window"] = nearest <= 1 # day before, of, and after
For cursor-based pagination, always read meta.next_cursor rather than incrementing an offset yourself. The cursor is opaque and the default page size is 50 (max 200), so a calendar with a long horizon will span several pages. Stop when next_cursor is null, which the loop above does.
Common pitfalls#
The quietest failure is the timezone mismatch. If you build bar dates from local time instead of UTC, a release scheduled near a session boundary lands on the wrong calendar day, and your has_event flag silently shifts by one. The bar t field is Unix epoch milliseconds in UTC and the calendar date is UTC; convert with utc=True on both sides, as the snippet does, and never let the local machine's offset enter the join.
The second is treating a date as a precise instant. A calendar entry says an event happens on a day, not at a millisecond, so don't slice intraday bars on an assumed release time and expect the move to be centered. If you need minute-level alignment around a print, widen the window and let the data show you where volatility actually clustered, rather than hard-coding a time the API never promised.
The third shows up only at volume. Every response carries X-RateLimit-Remaining, and a loop that re-pulls the calendar on every backtest iteration will burn through the free tier's 30 calls per minute fast, then start returning 429 with a Retry-After header. Pull the calendar once, cache it to a local frame or parquet file, and join against the cached copy. The schedule doesn't change between two runs of the same backtest.
The payoff is a backtest that knows what it was trading through. Once events sit on the same timeline as your bars, performance attribution stops being a guess and the strategy you measure is the one you could actually run.
Read the docs for the full economic calendar field list and the gzip requirements on heavy endpoints.
