Skip to content
All library documents

Bootstrapping a SORA OIS Curve with Semiannual Payments

Article Quant Q&A · Author: Winsor-Mavis

Summary

The document investigates why a QuantLib bootstrap for Singapore’s SORA overnight curve matches quoted rates at short maturities but diverges from the input quotes at longer maturities. The proposed diagnosis is a mismatch in floating payment frequency: the default helper schedule does not match Bloomberg’s assumed schedule beyond six months.

The suggested adjustment uses one payment for shorter tenors and semiannual payments for longer ones when creating OIS rate helpers. The revised curve still does not reproduce every input quote in the displayed comparison, but it brings longer-tenor model rates closer to the supplied market rates. This is a practical calibration clue rather than a definitive reconstruction of Bloomberg’s conventions: the source attributes residual differences to floating-period arithmetic, without documenting Bloomberg settings or validating the result against an independent curve.

Key ideas

  • A bootstrapped curve can diverge from market quotes when instrument conventions differ.
  • The proposed SORA OIS setup uses semiannual payment frequency for tenors beyond six months.
  • Changing the payment schedule improves the displayed longer-tenor fit but leaves residual differences.
  • Curve reconstruction should verify payment frequency and other market conventions against authoritative instrument specifications.

Tags

Full text
# QuantLib Bootstrapped Curve Tallies With Bloomberg Only Up to 1Y Tenor


# QuantLib Bootstrapped Curve Tallies With Bloomberg Only Up to 1Y Tenor












I'm trying to reconstruct Bloomberg's SORA curve and have noticed that the bootstrapped curve for some reason is only accurate up to the 1Y tenor (out of 30Y), and have no idea what could be causing it. Would appreciate any guidance.

This is the full code, the raw input is from Bloomberg:

```
import QuantLib as ql

evaluationDt = ql.Date(12, 5, 2025)
ql.Settings.instance().evaluationDate = evaluationDt

SORA_TENOR = ['1M', '2M', '3M', '6M', '9M', '12M', '18M', '2Y', '3Y', '4Y', '5Y', '7Y', '10Y', '12Y', '15Y', '20Y', '30Y']
SORA_SPOT = [x/100 for x in [2.115, 2.115, 2.095, 1.9975, 1.9175, 1.845, 1.775, 1.7225, 1.7455, 1.8169, 1.8825, 2.03, 2.2125, 2.2745, 2.314, 2.2875, 2.302]]

# Construct SORA curve
SORA_NAME = 'SGD Overnight Index'
SORA_CURRENCY = ql.SGDCurrency()
SORA_CALENDAR = ql.Singapore()
SORA_DAYCONV = ql.Actual365Fixed()
SORA_SETTLE_DAYS = 2

soraYts = ql.RelinkableYieldTermStructureHandle()
soraIndex = ql.OvernightIndex(
    SORA_NAME,
    SORA_SETTLE_DAYS,
    SORA_CURRENCY,
    SORA_CALENDAR,
    SORA_DAYCONV,
    soraYts
)

soraRateHelpers = []
for tenor, rate in zip(SORA_TENOR, SORA_SPOT):
    helper = ql.OISRateHelper(
        SORA_SETTLE_DAYS,
        ql.Period(tenor),
        ql.QuoteHandle(ql.SimpleQuote(rate)),
        soraIndex
    )
    soraRateHelpers.append(helper)

sora = ql.PiecewiseFlatForward(evaluationDt, soraRateHelpers, SORA_DAYCONV)
soraYts.linkTo(sora)

print(f"\nSORA Curve\n{'Maturity':>12} {'Market':>10} {'Model':>10} {'Zero Rate':>10} {'Discount Factor':>15} {'PV':>10}")
for tenor, rate in zip(SORA_TENOR, SORA_SPOT):
    oisSwap = ql.MakeOIS(ql.Period(tenor), soraIndex, rate)
    pv = oisSwap.NPV()
    fairRate = oisSwap.fairRate()
    mat = oisSwap.maturityDate()
    discFactor = sora.discount(mat)
    zeroRate = sora.zeroRate(mat, ql.Actual365Fixed(), ql.Continuous).rate()
    print(f'{tenor:>12} {rate:>10.6%} {fairRate:>10.6%} {zeroRate:>10.6%} {discFactor:>15.8f} {pv:>10,.2f}')
```

This is the output:

```
SORA Curve
    Maturity     Market      Model  Zero Rate Discount Factor         PV
          1M  2.115000%  2.115000%  2.112980%      0.99797591       0.00
          2M  2.115000%  2.115000%  2.111325%      0.99636243       0.00
          3M  2.095000%  2.095000%  2.089988%      0.99463202       0.00
          6M  1.997500%  1.997500%  1.988859%      0.98991618       0.00
          9M  1.917500%  1.917500%  1.905128%      0.98549159      -0.00
         12M  1.845000%  1.845000%  1.829738%      0.98177056       0.00
         18M  1.775000%  1.775000%  1.762551%      0.97364953      -0.00
          2Y  1.722500%  1.722500%  1.707907%      0.96632823      -0.00
          3Y  1.745500%  1.745500%  1.730808%      0.94922073      -0.00
          4Y  1.816900%  1.816900%  1.802711%      0.93029216      -0.00
          5Y  1.882500%  1.882500%  1.869151%      0.91063677      -0.00
          7Y  2.030000%  2.030000%  2.020344%      0.86792888      -0.00
         10Y  2.212500%  2.212500%  2.211203%      0.80142600      -0.00
         12Y  2.274500%  2.274500%  2.276118%      0.76075346      -0.00
         15Y  2.314000%  2.314000%  2.315707%      0.70628483       0.00
         20Y  2.287500%  2.287500%  2.277401%      0.63382698      -0.00
         30Y  2.302000%  2.302000%  2.290405%      0.50273790      -0.00
```

This is the model answer:

As we can see, the zero rates matches down to 5 decimal places up to the 12M tenor, but it diverges pretty significantly after that. At the 30Y mark, the zero rate is off by a whole basis point, which is triggering further downstream inaccuracies to a significant degree.

## Answer by Winsor-Mavis (score 3)

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

It looks like the SORA curve assumes a semiannual payment frequency for any periods beyond 6M. So changed the relevant OISRateHelper() segments to the following:

```
for tenor, rate in zip(SORA_TENOR, SORA_SPOT):
    if ql.Period(tenor) <= ql.Period('6M'):
        paymentFrequency = ql.Once
    else:
        paymentFrequency = ql.Semiannual
    helper = ql.OISRateHelper(
        SORA_SETTLE_DAYS,
        ql.Period(tenor),
        ql.QuoteHandle(ql.SimpleQuote(rate)),
        soraIndex,
        soraYts,
        False,
        0,
        ql.Following,
        paymentFrequency
    )
    soraRateHelpers.append(helper)
```

Gives the following:

```
SORA Curve
    Maturity     Market      Model  Zero Rate Discount Factor         PV
          1M  2.115000%  2.115000%  2.112980%      0.99797591       0.00
          2M  2.115000%  2.115000%  2.111325%      0.99636243       0.00
          3M  2.095000%  2.095000%  2.089988%      0.99463202       0.00
          6M  1.997500%  1.997500%  1.988859%      0.98991618       0.00
          9M  1.917500%  1.923414%  1.910915%      0.98544785       0.00
         12M  1.845000%  1.852788%  1.837344%      0.98169549       0.00
         18M  1.775000%  1.779834%  1.767309%      0.97357935       0.00
          2Y  1.722500%  1.729492%  1.714751%      0.96619561       0.00
          3Y  1.745500%  1.752841%  1.738010%      0.94901491       0.00
          4Y  1.816900%  1.824929%  1.810612%      0.92999756       0.00
          5Y  1.882500%  1.891172%  1.877711%      0.91024644       0.00
          7Y  2.030000%  2.040158%  2.030454%      0.86731390       0.00
         10Y  2.212500%  2.224623%  2.223430%      0.80044563       0.00
         12Y  2.274500%  2.287335%  2.289119%      0.75956619       0.00
         15Y  2.314000%  2.327305%  2.329192%      0.70485609       0.00
         20Y  2.287500%  2.300520%  2.290395%      0.63218002       0.00
         30Y  2.302000%  2.315203%  2.303567%      0.50075507       0.00
```

Which is close enough to Bloomberg's for me to consider it floating period arithmetic errors.

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.