2
$\begingroup$

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?

$\endgroup$

2 Answers 2

1
$\begingroup$

I realise the question is specifically about Quantlib, but I wanted to highlight an answer using Rateslib for Python, the answer is around 1.05mm USD as you predicted.

Setup your initial curve (note I have ignored most swaps to just approximate the 30y test swap)

from rateslib import Curve, IRS, dt, Solver curve = Curve( nodes={ dt(2022, 5, 9): 1.0, dt(2047, 5, 9): 1.0, dt(2052, 5, 9): 1.0, }, id="sofr" ) sofr_kws = dict( payment_lag=2, frequency="A", convention="act360", effective=dt(2022, 5, 11), calendar="nyc", curves="sofr", ) instruments = [ IRS(termination="25y", **sofr_kws), IRS(termination="30y", **sofr_kws), ] solver = Solver( curves=[curve], instruments=instruments, s=[2.71, 2.60], instrument_labels=["25Y", "30Y"], id="SOFR" ) 

Then we created your tess IRS and check its NPV.

>>> test_irs = IRS(termination="30Y", **sofr_kws, fixed_rate=2.60, notional=10e6) >>> test_irs.npv(solver=solver) <Dual: 0.000932, ('sofr0', 'sofr1', 'sofr2'), [ 7464130.57816169 -4800825.36772211 -10833705.54868424]> 

Then we build a second curve with new dates and rates.

curve2 = Curve( nodes={ dt(2023, 5, 9): 1.0, dt(2048, 5, 9): 1.0, dt(2053, 5, 9): 1.0, }, id="sofr" ) sofr_kws2 = dict( payment_lag=2, frequency="A", convention="act360", effective=dt(2023, 5, 11), calendar="nyc", curves="sofr", ) instruments = [ IRS(termination="25y", **sofr_kws2), IRS(termination="30y", **sofr_kws2), ] solver2 = Solver( curves=[curve2], instruments=instruments, s=[3.2, 3.12], instrument_labels=["25Y", "30Y"], id="SOFR" ) 

We need to add the fixings for the test IRS since it has a payment settlement coming up. I took a look at historical fixings and the average over last year has been about 3%.

test_irs = IRS(termination="30Y", **sofr_kws, fixed_rate=2.60, notional=10e6, leg2_fixings=3.0) test_irs.npv(solver=solver2) <Dual: 1,049,569.356238, ('sofr0', 'sofr1', 'sofr2'), [ 7596484.75971786 -6798185.22482419 -8777872.59764332]> 
$\endgroup$
1
$\begingroup$

You need to setup your objects so that you can change the curve they're using. In QuantLib, that's done via relinkable handles. Before you create the swaps, when you do:

sofrCurve = zero_curve(settlement_days,swaps,day_count) valuation_Curve = ql.YieldTermStructureHandle(sofrCurve) sofrIndex = ql.Sofr(valuation_Curve) swapEngine = ql.DiscountingSwapEngine(valuation_Curve) 

replace the second line with:

valuation_Curve = ql.RelinkableYieldTermStructureHandle(sofrCurve) 

which gives you the possibility to change the curve inside the handle.

Later, when you change the evaluation date, instead of:

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) 

you'll do:

sofrCurve_2023 = zero_curve(settlement_days,swaps,day_count) valuation_Curve.linkTo(sofrCurve_2023) 

Instead of rebuilding the Sofr index and the engine, the above lets the old objects use the new curve.

$\endgroup$
3
  • $\begingroup$ Would it have been a better design to keep just the instrument in one object - containing e.g. the name of the index used to reset the floater coupons, not needing to be relinked or otherwise modified - and the market data for the given date, including historical index values and projection curves, in a separate pricing environment object? $\endgroup$ Commented Sep 8, 2023 at 13:53
  • 1
    $\begingroup$ "better" depends on the use cases, but yes, it's certainly viable. You would probably still relink projection curves in the pricing environment. $\endgroup$ Commented Sep 11, 2023 at 15:08
  • $\begingroup$ Sensitivities calculation would be one example of a common use case: given many "bumps" (risk scenarios, e.g. bump some tenor of the projection curve), for each scenario, create a bumped pricing environment derived from the base pricing environment, reprice the same instrument object in a scenario-specific thread. There's no need to copy the instrument. $\endgroup$ Commented Sep 11, 2023 at 15:41

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.