Python Bootstrapping the Zero Curve
The Swap Curve
Pricing an interest rate or fixed income product requires calculating the net present value of its future cash flows. This applies to any interest rate product such as a bond, loan, mortgage, interest rate swap, cap/floor, interest rate option/futures contract, credit derivative etc.. To calculate the present value of a future cash payment, multiply the payment by the discount factor at that future point in time. While the amount and date of the payment are usually known, discount factors have to be derived from an interest rate curve, and in particular, the swap curve is selected for this purpose. The swap curve is called the LIBOR curve. It is depicted as the plot of swap rates that the most actively traded fixed income securities in the market have across their respective maturities / terms. The standard maturities / terms for which rates are available on the swap curve are: ON (Overnight), 1W, 1M, 3M, 6M, 9M, 1Y, 18M, 2Y, 3Y, 5Y, 7Y, 10Y.
It is not a straightforward matter, however, that this market observable curve can be used directly to obtain discount factors for valuation. The swap curve must be converted to a yield curve for discount factors to be derived from it. The rates on a yield curve at each maturity, as the name implies, is the rate of return or yield-to-maturity one would earn on a zero-coupon bond if it is held to maturity. A zero coupon bond does not pay a coupon periodically but instead pays the yield-to-maturity at the end of the bond's term. For this reason, the yield curve is also referred to as the zero coupon curve. The methodology for building the yield curve from market swap rates and their respective maturities, is referred to as bootstrapping the zero curve. Bootstrapping produces a no-arbitrage zero coupon yield curve. This means that the discount factors derived from bootstrapping can be applied to arrive back at the market swap rates used in their construction.
First, to construct the swap curve, different market instruments are assembled to obtain the rates for different maturities of the curve. For this reason, it is divided into three (short-, middle-, and long-) term buckets. The short end of the swap curve, out to three months, is derived using ON (Overnight), 1M, 2M, and 3M interbank deposit rates quoted in the market. The middle area of the swap curve, up to two years, is derived from either forward rate agreements (FRAs) or interest rate futures contracts, such as Eurodollar futures contracts for the USD swap curve. The latter requires a convexity adjustment to render it equivalent to FRAs. The long end, out to ten years, is constructed using swap par rates derived from the interest rate swap market.
The objective of bootstrapping is to construct a zero coupon yield curve from the swap curve. Each of the different instruments that contribute to the swap curve pay out at a different payment frequency. At the short end, each deposit instrument pays out a single payment at the end of its term. This is similar to a zero coupon bond that does not pay a coupon periodically but instead pays the yield-to-maturity at the end of the bond's term. Interest on a deposit instrument accrues on a simple interest rate basis and as such it is the most basic instrument to use in generating a discount factor. This, actually, is the reason we want to convert the entire swap curve to a zero curve, and that is, to be able to easily calculate the discount factors.
All of the Python code is available at the end of the article and on Github.
For a deposit rate the discount factor, D_deposit, is based on simple interest and is just:
D_deposit = 1 /(1 + R_simple × YF_deposit)
where:
D_deposit: discount factor of deposit instrument
R_simple: interest rate for deposit security observed in the market. This rate is based on simple interest compounding.
YF_deposit: year fraction of deposit instrument based on daycount convention e.g. Actual/360. Year fraction is discussed further below.
R_simple is what is observed in the market, and YF_deposit can be calculated based on the term and a chosen day count convention (more on that below). We would like, however, to express the zero rates for the yield curve as continuously compounded. Expressed in terms of a continuously compounded rate, the discount factor for the deposit rate is:
D_deposit = exp(- R_continuous × YF_deposit)
If we set the two, simple and continuous, D_deposit equations equal to each other, we can obtain R_continuous, that is:
exp(- R_continuous × YF_deposit) = 1 /(1 + R_simple × YF_deposit)
Taking the natural logarithm of both sides:
-R_continuous × YF_deposit = ln[(1 / R_simple × YF_deposit)]
Dividing both sides by YF_deposit:
-R_continuous = (1 / YF_deposit) × ln[(1 / (1 + R_simple × YF_deposit))]
Multiplying both sides by -1, and since -ln[x] = ln[1/x], we have:
R_continuous = (1 / YF_deposit) × ln[1 + R_simple × YF_deposit]
So given the market observed R_simple rates for the deposit instruments, we can now calculate each R_continuous, the continuously compounded zero rate for each maturity (ON, 1M, 3M, 6M, 9M, 1Y), at the short end of the yield curve. At this point, we are also in a position to start laying out our Python bootstrapping algorithm. We shall take an Object Oriented Programming approach.
Getting to the Python Implementation
Business Days
The maturities / terms of the swap curve that we have been speaking of are curve tenors. They are not actual dates. The first step then is to calculate the actual dates for each curve tenor. Additionally, in the financial markets interest can only accrue on business days; not on weekends or holidays. This means we have to find the actual business date for each tenor. For instance, what is the business date 3 months from today that the 3M tenor falls on, and so on for each curve term. We have to develop classes and methods to handle non-business days and shift them to business days. There are a few important financial markets' business rules that must be followed in doing so. Namely, these business rules are Business Day Convention, and Day Count Convention.
The business day convention labeled as Following adjusts curve tenors that fall on weekends and holidays to the following business day. The Modified Following business day convention also shifts non-business days to the following business day. However if the following business day is in a different month, the previous business day is adopted instead. Our date class must implement logic in its member methods to identify non-business days and to take into consideration the rules for shifting them to business days.
Another financial markets' business rule that must be followed when handling dates is the day count convention. This convention is used to count the number of days between two dates. It is important for calculating accrued interest. The notation used for day count conventions shows the number of days in any given month divided by the number of days in a year. The result represents the fraction of the year, called year fraction here. The 30/360 day count convention is the easiest to use because it assumes that there are 30 days in every month, even though some months actually have 31 days. For example, the period from May 1 to August 1 is considered to be 90 days apart, according to the 30/360 convention, but the actual number of days is higher because both May and July have 31 days. Actual/360 is calculated by using the actual number of days between the two periods, divided by 360. And Actual/365 is similar to the Actual/360, except that it uses 365 as the denominator.
The standard Python libraries are imported: numpy, pandas, datetime to hold and manipulate interest rate data and for date indexing. The math library is needed for exponent and natural log calculations, and the relativedelta module from the dateutil library for shifting dates.
class CountryHoliday: The IsHoliday method of the CountryHoliday class will tell us when a calendar day falls on a holiday for a particular country. If a curve tenor falls on a holiday it will need to be shifted to a business day for the particular country holiday schedule. The IsHoliday method will require a country as input. We illustrate how to find the US holidays. Here is the Python code for the CountryHoliday class:
import numpy as np
import pandas as pd
import math
from datetime import *
from dateutil.relativedelta import *
class CountryHoliday:
def __init__(self):
pass
def _IsHoliday_(self,date,calendar):
# check if date is a weeekend
weekdays = [0,1,2,3,4]
if not date.weekday() in weekdays:
return False
# check if date is a holiday
y = date.year
m = date.month
d = date.day
wkd = date.weekday()
if calendar == 'NY':
# January 1
if (m==1 and d==1) or (m==12 and d==31 and wkd==4) or \
(m==1 and d==2 and wkd==0):
return True
# Martin Luther King, third Monday of January
if m==1 and wkd==0 and (d>14 and d<22):
return True
# Washington's Birthday, third Monday of February
if m==2 and wkd==0 and (d>14 and d<22):
return True
# Memorial Day, last Monday of May
if m==5 and wkd==0 and d>24:
return True
# Independence Day, July 4
if (m==7 and d==4) or (m==7 and d==3 and wkd==4) or \
(m==7 and d==5 and wkd==0):
return True
# Labor Day, the first Monday of September
if m==9 and wkd==0 and d<8:
return True
# Columbus Day, second Monday of October
if m==10 and wkd==0 and (d>7 and d<15):
return True
# Veterans Day, November 11
if (m==11 and d==11) or (m==11 and d==10 and wkd==4) or \
(m==11 and d==12 and wkd==0):
return True
# Thanksgiving Day, fourth Thursday of November
if m==11 and wkd==3 and (d>21 and d<29):
return True
# Christmas Day, December 25
if (m==12 and d==25) or (m==12 and d==24 and wkd==4) or \
(m==12 and d==26 and wkd==0):
return True
return False
class CurveDate: The CurveDate class will inherit the Holiday class. Curve tenors (ON, 1M, 2M, etc.) will be given but it will be the methods of the CurveDate class that will do the work to ensure the tenors are adjusted to fall on actual business dates. Methods such as AddBusinessDays, AddBusinessMonths have to be created to check if a date is a weekend or holiday and use relativedelta from the dateutil library to shift the date as necessary. IsWeekend uses the weekday function of the Python date class in the datetime module, and returns an integer corresponding to the day of the week. The YFrac method will calculate the year fraction according to the selected Daycount convention. Here is the Python code for the CurveDate class.
class CurveDate(CountryHoliday):
def __init__(self):
pass
def _IsWeekend_(self,date):
weekdays = [0,1,2,3,4]
return date.weekday() not in weekdays
def _AddBusinessDays_(self,date,numdays,busdayconv,calendar):
next_date = date
for i in range(numdays):
next_date = next_date + relativedelta(days=+1)
while (self._IsWeekend_(next_date)) or (self._IsHoliday_(next_date,calendar)):
next_date = next_date + relativedelta(days=+1)
return next_date
def _AddBusinessMonths_(self,date,nummonths,busdayconv,calendar):
next_date = date
# for i in range(nummonths):
next_date = next_date + relativedelta(months=+nummonths)
while (self._IsWeekend_(next_date)) or (self._IsHoliday_(next_date,calendar)):
next_date = next_date + relativedelta(days=+1)
return next_date
def _AddBusinessYears_(self,date,numyears,busdayconv,calendar):
next_date = date
# for i in range(numyears):
next_date = next_date + relativedelta(years=+numyears)
while (self._IsWeekend_(next_date)) or (self._IsHoliday_(next_date,calendar)):
next_date = next_date + relativedelta(days=+1)
return next_date
def _YFrac_(self, date1, date2, daycountconvention):
if daycountconvention == 'ACTACT':
pass
elif daycountconvention == 'ACT365':
# the difference between two datetime objects is a timedelta object
delta = date2-date1
delta_fraction = delta.days / 365.0
return delta_fraction
elif daycountconvention == 'ACT360':
# the difference between two datetime objects is a timedelta object
delta = date2-date1
delta_fraction = delta.days / 360.0
return delta_fraction
elif daycountconvention == 'Thirty360':
pass
else:
# the difference between two datetime objects is a timedelta object
delta = date2-date1
delta_fraction = delta.days / 360.0
return delta_fraction
class YieldCurve: The YieldCurve class inherits the CurveDate and CountryHoliday classes. The GetSwapCurveData method will read in the swap curve data from an Excel file, in this example. We create a Python DataFrame to hold the curve data and set the index to Tenor. In addition to tenors and rates, the swap curve also has parameters such as number of settle days. Most financial trades and derivatives contracts are negotiated on a transaction date and then are settled or finalized on a few agreed upon number of days after. In the U.S. the number of days to settle for fixed income transactions is usually two days after the transaction date (i.e., T+2). This is called the settlement date and all cash flows are net present valued to the settlement date when the deal is struck. The curve settlement date therefore represents time zero for deriving the zero curve. So given a curve date of today, the curve settlement date is calculated as the number of settle days from today, adjusted to business days. Here is the Python code to start the YieldCurve class and for the GetSwapCurveData method.
class YieldCurve(CurveDate,CountryHoliday):
def __init__(self):
self.dfcurve = pd.DataFrame() # initialize dataframe for curve data
def __GetSwapCurveData__(self):
filein = 'swapcurvedata.xlsx'
self.dfcurve = pd.read_excel(filein, sheet_name='curvedata', index_col='Tenor')
self.dfcurveparams = pd.read_excel(filein, sheet_name='curveparams')
self.dfcurveparams.loc[self.dfcurveparams.index[0], 'Date'] = date.today()
busdayconv = self.dfcurveparams['BusDayConv'].iloc[0]
calendar = self.dfcurveparams['Calendar'].iloc[0]
curvedate = self.dfcurveparams['Date'].iloc[0]
while (self._IsWeekend_(curvedate) or self._IsHoliday_(curvedate,calendar)):
curvedate = curvedate + relativedelta(days=+1)
curvesettledays = self.dfcurveparams['SettleDays'].iloc[0]
curvesettledate = self._AddBusinessDays_(curvedate,curvesettledays,busdayconv,calendar)
self.dfcurveparams.loc[self.dfcurveparams.index[0], 'SettleDate'] = \ curvesettledate
Let's say we are given the swap curve data below:
We begin by calculating the business dates for the tenors using the DatesForTenors method:
def _DatesForTenors_(self):
curvesettledate = self.dfcurveparams['SettleDate'].iloc[0]
busdayconv = self.dfcurveparams['BusDayConv'].iloc[0]
calendar = self.dfcurveparams['Calendar'].iloc[0]
self.dfcurve.loc[self.dfcurve.index=='ON', 'Date'] = \
self._AddBusinessDays_(curvesettledate,1,busdayconv,calendar)
self.dfcurve.loc[self.dfcurve.index=='1W', 'Date'] = \
self._AddBusinessDays_(curvesettledate,5,busdayconv,calendar)
for i in range (len(self.dfcurve)):
if self.dfcurve.index[i][-1] == 'M':
num = int(self.dfcurve.index[i][:-1])
self.dfcurve.loc[self.dfcurve.index[i],'Date'] = \
self._AddBusinessMonths_(curvesettledate,num,busdayconv,calendar)
elif self.dfcurve.index[i][-1] == 'Y':
num = int(self.dfcurve.index[i][:-1])
self.dfcurve.loc[self.dfcurve.index[i],'Date'] = \
self._AddBusinessYears_(curvesettledate,num,busdayconv,calendar)
And then we follow that operation with calculating the year fraction between each date and also the cumulative year fraction. The cumulative year fraction is the year fraction from the curve settle date to the business date of the tenor. These are both calculated by the YearFractions method. Both the DatesForTenors and YearFractions methods belong to the YieldCurve class.
def _YearFractions_(self):
curvesettledate = self.dfcurveparams['SettleDate'].iloc[0]
for i in range(len(self.dfcurve)):
daycntconv = self.dfcurve['Daycount'].iloc[i]
if i == 0:
self.dfcurve.loc[self.dfcurve.index[i],'YearFraction'] = \
self._YFrac_(curvesettledate, self.dfcurve['Date'].iloc[i], daycntconv)
else:
self.dfcurve.loc[self.dfcurve.index[i], 'YearFraction'] = \
self._YFrac_(self.dfcurve['Date'].iloc[i-1], self.dfcurve['Date'].iloc[i], daycntconv)
self.dfcurve.loc[self.dfcurve.index[i], 'CumYearFraction'] = \
self._YFrac_(curvesettledate, self.dfcurve['Date'].iloc[i],
daycntconv)
fileout = "yieldcurve.xlsx"
self.dfcurve.to_excel(fileout, sheet_name='yieldcurve', index=True)
Once we have obtained the business dates for the tenors and the year fractions, we can calculate the continuously compounded zero rates for the deposit instruments. For that the ZeroRates method is employed which is also a member of the YieldCurve class.
def _ZeroRates_(self):
for i in range(len(self.dfcurve)):
if self.dfcurve['Type'].iloc[i] == 'Deposit':
self.dfcurve.loc[self.dfcurve.index[i],'ZeroRate'] = \
(1 / self.dfcurve['CumYearFraction'].iloc[i]) * \
np.log([1.0 + self.dfcurve['SwapRate'].iloc[i] * \
self.dfcurve['CumYearFraction'].iloc[i]])
Let us now turn our attention to finding the zero rates for the mid part of the yield curve. Eurodollar futures contracts are used to build the middle of the swap curve. These futures contracts are the most actively traded short term interest rate futures contracts. Futures prices are quoted as (100 - future interest rate × 100). Eurodollar futures contracts are 3-month contracts, hence the rates are quarterly rates expressed in annual terms. In general, the rates have to be adjusted for convexity to bring them in line with rates for forward contracts. Assume for our purposes that the Eurodollar futures rates given here are the quarterly compounded future interest rate adjusted for convexity. We shall come back and delve into convexity adjustment another time. The quarterly compounded future rate must be converted to the continuously compounded zero rate as before. There are two steps to doing this. First, convert the quarterly compounded rate to the continuously compounded rate. Then convert the latter to the zero rate using the following transformation:
exp(-R_eurofut_continuous × CYF) = (1 / (1 + R_quarterly × YF))** (CYF × 4)
where,
R_eurofut_continuous = the annualized 3-month continuous rate for the Eurodollar futures contract.
CYF: cumulative Year Fraction up to the maturity of the Eurodollar futures contract
Taking the natural logarithm of both sides:
-R_eurofut_continuous × CYF = ln[(1 / (1 + R_quarterly × YF))**(CYF × 4)]
Multiplying both sides by -1, and since -ln[x] = ln[1/x], we have:
R_eurofut_continuous × CYF = ln[(1 + R_quarterly × YF)**(CYF × 4)]
R_eurofut_continuous × CYF = (CYF × 4) × ln[(1 + R_quarterly × YF)]
Dividing both sides by CYF, we have:
R_eurofut_continuous = 4 × ln[(1 + R_quarterly × YF)]
The continuously compounded Eurodollar futures rate is then converted to a continuously compounded zero rate using the following transformation:
R_continuous = (R_eurofut_continuous × YF + R_continuous_t-1 × CYF_t-1) / CYF
Recommended by LinkedIn
where:
YF: year fraction of the Eurodollar Futures contract, approximately 3-months
R_continuous_t-1: the continuously compounded zero rate from the previous Eurodollar futures contract
CYF: the cumulative year fraction up to the maturity of the Eurodollar futures contract
CYF_t-1: the cumulative year fraction up to the maturity of the previous Eurodollar futures contract.
This process of obtaining the zero rate at a particular maturity using the zero rate from the previous maturity is called bootstrapping. Here is the Python code that includes the processing of the mid-section of the curve:
def _ZeroRates_(self):
for i in range(len(self.dfcurve)):
if self.dfcurve['Type'].iloc[i] == 'Deposit':
self.dfcurve.loc[self.dfcurve.index[i],'ZeroRate'] = \
(1 / self.dfcurve['CumYearFraction'].iloc[i]) * \
np.log([1.0 + self.dfcurve['SwapRate'].iloc[i] * \
self.dfcurve['CumYearFraction'].iloc[i]])
elif self.dfcurve['Type'].iloc[i] == 'EuroDollarFuture':
rate_continuous = 0.0
rate_continuous = 4 * np.log([1.0 + self.dfcurve['SwapRate'].iloc[i] * \
self.dfcurve['YearFraction'].iloc[i]])
self.dfcurve.loc[self.dfcurve.index[i], 'ZeroRate'] = \
(rate_continuous * self.dfcurve['YearFraction'].iloc[i] + \
self.dfcurve['ZeroRate'].iloc[i-1] * self.dfcurve['CumYearFraction'].iloc[i-1]) / \
self.dfcurve['CumYearFraction'].iloc[i]
The long end of the swap curve out to ten years is derived directly from observable coupon swap rates. These are plain vanilla interest rate swaps with fixed rates exchanged for floating interest rates. The fixed swap rates are quoted as par rates and are usually compounded semi-annually. The swap rates observed at the long end of the curve are for the 5Y, 7Y, and 10Y tenors. We therefore have to fill in the swap rates for tenors that we do not have and also the rates at the semi-annual terms. This is done by simple linear interpolation or by cubic spline interpolation if preferred. As we progress recursively along the long end of the swap curve interpolating the swap rates to be filled in, at the same time we apply the bootstrap method to derive zero-coupon interest rates from the swap par rates. Starting from the first swap rate, given all the continuously compounded zero rates for the coupon cash flows prior to maturity, the continuously compounded zero rate for the term of the swap is bootstrapped as follows:
R_T_continuous = - ln[(100 - Σ ᵢ ₌ ₘᵀ ⁻ ᵐ (c/m × exp(-Rᵢ × tᵢ)))/(100 + (c/m))]/T
where:
R_T_continuous: the bootstrapped continuously compounded zero rate for time T
m: the swap payment frequency per annum, usually semi-annual
c: the coupon per annum, which is equal to the observed swap rate times the swap notional. The coupon payment is therefore c/m.
Rᵢ: represents the continuously compounded zero rate for time tᵢ
Here is the Python code for the complete calculation of the zero coupon yield curve derived from the short, middle and long sections of the swap curve:
def _ZeroRates_(self):
for i in range(len(self.dfcurve)):
if self.dfcurve['Type'].iloc[i] == 'Deposit':
self.dfcurve.loc[self.dfcurve.index[i],'ZeroRate'] = \
(1 / self.dfcurve['CumYearFraction'].iloc[i]) * \
np.log([1.0 + self.dfcurve['SwapRate'].iloc[i] * \
self.dfcurve['CumYearFraction'].iloc[i]])
elif self.dfcurve['Type'].iloc[i] == 'EuroDollarFuture':
rate_continuous = 0.0
rate_continuous = 4 * np.log([1.0 + self.dfcurve['SwapRate'].iloc[i] * \
self.dfcurve['YearFraction'].iloc[i]])
self.dfcurve.loc[self.dfcurve.index[i], 'ZeroRate'] = \
(rate_continuous * self.dfcurve['YearFraction'].iloc[i] + \
self.dfcurve['ZeroRate'].iloc[i-1] * self.dfcurve['CumYearFraction'].iloc[i-1]) / \
self.dfcurve['CumYearFraction'].iloc[i]
else:
sumproduct = 0.0
if self.dfcurve['Type'].iloc[i] == 'Swap':
frequency = self.dfcurve['Frequency'].iloc[i]
term = int(self.dfcurve.index[i][:-1])
swap_year_fractions = self._SwapYearFractions_(frequency,term)
# set up interpolation object
x = pd.Series(self.dfcurve['CumYearFraction'][:i])
y = pd.Series(self.dfcurve['ZeroRate'][:i])
zero_tck = interpolate.splrep(x,y)
for swap_yf in swap_year_fractions:
zero_rate = 0.0
zero_rate = interpolate.splev(swap_yf, zero_tck)
sumproduct = sumproduct + (self.dfcurve['SwapRate'].iloc[i] / 2.0) * \
np.exp(-zero_rate * swap_yf)
self.dfcurve.loc[self.dfcurve.index[i], 'ZeroRate'] = (-1 * np.log((1.0 - sumproduct) / \
(1.0 + self.dfcurve['SwapRate'].iloc[i] / 2.0))) / \
self.dfcurve['CumYearFraction'].iloc[i]
fileout = "yieldcurve.xlsx"
self.dfcurve.to_excel(fileout, sheet_name='yieldcurve', index=True)
Once we have the zero coupon yield curve, or ZeroRates, it is a simple matter to calculate the discount factors at each term. The discount factor at each term is obtained from the zero coupon yield in the following way:
DiscountFactor_T = exp(-R_T_continuous × CYF)
where:
DiscountFactor_T: discount factor at term T
R_T_continuous: the continuously compounded zero rate for time T
CYF: the cumulative year fraction up to the respective term on the yield curve
Here is the Python code to calculate the discount factors from the yield curve. It is almost a one-liner:
def _DiscountFactors_(self):
for i in range(len(self.dfcurve)):
rate_i = self.dfcurve['SwapRate'].iloc[i]
yearfraction_i = self.dfcurve['YearFraction'].iloc[i]
cumyearfraction_i = self.dfcurve['CumYearFraction'].iloc[i]
self.dfcurve.loc[self.dfcurve.index[i], 'DiscountFactor'] = np.exp(-rate_i * cumyearfraction_i)
So that's it. That was the complete bootstrapping process of deriving a zero coupon yield curve from the swap curve. We can use the yield curve to value an interest rate swap. We can provide an example showing how to do this.
Before that though, the forward rate curve will be calculated from the discount factors. The forward rate curve is needed for pricing an interest rate swap. And the forward rate at each term is calculated in the following way:
ForwardRate_T = (1 / YF)×((DiscountFactor_T_minusone / DiscountFactor_T ) - 1)
The forward curve is the forward zero coupon yield curve. The forward rate at each term / maturity is a zero coupon rate. The Python code to determine the forward rate curve is:
def _ForwardRates_(self):
for i in range(len(self.dfcurve)):
if i == 0:
self.dfcurve.loc[self.dfcurve.index[i], 'ForwardRate'] = 0.0
else:
discountfactor_i = self.dfcurve['DiscountFactor'].iloc[i]
discountfactor_iminusone = self.dfcurve['DiscountFactor'].iloc[i-1]
yearfraction_i = self.dfcurve['YearFraction'].iloc[i]
self.dfcurve.loc[self.dfcurve.index[i], 'ForwardRate'] = \
(discountfactor_iminusone / discountfactor_i - 1) / yearfraction_i
Pricing an Interest Rate Swap
The most commonly traded and most liquid interest rate swaps are known as “plain vanilla” swaps. A plain vanilla interest rate swap exchanges periodic fixed-rate payments for periodic floating-rate payments based on the swap curve, or LIBOR curve.
On the fixed leg, when the swap contract is struck, the rate that is used to calculate the fixed payments is the rate on the swap curve for the particular maturity of the swap. For example, a 4-Year interest rate swap will use the 4 year rate from the swap curve to calculate the periodic fixed payments. This same rate is used in each period. This is what is meant by fixed payments.
On the floating leg, the periodic floating payments are calculated by using the forward rate for the particular period. This periodic forward rate which can be different for each future period is obtained from the forward curve. This is what is meant by floating payments.
To calculate the price of the interest rate swap, subtract the present value of the floating payments from the present value of the fixed payments.
The present value of the fixed payments is calculated as the sum of the discounted payments on the fixed leg:
PV Fixed = FixedNotional × Σ (YFᵢ × FixedRate × DFᵢ)
where:
FixedNotional: Notional amount of the Fixed Leg
YFᵢ : Year fraction for period i
FixedRate: Fixed rate taken from the swap curve when contract was first created
DFᵢ: Discount factor at the end of period i
The present value of the floating payments is calculated as the sum of the discounted payments on the floating leg:
PV Float = FloatNotional × Σ (YFⱼ × ForwardRateⱼ × DFⱼ)
where:
FloatNotional: Notional amount of the Floating Leg
YFⱼ : Year fraction for period j
ForwardRateⱼ: Forward rate for period j taken from the forward curve
DFⱼ: Discount factor at the end of period j
Therefore, the price of the interest rate swap is given by:
PV Swap = PV Fixed - PV Float
At the time a swap contract is agreed to, it is considered 'at the money' or at par, meaning that the present value of fixed leg payments over the life of the swap is exactly equal to the expected present value of floating leg payments. That means PV Swap = 0.
We can test the accuracy of our bootstrapping process by valuing a par interest rate swap and verifying that the value comes out to be zero. For example, let us create a par swap with a maturity of 4 years and a fixed rate of 0.004. In the standard plain vanilla USD interest rate swap, the fixed leg makes payments every six months or semi-annually, whereas, the floating leg makes payments every three months or quarterly. Additional details of the swap are:
The Python code for the valuation is in PriceInterestRateSwap method. This is a rough back of the hand valuation. No date adjustment is made to ensure the payments are made on business days The year fraction on the fixed leg is set to 0.5 for every semi-annual payment period. Similarly, the year fraction on the floating leg is set to 0.25 for every quarterly payment period. The discount factors and forward rates are interpolated from the previously calculated discount factor curve and forward curve.
The bottom line is that swap price comes out to be $197. Given that the swap notional is $10M, it means that the swap price is not much different from $0. This verifies that we have succeeded in matching back the swap rate using the discount factors and forward rates bootstrapped from the swap curve.
I hope you found this article useful. If you did, please give a thumbs up. And if you think others can benefit from this article, please share with your network and colleagues. The code is freely available for download. If there is anything that you think should be added or corrected, please let me know. I also look forward to seeing what creative enhancements you add in order to make improvements. Go Python!
The full code is presented below:
# PYTHON BOOTSTRAPPING THE YIELD CURVE
# Copyright Sheikh Pancham 2021 sheikh.pancham@gmail.com
# Connect on LinkedIn: https://www.garudax.id/in/sheikhpancham/
# I, Sheikh Pancham, legally own this product/code and all intellectual property because I wrote it, and am therefore
# authorized to distribute it freely to the global public. Any party is also authorized to freely distribute this
# product/code but not to sell it.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import math
from datetime import *
from dateutil.relativedelta import *
from scipy import interpolate
from openpyxl import Workbook, load_workbook
class CountryHoliday:
def __init__(self):
pass
def _IsHoliday_(self,date,calendar):
weekdays = [0,1,2,3,4]
if not date.weekday() in weekdays:
return False
y = date.year
m = date.month
d = date.day
wkd = date.weekday()
if calendar == 'NY':
# January 1
if (m==1 and d==1) or (m==12 and d==31 and wkd==4) or \
(m==1 and d==2 and wkd==0):
return True
# Martin Luther King, third Monday of January
if m==1 and wkd==0 and (d>14 and d<22):
return True
# Washington's Birthday, third Monday of February
if m==2 and wkd==0 and (d>14 and d<22):
return True
# Memorial Day, last Monday of May
if m==5 and wkd==0 and d>24:
return True
# Independence Day, July 4
if (m==7 and d==4) or (m==7 and d==3 and wkd==4) or \
(m==7 and d==5 and wkd==0):
return True
# Labor Day, the first Monday of September
if m==9 and wkd==0 and d<8:
return True
# Columbus Day, second Monday of October
if m==10 and wkd==0 and (d>7 and d<15):
return True
# Veterans Day, November 11
if (m==11 and d==11) or (m==11 and d==10 and wkd==4) or \
(m==11 and d==12 and wkd==0):
return True
# Thanksgiving Day, fourth Thursday of November
if m==11 and wkd==3 and (d>21 and d<29):
return True
# Christmas Day, December 25
if (m==12 and d==25) or (m==12 and d==24 and wkd==4) or \
(m==12 and d==26 and wkd==0):
return True
return False
class CurveDate(CountryHoliday):
def __init__(self):
pass
def _IsWeekend_(self,date):
weekdays = [0,1,2,3,4]
return date.weekday() not in weekdays
def _AddBusinessDays_(self,date,numdays,busdayconv,calendar):
next_date = date
for i in range(numdays):
next_date = next_date + relativedelta(days=+1)
while (self._IsWeekend_(next_date)) or (self._IsHoliday_(next_date,calendar)):
next_date = next_date + relativedelta(days=+1)
return next_date
def _AddBusinessMonths_(self,date,nummonths,busdayconv,calendar):
next_date = date
# for i in range(nummonths):
next_date = next_date + relativedelta(months=+nummonths)
while (self._IsWeekend_(next_date)) or (self._IsHoliday_(next_date,calendar)):
next_date = next_date + relativedelta(days=+1)
return next_date
def _AddBusinessYears_(self,date,numyears,busdayconv,calendar):
next_date = date
# for i in range(numyears):
next_date = next_date + relativedelta(years=+numyears)
while (self._IsWeekend_(next_date)) or (self._IsHoliday_(next_date,calendar)):
next_date = next_date + relativedelta(days=+1)
return next_date
def _YFrac_(self, date1, date2, daycountconvention):
if daycountconvention == 'ACTACT':
pass
elif daycountconvention == 'ACT365':
# the difference between two datetime objects is a timedelta object
delta = date2-date1
delta_fraction = delta.days / 365.0
return delta_fraction
elif daycountconvention == 'ACT360':
# the difference between two datetime objects is a timedelta object
delta = date2-date1
delta_fraction = delta.days / 360.0
return delta_fraction
elif daycountconvention == 'Thirty360':
pass
else:
# the difference between two datetime objects is a timedelta object
delta = date2-date1
delta_fraction = delta.days / 360.0
return delta_fraction
class YieldCurve(CurveDate,CountryHoliday):
def __init__(self):
self.dfcurve = pd.DataFrame() # initialize dataframe for curve data
def __GetSwapCurveData__(self):
filein = "C:/Users/sheik/Finance Python/swapcurvedata.xlsx"
self.dfcurve = pd.read_excel(filein, sheet_name='swapcurve', index_col='Tenor')
print(self.dfcurve)
self.dfcurveparams = pd.read_excel(filein, sheet_name='curveparams')
self.dfcurveparams.loc[self.dfcurveparams.index[0], 'Date'] = date.today()
busdayconv = self.dfcurveparams['BusDayConv'].iloc[0]
calendar = self.dfcurveparams['Calendar'].iloc[0]
curvedate = self.dfcurveparams['Date'].iloc[0]
while (self._IsWeekend_(curvedate) or self._IsHoliday_(curvedate,calendar)):
curvedate = curvedate + relativedelta(days=+1)
curvesettledays = self.dfcurveparams['SettleDays'].iloc[0]
curvesettledate = self._AddBusinessDays_(curvedate,curvesettledays,busdayconv,calendar)
self.dfcurveparams.loc[self.dfcurveparams.index[0], 'SettleDate'] = curvesettledate
def _DatesForTenors_(self):
curvesettledate = self.dfcurveparams['SettleDate'].iloc[0]
busdayconv = self.dfcurveparams['BusDayConv'].iloc[0]
calendar = self.dfcurveparams['Calendar'].iloc[0]
self.dfcurve.loc[self.dfcurve.index=='ON', 'Date'] = \
self._AddBusinessDays_(curvesettledate,1,busdayconv,calendar)
self.dfcurve.loc[self.dfcurve.index=='1W', 'Date'] = \
self._AddBusinessDays_(curvesettledate,5,busdayconv,calendar)
for i in range (len(self.dfcurve)):
if self.dfcurve.index[i][-1] == 'M':
num = int(self.dfcurve.index[i][:-1])
self.dfcurve.loc[self.dfcurve.index[i],'Date'] = \
self._AddBusinessMonths_(curvesettledate,num,busdayconv,calendar)
elif self.dfcurve.index[i][-1] == 'Y':
num = int(self.dfcurve.index[i][:-1])
self.dfcurve.loc[self.dfcurve.index[i],'Date'] = \
self._AddBusinessYears_(curvesettledate,num,busdayconv,calendar)
def _YearFractionsForTenors_(self):
curvesettledate = self.dfcurveparams['SettleDate'].iloc[0]
for i in range(len(self.dfcurve)):
daycntconv = self.dfcurve['Daycount'].iloc[i]
if i == 0:
self.dfcurve.loc[self.dfcurve.index[i],'YearFraction'] = \
self._YFrac_(curvesettledate, self.dfcurve['Date'].iloc[i], daycntconv)
else:
self.dfcurve.loc[self.dfcurve.index[i], 'YearFraction'] = \
self._YFrac_(self.dfcurve['Date'].iloc[i-1], self.dfcurve['Date'].iloc[i], daycntconv)
self.dfcurve.loc[self.dfcurve.index[i], 'CumYearFraction'] = \
self._YFrac_(curvesettledate, self.dfcurve['Date'].iloc[i], daycntconv)
def _SwapYearFractions_(self,frequency,term):
curvesettledate = self.dfcurveparams['SettleDate'].iloc[0]
busdayconv = self.dfcurveparams['BusDayConv'].iloc[0]
calendar = self.dfcurveparams['Calendar'].iloc[0]
if frequency == 'S':
period = 0.5
elif frequency == 'Q':
period = 0.25
else:
period = 0.5
swap_months, swap_dates, swap_year_fractions = [],[],[]
swap_months = [int(12 * period * i) for i in range(1,2*term)]
# print(swap_months)
for nummonths in swap_months:
swap_dates.append(self._AddBusinessMonths_(curvesettledate,nummonths,busdayconv,calendar))
# print(swap_dates)
for swap_date in swap_dates:
swap_year_fractions.append(self._YFrac_(curvesettledate,swap_date,'ACT/360'))
# print(swap_year_fractions)
return swap_year_fractions
def _ZeroRates_(self):
for i in range(len(self.dfcurve)):
if self.dfcurve['Type'].iloc[i] == 'Deposit':
self.dfcurve.loc[self.dfcurve.index[i],'ZeroRate'] = \
(1 / self.dfcurve['CumYearFraction'].iloc[i]) * \
np.log([1.0 + self.dfcurve['SwapRate'].iloc[i] * \
self.dfcurve['CumYearFraction'].iloc[i]])
elif self.dfcurve['Type'].iloc[i] == 'EuroDollarFuture':
rate_continuous = 0.0
rate_continuous = 4 * np.log([1.0 + self.dfcurve['SwapRate'].iloc[i] * \
self.dfcurve['YearFraction'].iloc[i]])
self.dfcurve.loc[self.dfcurve.index[i], 'ZeroRate'] = \
(rate_continuous * self.dfcurve['YearFraction'].iloc[i] + \
self.dfcurve['ZeroRate'].iloc[i-1] * self.dfcurve['CumYearFraction'].iloc[i-1]) / \
self.dfcurve['CumYearFraction'].iloc[i]
else:
sumproduct = 0.0
if self.dfcurve['Type'].iloc[i] == 'Swap':
frequency = self.dfcurve['Frequency'].iloc[i]
term = int(self.dfcurve.index[i][:-1])
swap_year_fractions = self._SwapYearFractions_(frequency,term)
# set up interpolation object
x = pd.Series(self.dfcurve['CumYearFraction'][:i])
y = pd.Series(self.dfcurve['ZeroRate'][:i])
zero_tck = interpolate.splrep(x,y)
for swap_yf in swap_year_fractions:
zero_rate = 0.0
zero_rate = interpolate.splev(swap_yf, zero_tck)
sumproduct = sumproduct + (self.dfcurve['SwapRate'].iloc[i] / 2.0) * \
np.exp(-zero_rate * swap_yf)
self.dfcurve.loc[self.dfcurve.index[i], 'ZeroRate'] = (-1 * np.log((1.0 - sumproduct) / \
(1.0 + self.dfcurve['SwapRate'].iloc[i] / 2.0))) / \
self.dfcurve['CumYearFraction'].iloc[i]
fileout = "C:/Users/sheik\Finance Python/yieldcurve.xlsx"
self.dfcurve.to_excel(fileout, sheet_name='yieldcurve', index=True)
print(self.dfcurve.head(20))
def _DiscountFactors_(self):
for i in range(len(self.dfcurve)):
rate_i = self.dfcurve['SwapRate'].iloc[i]
yearfraction_i = self.dfcurve['YearFraction'].iloc[i]
cumyearfraction_i = self.dfcurve['CumYearFraction'].iloc[i]
self.dfcurve.loc[self.dfcurve.index[i], 'DiscountFactor'] = np.exp(-rate_i * cumyearfraction_i)
def _ForwardRates_(self):
for i in range(len(self.dfcurve)):
if i == 0:
self.dfcurve.loc[self.dfcurve.index[i], 'ForwardRate'] = 0.0
else:
discountfactor_i = self.dfcurve['DiscountFactor'].iloc[i]
discountfactor_iminusone = self.dfcurve['DiscountFactor'].iloc[i-1]
yearfraction_i = self.dfcurve['YearFraction'].iloc[i]
self.dfcurve.loc[self.dfcurve.index[i], 'ForwardRate'] = \
(discountfactor_iminusone / discountfactor_i - 1) / yearfraction_i
def _PriceInterestRateSwap_(self):
# using openpyxl functionality
# calculate leg1 PV
self.wb = load_workbook('swapcurvedata.xlsx')
self.ws = self.wb['swap']
self.leg1FxdFlt = self.ws['B2'].value
self.leg1RateSpread = float(self.ws['B3'].value)
self.leg1datesettle = self.ws['B4'].value
self.leg1Freq = self.ws['B5'].value
self.leg1Horizon = self.ws['B6'].value
self.leg1Calendar = self.ws['B7'].value
self.leg1BusDayConv = self.ws['B8'].value
self.leg1DayCntConv = self.ws['B9'].value
self.leg1LegNotional = float(self.ws['B10'].value)
print(self.leg1LegNotional)
iLeg1NumFreq = int(self.leg1Freq[:-1])
iLeg1NumHorizon = int(self.leg1Horizon[:-1])
leg1sPeriod = self.leg1Freq[-1] + self.leg1Horizon[-1]
if leg1sPeriod == 'MM' or leg1sPeriod == 'YY':
leg1NumPayments = iLeg1NumHorizon // iLeg1NumFreq
elif leg1sPeriod == 'MY':
leg1NumPayments = iLeg1NumHorizon * 12 // iLeg1NumFreq
elif leg1sPeriod == 'YM':
leg1NumPayments = iLeg1NumHorizon // (iLeg1NumFreq * 12)
if self.leg1Freq == '6M':
leg1YF = 0.5
elif self.leg1Freq == '3M':
leg1YF = 0.25
x = pd.Series(self.dfcurve['CumYearFraction'])
y = pd.Series(self.dfcurve['DiscountFactor'])
df_tck = interpolate.splrep(x,y)
leg1PV = 0.0
for i in range(1,leg1NumPayments):
leg1cyf_i = leg1YF * i
leg1discountfactor_i = interpolate.splev(leg1cyf_i, df_tck)
leg1cashflow_i = self.leg1LegNotional * self.leg1RateSpread * leg1YF * leg1discountfactor_i
leg1PV = leg1PV + leg1cashflow_i
print(leg1PV)
# calculate leg2 PV
self.leg2FxdFlt = self.ws['E2'].value
self.leg2RateSpread = float(self.ws['E3'].value)
self.leg2datesettle = self.ws['E4'].value
self.leg2Freq = self.ws['E5'].value
self.leg2Horizon = self.ws['E6'].value
self.leg2Calendar = self.ws['E7'].value
self.leg2BusDayConv = self.ws['E8'].value
self.leg2DayCntConv = self.ws['E9'].value
self.leg2LegNotional = float(self.ws['E10'].value)
iLeg2NumFreq = int(self.leg2Freq[:-1])
iLeg2NumHorizon = int(self.leg2Horizon[:-1])
leg2sPeriod = self.leg2Freq[-1] + self.leg2Horizon[-1]
if leg2sPeriod == 'MM' or leg2sPeriod == 'YY':
leg2NumPayments = iLeg2NumHorizon // iLeg2NumFreq
elif leg2sPeriod == 'MY':
leg2NumPayments = iLeg2NumHorizon * 12 // iLeg2NumFreq
elif leg2sPeriod == 'YM':
leg2NumPayments = iLeg2NumHorizon // (iLeg2NumFreq * 12)
if self.leg2Freq == '6M':
leg2YF = 0.5
elif self.leg2Freq == '3M':
leg2YF = 0.25
a = pd.Series(self.dfcurve['CumYearFraction'])
b = pd.Series(self.dfcurve['ForwardRate'])
fr_tck = interpolate.splrep(a,b)
leg2PV = 0.0
for i in range(1,leg2NumPayments):
leg2cyf_i = leg2YF * i
leg2discountfactor_i = interpolate.splev(leg2cyf_i, df_tck)
leg2forwardrate_i = interpolate.splev(leg2cyf_i, fr_tck)
leg2cashflow_i = self.leg2LegNotional * leg2forwardrate_i * leg2YF * leg2discountfactor_i
leg2PV = leg2PV + leg2cashflow_i
print(leg2PV)
print(leg1PV + leg2PV)
self.ws['H2'] = leg1PV
self.ws['H3'] = leg2PV
self.ws['H4'] = leg1PV + leg2PV
self.wb.save('swapcurvedata.xlsx')
def BootstrapYieldCurve(self):
self.__GetSwapCurveData__()
self._DatesForTenors_()
self._YearFractionsForTenors_()
self._ZeroRates_()
self._DiscountFactors_()
self._ForwardRates_()
self._PriceInterestRateSwap_()
print(self.dfcurve)
irs = YieldCurve()
irs.BootstrapYieldCurve()
Hi Sheikh, in your process converting eurodollar futures rate from quarterly compounding to continuous compounding, you used formula of "R_eurofut_continuous × CYF = ln[(1 + R_quarterly × YF)**(CYF × 4)]". I am a bit confused for the case "YF" is not a quarter. In fact, if you do natural exponent on both sides, the right hand side becomes [(1 + R_quarterly × YF)**(CYF × 4)]. It is doing compounding quarterly with fixed rate of "R_quarterly x YF" for the whole period. But "R_quarterly x YF" is not a quarterly spot rate that you can compound quarterly.
Can this code be modified to calculate / bootstrap rates for other tenors such as 2M, 3M, 6Y, etc. based on the existing term structure?