A bond is just a promise of future cash flows — so its price is entirely a question of discounting. From that single idea follow the three numbers every fixed-income desk lives by: price, duration(first-order rate sensitivity, the bond's Delta) and convexity(second-order, the bond's Gamma).
1.Summary
A bond's price is the present value of its coupons and principal, discounted at the yield to maturity. Price and yield move inversely, and the relationship is curved, not linear. Duration measures the percentage price change for a small yield change; convexitycorrects that estimate for the curvature — and the correction always works in the bondholder's favour.
2.Intuition
2.1Discounting, not magic
When market yields rise from 2% to 4%, an old bond paying 2% coupons is suddenly a bad deal — nobody pays face value for below-market cash flows. Its price falls until a buyer earns the new market yield. No sentiment involved: pure arithmetic.
2.2Duration = weighted waiting time
Macaulay duration is the average time you wait for your money, weighting each cash flow by its share of present value. A 30y bond makes you wait decades → huge sensitivity. A 2y note returns your money quickly → barely reacts. Rule of thumb: a bond loses ~duration × Δy percent when yields rise by Δy.
2.3Convexity: the curvature that helps you
The price-yield curve is convex: losses from rising yields are smaller than the linear estimate, gains from falling yields are larger. Long bonds have the most convexity — which is why duration alone increasingly misleads for big yield moves.
3.Theory & Mechanics
With face , coupon rate (paid times a year), yield and periods:
Macaulay duration (years): , and modified duration .
Convexity is the second derivative of price w.r.t. yield, scaled by price. Both derivatives can also be computed numerically — which is how we'll verify the analytic formulas:
def bond_price(face, coupon, y, T, freq=2):
"""Price of a fixed-coupon bond (coupon = annual rate, y = YTM)."""
n = int(round(T * freq))
t = np.arange(1, n + 1)
cf = np.full(n, face * coupon / freq)
cf[-1] += face
return float(np.sum(cf / (1 + y / freq) ** t))
def duration_convexity(face, coupon, y, T, freq=2, h=1e-4):
"""Modified duration (years) and convexity via central differences."""
p0 = bond_price(face, coupon, y, T, freq)
p_up, p_dn = bond_price(face, coupon, y + h, T, freq), bond_price(face, coupon, y - h, T, freq)
dur = -(p_up - p_dn) / (2 * h * p0)
conv = (p_up - 2 * p0 + p_dn) / (h**2 * p0)
return dur, conv
# quick check against the rule of thumb
p = bond_price(100, 0.04, 0.04, 10)
d, c = duration_convexity(100, 0.04, 0.04, 10)
print(f"10y 4% bond at par: P = {p:.2f}, duration = {d:.2f}y, convexity = {c:.1f}")4.Applied Example — US Treasuries
4.1The price-yield curve, by maturity
Same 4% coupon, three maturities. Watch two things: the slope (longer = steeper = more duration) and the curvature (longer = more convex).
yields = np.linspace(0.001, 0.10, 200)
fig, ax = plt.subplots(figsize=(10, 5))
for T, color in [(2, "steelblue"), (10, "darkorange"), (30, "crimson")]:
prices = [bond_price(100, 0.04, y, T) for y in yields]
ax.plot(yields * 100, prices, color=color, label=f"{T}y Treasury")
ax.axhline(100, color="gray", ls=":", lw=1)
ax.axvline(4, color="gray", ls=":", lw=1)
ax.set_xlabel("Yield (%)"); ax.set_ylabel("Price")
ax.set_title("Price vs yield — 4% coupon, three maturities")
ax.legend()
plt.tight_layout(); plt.show()
4.2The duration ladder: a +100bp shock
The whole risk story in one table — duration grows with maturity, and so does the damage from the same yield move.
rows = []
for T in [1, 2, 5, 10, 20, 30]:
d, c = duration_convexity(100, 0.04, 0.04, T)
p0 = bond_price(100, 0.04, 0.04, T)
p1 = bond_price(100, 0.04, 0.05, T) # +100bp, exact
rows.append([T, round(d, 2), round(c, 1), f"{(p1/p0 - 1)*100:.2f}%"])
ladder = pd.DataFrame(rows, columns=["Maturity (y)", "Mod. duration", "Convexity", "Exact dP for +100bp"])
print(ladder.to_string(index=False))| Maturity (y) | Mod. duration | Convexity | Exact dP for +100bp |
|---|---|---|---|
| 1 | 0.97 | 1.4 | -0.96% |
| 2 | 1.90 | 4.6 | -1.88% |
| 5 | 4.49 | 23.5 | -4.38% |
| 10 | 8.18 | 78.9 | -7.79% |
| 20 | 13.68 | 239.9 | -12.55% |
| 30 | 17.38 | 420.8 | -15.45% |
4.3Reality check: SHY, IEF, TLT in 2022
2022 was the worst bond year in modern history: the 2y yield rose ~370bp, the 10y ~237bp, the 30y ~207bp. If duration analytics mean anything, the three Treasury ETFs' losses should line up with their durations (~1.9 / ~7.5 / ~17.5). Let's check against real prices.
def load_prices(ticker, start, end):
"""Adjusted-close prices: yfinance first, Stooq as fallback."""
try:
import yfinance as yf
df = yf.download(ticker, start=start, end=end, auto_adjust=True, progress=False)
if not df.empty:
return df["Close"].squeeze().rename(ticker).dropna()
except Exception as exc:
print(f"yfinance failed ({exc}); trying Stooq…")
url = f"https://stooq.com/q/d/l/?s={ticker.lower()}.us&i=d"
df = pd.read_csv(url, parse_dates=["Date"], index_col="Date")
return df.loc[start:end, "Close"].rename(ticker).dropna()
etfs = {"SHY": (1.9, 0.0370), "IEF": (7.5, 0.0237), "TLT": (17.5, 0.0207)} # (duration, 2022 dY)
px = pd.concat([load_prices(t, "2021-12-31", "2022-12-31") for t in etfs], axis=1).dropna()
total_ret = px.iloc[-1] / px.iloc[0] - 1
print(f"{'ETF':4} {'duration':>9} {'dY 2022':>8} {'predicted -D*dY':>16} {'actual 2022':>12}")
for t, (d, dy) in etfs.items():
print(f"{t:4} {d:9.1f} {dy:8.2%} {-d*dy:16.1%} {total_ret[t]:12.1%}")
(px / px.iloc[0] * 100).plot(figsize=(10, 4), color=["steelblue", "darkorange", "crimson"])
plt.title("2022: the duration ladder in real life (indexed to 100)")
plt.ylabel("Value"); plt.tight_layout(); plt.show()| ETF | duration | dY 2022 | predicted -D*dY | actual 2022 |
|---|---|---|---|---|
| SHY | 1.9 | 3.70% | -7.0% | -3.9% |
| IEF | 7.5 | 2.37% | -17.8% | -15.2% |
| TLT | 17.5 | 2.07% | -36.2% | -31.2% |

The ranking is exactly as duration predicts — and the gaps between predicted and actual returns are the tutorial's best teaching moment: coupon carry, convexity (helping TLT), and the fact that ETFs hold rolling portfolios rather than a single bond all show up in the residual.
4.4Sanity check: how good is the Taylor approximation?
Duration-only vs duration+convexity vs exact repricing, for the 30y bond across shocks up to ±300bp.
T = 30
p0 = bond_price(100, 0.04, 0.04, T)
d, c = duration_convexity(100, 0.04, 0.04, T)
shocks = np.linspace(-0.03, 0.03, 61)
exact = np.array([bond_price(100, 0.04, 0.04 + s, T) / p0 - 1 for s in shocks])
lin = -d * shocks
quad = -d * shocks + 0.5 * c * shocks**2
fig, ax = plt.subplots(figsize=(10, 4.5))
ax.plot(shocks * 100, exact * 100, "k", label="Exact repricing")
ax.plot(shocks * 100, lin * 100, "--", color="steelblue", label="Duration only")
ax.plot(shocks * 100, quad * 100, "--", color="darkorange", label="Duration + convexity")
ax.set_xlabel("Yield shock (%)"); ax.set_ylabel("Price change (%)")
ax.set_title(f"30y bond: Taylor approximation quality (D={d:.1f}, C={c:.0f})")
ax.legend()
plt.tight_layout(); plt.show()
s = 0.02
print(f"+200bp: exact {bond_price(100,0.04,0.06,T)/p0-1:+.2%} dur-only {-d*s:+.2%} dur+conv {-d*s+0.5*c*s*s:+.2%}")
5.Conclusion
5aStrengths
- One framework, any bond — price, duration and convexity summarise rate risk in three numbers
- First-order accuracy is excellent for small yield moves — the industry's daily risk language
- Convexity correction keeps the approximation honest even for ±200bp shocks
- Model-free verification — ETF drawdowns in 2022 line up with durations, straight from public data
5bWeaknesses & Limitations
- Parallel-shift assumption — duration assumes the whole curve moves together; real curves twist and steepen (key-rate durations fix this)
- Constant yield reinvestment — YTM assumes coupons reinvest at the same rate
- Credit ignored — this is a rates framework; spreads and default risk need their own layer
- ETF ≠ single bond — rolling portfolios have carry and roll-down effects the single-bond math misses
5cApplications in Practice
- Portfolio duration targeting and immunisation
- Rate-shock stress tests (the ±100bp tables in every risk report)
- Barbell vs bullet trades — convexity is the whole game
5dAlternatives & Extensions
- Key-rate durations — sensitivity to individual curve points
- Yield curve bootstrapping with QuantLib — the natural companion piece (curriculum M2)
- Term-structure models — Vasicek, Hull-White, CIR for simulating rates rather than shocking them
- The SVB collapse — our future case study on what happens when duration risk meets deposit flight
