Skip to main content

Chapter 4 · Quantitative Finance

The Geometry of Interest: Constructing Yield Curves and Forward Rates

Bootstrap a yield curve from real bond prices in Python, read the forward rates hidden inside it, and discount future cash flows honestly.

By Updated 12 min Intermediate

Illustration explaining yield curves and forward rates
On this page

Since Chapter 2 we have quietly leaned on a single interest rate. Every coupon, every fair-value formula, and every time we discounted a future payment, we treated the cost of money as one flat number r. That is not how the world works. Borrowing for one year and borrowing for thirty command completely different rates, and the collection of all those rates across every maturity is what we call the term structure of interest rates. This chapter is where we finally take that seriously.

The two tools we build here are the yield curve and the forward rates that fall out of it. The yield curve is a picture of what money costs at every maturity today. The forward rates are what that same curve implies about the cost of borrowing between two future dates. Neither one is handed to us directly by the market, so we have to construct them, and the construction turns out to be recursive in a way that is easy to lose track of in the algebra. By the end of the chapter you will have an honest way to discount a cash flow that lands ten years from now, which is exactly the tool we have been missing every time we waved our hands and picked a single r.

Constructing Yield Curves

The yield curve is a foundational object in fixed-income markets. It summarises how interest rates vary with maturity, and it feeds into bond pricing, risk management, and the valuation of nearly every more complicated instrument we build later. The catch is that you cannot simply read it off a screen. The curve is not directly observed, so we have to construct it from the prices of securities that actually trade. Each point on the curve is the interest rate that exactly reproduces the observed price of a zero-coupon bond at a given maturity. The price today of a zero-coupon bond (ZCBZCB) maturing at time TT with par value XX is:

PT=X(1+rT)TP_{T}= \frac{X}{(1+r_{T})^T}

Here rTr_{T} is the yield for maturity TT. If zero-coupon bonds existed at every maturity, building the curve would be trivial. We would observe each price and invert it to get the yield. In practice that clean situation does not exist, and we run into two problems:

  1. Incomplete maturities: Bonds do not mature on every possible date.
    1. Solution: Estimate the missing maturities using interpolation.
  2. Coupon bonds dominate the market: Most traded bonds pay coupons rather than being pure zeros.
    1. Solution: Extract synthetic ZCBZCB prices from coupon-bond prices using a procedure called bootstrapping.

So the overall recipe for building a yield curve comes down to three steps:

  1. Bootstrap zero-coupon prices from observed bond prices, for as many maturities as we can.
  2. Interpolate those prices to fill in the maturities we did not directly observe.
  3. Handle the practical mess, meaning market conventions and irregular cash-flow dates.

What Is Bootstrapping a Yield Curve?

To understand bootstrapping, it helps to set interpolation aside for a moment and assume we have enough well-spaced instruments. The idea is to work outward from the short end of the curve, one maturity at a time:

  • Start with Treasury bills or other short-dated instruments, which are effectively zero-coupon bonds already.
  • Use those short ZCBZCB prices to discount the coupon payments on longer-dated bonds.
  • Subtract the present value of all those coupons from the observed price of a coupon bond.
  • Divide by what is repaid at maturity to get the implied price of a zero-coupon bond for that maturity.

Once we have a set of ZCBZCB prices across maturities, we invert each one to get its yield, and that gives us the curve from short to long. The important thing to notice is that each new maturity depends on all the shorter ones we already solved. That recursion is the whole game, and it is also where things go wrong later, so keep it in mind.

Filling the gaps with interpolation

Bootstrapping works best when we have prices at many well-spaced maturities, but zero-coupon bonds are rarely issued on every coupon date we care about. To make the procedure workable, we estimate the prices at intermediate maturities by interpolating between the ones we know. There are plenty of sophisticated ways to do this, but for now the basic version, linear interpolation, is more than enough. Suppose we have two known points on the curve, (x1x_{1}, y1y_{1}) and (x2x_{2}, y2y_{2}), and we want the value of yy at some xx that sits between them. Linear interpolation gives:

y(x)=y1+y2−y1x2−x1(x−x1)y(x) = y_{1} + \frac{y_{2} - y_{1}}{x_{2} - x_{1}} (x-x_{1})

All this does is trace the straight line connecting the two known points. Plug in x=x1x=x_{1} and you get y1y_{1} back, plug in x=x2x=x_{2} and you get y2y_{2}. It is the crudest interpolation there is, but it is easy to understand and good enough to illustrate the idea, so it is what we will use.

Bootstrapping a curve in Python

Let’s make this concrete. We have a set of annual coupon bonds, each with a par value of 100, and we know their maturities, coupon rates, and market prices. Working from the shortest maturity outward, we can strip out the discount factor at each maturity. For an nn-year bond with annual coupon CC and price PP, and with the discount factors DF1,…,DFn−1DF_{1}, \dots, DF_{n-1} already solved from the shorter bonds, the price is the present value of every coupon plus the discounted repayment of par:

P=C∑k=1n−1DFk+(100+C) DFnP = C \sum_{k=1}^{n-1} DF_{k} + (100 + C)\, DF_{n}

Everything in that equation is known except DFnDF_{n}, so we rearrange for it:

DFn=P−C∑k=1n−1DFk100+CDF_{n} = \frac{P - C \sum_{k=1}^{n-1} DF_{k}}{100 + C}

Once we have the discount factor, the spot (zero) rate for that maturity follows from DFn=1/(1+rn)nDF_{n} = 1/(1+r_{n})^{n}, which rearranges to rn=DFn−1/n−1r_{n} = DF_{n}^{-1/n} - 1. Here is the whole procedure as a script:

import pandas as pd

PAR = 100.0

# Observed annual-coupon bonds: (maturity in years, annual coupon rate, market price)
# These are priced at par, so each coupon equals that maturity's par yield.
bonds = [
    (1, 0.0200, 100.0),
    (2, 0.0250, 100.0),
    (3, 0.0290, 100.0),
    (4, 0.0320, 100.0),
    (5, 0.0340, 100.0),
]

def bootstrap(bonds):
    """Strip discount factors from coupon-bond prices, shortest maturity first."""
    dfs = {}
    for maturity, coupon_rate, price in bonds:
        coupon = coupon_rate * PAR
        pv_known = sum(coupon * dfs[t] for t in dfs)  # coupons on already-solved dates
        dfs[maturity] = (price - pv_known) / (PAR + coupon)
    return dfs

def spot_rate(df, T):
    return df ** (-1.0 / T) - 1.0

dfs = bootstrap(bonds)
maturities = sorted(dfs)
spots = {T: spot_rate(dfs[T], T) for T in maturities}

df_out = pd.DataFrame({
    "Maturity": maturities,
    "DiscountFactor": [round(dfs[T], 6) for T in maturities],
    "SpotRate": [f"{spots[T] * 100:.3f}%" for T in maturities],
})
print(df_out.to_string(index=False))

The script starts at the one-year bond, where there are no earlier coupons to discount, so its discount factor is just 100/(100+2)=0.9804100 / (100 + 2) = 0.9804. From there each longer maturity reuses the discount factors already solved, subtracts the present value of the known coupons, and backs out the new one. Invert the discount factors and we have the spot curve, rising gently from 2.00% at one year to 3.44% at five. That upward slope is the “normal” shape, and it is worth building an intuition for how sensitive the curve is to the prices you feed in.

That is exactly what the widget below is for. Each slider is the par yield of one of the bonds, and the blue line is the spot curve the bootstrap produces from them. Drag any one of them and watch the whole curve re-solve, because every point downstream of the one you moved depends on it. The orange dashed line is the forward-rate strip, which is where we are headed next, so ignore it for a moment and just get a feel for the bootstrap. The preset buttons snap the market into a normal, an inverted, or a humped shape.

YieldCurveBuilder · from bond prices to the curve5 par bonds bootstrapped
Short (1y) spot
2.00%
Long (5y) spot
3.44%
Slope (5y − 1y)
+1.44%
Curve shape
Normal
— spot (zero) rate- - implied 1y forwardpeak forward 4.28%

Every point on the blue spot curve is bootstrapped from the shorter ones you already solved. The orange strip is the one-year forward rate implied between each pair of maturities: today's market view of where the short rate is heading. Drag any par yield, or snap the whole market to a shape, and watch both curves re-solve at once.

A normal, upward-sloping yield curve bootstrapped from five par bonds rising from 2.00% at one year to 3.44% at five years, with the implied one-year forward-rate strip sitting above it and climbing faster.
Bootstrap in motion: move a par yield and the whole spot curve re-solves, because every maturity is built on the shorter ones.

Finding forward rates

The yield curve tells us what it costs to borrow over different horizons starting today. What it does not tell us directly is how the market prices borrowing between two future dates. That is what forward rates are for. A forward rate is the interest rate, agreed today, for a loan that starts at one future date and ends at a later one. In other words, forward rates take the information already sitting in the yield curve and translate it into a statement about future borrowing.

The situation shows up all the time in practice. Imagine someone who has arranged to buy a new house at time t1t_{1} and sell their old one at some earlier time T<t1T < t_{1}, and who needs the sale to fund the purchase. At time t0<t1t_{0} < t_{1} they might arrange a bridging loan to cover the gap, and the rate on that loan is a forward rate. The market can quote it today even though the borrowing happens later.

Deriving Forward Rates by No-Arbitrage

We can pin down the forward rate from the yield curve using a no-arbitrage argument. The logic is that investing over a long horizon in one step has to earn the same return as investing over the same horizon in two shorter steps. If it did not, you could borrow one way and lend the other and pocket the difference for free, which the market does not allow to persist. So let PTP_{T} be the price today of a ZCBZCB paying out at maturity TT, take two maturities T1<T2T_{1} < T_{2}, and let f(T1,T2)f(T_{1}, T_{2}) be the forward rate for a loan running from T1T_{1} to T2T_{2}. We compare two strategies that both turn cash today into cash at T2T_{2}:

  1. Invest straight through to T2T_{2}: buy a ZCBZCB maturing at T2T_{2} at price PT2P_{T_{2}}.
  2. Invest to T1T_{1}, then roll forward to T2T_{2}: buy a ZCBZCB maturing at T1T_{1} at price PT1P_{T_{1}}, and at T1T_{1} reinvest the proceeds at the forward rate f(T1,T2)f(T_{1}, T_{2}).

No arbitrage says both strategies must be worth the same today, which gives:

1PT2=1PT1(1+f(T1,T2))T2−T1\frac{1}{P_{T_{2}}} = \frac{1}{P_{T_{1}}} \left(1 + f(T_{1}, T_{2})\right)^{T_{2} - T_{1}}

Rearranging, the forward rate implied by the curve is:

f(T1,T2)=(PT1PT2)1T2−T1−1f(T_{1}, T_{2}) = \left(\frac{P_{T_{1}}}{P_{T_{2}}}\right)^{\frac{1}{T_{2} - T_{1}}} - 1

So forward rates are fully determined by zero-coupon bond prices, which we already bootstrapped. We can compute the one-year forwards straight from the spot curve with a small script.

import pandas as pd

# Spot (zero) rates from the bootstrap above
spot_rates = {
    1: 0.02000,
    2: 0.02506,
    3: 0.02917,
    4: 0.03229,
    5: 0.03439,
}

def forward_rate(T1, T2, r1, r2):
    """One-year forward rate f(T1, T2) under annual compounding."""
    return ((1 + r2) ** T2 / (1 + r1) ** T1) ** (1 / (T2 - T1)) - 1

maturities = sorted(spot_rates)
forwards = []
for i in range(len(maturities) - 1):
    T1, T2 = maturities[i], maturities[i + 1]
    fwd = forward_rate(T1, T2, spot_rates[T1], spot_rates[T2])
    forwards.append((T1, T2, fwd))

df = pd.DataFrame(forwards, columns=["Start (Years)", "End (Years)", "Forward Rate"])
print(df.to_string(index=False))

Because the discount factors already encode the spot rates, we can also write the forward rate directly in terms of yields rather than prices. Substituting PT=1/(1+rT)TP_{T} = 1/(1+r_{T})^{T} into the formula above gives:

f(T1,T2)=((1+rT2)T2(1+rT1)T1)1T2−T1−1f(T_{1}, T_{2}) = \left(\frac{(1 + r_{T_{2}})^{T_{2}}}{(1 + r_{T_{1}})^{T_{1}}}\right)^{\frac{1}{T_{2} - T_{1}}} - 1

Why Forward Rates Sit Above the Spot Curve

Look at the numbers and one thing jumps out. The forwards sit above the spot curve and climb faster, running from 3.02% up to 4.28% while the spots only reach 3.44%. That is what an upward-sloping curve is really saying: for long rates to be higher than short rates today, the market has to be pricing in short rates that keep rising in the future, and the forwards are those future short rates made explicit. Flip the widget above to the inverted preset and the relationship inverts with it, with the forwards dropping below the spot curve.

What the Term Structure Buys You

We came into this chapter still pretending that money had a single price, and we leave it with a proper term structure: a spot curve bootstrapped from real bond prices, and the forward rates hidden inside it. Just as importantly, we now have an honest way to discount a cash flow arriving years from now, instead of reaching for one flat r and hoping. That discounting machinery is not going to sit idle. It is the same tool we lean on when we price options later in the series, and the risk-free term structure is one of the inputs Black-Scholes demands when we finally derive it in Chapter 11.

There is one temptation worth resisting on the way out. It is easy to read the forward rates as the market’s forecast of future interest rates, but they are not forecasts in any statistical sense. They are the rates that make an investor indifferent between the two strategies we compared, nothing more. Only under extra assumptions, such as risk neutrality, can you squint at a forward rate and call it an expectation of a future spot rate. Treating the curve as a crystal ball is its own kind of false hope, and it is a mistake a lot of confident people have made.

Next up is Chapter 5: Stocks, Returns and Distributions. We leave the orderly world of contractual cash flows behind and turn to real market prices, which do not amortise, mature, or promise anything. We will pull actual price series, turn them into returns, and start asking what their distribution really looks like, which is where the “false hope” in the title stops being about bonds and starts being about the market itself.

⚠️ Financial Education Disclaimer

The yield curve construction (bootstrapping) and forward rate models presented here are for educational and analytical purposes only.

  • Theoretical Models: These scripts use simplified assumptions (e.g., linear interpolation and annual compounding) that may differ from specific market conventions (like Actual/360 or semi-annual bond equivalent yields).
  • No Financial Advice: The outputs of these models do not constitute a recommendation to trade bonds or interest rate derivatives.
  • Market Risk: Interest rates are influenced by unpredictable macroeconomic factors, central bank policies, and inflation. Real-world bond pricing includes liquidity premiums and credit spreads not captured in these basic models.
  • Code Accuracy: While the logic follows standard financial theory, these scripts should be audited against professional financial libraries (like QuantLib) before being used for actual capital allocation.
Subscribe / Stdin

Get the next part

New parts of Finding P values and false hope land in your inbox the day they publish. No spam, unsubscribe in one click.

We send your address to Kit, our email provider, and ask you to confirm by email before anything is sent. See our Privacy Statement.