I am trying to price SOFR swaps in two different dates (the same swaps, just different curves and dates)
This are my initial parameters:
curve_date =ql.Date (9,5,2022)
ql.Settings.instance().evaluationDate = curve_date
sofr = ql.Sofr() #overnightIndex
swaps_calendar = ql.UnitedStates(ql.UnitedStates.FederalReserve)#calendar
day_count = ql.Actual360() #day count convention
settlement_days = 2 #t+2 settlement convention for SOFR swaps
this is the SOFR curve as of May 9th, 2022:
index | ticker | n | tenor | quote |
---|---|---|---|---|
0 | USOSFR1Z CBBT Curncy | 1 | 1 | 0.79 |
1 | USOSFR2Z CBBT Curncy | 2 | 1 | 0.81 |
2 | USOSFR3Z CBBT Curncy | 3 | 1 | 0.79 |
3 | USOSFRA CBBT Curncy | 1 | 2 | 0.8 |
4 | USOSFRB CBBT Curncy | 2 | 2 | 1.01 |
5 | USOSFRC CBBT Curncy | 3 | 2 | 1.19 |
6 | USOSFRD CBBT Curncy | 4 | 2 | 1.34 |
7 | USOSFRE CBBT Curncy | 5 | 2 | 1.47 |
8 | USOSFRF CBBT Curncy | 6 | 2 | 1.61 |
9 | USOSFRG CBBT Curncy | 7 | 2 | 1.71 |
10 | USOSFRH CBBT Curncy | 8 | 2 | 1.82 |
11 | USOSFRI CBBT Curncy | 9 | 2 | 1.93 |
12 | USOSFRJ CBBT Curncy | 10 | 2 | 2.01 |
13 | USOSFRK CBBT Curncy | 11 | 2 | 2.09 |
14 | USOSFR1 CBBT Curncy | 12 | 2 | 2.17 |
15 | USOSFR1F CBBT Curncy | 18 | 2 | 2.48 |
16 | USOSFR2 CBBT Curncy | 2 | 3 | 2.62 |
17 | USOSFR3 CBBT Curncy | 3 | 3 | 2.69 |
18 | USOSFR4 CBBT Curncy | 4 | 3 | 2.72 |
19 | USOSFR5 CBBT Curncy | 5 | 3 | 2.73 |
20 | USOSFR7 CBBT Curncy | 7 | 3 | 2.77 |
21 | USOSFR8 CBBT Curncy | 8 | 3 | 2.78 |
22 | USOSFR9 CBBT Curncy | 9 | 3 | 2.8 |
23 | USOSFR10 CBBT Curncy | 10 | 3 | 2.81 |
24 | USOSFR12 CBBT Curncy | 12 | 3 | 2.83 |
25 | USOSFR15 CBBT Curncy | 15 | 3 | 2.85 |
26 | USOSFR20 CBBT Curncy | 20 | 3 | 2.81 |
27 | USOSFR25 CBBT Curncy | 25 | 3 | 2.71 |
28 | USOSFR30 CBBT Curncy | 30 | 3 | 2.6 |
29 | USOSFR40 CBBT Curncy | 40 | 3 | 2.4 |
30 | USOSFR50 CBBT Curncy | 50 | 3 | 2.23 |
This data is stored in a df called:swap_data
and I use it to build tuples (rate, (tenor)) for the OISRateHelper
objects
swaps= [(row.quote,(row.n, row.tenor)) for row in swap_data.itertuples(index=True, name='Pandas')]
def zero_curve(settlement_days,swaps,day_count):
ois_helpers = [ ql.OISRateHelper(settlement_days, #settlementDays
ql.Period(*tenor), #tenor -> note that `tenor` in the list comprehension are (n,units), so uses * to unpack when calling ql.Period(n, units)
ql.QuoteHandle(ql.SimpleQuote(rate/100)), #fixedRate
sofr) #overnightIndex
for rate, tenor in swaps]
#for now I have chosen to use a logCubicDiscount term structure to ensure continuity in the inspection
sofrCurve = ql.PiecewiseLogCubicDiscount(settlement_days, #referenceDate
swaps_calendar,#calendar
ois_helpers, #instruments
day_count, #dayCounter
)
sofrCurve.enableExtrapolation() #allows for extrapolation at the ends
return sofrCurve
using this function I build a zero curve, a sofr object linked to that curve and a swap pricing engine
sofrCurve = zero_curve(settlement_days,swaps,day_count)
valuation_Curve = ql.YieldTermStructureHandle(sofrCurve)
sofrIndex = ql.Sofr(valuation_Curve)
swapEngine = ql.DiscountingSwapEngine(valuation_Curve)
With this I create OIS swaps and price them using this curve to ensure that it's correctly calibrated:
effective_date = swaps_calendar.advance(curve_date, settlement_days, ql.Days)
notional = 10_000_000
ois_swaps = []
for rate, tenor in swaps:
schedule = ql.MakeSchedule(effective_date,
swaps_calendar.advance(effective_date, ql.Period(*tenor)),
ql.Period('1Y'),
calendar = swaps_calendar)
fixedRate = rate/100
oisSwap = ql.MakeOIS(ql.Period(*tenor), sofrIndex, fixedRate, nominal=notional)
oisSwap.setPricingEngine(swapEngine)
ois_swaps.append(oisSwap)
the NPVs on all the swaps is zero so they seem. I went a step further to confirm that I was getting the PV of the legs correctly by constructing a function that yields a table with the leg relevant information
def leg_information(effective_date, day_count,ois_swap, leg_type, sofrCurve):
leg_df=pd.DataFrame(columns=['date','yearfrac','CF','discountFactor','PV','totalPV'])
cumSum_pv= 0
leg = ois_swap.leg(0) if leg_type == "fixed" else ois_swap.leg(1)
for index, cf in enumerate(leg):
yearfrac = day_count.yearFraction(effective_date,cf.date())
df = sofrCurve.discount(yearfrac)
pv = df * cf.amount()
cumSum_pv += pv
row={'date':datetime.datetime(cf.date().year(), cf.date().month(), cf.date().dayOfMonth()),'yearfrac':yearfrac, 'CF':cf.amount() ,'discountFactor':df,'PV':pv,'totalPV':cumSum_pv}
leg_df.loc[index]=row
return leg_df
Then I proceeded to view the fixed and float legs for the 30y swap:
fixed_leg = leg_information(effective_date, day_count,ois_swaps[-3], 'fixed', sofrCurve)
fixed_leg.tail()
date | yearfrac | CF | discountFactor | PV | totalPV |
---|---|---|---|---|---|
2048-05-11 | 26.38 | 263343.89 | 0.5 | 132298.29 | 4821684 |
2049-05-11 | 27.39 | 264067.36 | 0.49 | 130173.38 | 4951857.39 |
2050-05-11 | 28.41 | 264067.36 | 0.48 | 127789.7 | 5079647.08 |
2051-05-11 | 29.42 | 264067.36 | 0.48 | 125514.12 | 5205161.2 |
2052-05-13 | 30.44 | 266237.78 | 0.47 | 124346.16 | 5329507.36 |
float_leg = leg_information(effective_date, day_count,ois_swaps[-3], 'Float', sofrCurve)
float_leg.tail()
date | yearfrac | CF | discountFactor | PV | totalPV |
---|---|---|---|---|---|
2048-05-11 | 26.38 | 194630.64 | 0.5 | 97778.23 | 4976215.78 |
2049-05-11 | 27.39 | 191157.4 | 0.49 | 94232.04 | 5070447.82 |
2050-05-11 | 28.41 | 186532.08 | 0.48 | 90268.17 | 5160715.99 |
2051-05-11 | 29.42 | 181300.34 | 0.48 | 86174.05 | 5246890.04 |
2052-05-13 | 30.44 | 176892.09 | 0.47 | 82617.32 | 5329507.36 |
Also, the DV01 on the swap lines up with what I see in bloomberg:
ois_swaps[-3].fixedLegBPS()
= $20462.68. So at this point, I feel comfortable with what the swap object because it seems to match what I see on Bloomberg using SWPM
Now, when I change the date:
curve_date =ql.Date (9,5,2023)
ql.Settings.instance().evaluationDate = curve_date
effective_date = swaps_calendar.advance(curve_date, settlement_days, ql.Days)
and pull the new curve:
index | ticker | n | tenor | quote |
---|---|---|---|---|
0 | USOSFR1Z CBBT Curncy | 1 | 1 | 5.06 |
1 | USOSFR2Z CBBT Curncy | 2 | 1 | 5.06 |
2 | USOSFR3Z CBBT Curncy | 3 | 1 | 5.06 |
3 | USOSFRA CBBT Curncy | 1 | 2 | 5.07 |
4 | USOSFRB CBBT Curncy | 2 | 2 | 5.1 |
5 | USOSFRC CBBT Curncy | 3 | 2 | 5.11 |
6 | USOSFRD CBBT Curncy | 4 | 2 | 5.11 |
7 | USOSFRE CBBT Curncy | 5 | 2 | 5.09 |
8 | USOSFRF CBBT Curncy | 6 | 2 | 5.06 |
9 | USOSFRG CBBT Curncy | 7 | 2 | 5.03 |
10 | USOSFRH CBBT Curncy | 8 | 2 | 4.97 |
11 | USOSFRI CBBT Curncy | 9 | 2 | 4.92 |
12 | USOSFRJ CBBT Curncy | 10 | 2 | 4.87 |
13 | USOSFRK CBBT Curncy | 11 | 2 | 4.81 |
14 | USOSFR1 CBBT Curncy | 12 | 2 | 4.74 |
15 | USOSFR1F CBBT Curncy | 18 | 2 | 4.28 |
16 | USOSFR2 CBBT Curncy | 2 | 3 | 3.96 |
17 | USOSFR3 CBBT Curncy | 3 | 3 | 3.58 |
18 | USOSFR4 CBBT Curncy | 4 | 3 | 3.39 |
19 | USOSFR5 CBBT Curncy | 5 | 3 | 3.3 |
20 | USOSFR7 CBBT Curncy | 7 | 3 | 3.24 |
21 | USOSFR8 CBBT Curncy | 8 | 3 | 3.23 |
22 | USOSFR9 CBBT Curncy | 9 | 3 | 3.24 |
23 | USOSFR10 CBBT Curncy | 10 | 3 | 3.24 |
24 | USOSFR12 CBBT Curncy | 12 | 3 | 3.27 |
25 | USOSFR15 CBBT Curncy | 15 | 3 | 3.3 |
26 | USOSFR20 CBBT Curncy | 20 | 3 | 3.28 |
27 | USOSFR25 CBBT Curncy | 25 | 3 | 3.2 |
28 | USOSFR30 CBBT Curncy | 30 | 3 | 3.12 |
29 | USOSFR40 CBBT Curncy | 40 | 3 | 2.93 |
30 | USOSFR50 CBBT Curncy | 50 | 3 | 2.73 |
store the above data in swap_data
and proceed again to recalibrate the zero curve:
swaps= [(row.quote,(row.n, row.tenor)) for row in swap_data.itertuples(index=True, name='Pandas')]
sofrCurve_2023 = zero_curve(settlement_days,swaps,day_count)
valuation_Curve2023 = ql.YieldTermStructureHandle(sofrCurve_2023)
sofrIndex2023 = ql.Sofr(valuation_Curve2023)
swapEngine2023 = ql.DiscountingSwapEngine(valuation_Curve2023)
ois_swaps[-3].setPricingEngine(swapEngine2023)
and try to get the NPV of the swap
ois_swaps[-3].NPV()
It yields a value of $60968.42 .
I know that the NPV after changing the date forward is wrong. I did I simple calculation: the 30y swap rate moved from 2.60 to 3.12 ( I know it's a 29y swap 1 year later, but for illustration purposes the P&L is more less -20k* 52bps = -$1,040,000.
and If I try to view the floating leg by calling:
float_leg = leg_information(effective_date, day_count,ois_swaps[-3], 'Float', sofrCurve)
float_leg.tail()
I get the following:
RuntimeError: Missing SOFRON Actual/360 fixing for May 11th, 2022
Which makes me think that I need to relink to the OvernightIndex to sofrIndex2023
on that 30y swap (I just don't know how to do this, I have looked at the documentation and there's no hints about how to do this)
So what am I doing wrong?