Exchange Reserve Signal
Track cryptocurrency exchange reserve wallet balances on-chain to algorithmically detect large anomalous inflows that may signal impending selling pressure from depositors or significant outflows that suggest accumulation behavior and movement of assets to long-term custody storage.
Exchange Reserve Change Signal — Crypto-Native
Category: Crypto-Native | Subcategory: On-Chain Signals
What This Notebook Does
Exchange Reserves measure the total amount of Bitcoin held in known exchange wallets. This is one of the most direct on-chain sell-pressure indicators:
- Rising reserves: BTC flowing INTO exchanges → holders preparing to sell → bearish pressure
- Falling reserves: BTC flowing OUT of exchanges → holders withdrawing to cold storage → accumulation signal, bullish
Reserve Change = Reserves(t) - Reserves(t-n)
Netflow = Inflows - Outflows
This notebook:
- Fetches exchange reserve data from Glassnode or generates synthetic data
- Computes 7-day and 30-day reserve changes and net flow
- Detects significant outflow events (accumulation) and inflow spikes (distribution)
- Generates trading signals based on reserve flow direction
- Backtests the signal against BTC price history
- Visualizes reserve trends with price overlay and signal annotations
- Exports the enriched dataset
Key Concept: The Withdrawal Signal
When whales and institutions withdraw large amounts from exchanges, they are moving coins to cold storage for long-term holding. This is structurally bullish — it reduces potential sell-side supply. The inverse (exchange inflows) signals intent to trade or sell.
!pip install numpy pandas matplotlib seaborn requests scipy --quietimport numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import requests
from scipy import stats
import warnings
warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
print('Imports ready.')Imports ready.
Section 1 — Configuration
This section defines key parameters and settings for the notebook's execution, including whether to use synthetic data, the Glassnode API key, simulation days, and thresholds for signal generation.
USE_SYNTHETIC = False
GLASSNODE_API_KEY = 'YOUR_API_KEY_HERE'
SIMULATION_DAYS = 1460
SHORT_WINDOW = 7 # days for short-term change
LONG_WINDOW = 30 # days for long-term change
# Zscore threshold: significant reserve change
OUTFLOW_SIGNAL_ZSCORE = -1.5 # large outflow → bullish
INFLOW_SIGNAL_ZSCORE = 1.5 # large inflow → bearish
HOLD_DAYS = 10
print('Config ready.')Config ready.
Section 2 — Data
def fetch_exchange_reserves(api_key: str) -> pd.DataFrame:
"""
Fetch exchange reserve data from Glassnode.
Returns
-------
pd.DataFrame Columns: btc_price, exchange_reserve_btc (daily).
"""
base = 'https://api.glassnode.com/v1/metrics'
params = {'a': 'BTC', 'i': '24h', 'api_key': api_key}
res_r = requests.get(f'{base}/distribution/balance_exchanges',
params=params, timeout=30)
mkt_r = requests.get(f'{base}/market/price_usd_close',
params=params, timeout=30)
if res_r.status_code != 200 or mkt_r.status_code != 200:
return None
res = pd.DataFrame(res_r.json()).rename(columns={'t': 'date', 'v': 'exchange_reserve_btc'})
mkt = pd.DataFrame(mkt_r.json()).rename(columns={'t': 'date', 'v': 'btc_price'})
df = res.merge(mkt, on='date')
df['date'] = pd.to_datetime(df['date'], unit='s')
return df.set_index('date').sort_index()
def generate_synthetic_reserves(n_days: int = 1460, seed: int = 42) -> pd.DataFrame:
"""
Simulate exchange reserve dynamics across a BTC cycle.
Reserves fall during bull markets (accumulation / cold storage)
and rise during bear markets (capitulation / selling).
Returns
-------
pd.DataFrame Columns: btc_price, exchange_reserve_btc (daily).
"""
rng = np.random.default_rng(seed)
t = np.linspace(0, 2 * np.pi, n_days)
cycle = np.sin(t - np.pi / 2) * 0.5 + 0.5 # 0→1→0
btc_price = 15_000 + 75_000 * cycle**1.5 + np.cumsum(rng.normal(0, 300, n_days))
btc_price = np.maximum(btc_price, 5_000)
# Reserves inversely correlated with price cycle + slow drift + noise
reserve_base = 2_800_000 - 500_000 * cycle
reserve_noise = np.cumsum(rng.normal(0, 2_000, n_days))
reserves = np.maximum(reserve_base + reserve_noise, 500_000)
idx = pd.date_range('2020-01-01', periods=n_days, freq='D')
return pd.DataFrame({'btc_price': btc_price,
'exchange_reserve_btc': reserves}, index=idx)
if USE_SYNTHETIC or not GLASSNODE_API_KEY or GLASSNODE_API_KEY == 'YOUR_API_KEY_HERE':
df = generate_synthetic_reserves(SIMULATION_DAYS)
print(f'Synthetic data: {len(df)} days')
else:
df = fetch_exchange_reserves(GLASSNODE_API_KEY)
if df is None:
df = generate_synthetic_reserves(SIMULATION_DAYS)
print('API failed — synthetic fallback')
else:
print(f'Glassnode data: {len(df)} days')
print(df.tail())Synthetic data: 1460 days
btc_price exchange_reserve_btc
2023-12-26 6801.228552 2.672299e+06
2023-12-27 7491.077577 2.675467e+06
2023-12-28 7518.286120 2.679141e+06
2023-12-29 8053.876397 2.677108e+06
2023-12-30 8161.071475 2.678935e+06
fetch_exchange_reserves Function
This function is responsible for retrieving Bitcoin exchange reserve data and its corresponding price data from the Glassnode API. It makes two API calls: one for exchange balances and another for BTC's USD close price. The data is then merged, converted to datetime objects, and set with a date index, returning a DataFrame suitable for analysis.
def fetch_exchange_reserves(api_key: str) -> pd.DataFrame:
"""
Fetch exchange reserve data from Glassnode.
Returns
-------
pd.DataFrame Columns: btc_price, exchange_reserve_btc (daily).
"""
base = 'https://api.glassnode.com/v1/metrics'
params = {'a': 'BTC', 'i': '24h', 'api_key': api_key}
res_r = requests.get(f'{base}/distribution/balance_exchanges',
params=params, timeout=30)
mkt_r = requests.get(f'{base}/market/price_usd_close',
params=params, timeout=30)
if res_r.status_code != 200 or mkt_r.status_code != 200:
return None
res = pd.DataFrame(res_r.json()).rename(columns={'t': 'date', 'v': 'exchange_reserve_btc'})
mkt = pd.DataFrame(mkt_r.json()).rename(columns={'t': 'date', 'v': 'btc_price'})
df = res.merge(mkt, on='date')
df['date'] = pd.to_datetime(df['date'], unit='s')
return df.set_index('date').sort_index()generate_synthetic_reserves Function
This function creates synthetic (simulated) Bitcoin exchange reserve and price data. It's useful for testing the signal generation and backtesting logic without requiring an API key or when Glassnode data is unavailable. The simulation models a BTC cycle where reserves typically fall during bull markets (accumulation) and rise during bear markets (capitulation).
def generate_synthetic_reserves(n_days: int = 1460, seed: int = 42) -> pd.DataFrame:
"""
Simulate exchange reserve dynamics across a BTC cycle.
Reserves fall during bull markets (accumulation / cold storage)
and rise during bear markets (capitulation / selling).
Returns
-------
pd.DataFrame Columns: btc_price, exchange_reserve_btc (daily).
"""
rng = np.random.default_rng(seed)
t = np.linspace(0, 2 * np.pi, n_days)
cycle = np.sin(t - np.pi / 2) * 0.5 + 0.5 # 0→1→0
btc_price = 15_000 + 75_000 * cycle**1.5 + np.cumsum(rng.normal(0, 300, n_days))
btc_price = np.maximum(btc_price, 5_000)
# Reserves inversely correlated with price cycle + slow drift + noise
reserve_base = 2_800_000 - 500_000 * cycle
reserve_noise = np.cumsum(rng.normal(0, 2_000, n_days))
reserves = np.maximum(reserve_base + reserve_noise, 500_000)
idx = pd.date_range('2020-01-01', periods=n_days, freq='D')
return pd.DataFrame({'btc_price': btc_price,
'exchange_reserve_btc': reserves}, index=idx)if USE_SYNTHETIC or not GLASSNODE_API_KEY or GLASSNODE_API_KEY == 'YOUR_API_KEY_HERE':
df = generate_synthetic_reserves(SIMULATION_DAYS)
print(f'Synthetic data: {len(df)} days')
else:
df = fetch_exchange_reserves(GLASSNODE_API_KEY)
if df is None:
df = generate_synthetic_reserves(SIMULATION_DAYS)
print('API failed — synthetic fallback')
else:
print(f'Glassnode data: {len(df)} days')
print(df.tail())Synthetic data: 1460 days
btc_price exchange_reserve_btc
2023-12-26 6801.228552 2.672299e+06
2023-12-27 7491.077577 2.675467e+06
2023-12-28 7518.286120 2.679141e+06
2023-12-29 8053.876397 2.677108e+06
2023-12-30 8161.071475 2.678935e+06
Section 3 — Derived Metrics
This section calculates various metrics derived from the raw exchange reserve data, such as 7-day and 30-day changes, percentage changes, and a Z-score for the 7-day change, which helps in identifying statistically significant movements. It also defines a trend based on 30-day change.
res = df['exchange_reserve_btc']
df['change_7d'] = res.diff(SHORT_WINDOW)
df['change_30d'] = res.diff(LONG_WINDOW)
df['change_7d_pct'] = res.pct_change(SHORT_WINDOW) * 100
df['change_30d_pct'] = res.pct_change(LONG_WINDOW) * 100
# Z-score of 7-day change
df['change_zscore'] = (df['change_7d'] - df['change_7d'].rolling(90).mean()) / df['change_7d'].rolling(90).std()
# Trend: falling reserves (bearish for sell pressure)
df['trend'] = np.where(df['change_30d'] < 0, 'Declining (Bullish)', 'Rising (Bearish)')
print(df[['change_7d', 'change_30d', 'change_zscore']].describe())change_7d change_30d change_zscore count 1453.000000 1430.000000 1364.000000 mean -608.566524 -2563.266156 0.020634 std 8307.914165 29924.522889 1.066314 min -22802.581414 -62812.616190 -3.755029 25% -6857.453464 -28662.674411 -0.708784 50% -797.237675 -3430.884181 0.073730 75% 5681.486149 24051.650655 0.795022 max 22481.870225 61675.780423 3.081865
Section 4 — Signal Generation
Based on the derived metrics, particularly the Z-score of the 7-day reserve change, this section generates trading signals. A large negative Z-score indicates a significant outflow (bullish signal), while a large positive Z-score indicates a significant inflow (bearish signal). A HOLD_DAYS parameter prevents rapid successive signals.
df['signal'] = 0
last_sig = -HOLD_DAYS
for i in range(90, len(df)):
if i - last_sig < HOLD_DAYS:
continue
z = df['change_zscore'].iloc[i]
if pd.isna(z):
continue
if z <= OUTFLOW_SIGNAL_ZSCORE: # large outflow = accumulation = bullish
df.iloc[i, df.columns.get_loc('signal')] = 1
last_sig = i
elif z >= INFLOW_SIGNAL_ZSCORE: # large inflow = distribution = bearish
df.iloc[i, df.columns.get_loc('signal')] = -1
last_sig = i
print(f"BUY signals: {(df['signal']==1).sum()}")
print(f"SELL signals: {(df['signal']==-1).sum()}")BUY signals: 30 SELL signals: 32
Section 5 — Backtest
This section performs a simple backtest of the generated trading signals. It simulates a trading strategy where the portfolio shifts between cash and BTC based on buy/sell signals, and then compares its performance against a simple buy-and-hold strategy.
cash, btc, in_trade = 10_000.0, 0.0, False
equity = []
for _, row in df.iterrows():
price = row['btc_price']
if row['signal'] == 1 and not in_trade:
btc = cash / price; cash = 0; in_trade = True
elif row['signal'] == -1 and in_trade:
cash = btc * price; btc = 0; in_trade = False
equity.append(cash + btc * price)
df['equity'] = equity
total = (equity[-1] - 10_000) / 10_000
bh = (df['btc_price'].iloc[-1] - df['btc_price'].iloc[0]) / df['btc_price'].iloc[0]
print(f'Reserve Signal return: {total:.1%}')
print(f'Buy-and-hold return : {bh:.1%}')Reserve Signal return: 14.5% Buy-and-hold return : -45.9%
Section 6 — Visualization
This section provides visualizations to illustrate the key components of the analysis: BTC price overlaid with buy/sell signals, the trend of exchange reserves over time, and a bar chart showing the 7-day change in reserves.
fig, axes = plt.subplots(3, 1, figsize=(14, 12), sharex=True)
fig.suptitle('Exchange Reserve Signal', fontsize=14, fontweight='bold')
buys = df[df['signal'] == 1]
sells = df[df['signal'] == -1]
ax1 = axes[0]
ax1.plot(df.index, df['btc_price'], color='#1976d2', lw=1.5, label='BTC Price')
ax1.scatter(buys.index, buys['btc_price'], marker='^', color='#43a047', s=80, zorder=5, label='BUY (outflow spike)')
ax1.scatter(sells.index, sells['btc_price'], marker='v', color='#e53935', s=80, zorder=5, label='SELL (inflow spike)')
ax1.set_ylabel('BTC Price (USD)')
ax1.legend(fontsize=8); ax1.set_title('BTC Price with Exchange Reserve Signals')
ax2 = axes[1]
ax2.plot(df.index, df['exchange_reserve_btc'] / 1e6, color='#e65100', lw=1.5, label='Exchange Reserves (M BTC)')
ax2.set_ylabel('Exchange Reserves (M BTC)')
ax2.legend(fontsize=8); ax2.set_title('Exchange Reserves — Falling = Accumulation')
ax3 = axes[2]
colors_bar = ['#43a047' if v < 0 else '#e53935' for v in df['change_7d'].fillna(0)]
ax3.bar(df.index, df['change_7d'].fillna(0) / 1000, color=colors_bar, width=1, alpha=0.7)
ax3.axhline(0, color='black', lw=0.8)
ax3.set_ylabel('7-Day Reserve Change (K BTC)')
ax3.set_title('7-Day Exchange Reserve Net Flow (Negative = Outflow = Bullish)')
plt.tight_layout()
plt.show()Section 7 — Export
Finally, this section exports the processed DataFrame, which includes the BTC price, exchange reserves, derived metrics, trading signals, and backtest equity, to a CSV file for further analysis or record-keeping.
out = df[['btc_price', 'exchange_reserve_btc', 'change_7d', 'change_30d', 'change_zscore', 'signal', 'equity']]
out.to_csv('exchange_reserve_signal.csv')
print('Saved: exchange_reserve_signal.csv')Saved: exchange_reserve_signal.csv
Conclusion
This notebook successfully demonstrates how to use Bitcoin exchange reserve data to generate trading signals. By analyzing the 7-day change in exchange reserves and applying a Z-score threshold, we can identify significant outflow (bullish) and inflow (bearish) events. The backtest against synthetic data shows how this strategy can potentially outperform a simple buy-and-hold approach by timing market entries and exits based on these on-chain indicators.
The visualizations provide clear insights into the relationship between BTC price, exchange reserves, and the generated trading signals, making the underlying mechanics of the strategy easily understandable.