crypto-forecasting-ai / scripts /exchange_flow_study.py
chonthichar's picture
Claude Opus 5.5
Test whale transfers and labelled exchange flows against BTC volatility
b55a7c2
Raw History Blame Contribute Delete
5.57 kB
"""Exchange inflows and outflows and Bitcoin: the labelled version of the whale test.
The whale study (fig15) could not tell a sale from an exchange shuffling its
own coins. Here every transaction touching 22 publicly labelled exchange
wallets (outputs/onchain/exchange_addresses.csv: Binance, OKX, Bitfinex,
Robinhood, Crypto.com, Coincheck, Bitbank, Gate, BitMEX, Huobi) is split by
the sending side:
inflow BTC received by an exchange from addresses not known to belong to
that exchange: coins arriving, possibly to be sold
outflow BTC leaving a known exchange address for anywhere else
internal an exchange paying itself; dropped
The wallets are mostly cold storage, so "inflow" still includes an exchange
topping up cold storage from its own unlabelled hot wallets. That noise only
dilutes a real effect; it cannot create one.
Data: runtime/cache/exchange_flows.csv, written by the Colab cell
notebooks/colab_exchange_flows_cell.py from Google's public BigQuery dataset.
Method: whale_event_study.event_study and lead_lag, unchanged.
Usage:
python scripts/exchange_flow_study.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from trump_post_event_study import bars # noqa: E402
from whale_event_study import PLACEBO, SEED, draw, event_study, first_in_burst, lead_lag # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
FLOWS = ROOT / "runtime" / "cache" / "exchange_flows.csv"
GROUPS = { # name: (direction, min BTC, max BTC)
"Inflow 100 - 1,000 BTC": ("inflow", 100, 1_000),
"Inflow 1,000 BTC or more": ("inflow", 1_000, np.inf),
"Outflow 100 - 1,000 BTC": ("outflow", 100, 1_000),
"Outflow 1,000 BTC or more": ("outflow", 1_000, np.inf),
}
COLORS = {"Inflow 100 - 1,000 BTC": "#f2a37f", "Inflow 1,000 BTC or more": "#c24a12",
"Outflow 100 - 1,000 BTC": "#8fb8ea", "Outflow 1,000 BTC or more": "#0b3d91",
PLACEBO: "#8b8a85"}
def flows() -> pd.DataFrame:
if not FLOWS.exists():
raise SystemExit(f"missing {FLOWS.relative_to(ROOT)}: run the Colab cell "
"notebooks/colab_exchange_flows_cell.py and copy its CSV here")
d = pd.read_csv(FLOWS, keep_default_na=False)
d["ts"] = pd.to_datetime(d["block_timestamp"], utc=True)
def received(s: str) -> dict:
return {k: float(v) for k, v in (x.rsplit(":", 1) for x in s.split(";"))} if s else {}
out = d["out_exchanges"].map(received)
senders = d["in_exchanges"].map(lambda s: set(s.split(";")) if s else set())
d["inflow"] = [sum(v for k, v in o.items() if k not in s) for o, s in zip(out, senders)]
d["internal"] = [sum(v for k, v in o.items() if k in s) for o, s in zip(out, senders)]
# Leaving: what a known exchange sent that did not land on a known exchange address.
d["outflow"] = np.where(senders.map(len) > 0,
(d["total_btc"] - out.map(lambda o: sum(o.values()))).clip(lower=0), 0.0)
return d.drop_duplicates("hash").sort_values("ts").reset_index(drop=True)
def main() -> None:
sys.stdout.reconfigure(encoding="utf-8")
rng = np.random.default_rng(SEED)
d = flows()
close = bars()
start = max(close.index.min(), d["ts"].min()) + pd.Timedelta(days=2)
end = min(close.index.max(), d["ts"].max()) - pd.Timedelta(days=22)
print(f"{len(d):,} transactions: inflow {d['inflow'].sum():,.0f} BTC, outflow "
f"{d['outflow'].sum():,.0f} BTC, internal {d['internal'].sum():,.0f} BTC (dropped)")
groups = []
for name, (side, lo, hi) in GROUPS.items():
hit = d[(d[side] >= lo) & (d[side] < hi)]
groups.append((name, first_in_burst(hit["ts"]), d.loc[d[side] >= 100, "ts"]))
results, curves, rel = event_study(close, groups, rng, start, end)
lead = lead_lag(close, {side: d.loc[d[side] > 0].set_index("ts")[side]
for side in ("inflow", "outflow")}, start, end)
(ROOT / "outputs" / "exchange_flow_study.json").write_text(json.dumps({
"method": ("transactions touching 22 labelled exchange wallets (BigQuery crypto_bitcoin); "
"inflow = BTC received from addresses not labelled as the same exchange, outflow = "
"BTC a labelled exchange sent elsewhere; event study and lead-lag as "
"whale_event_study.py"),
"transactions": int(len(d)), "span": [str(d["ts"].min()), str(d["ts"].max())],
"btc_totals": {k: float(d[k].sum()) for k in ("inflow", "outflow", "internal")},
"groups": results, "lead_lag_hourly": lead}, indent=2))
fi = lead["inflow"]["hour_of_week_removed"]["flow_predicts_volatility"]
fo = lead["outflow"]["hour_of_week_removed"]["flow_predicts_volatility"]
draw(results, curves, rel, COLORS, "flow (events)",
"Exchange inflows and outflows on the Bitcoin blockchain and the BTC price",
"Transactions touching 22 publicly labelled exchange wallets (Binance, OKX, Bitfinex, Robinhood, "
"Crypto.com, …), timed by block; 5-minute BTC bars; exchange-to-itself moves dropped.\n"
f"Hourly lead-lag, weekly pattern removed: inflow → next hour's volatility t = {fi['t']:+.1f} "
f"(+{fi['r2_gain_pts']:.2f} pts R²); outflow → next hour's volatility t = {fo['t']:+.1f} "
f"(+{fo['r2_gain_pts']:.2f} pts R²).",
"fig16_exchange_flows.png")
if __name__ == "__main__":
main()