Chris Parmer — home

Yield curve

US Treasury daily par yields as a 3D surface over time

Example from the compendium of canonical charts

Yield curve — US Treasury daily par yields as a 3D surface over time

Python Code

"""Yield curve — US Treasury daily par yields as a 3D surface over time.

The classic NYT "3D yield curve" view: maturity on one axis, time on the
other, and the rate as height. Credit: Gregor Aisch & Amanda Cox, NYT.
"""


import numpy as np
import pandas as pd
import plotly.graph_objects as go

YEARS = range(1990, 2026)
URL = (
    "https://home.treasury.gov/resource-center/data-chart-center/interest-rates/"
    "daily-treasury-rates.csv/{year}/all"
    "?type=daily_treasury_yield_curve&field_tdr_date_value={year}&page&_format=csv"
)

# Full maturity grid (months). Some tenors are absent in some years
# (1/2/4 Mo before ~2001, 20 Yr discontinued 2002–2006); we interpolate
# across whatever tenors a given day reports to fill a complete grid.
TENORS = {
    "1 Mo": 1, "2 Mo": 2, "3 Mo": 3, "4 Mo": 4, "6 Mo": 6,
    "1 Yr": 12, "2 Yr": 24, "3 Yr": 36, "5 Yr": 60,
    "7 Yr": 84, "10 Yr": 120, "20 Yr": 240, "30 Yr": 360,
}
TICK_LABELS = ["1mo", "3mo", "6mo", "1y", "2y", "3y", "5y", "7y", "10y", "20y", "30y"]
TICK_MONTHS = [1, 3, 6, 12, 24, 36, 60, 84, 120, 240, 360]


def generate():
    # Cache the 36 yearly CSVs locally so styling iterations don't refetch.
    cache = Path(__file__).parent / "_cache.parquet"
    if cache.exists():
        print("loading cached US Treasury yields …")
        df = pd.read_parquet(cache)
    else:
        print("fetching US Treasury par yield curve, 1990–2025 …")
        frames = []
        for year in YEARS:
            try:
                d = fetch_csv(URL.format(year=year))
            except Exception as e:  # noqa: BLE001
                print(f"  {year}: skipped ({e})")
                continue
            d.columns = [c.strip() for c in d.columns]
            frames.append(d)
            print(f"  {year}: {len(d)} days")
        df = pd.concat(frames, ignore_index=True)
        df.to_parquet(cache)
    date_col = next(c for c in df.columns if "date" in c.lower())
    df[date_col] = pd.to_datetime(df[date_col], errors="coerce")
    df = df.dropna(subset=[date_col]).sort_values(date_col).set_index(date_col)

    # Months present as numeric columns, sorted by tenor length.
    present = [(TENORS[c], c) for c in TENORS if c in df.columns]
    present.sort()
    months = [m for m, _ in present]
    cols = [c for _, c in present]
    yields = df[cols].apply(pd.to_numeric, errors="coerce")

    # Monthly mean → one curve per month (clean, smooth surface).
    monthly = yields.resample("MS").mean()

    # Interpolate each month's curve onto the full maturity grid in
    # log-maturity space (np.interp holds the endpoints flat where a tenor
    # is missing, e.g. 30 Yr during 2002–2006).
    log_grid = np.log(TICK_MONTHS)
    z_rows, dates = [], []
    for date, row in monthly.iterrows():
        valid = row.notna().values
        if valid.sum() < 4:
            continue
        xs = np.log(np.array(months)[valid])
        ys = row.values[valid].astype(float)
        z_rows.append(np.interp(log_grid, xs, ys))
        dates.append(date)

    z = np.array(z_rows)                       # [time, maturity]
    y_idx = np.arange(len(dates))              # time axis (months)
    x_idx = np.arange(len(TICK_MONTHS))        # maturity axis

    # Year tick positions along the time axis.
    years = pd.DatetimeIndex(dates).year
    year_ticks = [i for i in range(len(dates))
                  if i == 0 or years[i] != years[i - 1]]
    year_ticks = year_ticks[::2]               # every other year, uncluttered

    # On-palette height gradient (opaque): low rates light teal → high rates
    # deep violet, like the NYT navy ramp.
    colorscale = [
        [0.0, "#cfeaf2"],
        [0.35, "#7fc1d6"],
        [0.65, "#845EEE"],
        [1.0, "#36206e"],
    ]

    fig = go.Figure()
    fig.add_trace(go.Surface(
        x=x_idx, y=y_idx, z=z,
        colorscale=colorscale,
        showscale=False,
        opacity=1.0,
        lighting=dict(ambient=0.8, diffuse=0.55, specular=0.05, roughness=0.95),
        # Wireframe mesh — date-curve ribs (y) + maturity lines (x) — so the
        # surface reads as structured rather than amorphous.
        contours=dict(
            x=dict(show=True, color="rgba(255,255,255,0.3)", width=1),
            y=dict(show=True, color="rgba(255,255,255,0.5)", width=1),
        ),
        hovertemplate=(
            "<b>%{customdata}</b><br>"
            "yield %{z:.2f}%<extra></extra>"
        ),
        # One combined "maturity · month" label per cell — Surface hovertemplate
        # doesn't resolve %{customdata[i]} index access, so flatten to a scalar.
        customdata=np.array(
            [[f"{lab} Treasury · {d.strftime('%b %Y')}" for lab in TICK_LABELS]
             for d in dates]
        ),
    ))

    # Iconic front-edge line: the short rate (3-month) traced over time.
    short_col = TICK_MONTHS.index(3)
    fig.add_trace(go.Scatter3d(
        x=[short_col] * len(dates), y=y_idx, z=z[:, short_col],
        mode="lines",
        line=dict(color=TEXT, width=3),
        hoverinfo="skip",
        showlegend=False,
    ))

    fig.update_layout(
        scene=dict(
            xaxis=dict(
                title="", tickvals=x_idx, ticktext=TICK_LABELS,
                tickfont=dict(size=10),
            ),
            yaxis=dict(
                title="", tickvals=year_ticks,
                ticktext=[str(years[i]) for i in year_ticks],
                tickfont=dict(size=10),
                autorange="reversed",   # 1990 recedes to the back, recent years to the front
            ),
            zaxis=dict(title="yield %", ticksuffix="%", tickfont=dict(size=10)),
            aspectmode="manual",
            aspectratio=dict(x=1.0, y=2.0, z=0.85),
            camera=dict(eye=dict(x=-1.8, y=-1.5, z=0.55)),
        ),
        margin=dict(t=10, b=10, l=10, r=10),
        height=620,
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly