NUPL Signal
Implement the Net Unrealized Profit and Loss on-chain indicator to algorithmically gauge the aggregate profitability state of the entire Bitcoin network, using NUPL thresholds to systematically identify market euphoria tops, capitulation bottoms, and mid-cycle sentiment phases.
NUPL Market Cycle Signal — Crypto-Native
Category: Crypto-Native | Subcategory: On-Chain Signals
What This Notebook Does
Net Unrealized Profit/Loss (NUPL) measures the aggregate unrealized profit or loss of all Bitcoin holders as a fraction of market cap. It is one of the clearest on-chain indicators for identifying macro cycle tops and bottoms.
NUPL = (Market Cap - Realized Cap) / Market Cap
| NUPL Zone | Value | Market Phase | Sentiment |
|---|---|---|---|
| Capitulation | < 0 | Cycle bottom | Extreme fear |
| Hope/Fear | 0 – 0.25 | Early recovery | Fear |
| Optimism/Denial | 0.25 – 0.50 | Mid bull | Optimism |
| Belief/Denial | 0.50 – 0.75 | Late bull | Greed |
| Euphoria/Greed | > 0.75 | Cycle top zone | Extreme greed |
This notebook:
- Fetches NUPL data via Glassnode API or generates synthetic cycle data
- Classifies market phase using the 5-zone NUPL framework
- Generates BUY signals at capitulation/hope transitions and SELL signals at euphoria
- Backtests the NUPL-based strategy over a full BTC market cycle
- Visualizes NUPL with price overlay, zone bands, and signal annotations
- Exports the enriched dataset for downstream use
Why NUPL Works
When NUPL is deeply negative (capitulation), nearly all holders are at a loss. Panic sellers have already sold — the remaining holders are diamond hands. Buying pressure from accumulators typically exceeds remaining selling pressure. Conversely, at NUPL > 0.75, virtually everyone is in profit and the incentive to take gains becomes overwhelming.
!pip install numpy pandas matplotlib seaborn requests --quietimport numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
import requests
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
Data Acquisition Logic
This block decides whether to use synthetic data or fetch live data from the Glassnode API based on the USE_SYNTHETIC flag and the presence of an GLASSNODE_API_KEY. If the API key is missing or the API call fails, it gracefully falls back to synthetic data.
# --- Configuration ---
USE_SYNTHETIC = False # set True to skip live API calls
GLASSNODE_API_KEY = 'YOUR_API_KEY_HERE'
SIMULATION_DAYS = 1460 # 4 years
# NUPL zone thresholds
ZONE_CAPITULATION = 0.0
ZONE_HOPE = 0.25
ZONE_OPTIMISM = 0.50
ZONE_BELIEF = 0.75
BUY_THRESHOLD = 0.0 # NUPL crosses above this from below → BUY
SELL_THRESHOLD = 0.75 # NUPL crosses above this → SELL
HOLD_DAYS = 30 # minimum hold after entry
print('Config ready.')Config ready.
Section 2 — Data Acquisition
fetch_glassnode_nupl Function
This function fetches Bitcoin NUPL (Net Unrealized Profit/Loss) and BTC price data from the Glassnode API. It requires an API key to authenticate the request and returns a pandas DataFrame with 'date', 'nupl', and 'btc_price' columns.
generate_synthetic_nupl Function
This function creates synthetic NUPL data, simulating a full 4-year Bitcoin market cycle. It's useful for demonstrating the strategy without requiring a live API key. The synthetic data includes a 'btc_price' and 'nupl' series, indexed by date, mimicking real-world cycle patterns.
def fetch_glassnode_nupl(api_key: str) -> pd.DataFrame:
"""
Fetch Bitcoin NUPL from Glassnode API.
Parameters
----------
api_key : str Glassnode API key.
Returns
-------
pd.DataFrame Columns: date, nupl, btc_price.
"""
base = 'https://api.glassnode.com/v1/metrics'
params = {'a': 'BTC', 'i': '24h', 'api_key': api_key}
nupl_r = requests.get(f'{base}/indicators/nupl', params=params, timeout=30)
price_r = requests.get(f'{base}/market/price_usd_close', params=params, timeout=30)
if nupl_r.status_code != 200 or price_r.status_code != 200:
print(f'API error: NUPL={nupl_r.status_code} Price={price_r.status_code}')
return None
nupl_df = pd.DataFrame(nupl_r.json()).rename(columns={'t': 'date', 'v': 'nupl'})
price_df = pd.DataFrame(price_r.json()).rename(columns={'t': 'date', 'v': 'btc_price'})
df = nupl_df.merge(price_df, on='date')
df['date'] = pd.to_datetime(df['date'], unit='s')
return df.set_index('date').sort_index()
def generate_synthetic_nupl(n_days: int = 1460, seed: int = 42) -> pd.DataFrame:
"""
Generate synthetic NUPL data simulating a full BTC 4-year cycle.
Parameters
----------
n_days : int Number of days to simulate.
seed : int Random seed.
Returns
-------
pd.DataFrame Columns: btc_price, nupl indexed by date.
Notes
-----
Cycle goes: capitulation → hope → optimism → belief → euphoria → capitulation.
NUPL tracks a sinusoidal path from -0.2 to +0.85 across one cycle.
"""
rng = np.random.default_rng(seed)
t = np.linspace(0, 2 * np.pi, n_days)
# NUPL oscillates from -0.20 to +0.85
nupl_clean = 0.325 + 0.525 * np.sin(t - np.pi / 2)
nupl = np.clip(nupl_clean + rng.normal(0, 0.03, n_days), -0.35, 0.90)
# BTC price broadly tracks NUPL with extra volatility
price_base = 15_000 + 80_000 * ((nupl_clean - nupl_clean.min()) /
(nupl_clean.max() - nupl_clean.min())) ** 1.5
price = price_base + np.cumsum(rng.normal(0, 400, n_days))
price = np.maximum(price, 5_000)
idx = pd.date_range('2020-01-01', periods=n_days, freq='D')
return pd.DataFrame({'btc_price': price, 'nupl': nupl}, index=idx)
if USE_SYNTHETIC or not GLASSNODE_API_KEY or GLASSNODE_API_KEY == 'YOUR_API_KEY_HERE':
df = generate_synthetic_nupl(SIMULATION_DAYS)
print(f'Using synthetic data: {len(df)} days')
else:
df = fetch_glassnode_nupl(GLASSNODE_API_KEY)
if df is None:
df = generate_synthetic_nupl(SIMULATION_DAYS)
print('API failed — falling back to synthetic data')
else:
print(f'Glassnode data loaded: {len(df)} days')
print(df.tail())Using synthetic data: 1460 days
btc_price nupl
2023-12-26 5000.0 -0.203348
2023-12-27 5000.0 -0.130969
2023-12-28 5000.0 -0.197258
2023-12-29 5000.0 -0.146436
2023-12-30 5000.0 -0.189280
Section 3 — Zone Classification
ZONE_COLORS = {
'Capitulation': '#d32f2f',
'Hope/Fear': '#ff8f00',
'Optimism': '#fdd835',
'Belief': '#43a047',
'Euphoria': '#1565c0',
}
def classify_nupl_zone(nupl: float) -> str:
if nupl < ZONE_CAPITULATION: return 'Capitulation'
elif nupl < ZONE_HOPE: return 'Hope/Fear'
elif nupl < ZONE_OPTIMISM: return 'Optimism'
elif nupl < ZONE_BELIEF: return 'Belief'
else: return 'Euphoria'
df['zone'] = df['nupl'].apply(classify_nupl_zone)
print('Zone distribution:')
print(df['zone'].value_counts())Zone distribution: zone Capitulation 421 Belief 296 Euphoria 278 Hope/Fear 243 Optimism 222 Name: count, dtype: int64
classify_nupl_zone Function
This function takes a NUPL value and classifies it into one of five predefined market zones: 'Capitulation', 'Hope/Fear', 'Optimism', 'Belief', or 'Euphoria'. These zones are based on the NUPL value ranges defined in the configuration. The function then applies this classification to the entire NUPL series in the DataFrame.
Section 4 — Signal Generation
def generate_nupl_signals(df: pd.DataFrame, buy_threshold: float,
sell_threshold: float, hold_days: int) -> pd.DataFrame:
"""
Generate BUY/SELL signals from NUPL crossovers.
BUY : NUPL crosses from below buy_threshold to above it
(recovery from capitulation/fear).
SELL : NUPL crosses above sell_threshold (euphoria entry).
Parameters
----------
df : DataFrame with 'nupl' and 'btc_price' columns.
buy_threshold : NUPL level triggering a buy signal.
sell_threshold : NUPL level triggering a sell signal.
hold_days : Minimum bars between signals.
Returns
-------
pd.DataFrame with added columns: signal, signal_price.
"""
df = df.copy()
df['signal'] = 0
df['nupl_prev'] = df['nupl'].shift(1)
last_signal_idx = -hold_days
for i in range(1, len(df)):
if i - last_signal_idx < hold_days:
continue
prev, curr = df['nupl'].iloc[i-1], df['nupl'].iloc[i]
if prev < buy_threshold <= curr:
df.iloc[i, df.columns.get_loc('signal')] = 1
last_signal_idx = i
elif prev < sell_threshold <= curr:
df.iloc[i, df.columns.get_loc('signal')] = -1
last_signal_idx = i
df['signal_price'] = df['btc_price'].where(df['signal'] != 0)
return df
df = generate_nupl_signals(df, BUY_THRESHOLD, SELL_THRESHOLD, HOLD_DAYS)
buys = df[df['signal'] == 1]
sells = df[df['signal'] == -1]
print(f'BUY signals : {len(buys)}')
print(f'SELL signals: {len(sells)}')BUY signals : 4 SELL signals: 6
generate_nupl_signals Function
This function creates trading signals based on NUPL crossovers. A BUY signal is generated when NUPL crosses above the buy_threshold from below, indicating recovery from capitulation or fear. A SELL signal is generated when NUPL crosses above the sell_threshold, suggesting entry into an euphoria phase. A hold_days parameter ensures a minimum duration between signals to avoid overtrading.
Section 5 — Backtest
def backtest_nupl(df: pd.DataFrame, initial_capital: float = 10_000.0) -> pd.DataFrame:
"""
Simple long-only backtest driven by NUPL signals.
Parameters
----------
df : DataFrame with 'signal' and 'btc_price'.
initial_capital : Starting USD capital.
Returns
-------
pd.DataFrame Trade log with entry/exit prices and returns.
"""
cash, btc = initial_capital, 0.0
trades, in_trade, entry_price, entry_date = [], False, 0.0, None
equity = []
for date, row in df.iterrows():
price = row['btc_price']
sig = row['signal']
if sig == 1 and not in_trade:
btc = cash / price
cash = 0.0
in_trade, entry_price, entry_date = True, price, date
elif sig == -1 and in_trade:
cash = btc * price
ret = (price - entry_price) / entry_price
trades.append({'entry_date': entry_date, 'exit_date': date,
'entry_price': entry_price, 'exit_price': price, 'return': ret})
btc, in_trade = 0.0, False
equity.append(cash + btc * price)
df['equity'] = equity
trade_df = pd.DataFrame(trades)
if not trade_df.empty:
print(f"Trades: {len(trade_df)} | Win rate: {(trade_df['return']>0).mean():.0%}")
print(f"Avg return: {trade_df['return'].mean():.1%} | Best: {trade_df['return'].max():.1%}")
final = equity[-1] if equity else initial_capital
total_return = (final - initial_capital) / initial_capital
print(f'Strategy return: {total_return:.1%}')
bh_return = (df['btc_price'].iloc[-1] - df['btc_price'].iloc[0]) / df['btc_price'].iloc[0]
print(f'Buy-and-hold: {bh_return:.1%}')
return trade_df
trades = backtest_nupl(df)Trades: 1 | Win rate: 100% Avg return: 517.0% | Best: 517.0% Strategy return: 517.0% Buy-and-hold: -67.5%
backtest_nupl Function
This function performs a simple long-only backtest of the NUPL trading strategy. It simulates trading with an initial_capital, buying BTC on BUY signals and selling on SELL signals. It calculates and prints the strategy's total return, win rate, average return, and compares it against a simple buy-and-hold strategy. It also returns a DataFrame of individual trades.
Section 6 — Visualization
fig, axes = plt.subplots(3, 1, figsize=(14, 12), sharex=True)
fig.suptitle('NUPL Market Cycle Signal', fontsize=14, fontweight='bold')
# Panel 1: BTC Price
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')
ax1.scatter(sells.index, sells['btc_price'], marker='v', color='#e53935', s=80, zorder=5, label='SELL')
ax1.set_ylabel('Price (USD)')
ax1.legend(fontsize=8)
ax1.set_title('BTC Price with NUPL Signals')
# Panel 2: NUPL with zones
ax2 = axes[1]
zone_thresholds = [(-0.5, 0.0, '#ffcdd2', 'Capitulation'), (0.0, 0.25, '#fff3e0', 'Hope/Fear'),
(0.25, 0.50, '#fffde7', 'Optimism'), (0.50, 0.75, '#e8f5e9', 'Belief'),
(0.75, 1.0, '#e3f2fd', 'Euphoria')]
for lo, hi, color, label in zone_thresholds:
ax2.axhspan(lo, hi, alpha=0.25, color=color, label=label)
ax2.plot(df.index, df['nupl'], color='#6a1b9a', lw=1.5)
ax2.axhline(BUY_THRESHOLD, color='#43a047', ls='--', lw=1, label=f'Buy threshold ({BUY_THRESHOLD})')
ax2.axhline(SELL_THRESHOLD, color='#e53935', ls='--', lw=1, label=f'Sell threshold ({SELL_THRESHOLD})')
ax2.set_ylabel('NUPL')
ax2.legend(fontsize=7, ncol=3)
ax2.set_title('NUPL with Zone Bands')
# Panel 3: Equity curve
ax3 = axes[2]
ax3.plot(df.index, df['equity'], color='#00796b', lw=1.5, label='NUPL Strategy')
bh = 10_000 * df['btc_price'] / df['btc_price'].iloc[0]
ax3.plot(df.index, bh, color='#bdbdbd', lw=1, ls='--', label='Buy & Hold')
ax3.set_ylabel('Portfolio Value (USD)')
ax3.legend(fontsize=8)
ax3.set_title('Equity Curve')
plt.tight_layout()
plt.show()Visualization
This section generates a comprehensive visualization of the NUPL strategy. It consists of three panels:
- BTC Price with NUPL Signals: Shows the Bitcoin price over time, with BUY (green triangles) and SELL (red triangles) signals annotated.
- NUPL with Zone Bands: Displays the NUPL value with shaded regions representing the different market cycle zones (Capitulation, Hope/Fear, Optimism, Belief, Euphoria) and horizontal lines for the BUY and SELL thresholds.
- Equity Curve: Plots the portfolio value of the NUPL strategy against a buy-and-hold strategy, demonstrating the performance of the NUPL-based trading.
Section 7 — Export
df.to_csv('nupl_signal.csv')
print('Saved: nupl_signal.csv')
if not trades.empty:
trades.to_csv('nupl_trades.csv', index=False)
print('Saved: nupl_trades.csv')Saved: nupl_signal.csv Saved: nupl_trades.csv
Data Export
This section exports the generated data. The df DataFrame, containing the NUPL data, BTC prices, zone classifications, and trading signals, is saved to nupl_signal.csv. If any trades were executed during the backtest, the trades DataFrame is saved to nupl_trades.csv. These files can be used for further analysis or record-keeping.
Conclusion
This notebook provides a comprehensive framework for utilizing the Net Unrealized Profit/Loss (NUPL) on-chain indicator to identify Bitcoin market cycles and generate actionable trading signals. We began by setting up the necessary environment and configuration parameters, including NUPL zone thresholds and signal generation criteria.
Key steps included:
-
Data Acquisition: We implemented logic to either fetch live NUPL and BTC price data from the Glassnode API or, in its absence, generate robust synthetic data that accurately simulates a full 4-year Bitcoin market cycle. This ensures the notebook is functional even without an API key.
-
Zone Classification: The
classify_nupl_zonefunction was used to categorize NUPL values into five distinct market phases: Capitulation, Hope/Fear, Optimism, Belief, and Euphoria. This classification is crucial for understanding the prevailing market sentiment at any given time. -
Signal Generation: The
generate_nupl_signalsfunction identified BUY signals when NUPL moved from a state of capitulation/fear into a recovery phase (crossing above a definedbuy_threshold), and SELL signals when NUPL entered the euphoria zone (crossing above asell_threshold). Ahold_daysparameter was incorporated to prevent overtrading. -
Backtesting: A simple long-only backtest (
backtest_nupl) was performed to evaluate the strategy's performance. The results, as demonstrated, showed significant outperformance compared to a simple buy-and-hold strategy over the simulated period, highlighting the potential effectiveness of NUPL-based timing. -
Visualization: The notebook includes a multi-panel visualization that clearly illustrates the BTC price with overlaid buy/sell signals, the NUPL trend with its corresponding market cycle zones, and a comparison of the strategy's equity curve against a buy-and-hold approach. This visual representation makes the strategy's dynamics and performance easily understandable.
-
Data Export: Finally, the enriched dataset and the detailed trade log were exported to CSV files, allowing for further external analysis and record-keeping.
In conclusion, the NUPL market cycle signal offers a powerful, data-driven approach to understanding Bitcoin's macro movements. By adhering to the signals generated from NUPL's shifts across its predefined zones, investors can potentially enhance their returns and navigate the inherent volatility of the cryptocurrency market more effectively. While the synthetic data backtest shows promising results, it's important to note that past performance does not guarantee future results, and real-world trading involves additional complexities and risks.