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.
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:
- Anchors historical halving dates (2012, 2016, 2020, 2024) and estimates future ones
- Aligns BTC price history relative to each halving (days before/after halving day = 0)
- Computes average return trajectories across cycles at each time offset
- Tests the cycle repeatability: are the patterns statistically similar across halvings?
- Overlays macro context: how do halving cycles interact with Fed policy and DXY?
- Builds a cycle position indicator: where are we in the current halving cycle?
- Exports aligned cycle data and the current cycle position signal
The Halving Narrative
| Phase | Timing | Typical BTC Behavior |
|---|---|---|
| Accumulation | 12-18 months pre-halving | Slow grind up, low vol |
| Pre-halving rally | 3-6 months before | Anticipation buying |
| Post-halving consolidation | 0-6 months after | Digestion period |
| Bull run | 6-18 months after | Exponential gains |
| Bear market top | ~18 months after | Peak |
| Crypto winter | 18-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.
!pip install yfinance pandas numpy matplotlib seaborn scipy --quietimport 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
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
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
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
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
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)Section 7 — Export
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)