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$ 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$ 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$ Sep 11, 2023 at 15:41

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.