Monthly returns seasonality heatmap for SPY over 20 years
Funds
Example from the compendium of canonical charts
Python Code
"""Funds — Monthly returns seasonality heatmap for SPY over 20 years."""
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import warnings
warnings.filterwarnings("ignore")
MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
def fetch_spy():
try:
import yfinance as yf
df = yf.download("SPY", period="20y", auto_adjust=True, progress=False)
if df.empty:
raise ValueError("empty data")
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
return df["Close"].dropna()
except Exception as e:
print(f" yfinance failed ({e}), using synthetic data")
return None
def make_synthetic():
"""Synthetic SPY-like monthly data with realistic seasonality."""
rng = np.random.default_rng(42)
# Typical monthly seasonality (Jan effect, sell-in-May, Santa rally, etc.)
seasonal = [0.8, 0.3, 0.6, 1.2, 0.2, 0.4, 0.7, -0.2, -0.5, -0.3, 1.0, 1.5]
dates = pd.bdate_range("2005-01-03", "2025-12-31")
monthly_end = pd.date_range("2005-01-31", "2025-12-31", freq="M")
prices = [100.0]
for d in monthly_end:
month_idx = d.month - 1
ret = (seasonal[month_idx] + rng.normal(0, 3.5)) / 100
prices.append(prices[-1] * (1 + ret))
return pd.Series(prices[1:], index=monthly_end)
def build_fig(close):
# Resample to month-end
if not isinstance(close.index, pd.DatetimeIndex):
close.index = pd.to_datetime(close.index)
monthly = close.resample("M").last()
monthly_ret = monthly.pct_change().dropna() * 100
# Pivot: rows=year, cols=month
df_pivot = pd.DataFrame({
"year": monthly_ret.index.year,
"month": monthly_ret.index.month,
"ret": monthly_ret.values,
})
pivot = df_pivot.pivot(index="year", columns="month", values="ret")
pivot.columns = [MONTH_NAMES[m - 1] for m in pivot.columns]
pivot = pivot.sort_index(ascending=False)
z = pivot.values
years = [str(y) for y in pivot.index.tolist()]
# Build annotation text
text_vals = []
for row in z:
text_vals.append([f"{v:+.1f}%" if not np.isnan(v) else "" for v in row])
# Cell font colors: white when dark background, black when light
font_colors = []
for row in z:
row_colors = []
for v in row:
if np.isnan(v):
row_colors.append(TEXT)
elif abs(v) > 3:
row_colors.append("white")
else:
row_colors.append(TEXT)
font_colors.append(row_colors)
fig = go.Figure(go.Heatmap(
z=z,
x=MONTH_NAMES,
y=years,
colorscale="RdYlGn",
zmid=0,
zmin=-8,
zmax=8,
text=text_vals,
texttemplate="%{text}",
textfont=dict(size=9),
colorbar=dict(
title=dict(text="Monthly Return"),
tickformat="+.0f",
ticksuffix="%",
thickness=12,
),
hovertemplate="%{y} %{x}: %{z:.2f}%<extra></extra>",
))
# Annual return column annotation (optional)
annual_rets = []
for y in pivot.index:
yr_data = df_pivot[df_pivot["year"] == y]["ret"]
if len(yr_data) >= 6:
# Approximate compound annual return
compound = (np.prod(1 + yr_data.values / 100) - 1) * 100
annual_rets.append((str(y), compound))
fig.update_layout(
xaxis=dict(title="Month", showgrid=False),
yaxis=dict(title="Year", showgrid=False),
margin=dict(t=40, b=60, l=60, r=20),
)
return fig
def generate():
close = fetch_spy()
if close is None or len(close) < 50:
close = make_synthetic()
print(" using synthetic SPY monthly data")
else:
print(f" fetched {len(close)} days from yfinance")
fig = build_fig(close)
return fig
if __name__ == "__main__":
generate()
Made with Plotly