Crypto-Native·On-Chain Signal Generation·Intermediate

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.

cryptoon-chainsignal-generation

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 ZoneValueMarket PhaseSentiment
Capitulation< 0Cycle bottomExtreme fear
Hope/Fear0 – 0.25Early recoveryFear
Optimism/Denial0.25 – 0.50Mid bullOptimism
Belief/Denial0.50 – 0.75Late bullGreed
Euphoria/Greed> 0.75Cycle top zoneExtreme greed

This notebook:

  1. Fetches NUPL data via Glassnode API or generates synthetic cycle data
  2. Classifies market phase using the 5-zone NUPL framework
  3. Generates BUY signals at capitulation/hope transitions and SELL signals at euphoria
  4. Backtests the NUPL-based strategy over a full BTC market cycle
  5. Visualizes NUPL with price overlay, zone bands, and signal annotations
  6. 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 --quiet
[ ]
import 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()
cell output

Visualization

This section generates a comprehensive visualization of the NUPL strategy. It consists of three panels:

  1. BTC Price with NUPL Signals: Shows the Bitcoin price over time, with BUY (green triangles) and SELL (red triangles) signals annotated.
  2. 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.
  3. 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:

  1. 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.

  2. Zone Classification: The classify_nupl_zone function 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.

  3. Signal Generation: The generate_nupl_signals function identified BUY signals when NUPL moved from a state of capitulation/fear into a recovery phase (crossing above a defined buy_threshold), and SELL signals when NUPL entered the euphoria zone (crossing above a sell_threshold). A hold_days parameter was incorporated to prevent overtrading.

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

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

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