Matching QuantLib OIS Curve Calibration and Swap Pricing
Summary
The document investigates why rates produced by a QuantLib bootstrap from USD SOFR overnight indexed swaps differ from the market quotes. The question includes a rate table, instrument conventions, and code that builds the curve with OISRateHelper, then compares those quotes with fair rates from swaps made using MakeVanillaSwap.
The accepted answer identifies an instrument mismatch: MakeVanillaSwap constructs a vanilla swap that pays LIBOR against fixed, while the bootstrap helpers represent OIS instruments. To compare like with like, use SwapRateHelper when the market instruments are vanilla swaps, or construct an OvernightIndexedSwap to check an OIS curve. The displayed discrepancies therefore do not establish that the bootstrap itself is wrong; they can arise because the comparison instrument differs from the calibration instrument. The answer does not examine every convention in the setup, so matching instrument type alone may not resolve any remaining convention or curve configuration differences.
Key ideas
- A calibration curve should be checked using the same instrument type used to build it.
- MakeVanillaSwap constructs a vanilla swap, which does not match the OIS instruments represented by OISRateHelper.
- Use SwapRateHelper for vanilla swap quotes or an OvernightIndexedSwap for OIS checks.
- A mismatch in fair rates can reflect different instrument definitions, and other convention differences may still need review.
Tags
Full text
# BootStrap with quantlib USD SOFR (vs. FIXED RATE) swap curve
# BootStrap with quantlib USD SOFR (vs. FIXED RATE) swap curve
I struggle to understand why my market rates does not match my bootstrap model. So I wonder why the spread is that high between market & model.
```
maturity | market | model
1W | 0.050640 | 0.050626
2W | 0.050670 | 0.050631
3W | 0.050720 | 0.050655
1M | 0.051021 | 0.050916
2M | 0.051391 | 0.051178
3M | 0.051745 | 0.051415
4M | 0.051940 | 0.051493
5M | 0.051980 | 0.051424
6M | 0.051820 | 0.051149
7M | 0.051584 | 0.050821
8M | 0.051310 | 0.050454
9M | 0.050924 | 0.049979
10M | 0.050604 | 0.049582
11M | 0.050121 | 0.049026
12M | 0.049550 | 0.048386
18M | 0.045585 | 0.044806
2Y | 0.042631 | 0.041774
3Y | 0.038952 | 0.038230
4Y | 0.036976 | 0.036321
5Y | 0.035919 | 0.035297
6Y | 0.035350 | 0.034745
7Y | 0.034998 | 0.034403
8Y | 0.034808 | 0.034219
9Y | 0.034738 | 0.034151
10Y | 0.034712 | 0.034125
12Y | 0.034801 | 0.034210
15Y | 0.034923 | 0.034327
20Y | 0.034662 | 0.034075
25Y | 0.033750 | 0.033193
30Y | 0.032826 | 0.032298
40Y | 0.030835 | 0.030369
50Y | 0.028960 | 0.028548
```
My curve only contain swaps.
Fixed leg :
- Discounting OIS
- Settlement T+2 Days
- Term 2 Week
- Day Count ACT/360
- Pay Freq Annual
- Bus Adj ModifiedFollowing
- Adjust Accrl and Pay Dates
- Roll Conv Backward (EOM)
- Calc Cal FD
- Pay Delay 2 Business Days
Float Leg
- Day Count ACT/360
- Pay Freq Annual
- Index SOFRRATE Index
- Reset Freq Daily
- Bus Adj ModifiedFollowing
```
self.swaps = {Period("1W"): 0.05064, Period("2W"): 0.05067, Period("3W"): 0.05072, Period("1M"): 0.051021000000000004, Period("2M"): 0.051391, Period("3M"): 0.051745, Period("4M"): 0.05194, Period("5M"): 0.051980000000000005, Period("6M"): 0.051820000000000005, Period("7M"): 0.051584000000000005, Period("8M"): 0.05131, Period("9M"): 0.050924, Period("10M"): 0.050603999999999996, Period("11M"): 0.050121, Period("12M"): 0.049550000000000004, Period("18M"): 0.04558500000000001, Period("2Y"): 0.042630999999999995, Period("3Y"): 0.038952, Period("4Y"): 0.036976, Period("5Y"): 0.035919, Period("6Y"): 0.03535, Period("7Y"): 0.034998, Period("8Y"): 0.034808, Period("9Y"):
0.034738000000000005, Period("10Y"): 0.034712, Period("12Y"): 0.034801, Period("15Y"): 0.034923, Period("20Y"): 0.034662, Period("25Y"): 0.03375, Period("30Y"): 0.032826, Period("40Y"): 0.030834999999999998, Period("50Y"): 0.02896}
```
Below is how I use `OISRateHelper`
```
rate_helpers = []
for tenor, rate in self.swaps.items():
helper = ql.OISRateHelper(2, tenor, ql.QuoteHandle(ql.SimpleQuote(rate)), self.swap_underlying)
rate_helpers.append(helper)
```
And below is how I compare new rates to given rates :
```
self.curve = ql.PiecewiseSplineCubicDiscount(calculation_date, rate_helpers, self.swap_day_count_conv)
yts = ql.YieldTermStructureHandle(self.curve)
# Link index to discount curve
index = index.clone(yts)
# Create engine with yield term structure
engine = ql.DiscountingSwapEngine(yts)
# Check the swaps reprice
print("maturity | market | model")
for tenor, rate in self.swaps.items():
swap = ql.MakeVanillaSwap(tenor,
index, 0.01,
ql.Period('0D'),
fixedLegTenor=ql.Period('2D'),
fixedLegDayCount=self.swap_day_count_conv,
fixedLegCalendar=ql.UnitedStates(ql.UnitedStates.GovernmentBond),
floatingLegCalendar=ql.UnitedStates(ql.UnitedStates.GovernmentBond),
pricingEngine=engine)
print(f" {tenor} | {rate:.6f} | {swap.fairRate():.6f}")
```
Also note that :
```
self.swap_underlying = ql.OvernightIndex("USD Overnight Index", 2, ql.USDCurrency(), ql.UnitedStates(ql.UnitedStates.Settlement), ql.Actual360())
self.swap_day_count_conv = ql.Actual360()
```
Did I miss something? Is the implementation I made correct? Are there any discrepancies in the parameters?
Note Curve description :
Swap description :
## Answer by Luigi Ballabio (score 3, accepted)
https://quant.stackexchange.com/a/75657
By using `ql.MakeVanillaSwap`, you're creating a swap that pays LIBOR vs fixed, not an OIS like the ones you used to bootstrap the curve. If you actually want to use vanilla swaps, you need to use `SwapRateHelper`, not `OISRateHelper`. If you do want to use OIS instead, you'll have to use `OvernightIndexedSwap` to build the swap and retrieve the fair rate.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.