Macro·Macro Strategy Implementations·Intermediate

BTC Halving Analysis

Analyze historical Bitcoin halving cycles and their consistent impact on BTC price dynamics, hash rate economics, and miner behavior patterns, building a quantitative framework for understanding the predictable supply-side scarcity dynamics of programmed monetary policy halving events.

macromacro-strategy-implementations

BTC Halving Cycle Analysis — Macro & Cross-Asset

Category: Macro & Cross-Asset | Subcategory: Strategies


What This Notebook Does

Bitcoin's supply schedule is determined by code: approximately every 210,000 blocks (~4 years), the block reward is cut in half. This event the halving is the most well-known structural driver of BTC's boom-and-bust cycles. Unlike macro events, the halving is known years in advance with certainty.

This notebook:

  1. Anchors historical halving dates (2012, 2016, 2020, 2024) and estimates future ones
  2. Aligns BTC price history relative to each halving (days before/after halving day = 0)
  3. Computes average return trajectories across cycles at each time offset
  4. Tests the cycle repeatability: are the patterns statistically similar across halvings?
  5. Overlays macro context: how do halving cycles interact with Fed policy and DXY?
  6. Builds a cycle position indicator: where are we in the current halving cycle?
  7. Exports aligned cycle data and the current cycle position signal

The Halving Narrative

PhaseTimingTypical BTC Behavior
Accumulation12-18 months pre-halvingSlow grind up, low vol
Pre-halving rally3-6 months beforeAnticipation buying
Post-halving consolidation0-6 months afterDigestion period
Bull run6-18 months afterExponential gains
Bear market top~18 months afterPeak
Crypto winter18-30 months after-70% to -90% drawdown

Caveat: Each cycle is unique. The 2020 cycle was supercharged by COVID stimulus. The 2024 cycle has the ETF approval tailwind but also much higher BTC market cap making the same percentage returns mathematically harder to achieve.

[1]
!pip install yfinance pandas numpy matplotlib seaborn scipy --quiet
[2]
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import seaborn as sns
from scipy import stats
import warnings

warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 6)
plt.rcParams['axes.spines.top']   = False
plt.rcParams['axes.spines.right'] = False
print('Imports ready.')
Imports ready.

Section 2 — Configuration

[3]
BTC_TICKER    = 'BTC-USD'
START_DATE    = '2013-01-01'  # goes back to first halving
USE_SYNTHETIC = False

# Historical and projected halving dates
HALVINGS = [
    {'date': '2012-11-28', 'number': 1, 'block_reward_before': 50,   'block_reward_after': 25},
    {'date': '2016-07-09', 'number': 2, 'block_reward_before': 25,   'block_reward_after': 12.5},
    {'date': '2020-05-11', 'number': 3, 'block_reward_before': 12.5, 'block_reward_after': 6.25},
    {'date': '2024-04-19', 'number': 4, 'block_reward_before': 6.25, 'block_reward_after': 3.125},
    {'date': '2028-04-01', 'number': 5, 'block_reward_before': 3.125, 'block_reward_after': 1.5625},  # estimated
]

CYCLE_WINDOW_DAYS = 548  # ±18 months from halving for cycle analysis

halvings_df = pd.DataFrame(HALVINGS)
halvings_df['date'] = pd.to_datetime(halvings_df['date'])
print(halvings_df)
        date  number  block_reward_before  block_reward_after
0 2012-11-28       1               50.000             25.0000
1 2016-07-09       2               25.000             12.5000
2 2020-05-11       3               12.500              6.2500
3 2024-04-19       4                6.250              3.1250
4 2028-04-01       5                3.125              1.5625

Section 3 — Data Acquisition

[4]
def fetch_full_btc_history(ticker: str, start: str) -> pd.Series:
    """
    Fetch complete BTC daily price history back to a given start date.

    Parameters
    ----------
    ticker : str
        Yahoo Finance ticker for BTC.
    start : str
        Start date in 'YYYY-MM-DD' format.

    Returns
    -------
    pd.Series
        Daily close prices indexed by date.

    Notes
    -----
    Yahoo Finance BTC data begins around 2014-09-17. Prices before that date
    (covering the 2012 halving and early 2013-2014 period) may need to be
    supplemented from alternative sources like CoinGecko or Blockchain.com.
    For this analysis we focus on the 2016+ cycles where Yahoo data is available.
    """
    data = yf.download(ticker, start=start, progress=False, auto_adjust=True)
    close = data['Close'].squeeze()
    close.index = pd.to_datetime(close.index)
    print(f'BTC history: {len(close)} days ({close.index[0].date()}{close.index[-1].date()})')
    return close


def generate_synthetic_btc_history(start: str) -> pd.Series:
    """
    Generate synthetic BTC price history with realistic halving-driven cycles.

    Parameters
    ----------
    start : str
        Start date in 'YYYY-MM-DD' format.

    Returns
    -------
    pd.Series
        Synthetic BTC daily close prices.
    """
    np.random.seed(42)
    n = 4000
    dates = pd.date_range(start, periods=n, freq='D')

    # Simulate cyclical price pattern with halving-driven peaks
    cycle = np.sin(np.linspace(0, 2.5 * np.pi, n)) * 0.7  # ~4yr cycle
    trend = np.linspace(0, 4, n)
    noise = 0.02 * np.random.randn(n)
    log_prices = trend + cycle + np.cumsum(noise)

    prices = np.exp(log_prices) * 1000
    return pd.Series(prices, index=dates, name='BTC')


if USE_SYNTHETIC:
    btc = generate_synthetic_btc_history(START_DATE)
    print('Using synthetic BTC history.')
else:
    try:
        btc = fetch_full_btc_history(BTC_TICKER, START_DATE)
    except Exception as e:
        print(f'Live fetch failed ({e}). Using synthetic.')
        btc = generate_synthetic_btc_history(START_DATE)
BTC history: 4287 days (2014-09-17 → 2026-06-12)

Section 4 — Cycle Alignment

[5]
def align_cycles_to_halving(
    btc: pd.Series,
    halvings_df: pd.DataFrame,
    window_days: int
) -> dict:
    """
    Align BTC price history to halving dates and normalize each cycle.

    For each halving, extract the BTC price series ±window_days and
    normalize so the price on halving day = 100 (rebased).

    Parameters
    ----------
    btc : pd.Series
        Daily BTC close prices.
    halvings_df : pd.DataFrame
        Halving dates with 'date' and 'number' columns.
    window_days : int
        Number of days before and after the halving to include.

    Returns
    -------
    dict
        Mapping of halving number → pd.Series of rebased price indexed by
        days_from_halving (negative = before, positive = after).

    Notes
    -----
    Normalizing to 100 at the halving date allows direct comparison of
    percentage moves across cycles, regardless of absolute price levels.
    The 2012 halving is excluded because reliable daily price data is sparse.
    """
    cycles = {}
    for _, row in halvings_df.iterrows():
        halving_date = row['date']
        halving_num  = row['number']

        if halving_date > btc.index.max():
            continue  # Future halving — no data yet

        start = halving_date - pd.Timedelta(days=window_days)
        end   = halving_date + pd.Timedelta(days=window_days)
        slice_ = btc[(btc.index >= start) & (btc.index <= end)].copy()

        if len(slice_) < 30:
            continue

        # Find the closest price to the halving date for normalization
        halving_price = btc.asof(halving_date)
        if halving_price > 0:
            normalized = (slice_ / halving_price) * 100
            # Reindex to days_from_halving
            days_offset = (slice_.index - halving_date).days
            normalized.index = days_offset
            cycles[halving_num] = normalized
            print(f'Halving {halving_num} ({halving_date.date()}): {len(normalized)} days, price={halving_price:.0f}')

    return cycles


cycles = align_cycles_to_halving(btc, halvings_df, CYCLE_WINDOW_DAYS)
print(f'\nAligned {len(cycles)} halving cycles.')
Halving 2 (2016-07-09): 1097 days, price=651
Halving 3 (2020-05-11): 1097 days, price=8602
Halving 4 (2024-04-19): 1097 days, price=63844

Aligned 3 halving cycles.

Section 5 — Cycle Position Indicator

[6]
def compute_cycle_position(
    halvings_df: pd.DataFrame,
    reference_date: str = None
) -> dict:
    """
    Compute where we are in the current halving cycle.

    Parameters
    ----------
    halvings_df : pd.DataFrame
        Halving schedule.
    reference_date : str, optional
        Date to compute position for. Defaults to today.

    Returns
    -------
    dict
        Keys: last_halving_date, next_halving_date, days_since_last_halving,
              days_until_next_halving, cycle_progress_pct, cycle_phase.

    Notes
    -----
    cycle_progress_pct = 0% means we just had a halving.
    cycle_progress_pct = 100% means we're exactly at the next halving.
    Historically, BTC peaks around 30-40% of the cycle (roughly 18 months post-halving)
    and bottoms around 80-90% (just before the next halving).
    """
    today = pd.to_datetime(reference_date) if reference_date else pd.Timestamp.today()

    past_halvings = halvings_df[halvings_df['date'] <= today]
    future_halvings = halvings_df[halvings_df['date'] > today]

    last_halving  = past_halvings['date'].max()
    next_halving  = future_halvings['date'].min() if len(future_halvings) > 0 else None

    days_since = (today - last_halving).days
    if next_halving is not None:
        total_cycle  = (next_halving - last_halving).days
        days_until   = (next_halving - today).days
        progress_pct = (days_since / total_cycle) * 100
    else:
        days_until   = None
        progress_pct = None

    # Assign phase
    if progress_pct is None:
        phase = 'unknown'
    elif progress_pct < 15:
        phase = 'post_halving_consolidation'
    elif progress_pct < 45:
        phase = 'bull_run'
    elif progress_pct < 65:
        phase = 'peak_and_early_bear'
    elif progress_pct < 85:
        phase = 'bear_market'
    else:
        phase = 'accumulation_pre_halving'

    info = {
        'reference_date':          today.date(),
        'last_halving_date':        last_halving.date(),
        'next_halving_date':        next_halving.date() if next_halving else None,
        'days_since_last_halving':  days_since,
        'days_until_next_halving':  days_until,
        'cycle_progress_pct':       round(progress_pct, 1) if progress_pct else None,
        'cycle_phase':              phase,
    }
    for k, v in info.items():
        print(f'  {k}: {v}')
    return info


print('Current halving cycle position:')
cycle_position = compute_cycle_position(halvings_df)
Current halving cycle position:
  reference_date: 2026-06-12
  last_halving_date: 2024-04-19
  next_halving_date: 2028-04-01
  days_since_last_halving: 784
  days_until_next_halving: 658
  cycle_progress_pct: 54.3
  cycle_phase: peak_and_early_bear

Section 6 — Visualization

[7]
def plot_halving_cycles(
    cycles: dict,
    halvings_df: pd.DataFrame
) -> None:
    """
    Overlay plot of all BTC halving cycles, normalized to halving day = 100.

    Parameters
    ----------
    cycles : dict
        Output of align_cycles_to_halving().
    halvings_df : pd.DataFrame
        Halving dates for labels.
    """
    fig, axes = plt.subplots(1, 2, figsize=(16, 6))

    colors = {2: 'purple', 3: 'steelblue', 4: 'orange', 5: 'green'}
    linestyles = {2: '--', 3: '-', 4: '-', 5: ':'}

    # Panel 1: All cycles overlaid
    for halving_num, cycle_data in cycles.items():
        halving_row = halvings_df[halvings_df['number'] == halving_num].iloc[0]
        label = f'Halving {halving_num} ({halving_row["date"].year})'
        axes[0].plot(cycle_data.index, cycle_data,
                     label=label, color=colors.get(halving_num, 'grey'),
                     linewidth=1.8, linestyle=linestyles.get(halving_num, '-'))

    axes[0].axhline(100, color='black', linewidth=0.8, linestyle='--', alpha=0.5)
    axes[0].axvline(0,   color='black', linewidth=1.2, linestyle='-',  alpha=0.7, label='Halving Day')
    axes[0].set_xlabel('Days from Halving (negative = before)')
    axes[0].set_ylabel('BTC Price (rebased, halving=100)')
    axes[0].set_title('BTC Halving Cycles — Normalized Overlay')
    axes[0].legend()
    axes[0].set_yscale('log')

    # Panel 2: Average cycle with confidence interval
    aligned = pd.DataFrame(cycles)
    cycle_mean = aligned.mean(axis=1)
    cycle_std  = aligned.std(axis=1)

    axes[1].plot(cycle_mean.index, cycle_mean, color='black', linewidth=2.0, label='Mean across cycles')
    axes[1].fill_between(cycle_mean.index,
                          cycle_mean - cycle_std,
                          cycle_mean + cycle_std,
                          alpha=0.2, color='steelblue', label='±1 std dev')
    axes[1].axhline(100, color='black', linewidth=0.8, linestyle='--')
    axes[1].axvline(0,   color='black', linewidth=1.2)
    axes[1].set_xlabel('Days from Halving')
    axes[1].set_ylabel('BTC Price (rebased, halving=100)')
    axes[1].set_title('Average Halving Cycle ± 1 Std Dev')
    axes[1].legend()
    axes[1].set_yscale('log')

    plt.tight_layout()
    plt.show()


def plot_cycle_phase_performance(
    cycles: dict
) -> None:
    """
    Bar chart of average BTC return in each cycle phase across all halvings.

    Parameters
    ----------
    cycles : dict
        Normalized cycle data from align_cycles_to_halving().
    """
    phases = [
        ('Pre-Halving 6m',  -180, 0),
        ('Post H 0-6m',      0,  180),
        ('Post H 6-12m',   180,  365),
        ('Post H 12-18m',  365,  548),
    ]

    phase_returns = {}
    for phase_name, start, end in phases:
        returns_per_cycle = []
        for cycle_data in cycles.values():
            sub = cycle_data[(cycle_data.index >= start) & (cycle_data.index < end)]
            if len(sub) >= 10:
                price_start = sub.iloc[0]
                price_end   = sub.iloc[-1]
                returns_per_cycle.append((price_end / price_start - 1) * 100)
        if returns_per_cycle:
            phase_returns[phase_name] = returns_per_cycle

    fig, ax = plt.subplots(figsize=(12, 5))
    x_pos = range(len(phase_returns))
    for i, (phase_name, returns) in enumerate(phase_returns.items()):
        mean_ret = np.mean(returns)
        color = 'green' if mean_ret > 0 else 'red'
        ax.bar(i, mean_ret, color=color, alpha=0.7, edgecolor='white')
        for j, r in enumerate(returns):
            ax.scatter(i + (j - len(returns)/2) * 0.1, r, color='black', s=30, zorder=5)

    ax.set_xticks(list(x_pos))
    ax.set_xticklabels(list(phase_returns.keys()))
    ax.axhline(0, color='black', linewidth=0.8)
    ax.set_ylabel('Average BTC Return (%)')
    ax.set_title('Average BTC Return by Halving Cycle Phase (Dots = Individual Cycles)')
    plt.tight_layout()
    plt.show()


plot_halving_cycles(cycles, halvings_df)
plot_cycle_phase_performance(cycles)
cell output
cell output

Section 7 — Export

[8]
def export_halving_data(cycles: dict, cycle_position: dict, halvings_df: pd.DataFrame) -> None:
    """
    Export cycle-aligned data and current cycle position.

    Parameters
    ----------
    cycles : dict
        Normalized cycle data.
    cycle_position : dict
        Current cycle position metrics.
    halvings_df : pd.DataFrame
        Halving schedule.
    """
    cycle_df = pd.DataFrame(cycles)
    cycle_df.index.name = 'days_from_halving'
    cycle_df.columns = [f'halving_{n}' for n in cycle_df.columns]
    cycle_df.to_csv('btc_halving_cycles.csv')

    position_df = pd.DataFrame([cycle_position])
    position_df.to_csv('btc_cycle_position.csv', index=False)

    halvings_df.to_csv('halving_schedule.csv', index=False)
    print('Exported: btc_halving_cycles.csv')
    print('Exported: btc_cycle_position.csv')
    print('Exported: halving_schedule.csv')


export_halving_data(cycles, cycle_position, halvings_df)
Exported: btc_halving_cycles.csv
Exported: btc_cycle_position.csv
Exported: halving_schedule.csv

Summary & Next Steps

Key Takeaways

  • The halving cycle shows remarkable consistency: pre-halving accumulation, post-halving bull run, then a deep bear market
  • Post-halving 6-18 months has historically been the most rewarding period for BTC longs
  • Pre-halving 6 months often sees a rally as anticipation builds (the 'buy the rumor' phase)
  • Each cycle has been affected by macro conditions: 2020 had COVID stimulus, 2024 has the ETF approval
  • The cycle's peak returns shrink as BTC market cap grows — the first cycles saw 10,000%+ from halving, recent cycles are more modest
  • Diminishing returns hypothesis: With each halving, the supply shock effect is smaller in percentage terms (50% → 25% reward = 50% cut vs 6.25% → 3.125% = same 50% cut but on much smaller base)