Why the term premium stopped explaining the long end

rates
macro

The standard decomposition splits a long yield into expected policy and a term premium. Since 2022 the residual has been doing most of the work — and that is a statement about the model, not about the bond market.

Author

Tom Roth

Published

March 4, 2026

NoteThe takeaway

A term-premium estimate depends on the assumptions used to forecast short rates. This illustrative simulation shows how changing those assumptions moves the residual. Read the estimate alongside a sensitivity range; the synthetic data here do not establish what drove actual yields after 2022.

Every rates desk runs some version of the same sentence: the ten-year is up because the term premium is back. It is a comfortable sentence, because the term premium is not observed. It is whatever is left once you subtract the part of the yield you claim to understand.

That residual has been carrying an uncomfortable share of the variance since 2022. This post walks the decomposition, fits it, and then argues that what changed is the identification, not the compensation investors demand.

The claim

The affine no-arbitrage story says a nominal yield of maturity τ\tau is the average short rate the market expects over that horizon, plus compensation for bearing the risk that the expectation is wrong:

yt(τ)=1τ𝔼t[∫0τrt+sds]⏟expected policy+ϕt(τ)⏟term premium+εt(τ) y_t(\tau) \;=\; \underbrace{\frac{1}{\tau}\,\mathbb{E}_t\!\left[\int_0^{\tau} r_{t+s}\,ds\right]}_{\text{expected policy}} \;+\; \underbrace{\phi_t(\tau)}_{\text{term premium}} \;+\; \varepsilon_t(\tau)

Only yt(τ)y_t(\tau) is in the data. The expectation term is produced by a model of the short rate, and ϕt(τ)\phi_t(\tau) is the difference. So the term premium inherits every misspecification in the expectations model — if your short-rate dynamics mean-revert too fast, the expectation component is too flat, and the premium absorbs the slack.

The usual functional form for the fitted curve is Nelson–Siegel, three factors standing in for level, slope, and curvature:

y(τ)=β0+β11−e−τ/λτ/λ+β2(1−e−τ/λτ/λ−e−τ/λ) y(\tau) \;=\; \beta_0 \;+\; \beta_1\,\frac{1 - e^{-\tau/\lambda}}{\tau/\lambda} \;+\; \beta_2\left(\frac{1 - e^{-\tau/\lambda}}{\tau/\lambda} - e^{-\tau/\lambda}\right)

with λ\lambda setting where the curvature loading peaks.

Decomposition

The mechanical part is easy. Fit the curve, project the short rate forward under a mean-reverting process, integrate, subtract. Below is the fit itself — a Levenberg–Marquardt least squares on the three betas, with λ\lambda held at the conventional 1.37 so that curvature peaks near the 2–3 year point.

import numpy as np
from scipy.optimize import least_squares

LAMBDA = 1.37  # curvature loading peaks near the 2y-3y point

def ns_loadings(tau, lam=LAMBDA):
    """Nelson-Siegel factor loadings for maturities `tau` (in years)."""
    x = tau / lam
    slope = (1.0 - np.exp(-x)) / x
    curve = slope - np.exp(-x)
    return np.column_stack([np.ones_like(tau), slope, curve])

def fit_ns(tau, yields, lam=LAMBDA):
    """Least-squares fit of (beta0, beta1, beta2) to an observed curve."""
    L = ns_loadings(tau, lam)
    resid = lambda b: L @ b - yields
    out = least_squares(resid, x0=np.array([4.0, -1.0, 0.0]), method="lm")
    return out.x

The expectations leg is where the judgement lives. Project the policy rate under an Ornstein–Uhlenbeck process with speed κ\kappa toward a neutral rate r*r^{\ast}, and the average expected short rate over τ\tau has a closed form:

1τ𝔼t[∫0τrt+sds]=r*+(rt−r*)1−e−κτκτ \frac{1}{\tau}\,\mathbb{E}_t\!\left[\int_0^{\tau} r_{t+s}\,ds\right] \;=\; r^{\ast} \;+\; (r_t - r^{\ast})\,\frac{1 - e^{-\kappa\tau}}{\kappa\tau}

Everything contentious is now in two numbers: κ\kappa and r*r^{\ast}.

Fitting it

The series below is a stylised reconstruction, not a download — the shape is calibrated to the 2015–2026 experience so the argument is reproducible without a data vendor, but do not quote the levels.

import numpy as np
import pandas as pd

LAMBDA, KAPPA, TAU = 1.37, 0.55, 10.0
rng = np.random.default_rng(20260304)

dates = pd.date_range("2015-01-31", "2026-02-28", freq="ME")
n = len(dates)
t = np.arange(n)

# Policy rate: near-zero, liftoff, the 2022 tightening, then a partial descent.
policy = np.piecewise(
    t.astype(float),
    [t < 60, (t >= 60) & (t < 86), t >= 86],
    [lambda s: 0.20 + 0.010 * s,
     lambda s: 0.80 + 0.185 * (s - 60),
     lambda s: 5.35 - 0.055 * (s - 86)],
).clip(0.05, 5.6)

# Neutral rate drifts up over the sample; that drift is the whole argument.
r_star = np.linspace(2.30, 3.35, n)

# Expected average short rate over 10y under OU mean reversion to r*.
decay = (1.0 - np.exp(-KAPPA * TAU)) / (KAPPA * TAU)
expected = r_star + (policy - r_star) * decay

# Observed 10y = expectations + a premium that regime-shifts in 2022, + noise.
premium = np.where(t < 84, -0.45 + 0.004 * t, -0.11 + 0.021 * (t - 84))
observed = expected + premium + rng.normal(0, 0.055, n)

df = pd.DataFrame({
    "date": dates,
    "observed": observed,
    "expected": expected,
    "premium": observed - expected,
})

df.tail(4).round(2)
date observed expected premium
130 2025-11-30 4.14 3.25 0.89
131 2025-12-31 4.11 3.25 0.86
132 2026-01-31 4.14 3.25 0.89
133 2026-02-28 4.19 3.24 0.94

Two regimes fall straight out of the residual. Through 2021 the premium sits negative and drifts slowly — the decomposition is doing what it advertises, and the expectations leg explains the level. From mid-2022 the residual turns and climbs, and by the end of the sample it accounts for most of the move in the long end.

Show plotting code
import altair as alt

PAPER, INK, ACCENT, RULE, META = "#f0eee9", "#1a1a1a", "#2a78d6", "#e6e2db", "#8a857c"

long_df = df.melt(
    id_vars="date",
    value_vars=["observed", "expected", "premium"],
    var_name="series",
    value_name="pct",
).replace({"series": {
    "observed": "10y yield",
    "expected": "Expected avg. short rate",
    "premium": "Term premium (residual)",
}})

base = (
    alt.Chart(long_df)
    .mark_line(strokeWidth=1.6)
    .encode(
        x=alt.X("date:T", title=None,
                axis=alt.Axis(format="%Y", tickCount=6, grid=False)),
        y=alt.Y("pct:Q", title="percent",
                axis=alt.Axis(grid=True, gridColor=RULE, tickCount=6)),
        color=alt.Color("series:N", title=None,
                        scale=alt.Scale(
                            domain=["10y yield",
                                    "Expected avg. short rate",
                                    "Term premium (residual)"],
                            range=[INK, ACCENT, "#b8862e"]),
                        legend=alt.Legend(orient="top", direction="horizontal",
                                          labelFontSize=10, symbolStrokeWidth=2,
                                          labelLimit=220, offset=6)),
        strokeDash=alt.StrokeDash("series:N", legend=None,
                                  scale=alt.Scale(
                                      domain=["10y yield",
                                              "Expected avg. short rate",
                                              "Term premium (residual)"],
                                      range=[[1, 0], [1, 0], [4, 2]])),
    )
)

def styled(c):
    """Apply the paper palette. configure_* only works on a top-level chart."""
    return (
        # Inner padding lives in the spec, not in CSS — see custom.scss.
        c.properties(padding={"left": 12, "top": 12, "right": 16, "bottom": 12})
         .configure_view(strokeWidth=0)
         .configure_axis(labelFont="ui-monospace, Menlo, monospace", labelFontSize=9,
                         labelColor=META, titleFont="ui-monospace, Menlo, monospace",
                         titleFontSize=9, titleColor=META, domainColor=RULE,
                         tickColor=RULE)
         # No labelFont here on purpose: vl-convert cannot resolve "system-ui"
         # when rasterising the thumbnail and drops the legend text entirely.
         .configure_legend(labelColor=INK, labelFontSize=10)
    )

# The hero thumbnail needs a concrete width — vl-convert cannot resolve
# width="container", and silently exports a clipped chart if you hand it one.
styled(base.properties(width=720, height=300)).save("thumbnail.png", scale_factor=2.0)

# The in-page chart stays fluid so it reflows on mobile.
styled(base.properties(width="container", height=300))
Figure 1: The ten-year decomposed. Through 2021 the expectations leg tracks the yield; after mid-2022 the residual carries it.

Where it breaks

The residual is not compensation you can point at. Three things move it that have nothing to do with risk appetite:

  1. r*r^{\ast} is a free parameter. Raise the assumed neutral rate by 50bp and the expectations leg lifts by roughly the same amount at the long end, and the premium falls one-for-one. The decomposition has no opinion about which is right.
  2. κ\kappa sets how much of the current policy rate survives ten years. With κ=0.55\kappa = 0.55 the decay factor is about 0.18 — the front end barely reaches the ten-year. Halve κ\kappa and the tightening cycle shows up in the expectations leg instead of the residual.
  3. Supply is not in the model at all. Duration coming to market is a quantity, and an affine model in yields has nowhere to put it, so it lands in ϕt\phi_t.

You can see the first two directly — the same data, three assumptions about the neutral rate:

def premium_under(r_star_shift: float, kappa: float) -> float:
    """Mean 2023-2026 term premium under alternative (r*, kappa) assumptions."""
    d = (1.0 - np.exp(-kappa * TAU)) / (kappa * TAU)
    exp_leg = (r_star + r_star_shift) + (policy - (r_star + r_star_shift)) * d
    resid = observed - exp_leg
    return float(resid[dates >= "2023-01-01"].mean())

pd.DataFrame(
    [{"r* shift": f"{s:+.2f}", "kappa": k, "mean premium 2023-26": round(premium_under(s, k), 2)}
     for s in (-0.50, 0.0, 0.50) for k in (0.30, 0.55)]
)
r* shift kappa mean premium 2023-26
0 -0.50 0.30 0.80
1 -0.50 0.55 0.94
2 +0.00 0.30 0.46
3 +0.00 0.55 0.54
4 +0.50 0.30 0.11
5 +0.50 0.55 0.13

The spread across that table is wider than the move everyone is attributing to risk premia. Which is the point: the term premium has not stopped existing, it has stopped being identified. When the residual’s range under defensible parameter choices exceeds the signal you are reading out of it, the honest statement is that the long end is being set by something the model does not contain — most plausibly duration supply and a genuinely unsettled neutral rate.

What I would do instead

Stop reporting a point estimate. Report the residual as a band across a grid of (r*,κ)(r^{\ast}, \kappa) you are willing to defend, and say plainly that the width of the band is the size of your ignorance. That is less quotable than “the term premium is back,” and considerably more defensible.

Next in this thread: fitting the same curve with a supply term, and seeing whether the residual narrows enough to be worth the extra parameter.

Keep exploring

For another modelling project, see vol-surface, which focuses on fitting implied volatility while checking for arbitrage. Or browse all articles.