Chris Parmer — home

Monthly returns seasonality heatmap for SPY over 20 years

Funds

Example from the compendium of canonical charts

Funds — Monthly returns seasonality heatmap for SPY over 20 years

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