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?