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 is the average short rate the market expects over that horizon, plus compensation for bearing the risk that the expectation is wrong:
Only is in the data. The expectation term is produced by a model of the short rate, and 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:
with 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 held at the conventional 1.37 so that curvature peaks near the 2–3 year point.
import numpy as npfrom scipy.optimize import least_squaresLAMBDA =1.37# curvature loading peaks near the 2y-3y pointdef 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 toward a neutral rate , and the average expected short rate over has a closed form:
Everything contentious is now in two numbers: and .
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 npimport pandas as pdLAMBDA, KAPPA, TAU =1.37, 0.55, 10.0rng = 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 altPAPER, 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:
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.
sets how much of the current policy rate survives ten years. With the decay factor is about 0.18 — the front end barely reaches the ten-year. Halve and the tightening cycle shows up in the expectations leg instead of the residual.
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 .
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_legreturnfloat(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 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.