Crypto-Native·On-Chain Signal Generation·Intermediate

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.

cryptoon-chainsignal-generation

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:

  1. Fetches exchange reserve data from Glassnode or generates synthetic data
  2. Computes 7-day and 30-day reserve changes and net flow
  3. Detects significant outflow events (accumulation) and inflow spikes (distribution)
  4. Generates trading signals based on reserve flow direction
  5. Backtests the signal against BTC price history
  6. Visualizes reserve trends with price overlay and signal annotations
  7. 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.

[1]
!pip install numpy pandas matplotlib seaborn requests scipy --quiet
[2]
import 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.

[3]
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

[4]
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.

[10]
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).

[11]
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)
[12]
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.

[5]
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.

[6]
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.

[7]
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.

[8]
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()
cell output

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.

[9]
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.