A bond is a stream of promises
A bond is a loan split into tradable pieces. The issuer promises a fixed coupon every period and the face value (par) at maturity. A 10-year bond with a face value of 1000 and a 5% coupon paid twice a year promises 25 every six months and 1000 on top of the last payment. Its price is what those promises are worth today: the present value of the cash flows, discounted at the yield the market currently demands for that risk and maturity.
import numpy as np
def bond_cashflows(face, coupon_rate, years, freq):
n = int(round(years * freq)) # number of payments
times = np.arange(1, n + 1) / freq # in years: 0.5, 1.0, 1.5, ...
flows = np.full(n, face * coupon_rate / freq) # a coupon each time
flows[-1] += face # and the face value with the last one
return times, flows
times, flows = bond_cashflows(1000, 0.05, 2, 2) # a 2-year bond, to keep the output short
print(times.tolist()) # [0.5, 1.0, 1.5, 2.0]
print(flows.tolist()) # [25.0, 25.0, 25.0, 1025.0]Price and yield move in opposite directions
Discount each flow at (1 + ytm / freq) ** (freq * time) and add them up. When the yield equals the coupon rate the bond is worth exactly its face value (it trades at par). If market yields rise above the coupon, new bonds pay more than this one, so its price falls below par (a discount); if yields fall, it trades at a premium. Same mechanism as in the previous topic, now with real payment dates. The current yield, annual coupon over price, is a quick but incomplete measure because it ignores the gain or loss at maturity.
def bond_price(face, coupon_rate, ytm, years, freq=2):
times, flows = bond_cashflows(face, coupon_rate, years, freq)
return (flows / (1 + ytm / freq) ** (freq * times)).sum()
for ytm in (0.03, 0.05, 0.06, 0.08):
print(ytm, round(bond_price(1000, 0.05, ytm, 10), 2))
# 0.03 1171.69 yield below the coupon: premium
# 0.05 1000.0 yield = coupon: par
# 0.06 925.61 yield above the coupon: discount
# 0.08 796.15
print(round(50 / bond_price(1000, 0.05, 0.06, 10), 5)) # 0.05402 current yield: 5.40%, not the 6% yield to maturityLonger bonds react more
The relative price change for a given yield move depends on the maturity. Payments far in the future are discounted by a high power, so they are very sensitive to the rate. A 1-year bond loses about 1% when yields rise by one point, a 10-year bond about 7%, and a 30-year bond nearly 14%. This is why bond investors talk about interest-rate risk and why the next topics build measures (yield to maturity, duration) to quantify it.
for years in (1, 5, 10, 30):
change = bond_price(1000, 0.05, 0.06, years) / 1000 - 1 # the coupon is 5%, the yield rises to 6%
print(years, round(change, 4))
# 1 -0.0096
# 5 -0.0427
# 10 -0.0744
# 30 -0.1384# The same 6% yield with annual, semiannual and monthly coupons: the quoting convention matters a little
for freq in (1, 2, 12):
print(freq, round(bond_price(1000, 0.05, 0.06, 10, freq), 2))
# 1 926.4
# 2 925.61
# 12 924.94freq) next to a yield. Real bonds also have accrued interest between coupon dates and day-count conventions, which this lab ignores by pricing on coupon dates.- Price = the sum of
flow / (1 + ytm / freq) ** (freq * time); coupons every period, the face value with the last one. - Yield = coupon rate: price = par; yield above the coupon: discount; below: premium. Price and yield move in opposite directions.
- Longer maturity, bigger price change for the same yield move: this interest-rate risk is what duration measures next.
Next up: yield to maturity, going the other way: from a market price back to the yield.
Lab · what's graded
- List the payments of a bond with
bond_cashflows25 pts - Discount the payments into a price with
bond_price25 pts - Compare the coupon to the price with
current_yield25 pts - See what a yield move does with
price_change25 pts