Skip to content
All library documents

SOFR Swap Repricing Depends on Matching Instrument Conventions

Article Quant Q&A · Author: lee lo

Summary

The document investigates why manually constructed SOFR overnight indexed swaps do not always reprice to zero against a curve built from SOFR market quotes. The reported examples show small nonzero NPVs for some maturities when the schedule is created separately, while swaps created with QuantLib’s MakeOIS helper reprice at approximately zero across the quote set.

The explanation is that the manually supplied schedule may use conventions different from those assumed by the curve helpers and the SOFR index. Schedule settings such as date-generation rule, business-day conventions, and end-of-month handling can affect cash flow dates and valuation. The practical lesson is to align instrument conventions with the conventions used to construct the curve, or use a helper that obtains conventions from the index. The examples illustrate a likely source of discrepancies, but do not establish which individual schedule parameter causes each nonzero result.

Key ideas

  • Swap NPVs can differ when the repricing instrument uses conventions that do not match curve construction.
  • A manually created schedule may omit date and business-day conventions supplied by a standard OIS builder.
  • QuantLib’s MakeOIS produces approximately zero NPVs for the quoted swaps in the example.
  • Schedule rules, date adjustments, and end-of-month treatment are candidate sources of cash flow mismatches.

Tags

Full text
# Repricing SOFR Quotes and Non-Zero NPV


# Repricing SOFR Quotes and Non-Zero NPV












I generated/calibrated a SOFR Curve using Quantlib Python and would like to know why when repricing the swaps have non-zero NPVs. Appreciate any assistance. Thank you.

Parameters

```
mktDate = ql.Date(8,3,2021)
ql.Settings.instance().evaluationDate = mktDate
Settlement = 2
Calendar = ql.UnitedStates()
DayCount = ql.Actual360()
```

OIS Curve Helper

```
oisHelper = []
for quote in marketQuotes:
    oisHelper.append(ql.OISRateHelper(Settlement, ql.Period(quote[0]), 
ql.QuoteHandle(ql.SimpleQuote(quote[1]/100)), ql.Sofr()))
```

Curve Specification

```
sofrCurve = ql.PiecewiseLinearZero(Settlement, Calendar, oisHelper, DayCount)
valCurve = ql.YieldTermStructureHandle(sofrCurve)
sofrIndex = ql.Sofr(valCurve)
swapEngine = ql.DiscountingSwapEngine(valCurve)
```

Reprice Quotes

```
for quote in marketQuotes:
    start = Calendar.advance(mktDate, Settlement, ql.Days)
    schedule = ql.MakeSchedule(start, Calendar.advance(start, ql.Period(quote[0])), ql.Period('1Y'), calendar = Calendar)
    fixedRate = quote[1]/100
    oisSwap = ql.OvernightIndexedSwap(
        ql.OvernightIndexedSwap.Receiver, 
        1E6, 
        schedule, 
        fixedRate, 
        DayCount,
        sofrIndex)
    oisSwap.setPricingEngine(swapEngine)
    print(quote, round(oisSwap.NPV(),3))
```

Output

```
('1W', 0.01982) 0.0
('2W', 0.02394) -0.0
('3W', 0.02503) -0.0
('1M', 0.02897) -0.0
('3M', 0.037) 0.0
('4M', 0.041) -0.0
('5M', 0.043) 0.0
('6M', 0.04597) 0.0
('7M', 0.04797) 0.0
('8M', 0.04997) -0.0
('9M', 0.05197) 0.0
('10M', 0.0535) 0.0
('11M', 0.055) 0.0
('1Y', 0.0565) -0.0
('15M', 0.06) -0.0
('18M', 0.069) 0.003
('21M', 0.083) 0.004
('2Y', 0.10403) 0.0
('3Y', 0.27409) 0.049
('4Y', 0.50109) -0.0
('5Y', 0.718) -0.0
('6Y', 0.90703) 0.0
('7Y', 1.066) -0.0
('8Y', 1.19203) 0.83
('9Y', 1.29306) 0.521
('10Y', 1.37903) -0.0
('12Y', 1.51294) -0.0
('15Y', 1.63591) -0.0
('20Y', 1.72494) 0.766
('25Y', 1.75318) 1.322
('30Y', 1.76979) -0.0
('40Y', 1.71094) 0.0
('50Y', 1.63649) -0.0
```

## Answer by David Duarte (score 2)

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

The difference probably comes from not having exactly the same conventions. When you use `ql.MakeOIS` all the conventions will come from the `ql.Sofr` index, but when you build the Instrument manually with `ql.OvernightIndexedSwap` you are entering all the convention by hand, namely for the schedule.

The `ql.MakeSchedule` class has many more parameters which mostly default to None:

- convention=None

- terminalDateConvention=None,

- rule=None

- forwards=False

- backwards=False,

- endOfMonth=None

- firstDate=None

- nextToLastDate=None

## Answer by lee lo (score 0)

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

Appears to work when using ql.MakeOIS. Still need to understand why there is a discrepancy. Any thoughts?

Reprice Quotes

```
for quote in marketQuotes:
    swapTenor = ql.Period(quote[0])
    fixedRate = quote[1]/100
    oisSwap = ql.MakeOIS(swapTenor, sofrIndex, fixedRate, nominal=1E6)
    print(quote, round(oisSwap.NPV(),3))
```

Output

```
('1W', 0.01982) 0.0
('2W', 0.02394) 0.0
('3W', 0.02503) 0.0
('1M', 0.02897) 0.0
('3M', 0.037) -0.0
('4M', 0.041) 0.0
('5M', 0.043) 0.0
('6M', 0.04597) 0.0
('7M', 0.04797) 0.0
('8M', 0.04997) 0.0
('9M', 0.05197) -0.0
('10M', 0.0535) -0.0
('11M', 0.055) -0.0
('1Y', 0.0565) 0.0
('15M', 0.06) 0.0
('18M', 0.069) 0.0
('21M', 0.083) -0.0
('2Y', 0.10403) -0.0
('3Y', 0.27409) -0.0
('4Y', 0.50109) 0.0
('5Y', 0.718) 0.0
('6Y', 0.90703) -0.0
('7Y', 1.066) 0.0
('8Y', 1.19203) -0.0
('9Y', 1.29306) -0.0
('10Y', 1.37903) 0.0
('12Y', 1.51294) 0.0
('15Y', 1.63591) 0.0
('20Y', 1.72494) 0.0
('25Y', 1.75318) 0.0
('30Y', 1.76979) 0.0
('40Y', 1.71094) 0.0
('50Y', 1.63649) 0.0
```

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.