Skip to content
All library documents

Matching QuantLib OIS Curves to Bloomberg Zero Rates

Article Quant Q&A · Author: TourEiffel

Summary

The document investigates why zero rates bootstrapped from USD overnight indexed swap quotes in QuantLib differ from Bloomberg values. It presents a curve-building example and identifies several convention mismatches that can explain the discrepancy: the OIS settlement lag, the actual instrument maturity date after business-day adjustments, and the zero-rate day-count and compounding conventions.

The accepted explanation recommends a two-business-day settlement lag for the cited USD SOFR swaps, using each instrument’s generated maturity date, and deriving the displayed zero rate from the discount factor with continuous compounding and an Actual/365 basis. The revised example reproduces the displayed Bloomberg values for its first four maturities while retaining matching market and model swap rates. The result illustrates that curve comparisons require aligned conventions and dates; the example is tied to its stated market setup and does not establish that the same settings apply to every currency, index, or Bloomberg curve configuration.

Key ideas

  • OIS settlement lag affects the dates and discount factors used when bootstrapping a curve.
  • Use each swap’s convention-adjusted maturity date when reading a zero rate from the curve.
  • Zero rates can differ because of compounding and day-count conventions even when instrument pricing matches.
  • The example aligns QuantLib settings with the cited Bloomberg USD SOFR setup and reproduces its first grid points.

Tags

Full text
# Discrepancy between Bootstraped Zero Rates: Gaps between Bloomberg and My Calculated Zero Rates


# Discrepancy between Bootstraped Zero Rates: Gaps between Bloomberg and My Calculated Zero Rates












I have been attempting to bootstrap zero rates using `quantlib`, but I am perplexed by the significant discrepancies between my calculated zero rates and those obtained from Bloomberg's bootstrapping process. I would greatly appreciate any insights or suggestions regarding potential reasons for this mismatch. Below is the reproducible example :

```
import QuantLib as ql

calculation_date = ql.Date().todaysDate() #When I posted this on Quant exchange the date was 26/5/2023

ql.Settings.instance().evaluationDate = calculation_date

index = ql.OvernightIndex("USD Overnight Index", 0, ql.USDCurrency(), ql.UnitedStates(ql.UnitedStates.Settlement), ql.Actual360())

swaps = {
    ql.Period("1W"): 0.05064,
    ql.Period("2W"): 0.05067,
    ql.Period("3W"): 0.05072,
    ql.Period("1M"): 0.051021000000000004,
    ql.Period("2M"): 0.051391,
    ql.Period("3M"): 0.051745,
    ql.Period("4M"): 0.05194,
    ql.Period("5M"): 0.051980000000000005,
    ql.Period("6M"): 0.051820000000000005,
    ql.Period("7M"): 0.051584000000000005,
    ql.Period("8M"): 0.05131,
    ql.Period("9M"): 0.050924,
    ql.Period("10M"): 0.050603999999999996,
    ql.Period("11M"): 0.050121,
    ql.Period("12M"): 0.049550000000000004,
    ql.Period("18M"): 0.04558500000000001,
    ql.Period("2Y"): 0.042630999999999995,
    ql.Period("3Y"): 0.038952,
    ql.Period("4Y"): 0.036976,
    ql.Period("5Y"): 0.035919,
    ql.Period("6Y"): 0.03535,
    ql.Period("7Y"): 0.034998,
    ql.Period("8Y"): 0.034808,
    ql.Period("9Y"): 0.034738000000000005,
    ql.Period("10Y"): 0.034712,
    ql.Period("12Y"): 0.034801,
    ql.Period("15Y"): 0.034923,
    ql.Period("20Y"): 0.034662,
    ql.Period("25Y"): 0.03375,
    ql.Period("30Y"): 0.032826,
    ql.Period("40Y"): 0.030834999999999998,
    ql.Period("50Y"): 0.02896
}

rate_helpers = []

for tenor, rate in swaps.items():
    helper = ql.OISRateHelper(0, tenor, ql.QuoteHandle(ql.SimpleQuote(rate)), index)
    rate_helpers.append(helper)

yts = ql.RelinkableYieldTermStructureHandle()
curve = ql.PiecewiseFlatForward(calculation_date, rate_helpers, ql.Actual360())
yts.linkTo(curve)

index = index.clone(yts)

engine = ql.DiscountingSwapEngine(yts)

print("maturity |  market  |   model   |   zero rate  |  discount factor")
for tenor, rate in swaps.items():
    schedule = ql.Schedule(calculation_date, 
                        calculation_date + tenor, 
                        ql.Period('1Y'), 
                        ql.UnitedStates(ql.UnitedStates.GovernmentBond), 
                        ql.ModifiedFollowing, 
                        ql.ModifiedFollowing, 
                        ql.DateGeneration.Forward, 
                        False)
    swap = ql.OvernightIndexedSwap(ql.OvernightIndexedSwap.Payer, 
                                1.0, 
                                schedule, 
                                0.01, 
                                ql.Actual360(), 
                                index)
    
    swap.setPricingEngine(engine)
    maturity_date = calculation_date + tenor
    zero_rate = curve.zeroRate(maturity_date, ql.Actual360() , ql.Compounded).rate()
    discount_factor = curve.discount(maturity_date)

    print(f"   {tenor}    | {rate*100:.6f} | {swap.fairRate()*100:.6f} | {zero_rate*100:.6f} | {discount_factor:.6f}")
```

The output of this code is :

```
maturity |  market  |   model  |   zero rate  |  discount factor
   1W    | 5.064000 | 5.064000 | 5.191792     | 0.999016
   2W    | 5.067000 | 5.067000 | 5.192324     | 0.998033
   3W    | 5.072000 | 5.072000 | 5.194951     | 0.997050
   1M    | 5.102100 | 5.102100 | 5.222740     | 0.995626
```

However, when referring to the output displayed on Bloomberg, I find it important to mention that the information presented, which I believe to be accurate, is as follows:

I am inclined to believe that the issue at hand could potentially be attributed to parameters.

I would greatly appreciate any suggestions, insights that could help me understand and resolve the disparities between my calculated zero rates and the accurate rates shown on Bloomberg. Thank you for your valuable assistance!

Below is the information I have from bloomberg regarding conventions

## Answer by user808182 (score 4, accepted)

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

There are several issues with your Python code:

- USD SOFR swaps have a settlement lag of two business days, see the T+2 under "Settlement" in your last screen shot from Bloomberg. So the first argument in your OISRateHelper must be 2, not 0.

- you should use the maturity_date of your OIS swap instruments when you print out your results, and not "calculation_date + tenor" since this does not take into account the settlement lag and holidays.

- the zero rate which Bloomberg shows, is derived from the discount factor with convention "continuously compounded", Actual365

With these and other modifications my code looks like this:

```
import QuantLib as ql
import math

calculation_date = ql.Date(26,5,2023)

ql.Settings.instance().evaluationDate = calculation_date

yts = ql.RelinkableYieldTermStructureHandle()
index = ql.OvernightIndex("USD Overnight Index", 0, ql.USDCurrency(), ql.UnitedStates(ql.UnitedStates.Settlement), ql.Actual360(), yts)

swaps = {
    ql.Period("1W"): 0.05064,
    ql.Period("2W"): 0.05067,
    ql.Period("3W"): 0.05072,
    ql.Period("1M"): 0.051021000000000004,
    ql.Period("2M"): 0.051391,
    ql.Period("3M"): 0.051745,
    ql.Period("4M"): 0.05194,
    ql.Period("5M"): 0.051980000000000005,
    ql.Period("6M"): 0.051820000000000005,
    ql.Period("7M"): 0.051584000000000005,
    ql.Period("8M"): 0.05131,
    ql.Period("9M"): 0.050924,
    ql.Period("10M"): 0.050603999999999996,
    ql.Period("11M"): 0.050121,
    ql.Period("12M"): 0.049550000000000004,
    ql.Period("18M"): 0.04558500000000001,
    ql.Period("2Y"): 0.042630999999999995,
    ql.Period("3Y"): 0.038952,
    ql.Period("4Y"): 0.036976,
    ql.Period("5Y"): 0.035919,
    ql.Period("6Y"): 0.03535,
    ql.Period("7Y"): 0.034998,
    ql.Period("8Y"): 0.034808,
    ql.Period("9Y"): 0.034738000000000005,
    ql.Period("10Y"): 0.034712,
    ql.Period("12Y"): 0.034801,
    ql.Period("15Y"): 0.034923,
    ql.Period("20Y"): 0.034662,
    ql.Period("25Y"): 0.03375,
    ql.Period("30Y"): 0.032826,
    ql.Period("40Y"): 0.030834999999999998,
    ql.Period("50Y"): 0.02896
}

rate_helpers = []

for tenor, rate in swaps.items():
    helper = ql.OISRateHelper(2, tenor, ql.QuoteHandle(ql.SimpleQuote(rate)), index)
    rate_helpers.append(helper)

curve = ql.PiecewiseFlatForward(calculation_date, rate_helpers, ql.Actual360())
yts.linkTo(curve)
engine = ql.DiscountingSwapEngine(yts)

print("maturity |  market  |  model  |  zero rate  |  discount factor |  present value")
for tenor, rate in swaps.items():
    ois_swap = ql.MakeOIS(tenor, index, rate)
    pv = ois_swap.NPV()
    fair_rate = ois_swap.fairRate()
    maturity_date = ois_swap.maturityDate()
    discount_factor = curve.discount(maturity_date)
    zero_rate = -math.log(discount_factor) * 365.0/(maturity_date-calculation_date)
    print(f"   {tenor}    | {rate*100:.6f} | {fair_rate*100:.6f} | {zero_rate*100:.6f} | {discount_factor:.6f} | {pv:.6f}")
```

And my result for the first four grid points is, as in Bloomberg

```
maturity |  market  |  model  |  zero rate  |  discount factor |  present value
   1W    | 5.064000 | 5.064000 | 5.131807 | 0.998314 | -0.000000
   2W    | 5.067000 | 5.067000 | 5.132185 | 0.997332 | 0.000000
   3W    | 5.072000 | 5.072000 | 5.134266 | 0.996349 | 0.000000
   1M    | 5.102100 | 5.102100 | 5.157684 | 0.995066 | -0.000000
```

You might also take a look here

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.