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.
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:
- Fetches SOPR data or generates a synthetic market cycle
- Smooths SOPR using EMA and detects pivots around the 1.0 level
- Classifies market regime: bull-market SOPR support or bear-market resistance
- Generates entry signals at SOPR dips toward 1.0 in bull markets
- Backtests the SOPR bounce strategy
- Exports the full SOPR analysis dataset
SOPR Signal — Crypto-Native
Category: Crypto-Native | Subcategory: On-Chain Signals
!pip install numpy pandas matplotlib seaborn requests --quietimport 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 oruse_syntheticis true. It then callsfetch_soprto 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}')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