From the price back to the yield
In the market a bond is quoted by its price, but investors compare bonds by yield. The yield to maturity (YTM) is the single discount rate at which the present value of all the bond's cash flows equals its price: the annualised return you earn if you buy now, hold to maturity and every payment is made. For a 10-year bond priced at 925.61 with a 5% coupon, it is 6%. Unlike the price, the YTM has no closed formula, because the unknown sits inside a polynomial of high degree.
import numpy as np
def bond_price(face, coupon_rate, ytm, years, freq=2):
n = int(round(years * freq))
times = np.arange(1, n + 1) / freq
flows = np.full(n, face * coupon_rate / freq)
flows[-1] += face
return (flows / (1 + ytm / freq) ** (freq * times)).sum()
price = bond_price(1000, 0.05, 0.06, 10)
print(round(price, 4)) # 925.6126 the market price we pretend to observe
# A back-of-the-envelope yield: annual coupon plus the yearly share of the discount, over the average of price and par
coupon = 1000 * 0.05
print(round((coupon + (1000 - price) / 10) / ((1000 + price) / 2), 5)) # 0.05966 close to 6%, but not exactSearching: bisection
The price falls steadily as the yield rises, so we can search. Take an interval [low, high] that surely contains the answer (0% to 100%). Price the bond at the midpoint: if the price is above the target, the yield must be higher, so move low up to the midpoint; otherwise move high down. Each step halves the interval, so 40 steps shrink it by a factor of a trillion. Bisection is slow compared with other methods, but it cannot fail as long as the answer is inside the interval, which makes it the safe fallback.
low, high = 0.0, 1.0
for step in range(6):
mid = (low + high) / 2
price_mid = bond_price(1000, 0.05, mid, 10)
print(step, round(mid, 5), round(price_mid, 2))
if price_mid > price: # priced too high: the yield must be higher
low = mid
else:
high = mid
# 0 0.5 110.38
# 1 0.25 275.86
# 2 0.125 578.47
# 3 0.0625 908.08
# 4 0.03125 1159.97
# 5 0.04688 1024.72 the interval keeps shrinking around 6% (the target price is 925.61)Searching faster: Newton
Newton's method uses the slope. Define f(y) = bond_price(y) - target; we want f(y) = 0. At the current guess, follow the tangent line down to zero: y_new = y - f(y) / f'(y). The slope can be estimated numerically with a tiny step h: (f(y + h) - f(y - h)) / (2h). Near the answer the number of correct digits roughly doubles each step, so four or five steps suffice where bisection needs forty. Newton can fail if started badly or if the slope is flat, so keep a step limit, and fall back to bisection if it does not converge.
y, h = 0.05, 1e-6 # start at the coupon rate
for step in range(5):
f = bond_price(1000, 0.05, y) - price
slope = (bond_price(1000, 0.05, y + h) - bond_price(1000, 0.05, y - h)) / (2 * h)
print(step, round(y, 8), round(f, 6))
y = y - f / slope
# 0 0.05 74.387374
# 1 0.05954347 3.245932
# 2 0.05999903 0.006916
# 3 0.06 0.0
# 4 0.06 0.0 converged to 6% after 3 steps# Check the answer by pricing the bond at the yield we found
print(round(bond_price(1000, 0.05, y, 10), 4), round(price, 4)) # 925.6126 925.6126 the same price: the yield is right- YTM is the discount rate that makes the bond's present value equal its price; it has no closed formula, so it is searched for.
- Bisection: keep halving
[low, high](price above target: raiselow); slow but safe. - Newton:
y = y - f / slope, with a numerical slope; it converges in a few steps but needs a step limit and a good start.
Next up: duration and convexity, measuring how sensitive a bond's price is to the yield.
Lab · what's graded
- Estimate the yield on the back of an envelope with
approx_ytm25 pts - Find the yield by halving the interval with
bisect_yield25 pts - Find the yield faster with
newton_yield25 pts - Name the situation of the bond with
classify_price25 pts