Compound interest is the eighth wonder of the world. He who understands it, earns it ... he who doesn't ... pays it. - Albert Einstein
Go directly to the code files:
Type | Code | Description |
---|---|---|
Z-Spread | Here | Method to calculate a bond's Z-spread. Also implements pricing with I-spread and calculating Macaulay duration. |
Spot Rate Bootstrap | Here | Process to take Treasury data and boostrap a spot rate curve (described in more detail below). |
Mortgage Cash Flows | Here | Cash flow engine written in Python. Generates monthly mortgage cash flows at various prepayment speeds. Can also calculate a mortgage's Weighted Average Life (WAL). |
- Obtain US Treasury Par Yield Data
- Interpolate Semi-Annual Par Yields
- Boostrap Semi-Annual Zero Coupon Rates
- Generate a Bond Cash Flow
- Calculate a Bond's Z-Spread
In fixed income markets, investors rely on various 'spread' measurments to help provide a more informative metric of incremental yield they are receiving vs. a benchmark instrument (ex: Treasury bonds that are often considered to be 'riskless'). This spread would represent compensation for various risks in a given bond that are not in a riskless, benchmark instrument, such as: prepayment risk, credit risk, liquidity risk, etc. Spreads can be readily calculated given a bond cash flow and price and will also give investors a tool to compare relative value between bonds that could have different characteristics. This project focuses on the calculation Z-Spread, which assumes zero-volatility in cash flows and interest rates - it is considered to be a 'static' valutaion tool, but still more informative than a simple yield spread.
-
Use Treasury data to calculate daily spot rate curves over a given time span
-
Generate mortgage and corporate bond cash flows with various prepayment assumptions
-
Calculate Z-spread for a given cash flow, provided a price, or vice-versa
This procedure follows the general methodology for how fixed income analytics providers would calculate zero coupons rates (aka the spot rate curve) with boostrapping and interpolation.
To calculate a Z-spread, it is necessary to first source Treasury rate data to construct a spot rate curve from. This raw data can is publically available online here.
Below is a 3D surface plot of the Treasury data illustrating how the level of rates and shape of the curve each day can flucuate significantly:
Given usable rates data, the first step is now to create a interpolated series with semi-annual increments. Practicioners can choose between various spline methods but the two most popular are linear or spline. This project employs spline interpolation.
# Generate interpolated yields data
def interpolate_yields(tsy, head) -> pd.DataFrame:
"""
Treasury Yields - Semi-Annual Frequency
Uses cubic spline interpolation.
"""
ylds = np.empty([1,62])
months = np.array([1,2,3,5,6,12,24,36,60,84,120,240,360])
x = np.linspace(0,360,61)
tsy_cols = list(tsy.columns.values)
cols = list(head.columns.values)
for i in range(len(tsy)):
row = np.array(tsy.loc[i])
date = row[0:1]
rates = row[1:]
f = CubicSpline(months, rates)
interp = f(x)
add = np.append(date, interp)
ylds = np.vstack((ylds, add))
ylds = pd.DataFrame(np.delete(ylds, 0, 0), columns=cols)
return ylds
Below is a chart that illustrates how spline interpolation compares to given Treasury Par Yields (marked as the green squares). This specifc curve is created from Treasury data on March 8th, 2024 where one can see the front-end is sharply inverted.
With semi-annual Treasury data, the next step is to perform a bootstrapping methodology to iterively solve for zero coupon yields. The bond market does not provide available pricing for zero coupon bond for every month in the future for the next 20-30 years, but it is possible to infer from actively traded Treasuries. For Z-spread pricing purposes, it is important to have zero coupon bond yields rather than Treasuries because a Treasury bonds yield factors in semi-annual interest payments. For a singular cash flow in a given month
def spot_rate_bootstrap(ylds, tsy, head) -> pd.DataFrame:
cols = list(head.columns.values)
spots = pd.DataFrame(np.zeros((ylds.shape[0], ylds.shape[1]), dtype=float), columns=cols)
# No interpolation required here - shorter-term treasuries are ZCBs
spots['Date'] = tsy['Date']
spots['0'] = tsy['1']
spots['6'] = tsy['6']
spots['12'] = tsy['12']
# Treasury bond assumptions for bootstrap; do not modify
face = 100
delta = 1/2
# Bootstrap methodology
for row in range(0,spots.shape[0]):
for col in range(0, spots.shape[1]):
if col <= 3: continue # spot rates already defined for shorter bonds
# Now solving for zero-coupon bond yield
int_cf = 0
cpn = ylds.iloc[row, col] # interpolated coupon for par bond
for i in range(2, col): # solve for intermediate cash flows
zcb = 1/((1+spots.iloc[row, i]/100*delta)**(i-1))
int_cf = int_cf +cpn/100*delta*face*zcb
zero = ((face + face*cpn/100*delta)/(face - int_cf)) # algebra to solve for zero rate
zero = zero**(1/(col-1))
zero = (zero-1)*2
spots.iloc[row, col] = zero*100
return spots
Employing the methodology above, a spot rate curve can be created for every daily Treasury curve provided in the data. See chart below for one example of overlaying the par yield and spot curve on March 8th, 2024.
This project focuses on mortgage pricing and has its own mortgage cash flow engine that can handle different payment delays, settle dates, and prepayment rate assumptions.
def mortgage_cash_flow(settle, cpn, wam, term, balloon, io, delay, speed, prepay_type, bal) -> pd.DataFrame:
# returns pd dataframe with monthly mortgage cash flows
# prepay_type is currently 'CPR' which is a conditional prepayment rate that can be converted to an SMM
One example of a cashflow input could be the following:
cf_7cpr = mbs.cash_flow('03/29/2024', 6.50, 360, 360, 360, 0, 54, 7, 'CPR', 1000000)
This is a $1,000,000 bond with a 6.5% coupon, 30-year balloon maturity, and 30-year amortization schedule. It is also assumed to prepay monthly principal at a rate of '7 CPR' which is where the variable naming comes from as well. Once this cash flow is created it is also possible to calculate its WAL (Weighted Average Life) which is important to have when determining a bond's yield when given a Z-Spread or I-Spread. A visualization of this cashflow overtime would look like:
The following code is used in this project to calculate WAL:
def wal(settle, cf) -> float:
'''
Calculate weighted-average-life of a mortgage cash flow.
'''
arr = cf
settle = pd.to_datetime(settle, format="%m/%d/%Y")
arr['settle'] = settle
arr['diff'] = (pd.to_datetime(arr['Date']) - arr['settle']).dt.days
num = (arr['diff']*((arr['Scheduled Principal'] + arr['Unscheduled Principal']))).sum()
denom = (arr['Scheduled Principal'] + arr['Unscheduled Principal']).sum()
wal = num/denom*1/365
return wal
Given a defined security cashflow and spread, it is possible to calculate a yield and Z-spread for the security. The following is the pricing engine that can calculate both Z-Spread and I-spread:
def price(cf, curve, settle, spread, typ) -> float:
# Cashflow characteristics given in provided dataframe
rate = cf["Rate"].loc[0]
curr = cf["Starting Balance"].loc[0]
delay = cf["Pay Delay"].loc[0]
# Handling settle dates and accrued interest
settle = pd.to_datetime(settle, format="%m/%d/%Y")
month = (settle + DateOffset(months=1)).to_pydatetime() # this works
pay = datetime.datetime(month.year, month.month, delay-29)
accrued = (settle.to_pydatetime() - datetime.datetime(settle.year, settle.month, 1)).days
days_pay = (pay - settle.to_pydatetime()).days
accr_int = accrued/360*rate/100*curr
mey = monthly_equiv_yld(settle, cf, curve, spread)
months = np.array((cf["Period"] - 1).astype(int))
cf_flow = np.array((cf["Cash Flow"]).astype(float))
# Z-Spread calculation
if typ == "Z":
# Extract correctly sized spot curve - assume monthly cashflows
spots = np.array(curve.iloc[0,0:len(cf)])
# Calculate z rates on each point of the spot curve
z_rate = spots + spread/100
# Calculate discount rates
z_zcb = 1/((1+z_rate/(12*100))**(months))
# Price bond
price = (np.sum(cf_flow*z_zcb)-accr_int)*\
100/curr*1/(1+mey/100*days_pay/360)
# I-Spread calculation
elif typ == "I":
price = (np.sum(cf_flow/((1+mey/(12*100))**(months)))\
-accr_int)*100/curr*1/(1+mey/100*days_pay/360)
return price
In order for the pricing engine to function above, a monthly equivalent yield (MEY) must be calculated that involves first calculating a bond's weighted average life and interpolating a yield at that point. Below is the helper function for MEY that assists the pricing engine:
def monthly_equiv_yld(settle, cf, curve, spread) -> float:
tenor = mbs.wal(settle, cf)*12
m = pd.DataFrame(curve.columns.values.astype(int), columns=["Months"])
# Points for interpolation
index = m["Months"].gt(tenor).idxmax()
m_ub = m["Months"].iloc[index]
m_lb = m["Months"].iloc[index-1]
y_ub = curve.iloc[0,index]
y_lb = curve.iloc[0,index-1]
# Linear interpolation
intrp = y_lb + (tenor - m_lb)*((y_ub - y_lb)/(m_ub - m_lb))
# Bond equivalent yield at WAL point
bond_equiv = intrp + spread/100
# Monthly equivalent yield
monthly_equiv = 12*((1+bond_equiv/(2*100))**(2/12)-1)*100
return monthly_equiv
Calculating spread given a bond price involves using the pricing engine with a root finding algorithm. In short, the function given is
def spread_solver(spread, cf, curve, settle, px, typ):
"""
Newton Root Finding Function - Solving for bond spread
"""
solver = price(cf, curve, settle, spread, typ) # using a spread to solve for spread
return (solver - px)
def spread(cf, curve, settle, px, typ) -> float:
"""
Bond Spread
"""
# Solver to calculate Z-spread
# Constants
s0 = 100
miter = 1000
sp = newton(spread_solver, s0, args=(cf, curve, settle, px, typ), maxiter=miter)
return sp
While this project focuses primarily on Z-Spread and the methodology behind it, the code above can also solve for I-Spread ('interpolated' yield spread).