Crypto-Native·On-Chain Signal Generation·Intermediate

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.

cryptoon-chainsignal-generation

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:

  1. Fetches MVRV data via Glassnode API or generates synthetic cycle data
  2. Computes MVRV z-score and cycle-adjusted versions
  3. Generates buy/sell signals at extreme MVRV levels
  4. Backtests the strategy on a full BTC market cycle
  5. Visualizes MVRV with price overlay and signal annotation
  6. Exports the enriched MVRV dataset

MVRV Components

TermDefinition
Market CapCurrent price × total circulating supply
Realized CapSum of (price at time each BTC last moved × quantity)
MVRV RatioMarket 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 --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.
[ ]
# --- 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)
cell output

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