File size: 12,411 Bytes
1498c4c 03f6e95 1498c4c 03f6e95 1498c4c 03f6e95 1498c4c 03f6e95 1498c4c 03f6e95 1498c4c 03f6e95 1498c4c 03f6e95 1498c4c 03f6e95 1498c4c 03f6e95 1498c4c 03f6e95 1498c4c 03f6e95 1498c4c 03f6e95 1498c4c 03f6e95 1498c4c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 | """Scheduled macro releases and Bitcoin: the clean version of the news test.
The topic study (news_topic_event_study.py) times each event by its Bitcoin
headline, which is written after the event and often after the move. A
scheduled release has no such problem: a Fed decision lands at 14:00 New York
time and a CPI report at 08:30, announced weeks ahead, so the moment the
information arrives is known to the minute and nobody can trade it earlier.
That makes the test asymmetric in a useful way. If the release moves price,
the excess move appears in the hours AFTER it and not before. A headline topic
cannot make that distinction; a scheduled release can.
Dates are copied from the primary sources, not summaries:
FOMC federalreserve.gov/monetarypolicy/fomccalendars.htm and the 2019/2020
historical pages; scheduled meetings only (unscheduled 2019-10 and
March 2020 actions were released at other times of day)
CPI archived release filenames at bls.gov/bls/news-release/cpi.htm
(cpi_MMDDYYYY.htm), which encode the publication date exactly
NFP archived empsit_MMDDYYYY.htm filenames, same archive layout
PCE ALFRED vintage dates of PCEPI and the BEA/FRED release calendars
Usage:
python scripts/macro_event_study.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from news_topic_event_study import (CONTROL, EVENT, GRID, INK, INK_2, SURFACE, # noqa: E402
WINDOW, bootstrap_ci, hourly_returns,
matched_controls, style, window)
ROOT = Path(__file__).resolve().parents[1]
FOMC = """2019-01-30 2019-03-20 2019-05-01 2019-06-19 2019-07-31 2019-09-18 2019-10-30 2019-12-11
2020-01-29 2020-04-29 2020-06-10 2020-07-29 2020-09-16 2020-11-05 2020-12-16
2021-01-27 2021-03-17 2021-04-28 2021-06-16 2021-07-28 2021-09-22 2021-11-03 2021-12-15
2022-01-26 2022-03-16 2022-05-04 2022-06-15 2022-07-27 2022-09-21 2022-11-02 2022-12-14
2023-02-01 2023-03-22 2023-05-03 2023-06-14 2023-07-26 2023-09-20 2023-11-01 2023-12-13
2024-01-31 2024-03-20 2024-05-01 2024-06-12 2024-07-31 2024-09-18 2024-11-07 2024-12-18
2025-01-29 2025-03-19 2025-05-07 2025-06-18 2025-07-30 2025-09-17 2025-10-29 2025-12-10
2026-01-28 2026-03-18 2026-04-29 2026-06-17 2026-07-29 2026-09-16""".split()
CPI_FILES = """02132019 03122019 04102019 05102019 06122019 07112019 08132019 09122019 10102019
11132019 12112019 01142020 02132020 03112020 04102020 05122020 06102020 07142020 08122020
09112020 10132020 11122020 12102020 01132021 02102021 03102021 04132021 05122021 06102021
07132021 08112021 09142021 10132021 11102021 12102021 01122022 02102022 03102022 04122022
05112022 06102022 07132022 08102022 09132022 10132022 11102022 12132022 01122023 02142023
03142023 04122023 05102023 06132023 07122023 08102023 09132023 10122023 11142023 12122023
01112024 02132024 03122024 04102024 05152024 06122024 07112024 08142024 09112024 10102024
11132024 12112024 01152025 02122025 03122025 04102025 05132025 06112025 07152025 08122025
09112025 10242025 12182025 01132026""".split()
# Jobs report: archived filenames at bls.gov/bls/news-release/empsit.htm
# (empsit_MMDDYYYY.htm); Nov 2026 onward from bls.gov/schedule. The Oct-Nov
# 2025 shutdown moved the September report to 20 Nov and merged Oct/Nov into 16 Dec.
NFP_FILES = """01042019 02012019 03082019 04052019 05032019 06072019 07052019 08022019 09062019
10042019 11012019 12062019 01102020 02072020 03062020 04032020 05082020 06052020 07022020
08072020 09042020 10022020 11062020 12042020 01082021 02052021 03052021 04022021 05072021
06042021 07022021 08062021 09032021 10082021 11052021 12032021 01072022 02042022 03042022
04012022 05062022 06032022 07082022 08052022 09022022 10072022 11042022 12022022 01062023
02032023 03102023 04072023 05052023 06022023 07072023 08042023 09012023 10062023 11032023
12082023 01052024 02022024 03082024 04052024 05032024 06072024 07052024 08022024 09062024
10042024 11012024 12062024 01102025 02072025 03072025 04042025 05022025 06062025 07032025
08012025 09052025 11202025 12162025 01092026 02112026 03062026 04032026 05082026 06052026
07022026 08072026 09042026 10022026 11062026 12042026""".split()
# PCE inflation (BEA Personal Income and Outlays): 2019-2024 from the ALFRED
# vintage dates of PCEPI, 2025 from the FRED release calendar (rid=54), 2026
# from bea.gov/news/schedule. All 08:30 except the one dated with its time.
PCE = """2019-03-01 2019-03-29 2019-04-29 2019-05-31 2019-06-28 2019-07-30 2019-08-30 2019-09-27
2019-10-31 2019-11-27 2019-12-20 2020-01-31 2020-02-28 2020-03-27 2020-04-30 2020-05-29
2020-06-26 2020-07-31 2020-08-28 2020-10-01 2020-10-30 2020-11-25 2020-12-23 2021-01-29
2021-02-26 2021-03-26 2021-04-30 2021-05-28 2021-06-25 2021-07-30 2021-08-27 2021-10-01
2021-10-29 2021-11-24 2021-12-23 2022-01-28 2022-02-25 2022-03-31 2022-04-29 2022-05-27
2022-06-30 2022-07-29 2022-08-26 2022-09-30 2022-10-28 2022-12-01 2022-12-23 2023-01-27
2023-02-24 2023-03-31 2023-04-28 2023-05-26 2023-06-30 2023-07-28 2023-08-31 2023-09-29
2023-10-27 2023-11-30 2023-12-22 2024-01-26 2024-02-29 2024-03-29 2024-04-26 2024-05-31
2024-06-28 2024-07-26 2024-08-30 2024-09-27 2024-10-31 2024-11-27 2024-12-20 2025-01-31
2025-02-28 2025-03-28 2025-04-30 2025-05-30 2025-06-27 2025-07-31 2025-08-29 2025-09-26
2025-12-05 2025-12-23 2026-01-22T10:00 2026-02-20 2026-03-13 2026-04-09 2026-04-30
2026-05-28 2026-06-25 2026-07-30 2026-08-26 2026-09-30 2026-10-29 2026-11-25 2026-12-23""".split()
RELEASES = {
"Fed decision (FOMC)": ([pd.Timestamp(d) for d in FOMC], "14:00"),
"CPI inflation report": ([pd.to_datetime(f, format="%m%d%Y") for f in CPI_FILES], "08:30"),
}
# Measured here alongside the two above; kept separate so the live forecast's
# calendar only grows when a backtest shows it helps.
EXTRA_RELEASES = {
"Jobs report (NFP)": ([pd.to_datetime(f, format="%m%d%Y") for f in NFP_FILES], "08:30"),
"PCE inflation": ([pd.Timestamp(d) for d in PCE], "08:30"),
}
def release_times(dates, clock) -> list[pd.Timestamp]:
"""New York release time -> UTC, so daylight saving shifts the hour. A date
carrying its own time of day overrides the group's clock."""
out = []
for day in dates:
at = f"{day:%H:%M}" if (day.hour or day.minute) else clock
local = pd.Timestamp(f"{day:%Y-%m-%d} {at}", tz="America/New_York")
out.append(local.tz_convert("UTC"))
return out
def main() -> None:
rng = np.random.default_rng(0)
ret = hourly_returns()
hours = np.arange(-WINDOW, WINDOW + 1)
after = (hours >= 0) & (hours <= 6)
before = (hours >= -6) & (hours <= -1)
# Controls must avoid every release hour, not only this event's.
every = {**RELEASES, **EXTRA_RELEASES}
release_hours = set()
for dates, clock in every.values():
release_hours |= {t.floor("h") for t in release_times(dates, clock)}
results, curves = [], {}
for name, (dates, clock) in every.items():
ev_abs, ctl_abs, d_after, d_before, signed, hour0 = [], [], [], [], [], []
for t in release_times(dates, clock):
at = t.floor("h") # 08:30 sits in the 08:00 bar: half of hour 0 precedes it
w = window(ret, at)
if w is None:
continue
controls = matched_controls(ret, at, rng, release_hours)
if not controls:
continue
c = np.mean([x[0] for x in controls], axis=0)
ev_abs.append(w[0]); ctl_abs.append(c)
d_after.append(w[0][after].mean() - c[after].mean())
d_before.append(w[0][before].mean() - c[before].mean())
hour0.append(w[0][hours == 0][0] - c[hours == 0][0])
signed.append(w[1][after].sum())
d_after, d_before, hour0 = map(np.array, (d_after, d_before, hour0))
results.append({
"release": name, "events": len(ev_abs), "clock_new_york": clock,
"excess_hour0": float(hour0.mean()), "excess_hour0_ci": bootstrap_ci(hour0, rng),
"excess_after_0_6h": float(d_after.mean()), "excess_after_ci": bootstrap_ci(d_after, rng),
"excess_before_6h": float(d_before.mean()), "excess_before_ci": bootstrap_ci(d_before, rng),
"share_of_releases_up_0_6h": float(np.mean(np.array(signed) > 0)),
})
curves[name] = (np.mean(ev_abs, axis=0), np.mean(ctl_abs, axis=0))
draw(results, curves, hours)
(ROOT / "outputs" / "macro_event_study.json").write_text(json.dumps(
{"method": "as news_topic_event_study.py, timed by the official release minute",
"releases": results}, indent=2))
for r in results:
print(f"{r['release']:22s} n={r['events']:3d} hour 0 {r['excess_hour0']:+.2f} "
f"[{r['excess_hour0_ci'][0]:+.2f}, {r['excess_hour0_ci'][1]:+.2f}] "
f"0-6h {r['excess_after_0_6h']:+.2f} "
f"[{r['excess_after_ci'][0]:+.2f}, {r['excess_after_ci'][1]:+.2f}] "
f"before {r['excess_before_6h']:+.2f} "
f"[{r['excess_before_ci'][0]:+.2f}, {r['excess_before_ci'][1]:+.2f}] "
f"up {r['share_of_releases_up_0_6h']:.0%}")
def draw(results, curves, hours) -> None:
plt.rcParams.update({"font.family": "DejaVu Sans", "text.color": INK,
"axes.labelcolor": INK_2})
fig, axes = plt.subplots(2, 2, figsize=(13, 9.4), facecolor=SURFACE, sharey=True)
fig.subplots_adjust(left=0.06, right=0.98, top=0.86, bottom=0.07, wspace=0.08, hspace=0.32)
axes = axes.ravel()
for ax, r in zip(axes, results):
style(ax)
ax.grid(axis="y", color=GRID, linewidth=0.8)
ev, ctl = curves[r["release"]]
ax.axvspan(-0.5, 6.5, color="#eef3fb", linewidth=0, zorder=0)
ax.plot(hours, ctl, color=CONTROL, linewidth=1.4, linestyle=(0, (4, 2)),
label="same hour, other weeks")
ax.plot(hours, ev, color=EVENT, linewidth=2, label="release day")
ax.axvline(0, color=INK_2, linewidth=0.8)
lo, hi = r["excess_hour0_ci"]
zero = int(np.flatnonzero(hours == 0)[0])
ax.set_title(f"{r['release']} · {r['events']} releases at {r['clock_new_york']} New York",
loc="left", fontsize=10.5, fontweight="bold")
# Both levels, not 1 + excess: the control hour is itself ~1.3x a normal
# hour, because release hours fall in active US trading time.
ax.text(0.99, 0.98, f"release hour {ev[zero]:.2f} vs {ctl[zero]:.2f} in other weeks "
f"({ev[zero] / ctl[zero]:.1f}×)\n"
f"excess {r['excess_hour0']:+.2f} [{lo:+.2f}, {hi:+.2f}]; "
f"6h before {r['excess_before_6h']:+.2f}, "
f"6h after {r['excess_after_0_6h']:+.2f}",
transform=ax.transAxes, ha="right", va="top", fontsize=8.5, color=INK_2,
bbox=dict(facecolor=SURFACE, edgecolor="none", pad=2), zorder=5)
ax.set_ylim(0.4, 3.5) # headroom for the note above the curves
ax.set_xticks([-24, -12, -6, 0, 6, 12, 24])
ax.set_xlabel("hours from the release", fontsize=8.5)
for ax in axes[::2]:
ax.set_ylabel("BTC move size (× the prior week's normal hour)", fontsize=8.5)
axes[0].legend(loc="center left", bbox_to_anchor=(0, 0.78), frameon=False, fontsize=8.5)
fig.suptitle("Scheduled US macro releases and Bitcoin, 2019 – 2026", x=0.06, ha="left",
fontsize=14, fontweight="bold", y=0.985)
fig.text(0.06, 0.915,
"Release times are fixed weeks in advance, so the information cannot arrive "
"early: an excess before the release would be anticipation, an excess after it "
"is the news itself.\nShaded: the release hour and the six after it. Control: "
"the same hour of the week in non-release weeks, scaled the same way.",
fontsize=9.2, color=INK_2, ha="left")
fig.savefig(ROOT / "outputs" / "figures" / "fig9_macro_releases.png", dpi=150,
facecolor=SURFACE)
plt.close(fig)
print("written: outputs/figures/fig9_macro_releases.png")
if __name__ == "__main__":
main()
|