Crypto-Native·On-Chain Signal Generation·Intermediate

SOPR Signal

Build a Spent Output Profit Ratio trading signal that measures whether transacted coins are being moved on-chain at an aggregate profit or loss relative to their last movement price, providing a real-time window into aggregate holder behavior and market sentiment conviction.

cryptoon-chainsignal-generation

Resources

SOPR Signal — Crypto-Native

Category: Crypto-Native | Subcategory: On-Chain Signals


What This Notebook Does

The Spent Output Profit Ratio (SOPR) measures the average profit or loss realized by all Bitcoin coins moved on a given day. It divides the price at which coins were sold by the price at which they were originally acquired:

  • SOPR > 1: On average, coins are being sold at a profit
  • SOPR = 1: Break-even — coins sold at exactly their acquisition price
  • SOPR < 1: On average, coins are being sold at a loss

The critical insight: SOPR reset to 1.0 is a strong support/resistance level. In bull markets, SOPR bouncing off 1.0 signals sellers are reluctant to sell at a loss — bullish. In bear markets, SOPR failing to reclaim 1.0 signals persistent selling pressure — bearish.

This notebook:

  1. Fetches SOPR data or generates a synthetic market cycle
  2. Smooths SOPR using EMA and detects pivots around the 1.0 level
  3. Classifies market regime: bull-market SOPR support or bear-market resistance
  4. Generates entry signals at SOPR dips toward 1.0 in bull markets
  5. Backtests the SOPR bounce strategy
  6. Exports the full SOPR analysis dataset

SOPR Signal — Crypto-Native

Category: Crypto-Native | Subcategory: On-Chain Signals


[ ]
!pip install numpy pandas matplotlib seaborn requests --quiet
[ ]
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
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.
[ ]
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
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.')

# --- Configuration ---
USE_SYNTHETIC      = True
GLASSNODE_API_KEY  = 'YOUR_API_KEY_HERE'
SOPR_EMA_PERIOD    = 14       # smooth SOPR to reduce noise
SOPR_BULL_SUPPORT  = 1.0      # SOPR dipping to this in bull market = buy
SOPR_PIVOT_WINDOW  = 5        # bars for local pivot detection
HOLD_BARS          = 14       # hold signal for N days after bounce
SIMULATION_DAYS    = 1460
print('Config ready.')
Imports ready.
Config ready.

This cell re-imports libraries (redundantly from the previous cell), sets up display preferences, and defines key configuration parameters for the SOPR analysis. These parameters include whether to use synthetic data, a placeholder for the Glassnode API key, EMA period, SOPR support level, pivot detection window, hold duration for signals, and simulation days. It prints 'Config ready.' upon completion.

Section 2 — Data

[ ]
def generate_synthetic_sopr(
    n_days: int = 1460,
    seed: int = 42
) -> pd.DataFrame:
    """
    Generate synthetic SOPR data with realistic bull/bear cycle dynamics.

    Parameters
    ----------
    n_days : int  Number of days.
    seed : int  Random seed.

    Returns
    -------
    pd.DataFrame
        Columns: btc_price, sopr, market_phase.

    Notes
    -----
    During bull market: SOPR oscillates above 1.0 with dips to 1.0 as support.
    During bear market: SOPR oscillates below 1.0 with bounces to 1.0 as resistance.
    """
    rng = np.random.default_rng(seed)
    t = np.linspace(0, 2 * np.pi, n_days)

    # Price cycle
    cycle = np.sin(t - np.pi / 2) * 0.5 + 0.5
    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)

    # SOPR: mean slightly above 1 in bull, below 1 in bear, with noise
    sopr_mean = 1.0 + 0.4 * (cycle - 0.3)  # >1 in bull, <1 in bear
    sopr = sopr_mean + rng.normal(0, 0.08, n_days)
    sopr = np.maximum(sopr, 0.5)

    market_phase = np.where(cycle > 0.5, 'bull', 'bear')

    index = pd.date_range('2020-01-01', periods=n_days, freq='D')
    return pd.DataFrame({'btc_price': btc_price, 'sopr': sopr, 'market_phase': market_phase}, index=index)


def fetch_sopr(
    api_key: str = None,
    use_synthetic: bool = False
) -> pd.DataFrame:
    """
    Fetch SOPR from Glassnode or return synthetic data.

    Parameters
    ----------
    api_key : str  Glassnode API key.
    use_synthetic : bool  Skip API call if True.

    Returns
    -------
    pd.DataFrame  SOPR dataset.
    """
    if use_synthetic or not api_key or api_key == 'YOUR_API_KEY_HERE':
        return generate_synthetic_sopr(SIMULATION_DAYS)
    try:
        url    = 'https://api.glassnode.com/v1/metrics/indicators/sopr'
        params = {'a': 'BTC', 'api_key': api_key, 'i': '24h', 'f': 'JSON'}
        resp   = requests.get(url, params=params, timeout=10)
        resp.raise_for_status()
        raw = pd.DataFrame(resp.json())
        raw.index = pd.to_datetime(raw['t'], unit='s')
        raw['sopr'] = raw['v'].astype(float)
        return raw[['sopr']]
    except Exception as e:
        print(f'Glassnode failed ({e}), using synthetic.')
        return generate_synthetic_sopr(SIMULATION_DAYS)


df = fetch_sopr(GLASSNODE_API_KEY, USE_SYNTHETIC)
print(f'SOPR range: {df["sopr"].min():.3f}{df["sopr"].max():.3f}')
print(f'Days above 1.0: {(df["sopr"] > 1).sum()} / {len(df)}')
SOPR range: 0.667 — 1.504
Days above 1.0: 917 / 1460

This cell defines two functions:

  • generate_synthetic_sopr: Creates synthetic SOPR data with bull/bear market cycles for testing purposes.
  • fetch_sopr: Retrieves real SOPR data from the Glassnode API or generates synthetic data if an API key is not provided or use_synthetic is true. It then calls fetch_sopr to get the data and prints statistics about the SOPR range and days above 1.0.

Section 3 — Signal Analysis

[ ]
def compute_sopr_features(
    df: pd.DataFrame,
    ema_period: int = 14
) -> pd.DataFrame:
    """
    Compute EMA-smoothed SOPR and related features.

    Parameters
    ----------
    df : pd.DataFrame  DataFrame with 'sopr' column.
    ema_period : int  EMA smoothing period.

    Returns
    -------
    pd.DataFrame  Input df with sopr_ema, sopr_above_1, sopr_streak columns.
    """
    df = df.copy()
    df['sopr_ema']     = df['sopr'].ewm(span=ema_period).mean()
    df['sopr_above_1'] = (df['sopr_ema'] > 1.0).astype(int)

    # Count consecutive days above/below 1.0
    streak = []
    count = 0
    for val in df['sopr_above_1']:
        if val == 1:
            count = count + 1 if count >= 0 else 1
        else:
            count = count - 1 if count <= 0 else -1
        streak.append(count)
    df['sopr_streak'] = streak
    return df


def detect_sopr_pivots(
    df: pd.DataFrame,
    window: int = 5,
    support_level: float = 1.0
) -> pd.DataFrame:
    """
    Detect SOPR pivots: dips to and bounces from the 1.0 support level.

    Parameters
    ----------
    df : pd.DataFrame  DataFrame with 'sopr_ema' column.
    window : int  Lookback for local minimum detection.
    support_level : float  SOPR level to watch for pivot signals.

    Returns
    -------
    pd.DataFrame  Input df with 'sopr_pivot' column (1=bounce from support).
    """
    df = df.copy()
    sopr = df['sopr_ema'].values
    pivot = np.zeros(len(df))

    for i in range(window, len(df) - window):
        # Local minimum near support level
        local_min = sopr[i] <= sopr[i-window:i].min() and sopr[i] <= sopr[i+1:i+window+1].min()
        near_support = abs(sopr[i] - support_level) < 0.05
        if local_min and near_support and sopr[i] > 0.9:
            pivot[i] = 1

    df['sopr_pivot'] = pivot
    return df


def generate_sopr_signal(
    df: pd.DataFrame,
    hold_bars: int = 14
) -> pd.DataFrame:
    """
    Generate buy signals from SOPR pivot detection.

    Enter on SOPR bounce from 1.0 support; exit after hold_bars days
    or if SOPR drops significantly below 1.0 (bear market signal).

    Parameters
    ----------
    df : pd.DataFrame  DataFrame with 'sopr_pivot' and 'sopr_ema' columns.
    hold_bars : int  Days to hold position after signal.

    Returns
    -------
    pd.DataFrame  Input df with 'sopr_signal' column.
    """
    df = df.copy()
    signal = np.zeros(len(df))
    hold_count = 0

    for i in range(len(df)):
        if df['sopr_pivot'].iloc[i] == 1:
            hold_count = hold_bars
        if hold_count > 0 and df['sopr_ema'].iloc[i] > 0.92:
            signal[i] = 1
            hold_count -= 1
        else:
            hold_count = max(0, hold_count - 1)

    df['sopr_signal'] = signal
    return df


df = compute_sopr_features(df, SOPR_EMA_PERIOD)
df = detect_sopr_pivots(df, SOPR_PIVOT_WINDOW)
df = generate_sopr_signal(df, HOLD_BARS)

print(f'SOPR pivots detected: {int(df["sopr_pivot"].sum())}')
print(f'Days in position: {int(df["sopr_signal"].sum())} / {len(df)}')
SOPR pivots detected: 17
Days in position: 206 / 1460

This cell defines three functions for signal analysis:

  • compute_sopr_features: Calculates Exponential Moving Average (EMA) of SOPR, identifies days where SOPR is above 1.0, and computes consecutive streaks above or below 1.0.
  • detect_sopr_pivots: Identifies 'pivots' where SOPR EMA dips to and bounces from a specified support level (defaulting to 1.0) within a given window.
  • generate_sopr_signal: Creates buy signals based on SOPR pivots, indicating when to enter a position and how long to hold it. It then applies these functions to the dataframe and prints the number of pivots detected and days a signal was active.

Section 4 — Backtest & Visualization

[ ]
def backtest_sopr_strategy(
    df: pd.DataFrame,
    initial_capital: float = 10_000.0
) -> pd.DataFrame:
    """
    Backtest SOPR pivot bounce strategy.

    Parameters
    ----------
    df : pd.DataFrame  DataFrame with 'sopr_signal' and 'btc_price' columns.
    initial_capital : float  Starting capital.

    Returns
    -------
    pd.DataFrame  Input df with 'equity' and 'bh_equity'.
    """
    df = df.copy()
    df['price_ret']    = df['btc_price'].pct_change()
    df['strategy_ret'] = df['sopr_signal'].shift(1) * df['price_ret']
    df['equity']       = initial_capital * (1 + df['strategy_ret'].fillna(0)).cumprod()
    df['bh_equity']    = initial_capital * (1 + df['price_ret'].fillna(0)).cumprod()
    return df


df = backtest_sopr_strategy(df)

# Visualization
fig, axes = plt.subplots(3, 1, figsize=(14, 13), sharex=True)

# Panel 1: BTC price with signal
axes[0].plot(df.index, df['btc_price'], color='steelblue', lw=1.0, label='BTC Price')
pivot_days = df[df['sopr_pivot'] == 1]
axes[0].scatter(pivot_days.index, pivot_days['btc_price'], color='green', s=40, zorder=5, marker='^', label='SOPR Pivot (Buy)')
axes[0].set_ylabel('Price (USD)')
axes[0].set_title('BTC Price with SOPR Pivot Signals')
axes[0].legend()

# Panel 2: SOPR
axes[1].plot(df.index, df['sopr'],     color='lightblue', lw=0.6, alpha=0.6, label='SOPR Raw')
axes[1].plot(df.index, df['sopr_ema'], color='purple',    lw=1.2, label=f'SOPR EMA({SOPR_EMA_PERIOD})')
axes[1].axhline(1.0, color='gold', lw=1.5, linestyle='--', label='SOPR = 1.0')
axes[1].scatter(pivot_days.index, pivot_days['sopr_ema'], color='green', s=40, zorder=5, marker='^')
axes[1].set_ylabel('SOPR')
axes[1].set_title('SOPR with EMA — Green markers = pivot signals')
axes[1].legend()

# Panel 3: Equity
axes[2].plot(df.index, df['equity'],    color='green',     lw=1.5, label='SOPR Strategy')
axes[2].plot(df.index, df['bh_equity'], color='steelblue', lw=1.0, linestyle='--', label='Buy & Hold')
axes[2].set_ylabel('Portfolio Value (USD)')
axes[2].set_xlabel('Date')
axes[2].set_title('SOPR Strategy vs Buy & Hold')
axes[2].legend()

plt.tight_layout()
plt.show()

final = df.iloc[-1]
print(f'SOPR Strategy: ${final["equity"]:,.0f}')
print(f'Buy & Hold:    ${final["bh_equity"]:,.0f}')
cell output
SOPR Strategy: $7,230
Buy & Hold:    $5,408

This cell defines the backtest_sopr_strategy function to simulate the performance of the SOPR trading strategy against a simple buy-and-hold approach. It calculates daily returns for both strategies and their cumulative equity. It then visualizes the BTC price with SOPR pivot signals, the SOPR and its EMA with the 1.0 level, and the equity curves of both strategies. Finally, it prints the final equity for both the SOPR strategy and Buy & Hold.

Section 5 — Export

[ ]
def export_sopr_data(df: pd.DataFrame) -> None:
    """
    Export SOPR signal dataset.

    Parameters
    ----------
    df : pd.DataFrame  Fully processed SOPR dataframe.
    """
    df.to_csv('sopr_signal.csv')
    print('Exported: sopr_signal.csv')


export_sopr_data(df)
Exported: sopr_signal.csv

This cell defines the export_sopr_data function, which saves the processed SOPR DataFrame to a CSV file named 'sopr_signal.csv'. It then calls this function to export the data and confirms the export.

Summary & Next Steps

Key Takeaways

  • SOPR = 1.0 is the critical level: in bull markets it acts as support; in bear markets as resistance
  • EMA smoothing (14-day) removes daily noise caused by miners, exchange settlements, and coin movements
  • SOPR dipping to ~1.0 and bouncing in a bull market is a high-confidence continuation entry signal
  • Prolonged SOPR < 1.0 with failing rallies = sellers realizing losses = late-stage bear market
  • SOPR is complementary to MVRV: MVRV tells you the aggregate situation, SOPR tells you today's sentiment