Skip to content
All library documents

Repricing SOFR Swaps Across Valuation Dates with Relinkable Curves

Article Quant Q&A · Author: Lucas Triana

Summary

The document explains how to revalue an existing SOFR overnight index swap using a curve built from market quotes on a later valuation date. Rebuilding a curve and pricing engine alone does not update the index and cash flows already embedded in the swap. A relinkable yield-curve handle lets the existing index and engine follow the new curve when the handle is pointed to it. The answer also identifies the need for historical SOFR fixings for accrual periods that have already begun; without them, floating-leg cash flows cannot be calculated.

An alternative example uses a different Python rates library and obtains a value near the questioner’s rough estimate after supplying an assumed fixing for the elapsed period. That illustrates why fixing history matters to the repricing result. The alternative setup simplifies the market curve and uses an approximate fixing, so its valuation is illustrative rather than a complete replication. Accurate results depend on consistent dates, conventions, curve construction, and actual historical fixings.

Key ideas

  • Existing swaps retain references to the index and pricing objects created with them.
  • A relinkable yield-curve handle allows those objects to use a newly calibrated curve.
  • Floating-leg valuation requires historical overnight index fixings for elapsed accrual periods.
  • A later-date valuation should account for the swap’s remaining term and cash flows already accrued.
  • Simplified curves or estimated fixings make alternative valuation examples illustrative.

Tags

Full text
# Quantlib SOFR swap repricing across 2 different dates


# Quantlib SOFR swap repricing across 2 different dates












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?

## Answer by Attack68 (score 1)

https://quant.stackexchange.com/a/75516

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]>
```

## Answer by Luigi Ballabio (score 1)

https://quant.stackexchange.com/a/76612

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.

Shown in full with attribution under the source's licence. Licence: CC BY-SA 4.0 (Stack Exchange)

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.