NUPL
Net Unrealised Profit/Loss
Measures the aggregate unrealised profit or loss of all Bitcoin holders as a percentage of market cap.
What is it?
NUPL = (Market Value − Realised Value) / Market Value. It represents the net unrealised profit or loss of all coins as a fraction of the total market cap. NUPL ranges from -1 to +1 (in practice, from about -0.5 to +0.75). The five sentiment zones: Capitulation (NUPL < 0): average holder is at a loss — maximum fear; Hope/Fear (0–0.25): recovering from lows; Optimism/Anxiety (0.25–0.5): mid-cycle; Belief/Denial (0.5–0.75): late bull market; Euphoria/Greed (> 0.75): cycle top territory. NUPL is closely related to MVRV but expressed as a percentage rather than a ratio.
Common pitfalls
- NUPL is a Bitcoin-specific metric. Applying it to altcoins without adjusting for different token distribution and vesting schedules produces unreliable results.
- NUPL can remain in the Belief/Denial zone for months during a strong bull market. Selling at the first position start into this zone means missing significant upside.
- Lost coins and long-dormant supply distort NUPL — coins that have not moved in years are included in the realised value calculation.
- NUPL is a macro tool. It provides no timing reading for short-term trades — it is useful for strategic allocation decisions, not tactical entries.
- The zone thresholds (0, 0.25, 0.5, 0.75) are based on historical Bitcoin data. As the market matures and institutional participation grows, these thresholds may shift.
Free code template
Requires Python 3.9+ and the listed pip packages. See inline comments for usage.
# NUPL Analysis — DAI Python Template
# Data source: Glassnode API
# pip install requests pandas matplotlib
import requests
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
GLASS_API_KEY = 'YOUR_GLASSNODE_API_KEY'
ZONES = [
(-1.0, 0.00, '#ef4444', 'Capitulation'),
( 0.00, 0.25, '#f97316', 'Hope / Fear'),
( 0.25, 0.50, '#f59e0b', 'Optimism / Anxiety'),
( 0.50, 0.75, '#22c55e', 'Belief / Denial'),
( 0.75, 1.00, '#a855f7', 'Euphoria / Greed'),
]
def fetch_glassnode(metric: str, asset: str = 'BTC') -> pd.DataFrame:
url = f'https://api.glassnode.com/v1/metrics/{metric}'
r = requests.get(url, params={'a': asset, 'i': '24h', 'api_key': GLASS_API_KEY, 'f': 'JSON'})
r.raise_for_status()
df = pd.DataFrame(r.json())
df['t'] = pd.to_datetime(df['t'], unit='s')
return df.set_index('t').rename(columns={'v': metric.split('/')[-1]})
def plot_nupl(df: pd.DataFrame):
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10), sharex=True)
fig.patch.set_facecolor('#0d1f3c')
ax1.semilogy(df.index, df['price_usd_close'], color='#3b82f6', linewidth=1.5)
ax1.set_facecolor('#0d1f3c'); ax1.tick_params(colors='white')
ax1.set_ylabel('BTC Price (log)', color='white')
nupl = df['nupl']
for lo, hi, color, label in ZONES:
ax2.fill_between(df.index, lo, hi, alpha=0.15, color=color, label=label)
ax2.plot(df.index, nupl, color='white', linewidth=1.5, label='NUPL')
ax2.axhline(0, color='#94a3b8', linestyle=':', alpha=0.5)
ax2.set_ylim(-0.6, 1.0)
ax2.set_facecolor('#0d1f3c'); ax2.tick_params(colors='white')
ax2.set_ylabel('NUPL', color='white')
ax2.legend(facecolor='#1B3A6B', labelcolor='white', loc='upper left', fontsize=8)
current_zone = next((label for lo, hi, _, label in ZONES if lo <= nupl.iloc[-1] < hi), 'Unknown')
print(f'Current NUPL: {nupl.iloc[-1]:.3f} — Zone: {current_zone}')
plt.tight_layout()
plt.savefig('nupl.png', dpi=150, bbox_inches='tight')
plt.show()
# price_df = fetch_glassnode('market/price_usd_close')
# nupl_df = fetch_glassnode('indicators/nupl')
# df = price_df.join(nupl_df)
# plot_nupl(df)