MVRV Signal
Implement the Market Value to Realized Value on-chain ratio as a cyclical trading signal that identifies Bitcoin market cycle tops when the market value significantly exceeds the aggregate on-chain cost basis and cycle bottoms when price approaches or dips below realized value.
MVRV Signal — Crypto-Native
Category: Crypto-Native | Subcategory: On-Chain Signals
What This Notebook Does
The Market Value to Realized Value (MVRV) ratio is one of the most powerful on-chain indicators for identifying Bitcoin market cycle tops and bottoms. It compares what the market is paying for BTC (market cap) versus what all BTC holders paid on average (realized cap).
- MVRV > 3.5: Market top zone — holders sitting on extreme unrealized profits, incentivized to sell
- MVRV 1.5–3.5: Normal bull market range — healthy uptrend
- MVRV 0.8–1.5: Accumulation zone — fair value, good risk/reward for buyers
- MVRV < 0.8: Historical buy zone — holders in aggregate are underwater (selling pressure exhausted)
This notebook:
- Fetches MVRV data via Glassnode API or generates synthetic cycle data
- Computes MVRV z-score and cycle-adjusted versions
- Generates buy/sell signals at extreme MVRV levels
- Backtests the strategy on a full BTC market cycle
- Visualizes MVRV with price overlay and signal annotation
- Exports the enriched MVRV dataset
MVRV Components
| Term | Definition |
|---|---|
| Market Cap | Current price × total circulating supply |
| Realized Cap | Sum of (price at time each BTC last moved × quantity) |
| MVRV Ratio | Market Cap / Realized Cap |
| Unrealized Profit | (Market Cap - Realized Cap) — positive = aggregate profit |
| MVRV Z-score | (Market Cap - Realized Cap) / std(Market Cap) |
!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.
# --- Configuration ---
USE_SYNTHETIC = False
GLASSNODE_API_KEY = 'YOUR_API_KEY_HERE' # optional — notebook works without it
MVRV_BUY_THRESHOLD = 1.0 # buy when MVRV falls below this
MVRV_SELL_THRESHOLD = 3.5 # sell when MVRV rises above this
ZSCORE_BUY = -0.5 # z-score buy threshold
ZSCORE_SELL = 2.0 # z-score sell threshold
SIMULATION_DAYS = 1460 # 4 years (one full cycle)
print('Config ready.')Config ready.
Section 2 — Data
generate_synthetic_mvrv_cycle
Generate a synthetic BTC market cycle with realistic MVRV dynamics.
Parameters
n_days : int Number of days to simulate (1460 = ~4 years). seed : int Random seed.
Returns
pd.DataFrame Columns: date, btc_price, market_cap, realized_cap, mvrv, unrealized_pnl.
Notes
Simulates one full BTC 4-year cycle: accumulation → bull → distribution → bear. MVRV reaches >3.5 at cycle peak and <1.0 at cycle bottom. Realized cap grows slowly — it only updates when coins move.
fetch_mvrv_data
Fetch MVRV ratio from Glassnode API or return synthetic data.
Parameters
api_key : str Glassnode API key (requires free tier). use_synthetic : bool Skip API call if True.
Returns
pd.DataFrame MVRV dataset.
def generate_synthetic_mvrv_cycle(
n_days: int = 1460,
seed: int = 42
) -> pd.DataFrame:
"""
Generate a synthetic BTC market cycle with realistic MVRV dynamics.
Parameters
----------
n_days : int Number of days to simulate (1460 = ~4 years).
seed : int Random seed.
Returns
-------
pd.DataFrame
Columns: date, btc_price, market_cap, realized_cap, mvrv, unrealized_pnl.
Notes
-----
Simulates one full BTC 4-year cycle: accumulation → bull → distribution → bear.
MVRV reaches >3.5 at cycle peak and <1.0 at cycle bottom.
Realized cap grows slowly — it only updates when coins move.
"""
rng = np.random.default_rng(seed)
t = np.linspace(0, 2 * np.pi, n_days)
# Cycle: accumulation → bull → distribution → bear
cycle_component = np.sin(t - np.pi / 2) * 0.5 + 0.5 # 0 → 1 → 0
# BTC price: follows cycle with noise
base_price = 15_000 + 80_000 * cycle_component**2
price_noise = np.cumsum(rng.normal(0, 1, n_days))
price_noise = price_noise / price_noise.std() * 3000
btc_price = np.maximum(base_price + price_noise, 3000)
# Supply: fixed at 19.5M
supply = 19_500_000
# Market cap
market_cap = btc_price * supply
# Realized cap: lags behind price — grows slowly in accumulation, spikes in distribution
realized_price_smooth = pd.Series(btc_price).ewm(span=120).mean().values
realized_cap = realized_price_smooth * supply
mvrv = market_cap / realized_cap
index = pd.date_range('2020-01-01', periods=n_days, freq='D')
return pd.DataFrame({
'btc_price': btc_price,
'market_cap': market_cap,
'realized_cap': realized_cap,
'mvrv': mvrv,
'unrealized_pnl': market_cap - realized_cap,
}, index=index)
def fetch_mvrv_data(api_key: str = None, use_synthetic: bool = False) -> pd.DataFrame:
"""
Fetch MVRV ratio from Glassnode API or return synthetic data.
Parameters
----------
api_key : str Glassnode API key (requires free tier).
use_synthetic : bool Skip API call if True.
Returns
-------
pd.DataFrame MVRV dataset.
"""
if use_synthetic or not api_key or api_key == 'YOUR_API_KEY_HERE':
print('Using synthetic MVRV cycle data.')
return generate_synthetic_mvrv_cycle(SIMULATION_DAYS)
try:
url = 'https://api.glassnode.com/v1/metrics/market/mvrv'
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['mvrv'] = raw['v'].astype(float)
print(f'Fetched {len(raw)} days of MVRV data from Glassnode.')
return raw[['mvrv']]
except Exception as e:
print(f'Glassnode API failed ({e}), using synthetic.')
return generate_synthetic_mvrv_cycle(SIMULATION_DAYS)
df = fetch_mvrv_data(GLASSNODE_API_KEY, USE_SYNTHETIC)
print(f'MVRV range: {df["mvrv"].min():.2f} — {df["mvrv"].max():.2f}')Using synthetic MVRV cycle data. MVRV range: 0.52 — 1.37
Section 3 — Signal Computation
compute_mvrv_zscore
Compute MVRV z-score and percentile rank.
Parameters
df : pd.DataFrame DataFrame with 'mvrv' column. window : int Rolling window for z-score normalization.
Returns
pd.DataFrame Input df with mvrv_zscore, mvrv_pct_rank, mvrv_phase columns.
generate_mvrv_signal
Generate buy/sell signals from MVRV thresholds.
Parameters
df : pd.DataFrame DataFrame with 'mvrv' column. buy_threshold : float Enter long when MVRV drops below this. sell_threshold : float Exit / go flat when MVRV rises above this.
Returns
pd.DataFrame Input df with 'mvrv_signal' column (1=long, 0=flat).
def compute_mvrv_zscore(
df: pd.DataFrame,
window: int = 365
) -> pd.DataFrame:
"""
Compute MVRV z-score and percentile rank.
Parameters
----------
df : pd.DataFrame DataFrame with 'mvrv' column.
window : int Rolling window for z-score normalization.
Returns
-------
pd.DataFrame Input df with mvrv_zscore, mvrv_pct_rank, mvrv_phase columns.
"""
df = df.copy()
roll_mean = df['mvrv'].rolling(window).mean()
roll_std = df['mvrv'].rolling(window).std()
df['mvrv_zscore'] = (df['mvrv'] - roll_mean) / (roll_std + 1e-9)
df['mvrv_pct_rank'] = df['mvrv'].rolling(window).rank(pct=True)
# Classify phase
conditions = [
df['mvrv'] < 0.8,
(df['mvrv'] >= 0.8) & (df['mvrv'] < 1.5),
(df['mvrv'] >= 1.5) & (df['mvrv'] < 2.5),
(df['mvrv'] >= 2.5) & (df['mvrv'] < 3.5),
df['mvrv'] >= 3.5,
]
labels = ['Capitulation', 'Accumulation', 'Bull Early', 'Bull Late', 'Distribution']
df['mvrv_phase'] = np.select(conditions, labels, default='Unknown')
return df
def generate_mvrv_signal(
df: pd.DataFrame,
buy_threshold: float = 1.0,
sell_threshold: float = 3.5
) -> pd.DataFrame:
"""
Generate buy/sell signals from MVRV thresholds.
Parameters
----------
df : pd.DataFrame DataFrame with 'mvrv' column.
buy_threshold : float Enter long when MVRV drops below this.
sell_threshold : float Exit / go flat when MVRV rises above this.
Returns
-------
pd.DataFrame Input df with 'mvrv_signal' column (1=long, 0=flat).
"""
df = df.copy()
signal = np.zeros(len(df))
in_position = False
for i, mvrv_val in enumerate(df['mvrv']):
if np.isnan(mvrv_val):
continue
if not in_position and mvrv_val <= buy_threshold:
in_position = True
elif in_position and mvrv_val >= sell_threshold:
in_position = False
signal[i] = 1 if in_position else 0
df['mvrv_signal'] = signal
return df
df = compute_mvrv_zscore(df)
df = generate_mvrv_signal(df, MVRV_BUY_THRESHOLD, MVRV_SELL_THRESHOLD)
print('MVRV Phase Distribution:')
print(df['mvrv_phase'].value_counts().to_string())MVRV Phase Distribution: mvrv_phase Accumulation 1090 Capitulation 370
Section 4 — Backtest
backtest_mvrv_strategy
Backtest the MVRV signal strategy vs buy-and-hold.
Parameters
df : pd.DataFrame DataFrame with 'mvrv_signal' and 'btc_price' columns. initial_capital : float Starting capital in USD.
Returns
pd.DataFrame Input df with equity and bh_equity columns.
def backtest_mvrv_strategy(
df: pd.DataFrame,
initial_capital: float = 10_000.0
) -> pd.DataFrame:
"""
Backtest the MVRV signal strategy vs buy-and-hold.
Parameters
----------
df : pd.DataFrame DataFrame with 'mvrv_signal' and 'btc_price' columns.
initial_capital : float Starting capital in USD.
Returns
-------
pd.DataFrame Input df with equity and bh_equity columns.
"""
df = df.copy()
df['price_ret'] = df['btc_price'].pct_change()
df['strategy_ret'] = df['mvrv_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()
# Max drawdown
running_max = df['equity'].cummax()
df['drawdown'] = (df['equity'] - running_max) / running_max
return df
df = backtest_mvrv_strategy(df)
final = df.iloc[-1]
print(f'MVRV Strategy: ${final["equity"]:,.0f} ({(final["equity"]/10000-1)*100:.0f}%)')
print(f'Buy & Hold: ${final["bh_equity"]:,.0f} ({(final["bh_equity"]/10000-1)*100:.0f}%)')
print(f'Max Drawdown: {df["drawdown"].min()*100:.1f}%')MVRV Strategy: $6,381 (-36%) Buy & Hold: $6,381 (-36%) Max Drawdown: -94.2%
Section 5 — Visualization
plot_mvrv_analysis
Three-panel MVRV dashboard: price with phase shading, MVRV ratio, equity curves.
Parameters
df : pd.DataFrame Fully processed MVRV dataframe.
def plot_mvrv_analysis(df: pd.DataFrame) -> None:
"""
Three-panel MVRV dashboard: price with phase shading, MVRV ratio, equity curves.
Parameters
----------
df : pd.DataFrame Fully processed MVRV dataframe.
"""
fig, axes = plt.subplots(3, 1, figsize=(14, 13), sharex=True)
phase_colors = {
'Capitulation': 'darkred', 'Accumulation': 'green',
'Bull Early': 'limegreen', 'Bull Late': 'orange', 'Distribution': 'red'
}
# Panel 1: Price with MVRV phase shading
axes[0].plot(df.index, df['btc_price'], color='steelblue', lw=1.0)
for phase, color in phase_colors.items():
mask = df['mvrv_phase'] == phase
if mask.any():
axes[0].fill_between(df.index, df['btc_price'].min(), df['btc_price'].max(),
where=mask, alpha=0.12, color=color, label=phase)
axes[0].set_ylabel('BTC Price (USD)')
axes[0].set_title('BTC Price with MVRV Phase Shading')
axes[0].legend(fontsize=8, ncol=5)
# Panel 2: MVRV ratio
axes[1].plot(df.index, df['mvrv'], color='purple', lw=1.2, label='MVRV')
axes[1].axhline(MVRV_SELL_THRESHOLD, color='red', lw=1.0, linestyle='--', label=f'Sell Zone (>{MVRV_SELL_THRESHOLD})')
axes[1].axhline(MVRV_BUY_THRESHOLD, color='green', lw=1.0, linestyle='--', label=f'Buy Zone (<{MVRV_BUY_THRESHOLD})')
axes[1].axhline(1.0, color='gray', lw=0.5, linestyle=':')
axes[1].set_ylabel('MVRV Ratio')
axes[1].set_title('MVRV Ratio Over Time')
axes[1].legend()
# Panel 3: Equity curves
axes[2].plot(df.index, df['equity'], color='green', lw=1.5, label='MVRV 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('MVRV Strategy vs Buy & Hold')
axes[2].legend()
plt.tight_layout()
plt.show()
plot_mvrv_analysis(df)Section 6 — Export
export_mvrv_data
Export the MVRV signal dataset.
Parameters
df : pd.DataFrame Fully processed MVRV dataframe.
def export_mvrv_data(df: pd.DataFrame) -> None:
"""
Export the MVRV signal dataset.
Parameters
----------
df : pd.DataFrame Fully processed MVRV dataframe.
"""
df.to_csv('mvrv_signal.csv')
print('Exported: mvrv_signal.csv')
export_mvrv_data(df)Exported: mvrv_signal.csv
Summary & Next Steps
Key Takeaways
- MVRV < 1.0 is historically one of the best risk/reward buy zones — holders are underwater and capitulated
- MVRV > 3.5 marks extreme profit territory — historically precedes major drawdowns
- MVRV works best over multi-month horizons; it is not a short-term timing tool
- The realized cap (denominator) can be approximated but requires on-chain UTXO data for accuracy
- Combine MVRV with SOPR and NUPL for a robust multi-metric cycle framework