I am attempting to build a forward curve for multiple tenors (1M / 3M / 6M / 12M) using the quantlib library. The input to my model are sofr swaps(1W through 50Y). It appears I am building my curve correctly as I can zero rates and discount factors for my spot curve directly back to bloomberg, but when I try getting forward rates I am no longer able to tie.
import pandas as pd
import QuantLib as ql
import math
rates = {
'1W':5.30694,
'2W':5.3096,
'3W':5.3119,
'1M':5.3155,
'2M':5.3381,
'3M':5.36788,
'4M':5.39426,
'5M':5.4169,
'6M':5.43035,
'7M':5.4355,
'8M':5.43425,
'9M':5.42945,
'10M':5.415,
'11M':5.39297,
'12M':5.36985,
'18M':5.071,
'2Y':4.81595,
'3Y':4.42031,
'4Y':4.1825,
'5Y':4.04375,
'6Y':3.95985,
'7Y':3.90315,
'8Y':3.8633,
'9Y':3.83855,
'10Y':3.82213,
'12Y':3.80775,
'15Y':3.79964,
'20Y':3.74566,
'25Y':3.63489,
'30Y':3.528,
'40Y':3.31713,
'50Y':3.11,
}
calculation_date = ql.Date(2,8,2023)
settle_time = 2
ql.Settings.instance().evaluationDate = calculation_date
yts = ql.RelinkableYieldTermStructureHandle()
index = ql.OvernightIndex("USD Overnight Index", 0, ql.USDCurrency(), ql.UnitedStates(ql.UnitedStates.Settlement), ql.Actual360(), yts)
swaps = {}
for x in rates.keys():
swaps.update(
{
ql.Period(x):rates.get(x)/100
}
)
#build helpers
rate_helpers = []
for tenor, rate in swaps.items():
helper = ql.OISRateHelper(settle_time, tenor, ql.QuoteHandle(ql.SimpleQuote(rate)), index)
rate_helpers.append(helper)
#build curve based on swap helpers
curve = ql.PiecewiseFlatForward(calculation_date, rate_helpers, ql.Actual360())
curve.enableExtrapolation()
yts.linkTo(curve)
engine = ql.DiscountingSwapEngine(yts)
#used to compare zeros and discount factors back to bloomberg
print("maturity | market | model | zero rate | discount factor | present value")
for tenor, rate in swaps.items():
ois_swap = ql.MakeOIS(tenor, index, rate)
pv = ois_swap.NPV()
fair_rate = ois_swap.fairRate()
maturity_date = ois_swap.maturityDate()
discount_factor = curve.discount(maturity_date)
zero_rate = -math.log(discount_factor) * 365.0/(maturity_date-calculation_date)
print(f" {tenor} | {rate*100:.6f} | {fair_rate*100:.6f} | {zero_rate*100:.6f} | {discount_factor:.6f} | {pv:.6f}")
#create monthly cadence for differrent tenors of forward rates
days = ql.MakeSchedule(curve.referenceDate(), curve.maxDate(), ql.Period('3M'))
dates,list_3mo,list_1mo,list_6mo,list_12mo = [],[],[],[],[]
for d in days:
forward_3mo = curve.forwardRate(
d,
ql.UnitedStates(ql.UnitedStates.Settlement).advance(d,90,ql.Days),
ql.Actual365Fixed(),
ql.Continuous,
).rate()
forward_1mo = curve.forwardRate(
d,
ql.UnitedStates(ql.UnitedStates.Settlement).advance(d,30,ql.Days),
ql.Actual365Fixed(),
ql.Continuous,
).rate()
forward_6mo = curve.forwardRate(
d,
ql.UnitedStates(ql.UnitedStates.Settlement).advance(d,ql.Period('6M')),
ql.Actual365Fixed(),
ql.Continuous,
).rate()
forward_12mo = curve.forwardRate(
d,
ql.UnitedStates(ql.UnitedStates.Settlement).advance(d,360,ql.Days),
ql.Actual365Fixed(),
ql.Continuous,
).rate()
dates.append(d)
list_1mo.append(forward_1mo)
list_3mo.append(forward_3mo)
list_6mo.append(forward_6mo)
list_12mo.append(forward_12mo)
#save output
df = pd.DataFrame(data=zip(dates,list_1mo,list_3mo,list_6mo,list_12mo),columns=('Dates','SOFR_1MO','SOFR_3MO','SOFR_6MO','SOFR_12MO'))
print(df)
df.to_csv('forward_rates.csv')
The dates that I return versus the screenshot are off by 2 days and the forward rates / zero rates are slightly off. Can anyone provide any insight into what some of the difference might be? The forward rates shown by bloomberg are continuous | ACT/365.