crypto-forecasting-ai / scripts /onchain_event_study.py
chonthichar's picture
Claude Opus 5.5
Add fig10: blockchain activity against the Fed, on one daily scale
f82fa6f
Raw History Blame Contribute Delete
10.6 kB
"""Does blockchain activity move Bitcoin as much as the Fed does?
Puts on-chain activity and scheduled macro releases on one scale: how much
larger is BTC's daily move on the day in question than on a matched normal
day? Everything is daily, because on-chain data is.
* Fed decision and CPI days: the release date is known weeks ahead, so the
release day itself is a fair forecasting target.
* On-chain spike days: the top 5% of daily jumps in active addresses,
transaction count and hash rate. A day's on-chain figures are only
complete when the day ends, so the usable test is the NEXT day. The same
day is shown as well, labelled, because it cannot be used to forecast.
Market cap is price times supply, and MVRV is computed from market cap; both
mirror the price and are left out.
Each day's move is scaled by the mean move of the 30 days ending the day
before, and compared with the same weekday 2 to 26 weeks away, excluding
event days, so neither a volatile year nor a busy weekday is credited to the
event.
Usage:
python scripts/onchain_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 macro_event_study import RELEASES, release_times # noqa: E402
from news_topic_event_study import (GRID, INK, INK_2, SURFACE, # noqa: E402
bootstrap_ci, hourly_returns, style)
ROOT = Path(__file__).resolve().parents[1]
BASE_DAYS = 30
CONTROLS = 8
SPIKE_Q = 0.95
LAGS = 7
METRICS = {"AdrActCnt": "Active addresses", "TxCnt": "Transaction count",
"HashRate": "Hash rate"}
MACRO_C, CHAIN_C = "#2a78d6", "#1baf7a" # validated slots 1 and 3
def daily_frame() -> pd.DataFrame:
r = hourly_returns()
ret = r.resample("1D").sum(min_count=20)
chain = pd.read_csv(ROOT / "outputs" / "sentiment_replication" / "onchain.csv")
chain["date"] = pd.to_datetime(chain["date"], utc=True).dt.floor("D")
chain = chain.set_index("date")[list(METRICS)]
frame = pd.concat({"ret": ret}, axis=1).join(chain, how="inner").dropna()
frame["abs"] = frame["ret"].abs()
base = frame["abs"].rolling(BASE_DAYS, min_periods=25).mean().shift(1)
frame["scaled"] = frame["abs"] / base # 1.0 = a normal day lately
for col in METRICS:
change = np.log(frame[col]).diff()
# Weekday-adjusted: activity dips at weekends, and so do moves.
frame[f"d_{col}"] = change - change.groupby(change.index.dayofweek).transform("mean")
return frame.dropna(subset=["scaled"])
def event_ratio(frame: pd.DataFrame, days, rng, exclude: set) -> dict | None:
"""Mean scaled move on event days divided by matched control days."""
ev, ctl = [], []
index = set(frame.index)
for day in days:
if day not in index:
continue
pool = []
for _ in range(CONTROLS * 4):
weeks = int(rng.integers(2, 27)) * (1 if rng.random() < 0.5 else -1)
c = day + pd.Timedelta(weeks=weeks)
if c in index and c not in exclude:
pool.append(frame.at[c, "scaled"])
if len(pool) == CONTROLS:
break
if pool:
ev.append(frame.at[day, "scaled"]); ctl.append(np.mean(pool))
if len(ev) < 15:
return None
ev, ctl = np.array(ev), np.array(ctl)
# Bootstrap the ratio over events, so its interval reflects how much the
# effect varies from one event to the next.
idx = rng.integers(0, len(ev), size=(2000, len(ev)))
ratios = ev[idx].mean(axis=1) / ctl[idx].mean(axis=1)
return {"n": int(len(ev)), "ratio": float(ev.mean() / ctl.mean()),
"ci": (float(np.quantile(ratios, 0.025)), float(np.quantile(ratios, 0.975)))}
def lead_lag(frame: pd.DataFrame, col: str) -> pd.DataFrame:
rows = []
for k in range(-LAGS, LAGS + 1):
pair = pd.concat([frame[f"d_{col}"], frame["abs"].shift(-k),
frame["ret"].shift(-k)], axis=1).dropna()
rows.append({"lag": k,
"vs_size": float(pair.iloc[:, 0].corr(pair.iloc[:, 1])),
"vs_direction": float(pair.iloc[:, 0].corr(pair.iloc[:, 2])),
"n": len(pair)})
return pd.DataFrame(rows)
def main() -> None:
rng = np.random.default_rng(0)
frame = daily_frame()
macro_days = {}
for name, (dates, clock) in RELEASES.items():
# The UTC calendar day containing the release minute.
macro_days[name] = sorted({t.floor("D") for t in release_times(dates, clock)})
all_macro = set().union(*macro_days.values())
rows = []
for name, days in macro_days.items():
res = event_ratio(frame, days, rng, all_macro)
rows.append({"label": f"{name}: release day", "kind": "macro", "usable": True, **res})
spike_days = {}
for col, label in METRICS.items():
cut = frame[f"d_{col}"].quantile(SPIKE_Q)
spikes = frame.index[frame[f"d_{col}"] >= cut]
spike_days[col] = spikes
exclude = set(spikes) | all_macro
nxt = [d + pd.Timedelta(days=1) for d in spikes]
same = event_ratio(frame, list(spikes), rng, exclude | set(nxt))
after = event_ratio(frame, nxt, rng, exclude | set(nxt))
rows.append({"label": f"{label} spike: same day", "kind": "chain", "usable": False, **same})
rows.append({"label": f"{label} spike: next day", "kind": "chain", "usable": True, **after})
table = pd.DataFrame(rows)
lags = {col: lead_lag(frame, col) for col in METRICS}
band = 1.96 / np.sqrt(min(t["n"].min() for t in lags.values()))
draw(table, lags, band, frame)
(ROOT / "outputs" / "onchain_event_study.json").write_text(json.dumps({
"days": int(len(frame)),
"span": f"{frame.index[0]:%Y-%m-%d} to {frame.index[-1]:%Y-%m-%d}",
"method": ("daily |return| / mean |return| of the prior 30 days, vs the same weekday "
"2-26 weeks away; on-chain changes are log changes, weekday-adjusted; "
f"spike = top {100 - SPIKE_Q * 100:.0f}% of daily change"),
"events": table.to_dict(orient="records"),
"lead_lag": {c: t.to_dict(orient="records") for c, t in lags.items()},
"significance_band": float(band)}, indent=2, default=list))
for r in table.itertuples():
print(f" {r.label:40s} n={r.n:4d} {r.ratio:.2f}x [{r.ci[0]:.2f}, {r.ci[1]:.2f}]")
for col, t in lags.items():
before = t[t["lag"] > 0]
print(f" {METRICS[col]:18s} next-day size r={t.loc[t['lag'] == 1, 'vs_size'].iloc[0]:+.3f} "
f"max |r| before any move: size {before['vs_size'].abs().max():.3f}, "
f"direction {before['vs_direction'].abs().max():.3f} (band ±{band:.3f})")
def draw(table: pd.DataFrame, lags: dict, band: float, frame: pd.DataFrame) -> None:
plt.rcParams.update({"font.family": "DejaVu Sans", "text.color": INK,
"axes.labelcolor": INK_2})
fig = plt.figure(figsize=(13, 6.6), facecolor=SURFACE)
grid = fig.add_gridspec(1, 2, width_ratios=[1.25, 1], wspace=0.42, left=0.24, right=0.98,
top=0.70, bottom=0.13)
ax = fig.add_subplot(grid[0]); style(ax)
ax.grid(axis="x", color=GRID, linewidth=0.8)
y = np.arange(len(table))[::-1].astype(float)
y[table["kind"].to_numpy() == "chain"] -= 0.6
colors = [MACRO_C if k == "macro" else CHAIN_C for k in table["kind"]]
alphas = [1.0 if u else 0.35 for u in table["usable"]]
for yi, r, c, a in zip(y, table.itertuples(), colors, alphas):
ax.barh(yi, r.ratio - 1, left=1, color=c, alpha=a, height=0.62, zorder=2)
ax.errorbar(r.ratio, yi, xerr=[[r.ratio - r.ci[0]], [r.ci[1] - r.ratio]], fmt="none",
ecolor=INK_2, elinewidth=1, capsize=2.5, zorder=3)
ax.text(max(r.ci[1], r.ratio) + 0.03, yi, f"{r.ratio:.2f}×", va="center",
fontsize=8.5, color=INK_2)
ax.axvline(1, color=INK_2, linewidth=0.9)
ax.set_yticks(y)
ax.set_yticklabels(table["label"], fontsize=9)
ax.set_xlabel("BTC daily move vs a matched normal day (1.0 = no effect; bar = 95% interval)\n"
"faded = same day: on-chain data is only complete at day end, so it "
"cannot forecast that day", fontsize=8.5)
ax.set_title("How much bigger is the day's move?", loc="left", fontsize=11,
fontweight="bold")
ax2 = fig.add_subplot(grid[1]); style(ax2)
ax2.grid(axis="y", color=GRID, linewidth=0.8)
ax2.axhspan(-band, band, color=GRID, linewidth=0, zorder=0)
ax2.axhline(0, color=INK_2, linewidth=0.8)
ax2.axvline(0, color="#8b8a85", linewidth=0.8, linestyle=(0, (3, 3)))
for (col, t), ls in zip(lags.items(), ("-", (0, (4, 2)), (0, (1, 1.5)))):
ax2.plot(t["lag"], t["vs_size"], color=CHAIN_C, linestyle=ls, linewidth=2,
marker="o", markersize=4, markeredgecolor=SURFACE, label=METRICS[col])
ax2.set_xticks(range(-LAGS, LAGS + 1, 7))
ax2.legend(loc="upper right", frameon=False, fontsize=8.5)
ax2.set_xlabel("lag k (days): activity change on day t vs |BTC move| on day t+k\n"
"k > 0: activity before the move; grey = zero at 95%",
fontsize=8.5)
ax2.set_title("Does activity come before big moves?", loc="left", fontsize=11,
fontweight="bold")
fig.suptitle("Blockchain activity vs the Fed: which moves Bitcoin more?", x=0.03,
ha="left", fontsize=14.5, fontweight="bold", y=0.97)
fig.text(0.03, 0.80,
f"Daily, {frame.index[0]:%b %Y} – {frame.index[-1]:%b %Y}. On-chain from CoinMetrics; "
"a spike is the top 5% of daily jumps, weekday-adjusted. Each day is scaled by the "
"previous 30 days and compared\nwith the same weekday in other weeks. Market cap and "
"MVRV are left out: both are computed from the price. At this daily scale the Fed's "
"effect washes out:\nfig9 shows it doubles the release HOUR, but one hour is too "
"small a share of a 24-hour day to make the day detectably bigger.",
fontsize=9, color=INK_2, ha="left")
fig.savefig(ROOT / "outputs" / "figures" / "fig10_onchain_vs_fed.png", dpi=150,
facecolor=SURFACE)
plt.close(fig)
print("written: outputs/figures/fig10_onchain_vs_fed.png")
if __name__ == "__main__":
main()