---
title: "Chapter 19. Bonds, Yield Curves, and Duration"
---
::: {.chapter-objectives}
## Learning Goals
By the end of this chapter, students should be able to:
- define zero-coupon bonds, discount factors, spot rates, forward rates, and coupon bonds;
- convert between discount factors, continuously compounded zero rates, and one-period forward rates;
- price deterministic cash flows by linear discounting;
- explain why fixed-income prices are sensitive to the full term structure, not only to a single interest rate;
- compute yield to maturity and understand why it is an internal rate, not a primitive market object;
- derive Macaulay duration, modified duration, dollar duration, and convexity;
- use duration and convexity to approximate bond-price changes;
- bootstrap discount factors from zero-coupon and coupon-bond prices;
- construct basic immunization arguments using duration and convexity;
- implement fixed-income calculations in Python and audit them with AI tools.
:::
::: {.hero-box}
Fixed-income mathematics begins with one simple idea: a cash flow at a future date must be converted into value today.
If $P(t,T)$ is the time-$t$ price of one dollar paid with certainty at time $T$, then a deterministic cash-flow stream
$$
(c_1,\ldots,c_n) \quad \text{paid at} \quad T_1<\cdots<T_n
$$
has time-$t$ value
$$
V_t = \sum_{i=1}^n c_i P(t,T_i).
$$
This formula looks elementary, but it is the starting point for yield curves, bond pricing, duration, convexity, immunization, and interest-rate derivatives.
:::
## 19.1 Why Fixed Income Needs Its Own Mathematics
In equity-option pricing, the main random object was often the future stock price $S_T$. In fixed income, the most important object is usually the **term structure of interest rates**. The price of a bond depends on cash flows at many different dates, so one interest rate is usually not enough.
For example, a two-year coupon bond has cash flows at several dates. A five-year coupon bond has a different collection of dates. A ten-year bond is exposed to longer-term discount rates. Therefore, fixed-income pricing naturally uses a curve indexed by maturity.
The central object in this chapter is the zero-coupon bond price
$$
P(t,T), \qquad 0\le t\le T.
$$
It is the time-$t$ price of one unit of currency paid with certainty at time $T$.
::: {.callout-note}
## Interpretation
The zero-coupon bond price $P(t,T)$ is also called a discount factor. It tells us how much one future dollar at time $T$ is worth at time $t$.
:::
Throughout most of this chapter, we work with deterministic cash flows and deterministic yield curves. Stochastic interest-rate models begin in Chapter 20.
## 19.2 Zero-Coupon Bonds and Discount Factors
A **zero-coupon bond** with maturity $T$ pays exactly one unit at time $T$ and no intermediate coupons.
At time $t\le T$, its price is denoted by
$$
P(t,T).
$$
By definition,
$$
P(T,T)=1.
$$
If interest rates are positive, we usually expect
$$
0<P(t,T)\le 1
$$
for $T\ge t$. However, in markets with negative yields, $P(t,T)$ may exceed $1$. The mathematical definition still makes sense.
### Present value of one future cash flow
If a deterministic cash flow $C$ is paid at time $T$, its time-$t$ present value is
$$
PV_t(C,T)=C P(t,T).
$$
This is a linear rule in the cash flow $C$.
### Present value of several cash flows
If cash flows $c_i$ are paid at maturities $T_i$, then
$$
V_t = \sum_{i=1}^n c_i P(t,T_i).
$$
This is the fixed-income version of the linear pricing principle from earlier chapters.
::: {.definition}
## Deterministic cash-flow pricing functional
For a fixed valuation date $t$, define
$$
\Pi_t(c_1,\ldots,c_n)=\sum_{i=1}^n c_iP(t,T_i).
$$
Then $\Pi_t$ is a linear functional on the vector space of deterministic cash-flow vectors.
:::
This linear algebra viewpoint is useful. The curve $P(t,T_i)$ gives the coordinates of the pricing functional.
## 19.3 Spot Rates and Continuous Compounding
Instead of quoting discount factors directly, markets often quote rates. For mathematical work, continuously compounded rates are especially convenient.
For $t<T$, define the continuously compounded spot rate $R(t,T)$ by
$$
P(t,T)=e^{-R(t,T)(T-t)}.
$$
Equivalently,
$$
R(t,T)=-\frac{\log P(t,T)}{T-t}.
$$
When $t=0$, we often write
$$
P(0,T)=e^{-R(0,T)T}.
$$
::: {.example}
## Example: discount factor to zero rate
Suppose $P(0,5)=0.82$. Then the five-year continuously compounded zero rate is
$$
R(0,5)=-\frac{1}{5}\log(0.82)\approx 0.03969.
$$
So the continuously compounded five-year zero rate is approximately $3.969\%$.
:::
### Annual compounding convention
If the annually compounded spot rate is $y(t,T)$, then
$$
P(t,T)=\frac{1}{(1+y(t,T))^{T-t}}.
$$
Thus
$$
y(t,T)=P(t,T)^{-1/(T-t)}-1.
$$
Continuously compounded rates and annually compounded rates are simply different coordinate systems for the same discount factor.
## 19.4 Forward Rates
A spot rate discounts from today to a future date. A forward rate describes the implied rate between two future dates.
For $t<T_1<T_2$, the forward discount factor from $T_1$ to $T_2$ observed at time $t$ is
$$
F_D(t;T_1,T_2)=\frac{P(t,T_2)}{P(t,T_1)}.
$$
This is the price, at time $T_1$, of one unit paid at time $T_2$, locked in at time $t$.
The continuously compounded forward rate $F(t;T_1,T_2)$ is defined by
$$
\frac{P(t,T_2)}{P(t,T_1)}
=
\exp\{-F(t;T_1,T_2)(T_2-T_1)\}.
$$
Therefore,
$$
F(t;T_1,T_2)
=
-\frac{\log P(t,T_2)-\log P(t,T_1)}{T_2-T_1}.
$$
If $t=0$ and $P(0,T)=e^{-R(0,T)T}$, then
$$
F(0;T_1,T_2)
=
\frac{R(0,T_2)T_2-R(0,T_1)T_1}{T_2-T_1}.
$$
This identity is fundamental: forward rates are slopes of the function $T\mapsto -\log P(0,T)$.
::: {.callout-important}
## Forward rates are implied rates
A forward rate is not the expected future spot rate unless additional assumptions are imposed. It is an arbitrage-free rate implied by today's discount curve.
:::
## 19.5 Instantaneous Forward Rates
The instantaneous forward rate is obtained by shrinking the forward interval.
Assume $T\mapsto P(t,T)$ is differentiable. Define
$$
f(t,T)=-\frac{\partial}{\partial T}\log P(t,T).
$$
Then
$$
P(t,T)=\exp\left(-\int_t^T f(t,u)\,du\right).
$$
This representation is especially important for the Heath--Jarrow--Morton framework in Chapter 21.
At $t=0$,
$$
R(0,T)=\frac{1}{T}\int_0^T f(0,u)\,du.
$$
Thus the zero rate is an average of instantaneous forward rates.
## 19.6 Interactive: From Yield Curve to Discount Curve
The following interactive example shows how a simple parametric zero-rate curve determines discount factors and instantaneous forward rates.
::: {.interactive-card}
### Interactive Demo 19.1: zero curve, discount curve, and forward curve
Use the sliders to change the level, slope, and curvature of a zero-rate curve. The discount curve and forward curve update automatically.
<div class="mf-control-row">
<label for="ch19-level">Level</label>
<input id="ch19-level" type="range" min="0" max="8" step="0.1" value="3.0">
<span id="ch19-level-value" class="mf-badge">3.0%</span>
</div>
<div class="mf-control-row">
<label for="ch19-slope">Slope</label>
<input id="ch19-slope" type="range" min="-4" max="4" step="0.1" value="1.2">
<span id="ch19-slope-value" class="mf-badge">1.2%</span>
</div>
<div class="mf-control-row">
<label for="ch19-curvature">Curvature</label>
<input id="ch19-curvature" type="range" min="-4" max="4" step="0.1" value="0.8">
<span id="ch19-curvature-value" class="mf-badge">0.8%</span>
</div>
<div id="ch19-yield-demo" class="plotly-demo"></div>
<div id="ch19-discount-demo" class="plotly-demo"></div>
:::
```{=html}
<script>
window.mfPlotlyReady(function() {
var levelSlider = document.getElementById("ch19-level");
var slopeSlider = document.getElementById("ch19-slope");
var curvSlider = document.getElementById("ch19-curvature");
var levelValue = document.getElementById("ch19-level-value");
var slopeValue = document.getElementById("ch19-slope-value");
var curvValue = document.getElementById("ch19-curvature-value");
var yieldDiv = document.getElementById("ch19-yield-demo");
var discountDiv = document.getElementById("ch19-discount-demo");
if (!levelSlider || !slopeSlider || !curvSlider || !yieldDiv || !discountDiv) return;
function maturities() {
var xs = [];
for (var i = 1; i <= 120; i++) xs.push(i / 4);
return xs;
}
function zeroRate(T, level, slope, curvature) {
var x = T / 30;
return level + slope * (1 - Math.exp(-T / 6)) + curvature * x * Math.exp(1 - 3 * x);
}
function update() {
var level = Number(levelSlider.value) / 100;
var slope = Number(slopeSlider.value) / 100;
var curvature = Number(curvSlider.value) / 100;
levelValue.textContent = Number(levelSlider.value).toFixed(1) + "%";
slopeValue.textContent = Number(slopeSlider.value).toFixed(1) + "%";
curvValue.textContent = Number(curvSlider.value).toFixed(1) + "%";
var T = maturities();
var R = T.map(function(t) { return zeroRate(t, level, slope, curvature); });
var D = T.map(function(t, i) { return Math.exp(-R[i] * t); });
var fwd = [];
for (var i = 0; i < T.length; i++) {
var h = 0.01;
var Rp = zeroRate(T[i] + h, level, slope, curvature);
var Rm = zeroRate(Math.max(0.001, T[i] - h), level, slope, curvature);
var gp = Rp * (T[i] + h);
var gm = Rm * Math.max(0.001, T[i] - h);
fwd.push((gp - gm) / ((T[i] + h) - Math.max(0.001, T[i] - h)));
}
Plotly.react(yieldDiv, [
{x: T, y: R.map(function(r) { return 100*r; }), mode: "lines", name: "zero rate"},
{x: T, y: fwd.map(function(r) { return 100*r; }), mode: "lines", name: "instantaneous forward rate"}
], {
title: "Zero Rates and Instantaneous Forward Rates",
xaxis: {title: "maturity T in years"},
yaxis: {title: "rate (%)"},
margin: {l: 55, r: 20, t: 50, b: 50}
}, {responsive: true});
Plotly.react(discountDiv, [
{x: T, y: D, mode: "lines", name: "P(0,T)"}
], {
title: "Discount Factors P(0,T)",
xaxis: {title: "maturity T in years"},
yaxis: {title: "discount factor"},
margin: {l: 55, r: 20, t: 50, b: 50}
}, {responsive: true});
}
[levelSlider, slopeSlider, curvSlider].forEach(function(s) {
s.addEventListener("input", update);
});
update();
});
</script>
```
## 19.7 Coupon Bonds
A coupon bond pays a sequence of coupons and a final principal payment. Suppose the bond has face value $F$, coupon rate $q$ per year, payment frequency $m$ per year, and maturity $T$. The coupon payment is
$$
C=\frac{qF}{m}.
$$
If the payment dates are
$$
T_1,T_2,\ldots,T_n=T,
$$
then the cash flows are
$$
c_i=C \quad (1\le i<n),
\qquad
c_n=F+C.
$$
The arbitrage-free price is
$$
B_0=\sum_{i=1}^{n} c_i P(0,T_i).
$$
The symbol $B_0$ here denotes the bond price, not the money-market account. Context matters.
::: {.example}
## Example: pricing a coupon bond from discount factors
Consider a three-year annual coupon bond with face value $100$, coupon rate $5\%$, and discount factors
$$
P(0,1)=0.9700,
\qquad
P(0,2)=0.9300,
\qquad
P(0,3)=0.8900.
$$
The cash flows are $5,5,105$. Therefore
$$
B_0=5(0.9700)+5(0.9300)+105(0.8900)=102.95.
$$
:::
## 19.8 Yield to Maturity
The **yield to maturity** is the single constant rate that reproduces the observed bond price when applied to all cash flows.
For annual compounding, the yield $y$ solves
$$
B_0=\sum_{i=1}^n \frac{c_i}{(1+y)^{T_i}}.
$$
For continuously compounded yield, the yield $y_c$ solves
$$
B_0=\sum_{i=1}^n c_i e^{-y_cT_i}.
$$
::: {.callout-warning}
## Yield to maturity is not the yield curve
Yield to maturity compresses the entire cash-flow profile and entire discount curve into one number. It is useful for quoting and comparison, but the mathematically primitive objects are discount factors or zero rates.
:::
### Price-yield relationship
For fixed cash flows $c_i>0$, the price as a function of yield is
$$
B(y)=\sum_{i=1}^n c_i(1+y)^{-T_i}
$$
under annual compounding. Differentiating,
$$
B'(y)= -\sum_{i=1}^n T_i c_i(1+y)^{-T_i-1}<0.
$$
Thus bond price decreases as yield increases.
The second derivative is
$$
B''(y)=\sum_{i=1}^n T_i(T_i+1)c_i(1+y)^{-T_i-2}>0
$$
when payment times are integer years. Thus the price-yield curve is convex.
## 19.9 Python Example: Bond Pricing and Yield to Maturity
```{python}
import numpy as np
from math import exp, log
face = 100.0
coupon_rate = 0.05
maturity = 3
payment_times = np.array([1, 2, 3], dtype=float)
cash_flows = np.array([5, 5, 105], dtype=float)
discount_factors = np.array([0.9700, 0.9300, 0.8900])
price = np.dot(cash_flows, discount_factors)
price
```
```{python}
def bond_price_from_ytm(y, times, cfs):
return np.sum(cfs / (1 + y) ** times)
def ytm_bisection(target_price, times, cfs, low=-0.9, high=1.0, tol=1e-12, max_iter=200):
for _ in range(max_iter):
mid = 0.5 * (low + high)
mid_price = bond_price_from_ytm(mid, times, cfs)
if abs(mid_price - target_price) < tol:
return mid
# Price is decreasing in y.
if mid_price > target_price:
low = mid
else:
high = mid
return 0.5 * (low + high)
ytm = ytm_bisection(price, payment_times, cash_flows)
ytms_percent = 100 * ytm
ytms_percent
```
The yield to maturity is the constant annual-compounded rate that reproduces the price computed from the term structure.
## 19.10 Duration: Weighted Average Time of Cash Flows
Duration measures first-order sensitivity of a bond price to interest-rate changes. It also has a useful interpretation as a weighted average maturity.
Suppose a bond has positive cash flows $c_i$ at times $T_i$, and its price under a constant continuously compounded yield $y$ is
$$
B(y)=\sum_{i=1}^n c_i e^{-yT_i}.
$$
Define present-value weights
$$
w_i(y)=\frac{c_i e^{-yT_i}}{B(y)}.
$$
Then $w_i(y)>0$ and
$$
\sum_{i=1}^n w_i(y)=1.
$$
The Macaulay duration under continuous compounding is
$$
D_{\mathrm{Mac}}(y)=\sum_{i=1}^n T_i w_i(y).
$$
Thus duration is the present-value-weighted average payment time.
### Duration as price sensitivity
Differentiate the price:
$$
\frac{dB}{dy}
=
-\sum_{i=1}^n T_i c_i e^{-yT_i}.
$$
Divide by $B(y)$:
$$
-\frac{1}{B(y)}\frac{dB}{dy}
=\sum_{i=1}^n T_i\frac{c_i e^{-yT_i}}{B(y)}
=D_{\mathrm{Mac}}(y).
$$
Therefore,
$$
\frac{dB}{B}\approx -D_{\mathrm{Mac}}\,dy.
$$
For continuously compounded yields, Macaulay duration is also the modified duration.
For annual compounding, if
$$
B(y)=\sum_{i=1}^n c_i(1+y)^{-T_i},
$$
then the Macaulay duration is
$$
D_{\mathrm{Mac}}(y)=\sum_{i=1}^n T_i\frac{c_i(1+y)^{-T_i}}{B(y)},
$$
and the modified duration is
$$
D_{\mathrm{mod}}(y)=\frac{D_{\mathrm{Mac}}(y)}{1+y}.
$$
The first-order approximation is
$$
\frac{\Delta B}{B}\approx -D_{\mathrm{mod}}\Delta y.
$$
## 19.11 Convexity
Duration gives a linear approximation. Convexity improves the approximation by adding a second-order term.
For continuously compounded yield,
$$
B(y)=\sum_{i=1}^n c_i e^{-yT_i}.
$$
The second derivative is
$$
\frac{d^2B}{dy^2}=\sum_{i=1}^n T_i^2 c_i e^{-yT_i}.
$$
Define convexity by
$$
C(y)=\frac{1}{B(y)}\frac{d^2B}{dy^2}
=
\sum_{i=1}^n T_i^2 w_i(y).
$$
Then Taylor's theorem gives
$$
\frac{\Delta B}{B}
\approx
-D_{\mathrm{Mac}}\Delta y+rac12 C(\Delta y)^2.
$$
Convexity is useful because bond-price curves are typically convex in yield. For a given magnitude of yield change, positive convexity makes price gains from yield decreases larger than price losses from yield increases, relative to a purely linear approximation.
## 19.12 Interactive: Duration and Convexity Approximation
::: {.interactive-card}
### Interactive Demo 19.2: price-yield curve, duration tangent, and convexity correction
This demo compares the exact bond price with the duration-only approximation and the duration-plus-convexity approximation.
<div class="mf-control-row">
<label for="ch19-coupon">Coupon rate</label>
<input id="ch19-coupon" type="range" min="0" max="10" step="0.25" value="5">
<span id="ch19-coupon-value" class="mf-badge">5.00%</span>
</div>
<div class="mf-control-row">
<label for="ch19-maturity">Maturity</label>
<input id="ch19-maturity" type="range" min="1" max="30" step="1" value="10">
<span id="ch19-maturity-value" class="mf-badge">10y</span>
</div>
<div class="mf-control-row">
<label for="ch19-base-yield">Base yield</label>
<input id="ch19-base-yield" type="range" min="0" max="10" step="0.25" value="4">
<span id="ch19-base-yield-value" class="mf-badge">4.00%</span>
</div>
<div id="ch19-duration-demo" class="plotly-demo"></div>
<div id="ch19-duration-output" class="mf-output"></div>
:::
```{=html}
<script>
window.mfPlotlyReady(function() {
var couponSlider = document.getElementById("ch19-coupon");
var maturitySlider = document.getElementById("ch19-maturity");
var yieldSlider = document.getElementById("ch19-base-yield");
var couponValue = document.getElementById("ch19-coupon-value");
var maturityValue = document.getElementById("ch19-maturity-value");
var yieldValue = document.getElementById("ch19-base-yield-value");
var plotDiv = document.getElementById("ch19-duration-demo");
var outputDiv = document.getElementById("ch19-duration-output");
if (!couponSlider || !maturitySlider || !yieldSlider || !plotDiv || !outputDiv) return;
function price(y, couponRate, maturity) {
var face = 100;
var c = couponRate * face;
var p = 0;
for (var i = 1; i <= maturity; i++) {
var cf = (i < maturity) ? c : c + face;
p += cf / Math.pow(1 + y, i);
}
return p;
}
function durationConvexity(y, couponRate, maturity) {
var face = 100;
var c = couponRate * face;
var p = price(y, couponRate, maturity);
var mac = 0;
var convex = 0;
for (var i = 1; i <= maturity; i++) {
var cf = (i < maturity) ? c : c + face;
var pv = cf / Math.pow(1 + y, i);
mac += i * pv / p;
convex += i * (i + 1) * pv / (p * Math.pow(1 + y, 2));
}
var mod = mac / (1 + y);
return {price: p, mac: mac, mod: mod, convex: convex};
}
function update() {
var couponRate = Number(couponSlider.value) / 100;
var maturity = Number(maturitySlider.value);
var y0 = Number(yieldSlider.value) / 100;
couponValue.textContent = Number(couponSlider.value).toFixed(2) + "%";
maturityValue.textContent = maturity + "y";
yieldValue.textContent = Number(yieldSlider.value).toFixed(2) + "%";
var stats = durationConvexity(y0, couponRate, maturity);
var yields = [];
for (var k = 0; k <= 160; k++) yields.push(k / 1000);
var exact = yields.map(function(y) { return price(y, couponRate, maturity); });
var tangent = yields.map(function(y) {
return stats.price * (1 - stats.mod * (y - y0));
});
var convexApprox = yields.map(function(y) {
var dy = y - y0;
return stats.price * (1 - stats.mod * dy + 0.5 * stats.convex * dy * dy);
});
Plotly.react(plotDiv, [
{x: yields.map(function(y) { return 100*y; }), y: exact, mode: "lines", name: "exact price"},
{x: yields.map(function(y) { return 100*y; }), y: tangent, mode: "lines", name: "duration approximation"},
{x: yields.map(function(y) { return 100*y; }), y: convexApprox, mode: "lines", name: "duration + convexity"}
], {
title: "Bond Price as a Function of Yield",
xaxis: {title: "yield (%)"},
yaxis: {title: "price"},
margin: {l: 55, r: 20, t: 50, b: 50}
}, {responsive: true});
outputDiv.innerHTML =
"Price at base yield: <strong>" + stats.price.toFixed(3) + "</strong><br>" +
"Macaulay duration: <strong>" + stats.mac.toFixed(3) + " years</strong><br>" +
"Modified duration: <strong>" + stats.mod.toFixed(3) + "</strong><br>" +
"Convexity: <strong>" + stats.convex.toFixed(3) + "</strong>";
}
[couponSlider, maturitySlider, yieldSlider].forEach(function(s) {
s.addEventListener("input", update);
});
update();
});
</script>
```
## 19.13 Python Example: Duration and Convexity
```{python}
import numpy as np
def bond_cash_flows(face=100, coupon_rate=0.05, maturity=10, frequency=1):
n = int(maturity * frequency)
times = np.arange(1, n + 1) / frequency
coupon = face * coupon_rate / frequency
cashflows = np.full(n, coupon)
cashflows[-1] += face
return times, cashflows
def bond_price_continuous(y, times, cashflows):
return np.sum(cashflows * np.exp(-y * times))
def duration_convexity_continuous(y, times, cashflows):
pv = cashflows * np.exp(-y * times)
price = np.sum(pv)
weights = pv / price
duration = np.sum(times * weights)
convexity = np.sum(times**2 * weights)
return price, duration, convexity
times, cfs = bond_cash_flows(face=100, coupon_rate=0.05, maturity=10)
y = 0.04
price, duration, convexity = duration_convexity_continuous(y, times, cfs)
price, duration, convexity
```
```{python}
for dy in [-0.01, -0.005, 0.005, 0.01]:
exact_price = bond_price_continuous(y + dy, times, cfs)
linear_approx = price * (1 - duration * dy)
convex_approx = price * (1 - duration * dy + 0.5 * convexity * dy**2)
print(
f"dy={dy:+.3%} | exact={exact_price:8.4f} | "
f"duration={linear_approx:8.4f} | duration+convexity={convex_approx:8.4f}"
)
```
## 19.14 Yield Curve Bootstrapping
Bootstrapping means extracting discount factors from observed bond prices.
Suppose annual zero-coupon or coupon-bond prices are available. If the one-year discount factor $P(0,1)$ is known, then a two-year coupon bond price can be used to solve for $P(0,2)$.
For example, suppose a two-year annual coupon bond has coupon $C$, face value $F$, and price $B_0$. Its pricing equation is
$$
B_0=C P(0,1)+(F+C)P(0,2).
$$
Therefore,
$$
P(0,2)=\frac{B_0-C P(0,1)}{F+C}.
$$
More generally, if a bond maturing at $T_n$ has cash flows $c_1,\ldots,c_n$ and earlier discount factors are known, then
$$
P(0,T_n)
=
\frac{B_0-\sum_{i=1}^{n-1}c_iP(0,T_i)}{c_n}.
$$
This is a triangular linear system. The maturities must be ordered so that each new instrument introduces one new discount factor.
## 19.15 Python Example: Bootstrapping Discount Factors
```{python}
# Instruments with annual payments, ordered by maturity.
# Each row: observed price and cash flows at years 1,2,...,n.
instruments = [
{"price": 97.0874, "cashflows": [100.0]}, # 1-year zero
{"price": 99.1400, "cashflows": [4.0, 104.0]}, # 2-year 4% coupon
{"price": 101.2500, "cashflows": [5.0, 5.0, 105.0]},
{"price": 102.6000, "cashflows": [6.0, 6.0, 6.0, 106.0]},
]
dfs = []
for inst in instruments:
price = inst["price"]
cfs = np.array(inst["cashflows"], dtype=float)
known_value = np.dot(cfs[:-1], np.array(dfs)) if len(dfs) > 0 else 0.0
new_df = (price - known_value) / cfs[-1]
dfs.append(new_df)
dfs = np.array(dfs)
zero_rates = -np.log(dfs) / np.arange(1, len(dfs) + 1)
list(zip(range(1, len(dfs) + 1), dfs, zero_rates))
```
This calculation is simple because each instrument adds one new maturity. Real bond bootstrapping must handle day-count conventions, accrued interest, coupon frequencies, holidays, bid-ask spreads, and interpolation.
## 19.16 Yield-Curve Risk and Key Rate Duration
A parallel yield shift assumes every maturity changes by the same amount. This is often too simple. In reality, short rates, medium rates, and long rates can move differently.
Let a bond price be written as a function of discount rates at different maturities:
$$
B=\sum_{i=1}^n c_i e^{-R_iT_i}.
$$
The sensitivity to the rate $R_j$ at maturity $T_j$ is
$$
\frac{\partial B}{\partial R_j}
=
-T_jc_j e^{-R_jT_j}.
$$
The normalized sensitivity is
$$
-\frac{1}{B}\frac{\partial B}{\partial R_j}
=
T_j\frac{c_j e^{-R_jT_j}}{B}.
$$
These maturity-specific sensitivities are the clean mathematical prototype of **key rate durations**.
In practice, key rate duration is defined relative to chosen benchmark maturities and an interpolation scheme. The idea is to measure exposure to localized curve movements rather than only to parallel movements.
## 19.17 Immunization
Duration can be used to match the sensitivity of assets and liabilities.
Suppose a liability $L$ is due at time $T_L$. Under continuous compounding, its present value is
$$
PV_L(y)=L e^{-yT_L}.
$$
Its duration is exactly
$$
D_L=T_L.
$$
An asset portfolio with present value $PV_A(y)$ and duration $D_A(y)$ is first-order immunized against small parallel yield changes if
$$
PV_A(y)=PV_L(y)
$$
and
$$
D_A(y)=D_L.
$$
The first condition matches value. The second condition matches first-order interest-rate sensitivity.
Convexity provides second-order protection. If value and duration are matched and the asset portfolio has larger convexity than the liability, then for small yield changes the asset value tends to stay above the liability value.
::: {.callout-warning}
## Immunization assumptions
Classical duration immunization assumes parallel shifts, deterministic cash flows, no default, no transaction costs, and stable curve dynamics. It is a useful first-order mathematical model, not a guarantee.
:::
## 19.18 Python Example: Duration Matching
```{python}
# Match a 7-year zero-coupon liability using two zero-coupon bonds.
# Asset 1 matures in 3 years, asset 2 matures in 12 years.
y = 0.04
liability_face = 1000.0
liability_time = 7.0
asset_times = np.array([3.0, 12.0])
liability_pv = liability_face * np.exp(-y * liability_time)
liability_duration = liability_time
# Let x1 and x2 be present values invested in the two zero-coupon assets.
# Conditions: x1 + x2 = liability_pv
# 3 x1 + 12 x2 = 7 liability_pv
A = np.array([[1.0, 1.0], asset_times])
b = np.array([liability_pv, liability_duration * liability_pv])
asset_pvs = np.linalg.solve(A, b)
asset_faces = asset_pvs * np.exp(y * asset_times)
asset_pvs, asset_faces
```
```{python}
def pv_zero(face, T, y):
return face * np.exp(-y * T)
for dy in [-0.02, -0.01, 0.0, 0.01, 0.02]:
yy = y + dy
asset_value = sum(pv_zero(face, T, yy) for face, T in zip(asset_faces, asset_times))
liability_value = pv_zero(liability_face, liability_time, yy)
surplus = asset_value - liability_value
print(f"yield={yy:.2%} | asset={asset_value:8.3f} | liability={liability_value:8.3f} | surplus={surplus:8.3f}")
```
The constructed portfolio matches present value and duration at the base yield. Because the asset portfolio has a different convexity than the liability, surplus changes nonlinearly as yields move away from the base value.
## 19.19 Fixed-Income Pricing as Linear Algebra
Choose cash-flow dates $T_1,\ldots,T_n$. A deterministic cash-flow stream is a vector
$$
c=(c_1,\ldots,c_n)^\top\in\mathbb R^n.
$$
The discount vector is
$$
p=(P(0,T_1),\ldots,P(0,T_n))^\top.
$$
The price is the dot product
$$
\Pi(c)=p^\top c.
$$
If we observe $m$ traded bonds, collect their cash-flow vectors as columns of a matrix
$$
C=\begin{bmatrix}
| & | & & |\\
c^{(1)} & c^{(2)} & \cdots & c^{(m)}\\
| & | & & |
\end{bmatrix}\in\mathbb R^{n\times m}.
$$
If their market prices are
$$
b=(b_1,\ldots,b_m)^\top,
$$
then absence of deterministic cash-flow arbitrage requires the existence of a discount vector $p$ such that
$$
C^\top p=b.
$$
If the system has no positive solution $p$ consistent with the market conventions, the quoted prices contain an arbitrage or inconsistency.
This is the fixed-income analogue of the state-price approach from Chapters 3 and 6.
## 19.20 Python Example: Solving for Discount Factors by Linear Algebra
```{python}
# Four bonds, four annual cash-flow dates.
C = np.array([
[100.0, 4.0, 5.0, 6.0],
[ 0.0, 104.0, 5.0, 6.0],
[ 0.0, 0.0, 105.0, 6.0],
[ 0.0, 0.0, 0.0, 106.0],
])
prices = np.array([97.0874, 99.1400, 101.2500, 102.6000])
# Prices satisfy C.T @ discount_factors = prices.
discount_factors_linear = np.linalg.solve(C.T, prices)
zero_rates_linear = -np.log(discount_factors_linear) / np.arange(1, 5)
discount_factors_linear, zero_rates_linear
```
The triangular bootstrapping formula is a special case of solving a linear system.
## 19.21 AI-Assisted Learning Components
::: {.ai-box}
## AI prompt: explain the difference between yield and discount factor
Ask an AI tool:
> Explain why a discount factor is more fundamental than a yield to maturity when pricing a coupon bond. Use a three-year coupon bond as the example. Then identify one way your explanation could be misleading.
A strong answer should say that the bond price is a linear combination of discount factors, while yield to maturity is the single internal rate that reproduces that price for a particular bond.
:::
::: {.ai-box}
## AI prompt: audit duration formulas
Ask an AI tool:
> A student writes: "Duration is the derivative of bond price with respect to yield." Correct this statement precisely for both continuously compounded and annually compounded yield conventions.
A strong answer should distinguish $dB/dy$, $-(1/B)dB/dy$, Macaulay duration, and modified duration.
:::
::: {.ai-box}
## AI prompt: code review for bootstrapping
Ask an AI tool to review your bootstrapping code for the following issues:
1. Are the bonds sorted by maturity?
2. Does each new instrument introduce exactly one new unknown discount factor?
3. Are cash-flow dates aligned across instruments?
4. Does the resulting discount curve imply reasonable zero rates?
5. Are coupon frequencies and day-count conventions being ignored?
:::
## 19.22 Common Mistakes
1. **Using one yield for every cash flow without checking the curve.** Coupon-bond pricing should use the discount factor for each payment date.
2. **Confusing yield to maturity with expected return.** Yield to maturity is an internal rate based on price and promised cash flows.
3. **Forgetting compounding conventions.** Continuous, annual, semiannual, and money-market conventions are different coordinates.
4. **Treating duration as exact.** Duration is a first-order approximation. Convexity matters for larger yield moves.
5. **Assuming all yield-curve moves are parallel.** Key rate durations are needed when the curve changes shape.
6. **Ignoring credit and liquidity.** Treasury discount factors, corporate bond yields, and risky promised cash flows are not the same object.
## 19.23 Summary
This chapter introduced the mathematical foundations of fixed-income pricing.
The central objects are discount factors:
$$
P(t,T).
$$
They price deterministic future cash flows by the linear formula
$$
V_t=\sum_i c_iP(t,T_i).
$$
Zero rates and forward rates are alternative descriptions of the same discount curve:
$$
P(t,T)=e^{-R(t,T)(T-t)}
$$
and
$$
f(t,T)=-\frac{\partial}{\partial T}\log P(t,T).
$$
A coupon bond is just a portfolio of zero-coupon bonds. Its yield to maturity is an internal rate, not the primitive pricing input. Duration and convexity describe how bond prices respond to yield changes:
$$
\frac{\Delta B}{B}
\approx
-D\Delta y+rac12 C(\Delta y)^2.
$$
Bootstrapping converts market bond prices into discount factors by solving a triangular linear system. Immunization uses value and duration matching to control first-order interest-rate risk.
## 19.24 Exercises
### Conceptual exercises
1. Explain the difference between a zero-coupon bond, a coupon bond, a discount factor, and a yield to maturity.
2. Why is a coupon bond mathematically a portfolio of zero-coupon bonds?
3. Explain why yield to maturity is not the same as the expected return on a bond.
4. Explain why a forward rate is not automatically an expected future spot rate.
5. Give an example of a yield-curve movement that is not a parallel shift.
### Mathematical exercises
6. Suppose $P(0,1)=0.96$ and $P(0,2)=0.90$. Compute the one-year continuously compounded spot rate, the two-year continuously compounded spot rate, and the one-year forward rate from year $1$ to year $2$.
7. A three-year annual coupon bond has face value $100$ and coupon rate $6\%$. If $P(0,1)=0.97$, $P(0,2)=0.93$, and $P(0,3)=0.88$, compute its price.
8. Prove that if all cash flows are positive, the annually compounded bond price $B(y)=\sum_i c_i(1+y)^{-T_i}$ is strictly decreasing in $y$ for $y>-1$.
9. Derive the Macaulay duration formula for continuously compounded yield from $B(y)=\sum_i c_ie^{-yT_i}$.
10. Derive the convexity formula for continuously compounded yield.
11. Show that a zero-coupon bond maturing at $T$ has duration $T$ under continuous compounding.
12. Show that the continuously compounded zero rate $R(0,T)$ is the average of instantaneous forward rates over $[0,T]$.
### Computational exercises
13. Write a Python function that takes cash-flow times, cash flows, and discount factors and returns the bond price.
14. Write a Python function that computes yield to maturity by bisection.
15. For a 20-year coupon bond, plot price as a function of yield from $0\%$ to $12\%$.
16. Compare exact price changes with the duration approximation and the duration-plus-convexity approximation for yield changes of $\pm 50$, $\pm100$, and $\pm200$ basis points.
17. Bootstrap discount factors from a set of annual coupon bonds. Then convert them to continuously compounded zero rates.
18. Create a two-bond portfolio that duration-matches a liability due at year $8$.
### AI-assisted exercises
19. Ask an AI tool to explain why bond prices are convex in yield. Then rewrite the explanation using Taylor's theorem.
20. Ask an AI tool to generate a bootstrapping example. Check whether the cash-flow dates are internally consistent.
21. Ask an AI tool to compare Macaulay duration, modified duration, and dollar duration. Correct any convention errors.
22. Ask an AI tool to critique the statement: "A higher-yield bond is always a better investment." Identify the missing assumptions.