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.
When to use it
- Cycle top identification: NUPL entering the Euphoria zone (above 0.75) has historically marked the final stage of Bitcoin bull cycles.
- Cycle bottom identification: NUPL entering Capitulation (below 0) means the average holder is at a loss — historically a high-conviction accumulation zone.
- Sentiment tracking: monitor NUPL's progression through zones to gauge where in the market cycle Bitcoin currently sits.
- Combining with MVRV: when both MVRV Z-Score and NUPL are in extreme zones simultaneously, the signal is stronger.
- Relative NUPL: compare current NUPL to the same level in prior cycles to assess whether the current cycle is running ahead of or behind historical patterns.
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 entry 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 signal 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)