Python Bootstrapping the Zero Curve

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:

No alt text provided for this image

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)
                        


No alt text provided for this image

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]])
        
No alt text provided for this image

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

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)
        
No alt text provided for this image

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:

No alt text provided for this image

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?

Like
Reply

To view or add a comment, sign in

More articles by Sheikh Pancham

Others also viewed

Explore content categories