Skip to content
All library documents

Matching QuantLib Swaption Prices to Bloomberg with Normal Volatility and SOFR OIS

Article Quant Q&A · Author: Echo Liu

Summary

The discussion diagnoses why a QuantLib swaption price differs sharply from a Bloomberg quote. Its first correction is to use a Bachelier engine for normal volatility rather than a Black engine for lognormal volatility. A further comparison finds that the example builds a USD Libor vanilla swap, while the Bloomberg instrument is a SOFR overnight indexed swaption; the underlying and curve construction therefore also need to match.

The replies describe bootstrapping a SOFR curve with overnight indexed swap helpers, creating a SOFR OIS underlying, and pricing it with the normal model. They report that closer curve and forward-rate matching brings the price near Bloomberg’s figure, while residual differences may reflect curve details and quote rounding. The code example’s reported price still differs from the Bloomberg quote, so it illustrates the direction of the fixes rather than an exact replication. The discussion also cautions that Bloomberg DV01 and QuantLib’s swaption delta are not directly comparable.

Key ideas

  • Normal volatility calls for Bachelier swaption pricing, while Black pricing assumes lognormal volatility.
  • The underlying swap and curve instruments must reflect the SOFR OIS market convention used by the reference quote.
  • Differences in curve construction and forward rates can remain after correcting the model and underlying.
  • A platform’s DV01 and QuantLib’s swaption delta may represent different sensitivities.

Tags

Full text
# QuantLib Swaption Pricing


# QuantLib Swaption Pricing












I'm trying to replicate Bloomberg's swaption pricer(SWPM -OV) in QauntLib. I'm using the same curve(USD SOFR) for both forward and discount curves. However, the code I wrote in python gave me unreasonable npv - in this example, npv= 8.97 and delta= 235944.37 for a 1MM notional 1Mx10Y swaption, while I was expecting NPV=10359 and delta=-414 as shown Bloomberg (see picture attached).

I have searched relevant posts but couldn't solve the problem. Something must be totally off given the scale of the outputs is completely wrong.

Below is my code, which should be able to run given the right file path. Sorry for the code complexity, you can ignore the helper functions. I'm attaching the complete code in case anyone needs to run it himself.

```
import pandas as pd
import os
import datetime
import QuantLib as ql
import numpy as np

def get_swap(forward_term_structure, fixed_coupon_rate, notional, start_data, maturity_date, fixed_payment_frequency, 
                float_payment_frequency, pay_rec=ql.Swap.Payer):
    calendar = ql.UnitedStates(ql.UnitedStates.FederalReserve)

    if not isinstance(start_data, ql.Date):
        start_data = datetime_to_ql_date(start_data)
    if not isinstance(maturity_date, ql.Date):
        maturity_date = datetime_to_ql_date(maturity_date)

    index = ql.USDLibor(ql.Period('1Y'), forward_term_structure)

    fixed_leg_tenor = ql.Period(*parse_frequency(fixed_payment_frequency)) # payment frequency
    fixed_leg_adjustment = ql.ModifiedFollowing
    fixed_leg_daycount = ql.Actual360() # convention

    floating_leg_tenor = ql.Period(*parse_frequency(float_payment_frequency))
    floating_leg_adjustment = ql.ModifiedFollowing
    floating_leg_daycount = ql.Actual360()

    fixed_schedule = ql.Schedule(
        start_data,
        maturity_date,
        fixed_leg_tenor,
        calendar, 
        fixed_leg_adjustment,
        fixed_leg_adjustment,
        ql.DateGeneration.Forward,
        False
    )

    floating_schedule = ql.Schedule(
        start_data,
        maturity_date,
        floating_leg_tenor,
        calendar,
        floating_leg_adjustment,
        floating_leg_adjustment,
        ql.DateGeneration.Forward,
        False
    )

    swap = ql.VanillaSwap(
        pay_rec,
        notional,
        fixed_schedule,
        fixed_coupon_rate,
        fixed_leg_daycount,
        floating_schedule,
        index,
        0.0,
        floating_leg_daycount
    )

    return swap

def swaption_pricer(swap_rates, volatility, evaluation_date, exercise_date, settlement_date, maturity_date, notional, fixed_coupon_rate,   
                    fixed_payment_frequency, float_payment_frequency, pay_rec=ql.Swap.Payer, mode='forward'):

    calendar = ql.UnitedStates(ql.UnitedStates.FederalReserve)
    
    if not isinstance(evaluation_date, ql.Date):
        evaluation_date = datetime_to_ql_date(evaluation_date)
    if not isinstance(exercise_date, ql.Date):
        exercise_date = datetime_to_ql_date(exercise_date)
    if not isinstance(settlement_date, ql.Date):
        settlement_date = datetime_to_ql_date(settlement_date)
    if not isinstance(maturity_date, ql.Date):
        maturity_date = datetime_to_ql_date(maturity_date)

    ql.Settings.instance().evaluationDate = evaluation_date

    dates, rates = zip(*swap_rates)
    dates, rates = list(dates), list(rates)

    curve_handle = None

    quotes = []
    swap_helpers = []
    for tenor, rate in swap_rates:
        quote = ql.SimpleQuote(rate)
        quotes.append(quote)
        swap_helpers.append(ql.SwapRateHelper(ql.QuoteHandle(quote),
                                            tenor_to_ql_period(tenor),
                                            calendar,
                                            ql.Annual,
                                            ql.ModifiedFollowing,
                                            ql.Actual360(),
                                            ql.USDLibor(ql.Period('1Y')))) #check the use of libor curve here
        
    forward_curve = ql.PiecewiseLinearForward(evaluation_date, swap_helpers, ql.Actual360())
    forward_curve.enableExtrapolation()
    curve_handle = ql.YieldTermStructureHandle(forward_curve)

    black_engine = ql.BlackSwaptionEngine(curve_handle, ql.QuoteHandle(ql.SimpleQuote(volatility)), ql.ActualActual(ql.ActualActual.ISMA))

    swap = get_swap(curve_handle, fixed_coupon_rate, notional, settlement_date, maturity_date, fixed_payment_frequency, float_payment_frequency, pay_rec)

    swaption = ql.Swaption(swap, ql.EuropeanExercise(exercise_date), ql.Settlement.Cash, ql.Settlement.ParYieldCurve)
    swaption.setPricingEngine(black_engine)

    try:
        npv = swaption.NPV()
    except:
        npv = np.nan
    try:
        delta = swaption.delta()
    except:
        delta = np.nan
    try:
        gamma = swaption.gamma()
    except:
        gamma = np.nan
    # vega and theta are not calculated
    vega = np.nan
    theta = np.nan

    return npv, delta, gamma, vega, theta

def datetime_to_ql_date(dt):
    """
    Converts a datetime object to a QuantLib Date object.
    
    Parameters:
    dt (datetime.datetime): The datetime object to convert.
    
    Returns:
    ql.Date: The corresponding QuantLib Date object.
    """
    day = dt.day
    month = dt.month
    year = dt.year
    
    # Map the month number to the corresponding QuantLib month enumeration
    ql_month = {
        1: ql.January, 2: ql.February, 3: ql.March, 4: ql.April,
        5: ql.May, 6: ql.June, 7: ql.July, 8: ql.August,
        9: ql.September, 10: ql.October, 11: ql.November, 12: ql.December
    }[month]
    
    return ql.Date(day, ql_month, year)

def tenor_to_ql_period(tenor):

    unit = tenor[-1]
    value = int(tenor[:-1])
    
    if unit == 'd':
        return ql.Period(value, ql.Days)
    elif unit == 'w':
        return ql.Period(value, ql.Weeks)
    elif unit == 'm':
        return ql.Period(value, ql.Months)
    elif unit == 'y':
        return ql.Period(value, ql.Years)
    else:
        raise ValueError("Invalid tenor unit. Must be one of 'd', 'w', 'm', 'y'.")

def parse_frequency(frequency):
    frequency = frequency.lower()

    # Extract the numeric part and the unit
    number = int(''.join(filter(str.isdigit, frequency)))
    unit = ''.join(filter(str.isalpha, frequency))

    # Map the unit to QuantLib period type
    if unit == 'y':
        return number, ql.Years
    elif unit == 'm':
        return number, ql.Months
    elif unit == 'd':
        return number, ql.Days
    elif unit == 'w':
        return number, ql.Weeks
    else:
        raise ValueError("Invalid period string format")
    

# Run from here
SRC_PATH = os.path.dirname(os.path.abspath(__file__)).replace('\\', '/')
PROJ_PATH = os.path.dirname(SRC_PATH).replace('\\', '/')
file_path = PROJ_PATH + '/test/rates.csv'

df = pd.read_csv(file_path)

def convert_tenor(tenor):
    return (tenor.replace(' MO', 'm')
                 .replace(' WK', 'w')
                 .replace(' YR', 'y'))

df['tenor'] = df['tenor'].apply(convert_tenor)

rates = list(zip(df['tenor'], df['rate'] / 100.0))
volatility = 0.010267
notional = 1e6
strike_price = 0.033674
evaluation_date = datetime.date(2024, 8, 14)
exercise_date = datetime.date(2024, 9, 16)
settle_date = datetime.date(2024, 9, 18)
maturity_date = datetime.date(2034, 9, 18)

npv, delta, gamma, vega, theta = swaption_pricer(rates, volatility, evaluation_date, exercise_date, settle_date, maturity_date,
                                                notional, strike_price, '1Y', '1Y', ql.Swap.Payer, mode='forward')

print(f'npv: {npv}, delta: {delta}, gamma: {gamma}, vega: {vega}, theta: {theta}')
```

Output:

```
npv: 8.971815427502813, delta: 235944.37335515753, gamma: nan, vega: nan, theta: nan
```

These are the rates I'm using which is copied from Bloomberg:

```
tenor,rate
1 WK,5.3341
2 WK,5.33585
3 WK,5.33814
1 MO,5.34261
2 MO,5.20565
3 MO,5.11385
4 MO,5.02265
5 MO,4.903
6 MO,4.7885
7 MO,4.7085
8 MO,4.60968
9 MO,4.52458
10 MO,4.44082
11 MO,4.3638
12 MO,4.2871
18 MO,3.9293
2 YR,3.7348
3 YR,3.5127
4 YR,3.40905
5 YR,3.36448
6 YR,3.349
7 YR,3.3454
8 YR,3.351
9 YR,3.36048
10 YR,3.37219
12 YR,3.40177
15 YR,3.43655
20 YR,3.4382
25 YR,3.3726
30 YR,3.2943
40 YR,3.1005
50 YR,2.90915
```

## Answer by Echo Liu (score 3)

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

I'm answering this myself since the comments have pointed out the problem: the issue was because I was using the wrong model. Bloomberg convention is Normal model, while Black model assumes log normal. Changing BlackSwaptionEngine to BachelierSwaptionEngine gives the right number:

```
#black_engine = ql.BlackSwaptionEngine(curve_handle, ql.QuoteHandle(ql.SimpleQuote(volatility)), ql.ActualActual(ql.ActualActual.ISMA))
bachelier_engine = ql.BachelierSwaptionEngine(curve_handle, ql.QuoteHandle(ql.SimpleQuote(volatility)), ql.ActualActual(ql.ActualActual.ISMA))
```

## Answer by pandashark (score 1)

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

The self-answer is correct that the first issue is the volatility model: Bloomberg is clearly using normal vol, so in QuantLib this should be `BachelierSwaptionEngine`, not `BlackSwaptionEngine`.

But there is a second mismatch in the posted code: the underlying is build as a USDLibor(1Y) VanillaSwap, while the Bloomberg screenshots show a SOFR OIS swaption (1D SOFRRATE, USD SOFR forward/discount curves). So this is not only a Black-vs-Bachelier issue; it is also a Libor-vs-SOFR-OIS issue.

I checked this in QuantLib against the Bloomber screenshots:

- Black + Libor vanilla gives a tiny price

- Bachelier + Libor vanilla moves much closer

- Bachelier + SOFR OIS moves closer again

Rebuilding the curve from the Bloomber discount-factor screenshot and matching the Bloomberg ATM forward (3.367463%) gets QuantLib to about 10.37k versus Bloomberg 10.359k on 1MM notional, so at that point the remaining gap is small and is likely due to screenshot rounding / Bloomberg internal curve details.

So the practical fix is:

- use `BachelierSwaptionEngine`

- use a SOFR OIS underlying instead of a USDLibor(1Y) vanilla swap

- if you want a tight Bloomberg match, make sure the SOFR curve construction matches Bloomberg as closely as possible

One last point: Bloomberg DV01 is not the same object as QuantLib's swaption delta(), so those should not be compared directly.

Working C++ code demonstrating both fixes (using the rates from the original post):

```
#include <ql/quantlib.hpp>
#include <iostream>
#include <iomanip>

using namespace QuantLib;

int main() {
    Date evalDate(14, August, 2024);
    Settings::instance().evaluationDate() = evalDate;

    // 1. Bootstrap SOFR OIS curve from Bloomberg rates (%)
    auto sofr = ext::make_shared<Sofr>();

    struct { Period tenor; Rate rate; } mktData[] = {
        {1*Weeks,   5.3341 }, {2*Weeks,   5.33585},
        {3*Weeks,   5.33814}, {1*Months,  5.34261},
        {2*Months,  5.20565}, {3*Months,  5.11385},
        {4*Months,  5.02265}, {5*Months,  4.903  },
        {6*Months,  4.7885 }, {7*Months,  4.7085 },
        {8*Months,  4.60968}, {9*Months,  4.52458},
        {10*Months, 4.44082}, {11*Months, 4.3638 },
        {12*Months, 4.2871 }, {18*Months, 3.9293 },
        {2*Years,   3.7348 }, {3*Years,   3.5127 },
        {4*Years,   3.40905}, {5*Years,   3.36448},
        {6*Years,   3.349  }, {7*Years,   3.3454 },
        {8*Years,   3.351  }, {9*Years,   3.36048},
        {10*Years,  3.37219}, {12*Years,  3.40177},
        {15*Years,  3.43655}, {20*Years,  3.4382 },
        {25*Years,  3.3726 }, {30*Years,  3.2943 },
        {40*Years,  3.1005 }, {50*Years,  2.90915},
    };

    std::vector<ext::shared_ptr<RateHelper>> helpers;
    for (const auto& d : mktData)
        helpers.push_back(ext::make_shared<OISRateHelper>(
            2, d.tenor, d.rate / 100.0, sofr,
            Handle<YieldTermStructure>(), true));

    auto curve = ext::make_shared<
        PiecewiseYieldCurve<Discount, LogLinear>>(
            evalDate, helpers, Actual365Fixed());
    curve->enableExtrapolation();
    Handle<YieldTermStructure> yts(curve);

    // 2. Build the SOFR OIS underlying (1M x 10Y)
    sofr = ext::make_shared<Sofr>(yts);
    Real  notional = 1000000.0;
    Rate  strike   = 0.03367463;  // Bloomberg ATM

    ext::shared_ptr<OvernightIndexedSwap> ois =
        MakeOIS(10*Years, sofr, strike)
            .withEffectiveDate(Date(18, September, 2024))
            .withTerminationDate(Date(18, September, 2034))
            .withNominal(notional)
            .withFixedLegDayCount(Actual360());

    // 3. Swaption — Bachelier (normal vol), cash settled
    // Bloomberg quotes vol in bp; QuantLib takes decimal (102.67 bp → 0.010267)
    Volatility normalVol = 0.010267;
    auto engine = ext::make_shared<BachelierSwaptionEngine>(
        yts, normalVol, Actual365Fixed());

    Swaption swaption(ois,
        ext::make_shared<EuropeanExercise>(Date(16, September, 2024)),
        Settlement::Cash, Settlement::ParYieldCurve);
    swaption.setPricingEngine(engine);

    std::cout << std::fixed << std::setprecision(2)
              << "QuantLib NPV  : " << swaption.NPV()  << "\n"
              << "Bloomberg NPV : 10359.49\n";
}
```

Output:

```
QuantLib NPV  : 9745.84
Bloomberg NPV : 10359.49
```

The remaining gap comes from curve construction — the CSV rates don't match Bloomberg's internal curve exactly (QuantLib forward: 3.352% vs Bloomberg ATM: 3.367%). Pricing at QuantLib's own ATM forward gives 10,389, within 0.3% of Bloomberg.

The key changes versus the original code:

| Original | Fix |
| SwapRateHelper + USDLibor(1Y) | OISRateHelper + Sofr() |
| BlackSwaptionEngine | BachelierSwaptionEngine |
| VanillaSwap | OvernightIndexedSwap (via MakeOIS ) |

## Answer by Lut Ming Man (score 0)

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

the implied swap rate for 1Y1Y swap in quantlib is 0.03348361161688867 meaning the underlying swap is built/priced different compare to BBG. having said this, using this as the strike rate I am getting something reasonably close to BBG.

```
    yts = ql.RelinkableYieldTermStructureHandle()
    yts.linkTo(forward_curve)
    swap_engine = ql.DiscountingSwapEngine(yts)
    swap.setPricingEngine(swap_engine)
    print('Total NPV: {}'.format(-1*swap.NPV()))
```

Total NPV: 5.238689482212067e-10 npv: 9975.45088743009, delta: 4218320.11898054, gamma: nan, vega: nan, theta: nan

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.