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