Download scripts/macro_event_study.py from chonthichar/crypto-forecasting-ai: direct link, hf CLI and curl.
- Browser
- Download file 12.4 kB
-
https://huggingface.co/spaces/chonthichar/crypto-forecasting-ai/resolve/main/scripts/macro_event_study.py
- Command line
-
hf download hf://spaces/chonthichar/crypto-forecasting-ai/scripts/macro_event_study.py
-
curl -L -o macro_event_study.py https://huggingface.co/spaces/chonthichar/crypto-forecasting-ai/resolve/main/scripts/macro_event_study.py
12.4 kB
| """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() | |