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()