I have bootstrapped the the yield curve using both zero-coupon bonds and fixed coupon bonds using the code below;
# Importing Libraries:
# The code imports necessary libraries:
# pandas for data manipulation, matplotlib.pyplot for plotting, and QuantLib (ql) for quantitative finance calculations.
import pandas as pd
import matplotlib.pyplot as plt
# Use the QuantLib or ORE Libraries
import QuantLib as ql
# Setting Evaluation Date:
# Sets the evaluation date to May 31, 2023, for all subsequent calculations.
today = ql.Date(21, ql.November, 2023)
ql.Settings.instance().evaluationDate = today
# Calendar and Day Count:
# Creates a calendar object for South Africa and specifies the day-count convention (Actual/365 Fixed)
calendar = ql.NullCalendar()
day_count = ql.Actual365Fixed()
# Settlement Days:
zero_coupon_settlement_days = 4
coupon_bond_settlement_days = 3
# Face Value
faceAmount = 100
data = [
('11-09-2023', '11-12-2023', 0, 99.524, zero_coupon_settlement_days),
('11-09-2023', '11-03-2024', 0, 96.539, zero_coupon_settlement_days),
('11-09-2023', '10-06-2024', 0, 93.552, zero_coupon_settlement_days),
('11-09-2023', '09-09-2024', 0, 89.510, zero_coupon_settlement_days),
('22-08-2022', '22-08-2024', 9.0, 96.406933, coupon_bond_settlement_days),
('27-06-2022', '27-06-2025', 10.0, 88.567570, coupon_bond_settlement_days),
('27-06-2022', '27-06-2027', 11.0, 71.363073, coupon_bond_settlement_days),
('22-08-2022', '22-08-2029', 12.0, 62.911623, coupon_bond_settlement_days),
('27-06-2022', '27-06-2032', 13.0, 55.976845, coupon_bond_settlement_days),
('22-08-2022', '22-08-2037', 14.0, 52.656596, coupon_bond_settlement_days)]
helpers = []
for issue_date, maturity, coupon, price, settlement_days in data:
price = ql.QuoteHandle(ql.SimpleQuote(price))
today = ql.Date(21, ql.November, 2023)
issue_date = ql.Date(issue_date, '%d-%m-%Y')
maturity = ql.Date(maturity, '%d-%m-%Y')
schedule = ql.Schedule(today, maturity, ql.Period(ql.Semiannual), calendar, ql.DateGeneration.Backward,
ql.Following, ql.DateGeneration.Backward, False)
helper = ql.FixedRateBondHelper(price, settlement_days, faceAmount, schedule, [coupon / 100], day_count,
False)
helpers.append(helper)
curve = ql.PiecewiseCubicZero(today, helpers, day_count)
# Enable Extrapolation:
# This line enables extrapolation for the yield curve.
# Extrapolation allows the curve to provide interest rates or rates beyond the observed data points,
# which can be useful for pricing or risk management purposes.
curve.enableExtrapolation()
# Zero Rate and Discount Rate Calculation:
# Calculates and prints the zero rate and discount rate at a specific
# future date (May 28, 2048) using the constructed yield curve.
date = ql.Date(11, ql.December, 2023)
zero_rate = curve.zeroRate(date, day_count, ql.Annual).rate()
forward_rate = curve.forwardRate(date, date + ql.Period(1, ql.Years), day_count, ql.Annual).rate()
discount_rate = curve.discount(date)
print("Zero rate as at 28.05.2048: " + str(round(zero_rate*100, 4)) + str("%"))
print("Forward rate as at 28.05.2048: " + str(round(forward_rate*100, 4)) + str("%"))
print("Discount factor as at 28.05.2048: " + str(round(discount_rate, 4)))
# Print the Zero Rates, Forward Rates and Discount Factors at node dates
# print(pd.DataFrame(curve.nodes()))
node_data = {'Date': [],
'Zero Rates': [],
'Forward Rates': [],
'Discount Factors': []}
for dt in curve.dates():
node_data['Date'].append(dt)
node_data['Zero Rates'].append(curve.zeroRate(dt, day_count, ql.Annual).rate())
node_data['Forward Rates'].append(curve.forwardRate(dt, dt + ql.Period(1, ql.Years), day_count, ql.Annual).rate())
node_data['Discount Factors'].append(curve.discount(dt))
node_dataframe = pd.DataFrame(node_data)
print(node_dataframe)
node_dataframe.to_excel('NodeRates.xlsx')
# Printing Daily Zero Rates:
# Prints the daily zero rates from the current date (May 31, 2023) to a maturity date that is 30
# years later. It calculates and prints the zero rates for each year using the constructed yield curve.
maturity_date = calendar.advance(today, ql.Period(1, ql.Years))
current_date = today
while current_date <= maturity_date:
zero_rate = curve.zeroRate(current_date, day_count, ql.Annual).rate()
print(f"Date: {current_date}, Zero Rate: {zero_rate}")
current_date = calendar.advance(current_date, ql.Period(1, ql.Years))
# Creating Curve Data for Plotting:
# Creates lists of curve dates, zero rates, and forward rates for plotting.
# It calculates both zero rates and forward rates for each year up to 25 years from the current date.
'Zero Rate': [],
'Discount Factor': [],
'Clean Price': [],
'Dirty Price': []}
# Calculate bond prices and yields
for issue_date, maturity, coupon, price, settlement_days in data:
price = ql.QuoteHandle(ql.SimpleQuote(price))
today = ql.Date(21, ql.November, 2023)
issue_date = ql.Date(issue_date, '%d-%m-%Y')
maturity = ql.Date(maturity, '%d-%m-%Y')
schedule = ql.Schedule(today, maturity, ql.Period(ql.Semiannual), calendar, ql.DateGeneration.Backward,
ql.Following, ql.DateGeneration.Backward, False)
bondEngine = ql.DiscountingBondEngine(ql.YieldTermStructureHandle(curve))
bond = ql.FixedRateBond(settlement_days, faceAmount, schedule, [coupon / 100], day_count)
bond.setPricingEngine(bondEngine)
# Calculate bond yield, clean price, and dirty price
bondYield = bond.bondYield(day_count, ql.Compounded, ql.Annual)
bondCleanPrice = bond.cleanPrice()
bondDirtyPrice = bond.dirtyPrice()
zero_rate = curve.zeroRate(maturity, day_count, ql.Annual).rate()
discount_factor = curve.discount(maturity)
# Append the results to the DataFrame
bond_results['Issue Date'].append(issue_date)
bond_results['Maturity Date'].append(maturity)
bond_results['Coupon Rate'].append(coupon)
bond_results['Price'].append(price.value())
bond_results['Settlement Days'].append(settlement_days)
bond_results['Yield'].append(bondYield)
bond_results['Zero Rate'].append(zero_rate)
bond_results['Discount Factor'].append(discount_factor)
bond_results['Clean Price'].append(bondCleanPrice)
bond_results['Dirty Price'].append(bondDirtyPrice)
# Create a DataFrame from the bond results
bond_results_df = pd.DataFrame(bond_results)
# Print the results
print(bond_results_df)
bond_results_df.to_excel('BondResults.xlsx')
I have the following questions or queries; (i) The yield to maturity that I am getting for the first 4 zero-coupon bonds is slightly different from the zero (or spot) rates. My expectation is that the yield to maturity (YTM) and the zero rates for the first 4 zero-coupon bonds should be the same. What is causing the slight differences and how can I resolve this? (ii) In trying to manually calculate the prices of the first 4 zero-coupon bonds by simply discounting the face amount of 100 using the YTM (which should be similar to zero rates) and the accrual period, I have noted that I need to adjust for the settlement days (T+4) in this case 4 days for the zero coupon bonds. My understanding of settlement days is that in this case the bond is settled 4 days after maturity hence this will increase the accrual period (or discounting period) by 4 days. Surprisingly, to get to the same price for the zero-coupon bonds, I have noticed that I have reduced the accrual period by 4 days instead of increasing. Am I missing something or this is not correct.
The final results will be in the BondResults.xlsx workbook
4 settlement days means that, if I buy the bond from you today, I won't get it right away but in 4 business days. As you have seen, this decreases the discounting period; you need to discount not from maturity to today, but from maturity to 4 days from today.
As for zero-rate vs yield for zero-coupon bonds: the yield is calculated from the settlement date to the maturity, while the zero rate returned from the curve is from today to maturity. If you write:
zero_rate = curve.forwardRate(bond.settlementDate(), maturity,
day_count, ql.Compounded, ql.Annual).rate()
you'll get the same numbers.
One thing: you're calling in a couple of places
curve.zeroRate(maturity, day_count, ql.Annual).rate()
but it should be
curve.zeroRate(maturity, day_count, ql.Compounded, ql.Annual).rate()
In this particular case, it worked because ql.Annual
and ql.Compounded
both happened to have value 1 and because ql.Annual
is the default for the frequency you weren't passing.