The quiet way macro backtests lie
Most macro event studies fail before a single trade is simulated. The failure is not in the strategy logic — it is in the data: a "consensus" column that was actually revised after the release, a forecast timestamped by reference period instead of publication time, or an actual value that silently reflects a later revision rather than the number the market saw at 8:30am. Each of these injects information from the future into the past, and the backtest quietly reports an edge that never existed.
The discipline that prevents this has one rule: a value may enter the study only if it was knowable before the event. For a forecast, that means its publication timestamp must precede the release timestamp. For an actual, it means using the release-timestamped first print, not today's revised series. This article walks through how to apply that rule to FX macro event research end to end — and, just as importantly, how to check what pre-release history actually exists before you design the study.
The acceptance rule, drawn out
Every forecast row served by the FXMacroData predictions API carries a generated_at timestamp, and every announcement carries an announcement_datetime. The acceptance test sits between them:
Point-in-time acceptance timeline
Before the release: usable
Forecasts with generated_at < announcement_datetime. Nowcasts, surveys, and projections published ahead of the print.
announcement_datetime
The release moment. The first print enters the information set here.
After the release: reject
Any forecast generated at or after the release is look-ahead and must not enter the study.
Takeaway: the test is a single timestamp comparison per forecast row.
With most data vendors you have to run that rejection yourself and hope the vendor's timestamps are honest. FXMacroData enforces it structurally: the prediction store refuses to persist any forecast generated at or after its announcement, so the rejected region of that timeline is empty by construction. Query with pre_release_only=true (the default), keep your own generated_at < announcement_datetime assertion as a belt-and-braces check, and it will simply never fire.
Step 1: check coverage before designing the study
Pre-release forecast history exists only where an official publisher has been producing forecasts for years. That makes coverage a property of the upstream source, not of any subscription tier — and it means an honest event study starts by checking depth per series, not by assuming a uniform consensus archive back to 2014. For the United States, the verified picture looks like this:
| Event family | Pre-release source | Verified history | Study suitability |
|---|---|---|---|
| CPI and Core CPI (y/y and m/m) | Cleveland Fed nowcast | Every monthly release since the August 2013 reference period | Full event study, about 155 released months through August 2026 |
| Unemployment rate | FOMC SEP medians + NY Fed Survey of Market Expectations | Every year-end (December reference month) release since 2015, each with the SEP vintages published before it; NY Fed SME added from the 2023 year-end | Year-end event studies with vintage paths; monthly releases uncovered |
| FOMC rate decision | Atlanta Fed Market Probability Tracker | Ten past meetings from September 2023 to September 2026, roughly three per year | Partial meeting coverage |
| Non-Farm Payrolls, earnings, PPI, retail sales | — | No historical event-specific market consensus | Not backtestable on consensus |
The same honesty applies across currencies: the forecast coverage matrix lists every currency and indicator pair that carries an external, source-labelled forecast feed, from the ECB's Survey of Professional Forecasters to the RBA's market economists' table. A pair that is absent from the matrix has no external forecast history, on any plan — designing a study around it wastes a week discovering what the matrix states in one row.
Step 2: pull forecasts and actuals, join on announcement_id
Both endpoints share a stable join key, announcement_id, in the form {currency}_{indicator}_{date}. The predictions side contributes the forecast, its source, and generated_at; the announcements side contributes the release-timestamped actual, the previous value, and the revision history. Always pass explicit dates and page through the results. Without dates, predictions default to a window of roughly the last six months and announcements return 20 rows per page, and even with dates a page holds at most limit rows, newest first. The CPI m/m join below spans about 155 releases, so a single page of 100 would silently drop 2013 to early 2018:
import requests
BASE = "https://api.fxmacrodata.com/v1"
HEADERS = {"X-API-Key": "YOUR_API_KEY"}
PARAMS = {"frequency": "mom",
"start_date": "2013-08-01", "end_date": "2026-08-31", "limit": 100}
def fetch_all(path, params):
"""Follow offset pagination until the endpoint reports no more rows."""
rows, offset = [], 0
while True:
body = requests.get(f"{BASE}/{path}", params={**params, "offset": offset},
headers=HEADERS, timeout=30).json()
rows.extend(body["data"])
has_more = body.get("has_more") or body.get("pagination", {}).get("has_more")
if not has_more or not body["data"]:
return rows
offset += len(body["data"])
preds = fetch_all("predictions/usd/inflation", {**PARAMS, "pre_release_only": "true"})
actuals = fetch_all("announcements/usd/inflation", {**PARAMS, "revisions": "all"})
actual_by_id = {row["announcement_id"]: row for row in actuals}
events = []
for group in preds:
actual = actual_by_id.get(group["announcement_id"])
if actual is None:
continue
first = actual["revisions"][0] # earliest captured vintage
known_at = first.get("epoch") or actual["announcement_datetime"]
for p in group["predictions"]:
# Belt-and-braces: the store already guarantees this holds.
assert p["generated_at"] < group["announcement_datetime"]
events.append({
"release_utc": group["announcement_datetime"],
"known_at": known_at,
"forecast": p["predicted_value"],
"source": p["prediction_source"],
"actual_first_print": first["val"],
"surprise": first["val"] - p["predicted_value"],
})
Two details in that snippet carry the point-in-time weight. First, the actual comes from revisions[0], the earliest captured vintage, not from the latest value of the series. Each vintage carries its own epoch where one was recorded; when it is null, as on some recently captured rows, fall back to the row's announcement_datetime. Second, the m/m variant is selected with frequency=mom on both endpoints, so the forecast and the actual describe the same series rather than a y/y forecast being compared against an m/m print.
Step 3: decide what each row is allowed to know
Once events are joined, the remaining look-ahead risks are in your own feature engineering. A useful habit is to write the knowability budget down as a table before coding:
| Input | Knowable at event time? | Correct field |
|---|---|---|
| Pre-release forecast | Yes, when generated_at precedes the release | predictions[].predicted_value |
| Actual, as the market saw it | Yes, from the release timestamp onward | revisions[0].val at announcement_datetime |
| Previous value | Yes — it was released earlier | previous_value, previous_announcement_datetime |
| Revised actual | Not until the revision's own epoch | Later revisions[] entries, gated by their epoch |
| A forecast generated after the event | Never | Does not exist in the store |
The revision entries deserve emphasis. Each carries its own epoch, which means revisions are not a contaminant to be avoided — they are additional point-in-time events. A study of how Federal Reserve communication responds to data can legitimately use a revised CPI print, as long as the row enters the information set at the revision's epoch rather than the original release date.
Where to go deeper
The compact reference version of this workflow — field tables for both endpoints, the pagination pattern for full-archive pulls, and access details — lives in the point-in-time backtesting guide. Series-level history start dates for every published pair are on the data coverage catalogue, and upcoming events to test forward are on the release calendar. USD announcements are open without a key for recent-window evaluation, and forecasts need a key on every currency; the full archive across every supported currency comes with the Individual plan — the same data on every paid tier, with no deeper archive hiding behind a higher price.