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