QuantLib icon indicating copy to clipboard operation
QuantLib copied to clipboard

Long execution time when pricing Swaption out from ql.SabrSwaptionVolatilityCube()

Open LZ1153 opened this issue 3 months ago • 7 comments

Pricing Swaptions out from ql.SabrSwaptionVolatilityCube() works fine when using small set of expiries and underlyings. However when I use 22 expiries and 14 underlyings it took me 200 seconds. Basically I've tried to extract vol directly from built ql.SabrSwaptionVolatilityCube() using smileSection() and ql.BachelierSwaptionEngine(), both taking similar time of 200 seconds. Result looks correct but just it is taking too long. Please can expert advise am I missing something please?

LZ1153 avatar Sep 05 '25 05:09 LZ1153

Hi, can you post a minimal test case please?

kp9991-git avatar Sep 17 '25 19:09 kp9991-git

Dear @kp9991-git , apologies I missed your reply. Please see below code. FYI, I get roughly 0.3s for pricing IRS underlying but 3 mins for pricing OIS underlying. Hope this info helps, or appreciate if you could point out which part I was implementing wrongly.

import QuantLib as ql

CAL = ql.TARGET()

ATM_NORM_VOLS = (
    (0.0086, 0.00128, 0.00195, 0.00269, 0.00327, 0.00361, 0.00387,
     0.00409, 0.00427, 0.00443, 0.00488, 0.00504, 0.00508, 0.00504),
    (0.0092, 0.00134, 0.00197, 0.00264, 0.00319, 0.00352, 0.00383,
     0.00402, 0.00419, 0.00431, 0.00478, 0.00499, 0.00507, 0.00503),
    (0.00112, 0.00153, 0.00210, 0.00276, 0.00327, 0.00353, 0.00384,
     0.00408, 0.00426, 0.00445, 0.00486, 0.00505, 0.00509, 0.00510),
    (0.00129, 0.00171, 0.00226, 0.00288, 0.00335, 0.00360, 0.00388,
     0.00410, 0.00430, 0.00446, 0.00487, 0.00506, 0.00511, 0.00510),
    (0.00146, 0.00187, 0.00246, 0.00301, 0.00342, 0.00369, 0.00393,
     0.00413, 0.00432, 0.00449, 0.00489, 0.00510, 0.00513, 0.00515),
    (0.00165, 0.00209, 0.00263, 0.00313, 0.00350, 0.00376, 0.00400,
     0.00420, 0.00437, 0.00453, 0.00488, 0.00509, 0.00514, 0.00517),
    (0.00209, 0.00253, 0.00300, 0.00340, 0.00370, 0.00395, 0.00419,
     0.00434, 0.00450, 0.00464, 0.00493, 0.00510, 0.00513, 0.00519),
    (0.00251, 0.00289, 0.00332, 0.00362, 0.00392, 0.00412, 0.00432,
     0.00447, 0.00460, 0.00473, 0.00496, 0.00510, 0.00513, 0.00516),
    (0.00340, 0.00366, 0.00392, 0.00411, 0.00432, 0.00445, 0.00461,
     0.00472, 0.00480, 0.00490, 0.00503, 0.00513, 0.00513, 0.00512),
    (0.00403, 0.00418, 0.00436, 0.00449, 0.00461, 0.00471, 0.00482,
     0.00492, 0.00499, 0.00505, 0.00512, 0.00513, 0.00509, 0.00507),
    (0.00440, 0.00448, 0.00460, 0.00471, 0.00484, 0.00491, 0.00499,
     0.00507, 0.00514, 0.00519, 0.00516, 0.00514, 0.00506, 0.00502),
    (0.00496, 0.00497, 0.00504, 0.00512, 0.00518, 0.00522, 0.00526,
     0.00529, 0.00533, 0.00538, 0.00526, 0.00517, 0.00504, 0.00496),
    (0.00539, 0.00537, 0.00540, 0.00542, 0.00544, 0.00545, 0.00545,
     0.00544, 0.00544, 0.00549, 0.00531, 0.00518, 0.00501, 0.00491),
    (0.00540, 0.00537, 0.00538, 0.00537, 0.00535, 0.00536, 0.00535,
     0.00533, 0.00535, 0.00537, 0.00514, 0.00498, 0.00479, 0.00466),
    (0.00528, 0.00524, 0.00526, 0.00523, 0.00522, 0.00523, 0.00520,
     0.00519, 0.00518, 0.00518, 0.00495, 0.00474, 0.00454, 0.00438),
    (0.00514, 0.00512, 0.00513, 0.00510, 0.00508, 0.00507, 0.00503,
     0.00499, 0.00498, 0.00497, 0.00476, 0.00453, 0.00431, 0.00414),
    (0.00496, 0.00496, 0.00497, 0.00495, 0.00495, 0.00492, 0.00486,
     0.00479, 0.00474, 0.00471, 0.00451, 0.00429, 0.00408, 0.00392))

ATM_NORM_VOL_OPT_TENORS = (ql.Period(1, ql.Months), ql.Period(2, ql.Months),
                           ql.Period(3, ql.Months), ql.Period(6, ql.Months),
                           ql.Period(9, ql.Months), ql.Period(1, ql.Years),
                           ql.Period(18, ql.Months), ql.Period(2, ql.Years),
                           ql.Period(3, ql.Years), ql.Period(4, ql.Years),
                           ql.Period(5, ql.Years), ql.Period(7, ql.Years),
                           ql.Period(10, ql.Years), ql.Period(15, ql.Years),
                           ql.Period(20, ql.Years), ql.Period(25, ql.Years),
                           ql.Period(30, ql.Years))

ATM_NORM_VOL_SWAP_TENORS = (ql.Period(1, ql.Years), ql.Period(2, ql.Years),
                            ql.Period(3, ql.Years), ql.Period(4, ql.Years),
                            ql.Period(5, ql.Years), ql.Period(6, ql.Years),
                            ql.Period(7, ql.Years), ql.Period(8, ql.Years),
                            ql.Period(9, ql.Years), ql.Period(10, ql.Years),
                            ql.Period(15, ql.Years), ql.Period(20, ql.Years),
                            ql.Period(25, ql.Years), ql.Period(30, ql.Years))

BETA = [
    [0.40, 0.42, 0.45, 0.46, 0.48, 0.49, 0.50, 0.52, 0.52],
    [0.40, 0.43, 0.45, 0.46, 0.48, 0.49, 0.50, 0.52, 0.52],
    [0.40, 0.44, 0.46, 0.47, 0.49, 0.50, 0.51, 0.53, 0.53],
    [0.42, 0.44, 0.47, 0.48, 0.50, 0.51, 0.52, 0.54, 0.54],
    [0.43, 0.45, 0.48, 0.49, 0.51, 0.52, 0.53, 0.55, 0.55],
    [0.44, 0.46, 0.48, 0.50, 0.52, 0.53, 0.54, 0.56, 0.56],
    [0.45, 0.47, 0.49, 0.50, 0.52, 0.53, 0.54, 0.56, 0.56],
    [0.46, 0.48, 0.50, 0.51, 0.52, 0.54, 0.55, 0.57, 0.57],
    [0.47, 0.49, 0.51, 0.52, 0.53, 0.55, 0.56, 0.58, 0.58],
    [0.48, 0.50, 0.52, 0.53, 0.54, 0.56, 0.57, 0.59, 0.59],
    [0.49, 0.51, 0.53, 0.54, 0.55, 0.57, 0.58, 0.60, 0.60],
    [0.50, 0.52, 0.54, 0.55, 0.56, 0.58, 0.59, 0.61, 0.61],
]

NU = [
    [0.90, 0.80, 0.70, 0.60, 0.55, 0.50, 0.45, 0.40, 0.38],
    [0.85, 0.75, 0.65, 0.58, 0.53, 0.48, 0.44, 0.39, 0.37],
    [0.80, 0.72, 0.62, 0.55, 0.50, 0.46, 0.42, 0.38, 0.36],
    [0.70, 0.65, 0.58, 0.52, 0.48, 0.44, 0.40, 0.36, 0.34],
    [0.60, 0.56, 0.50, 0.46, 0.43, 0.40, 0.37, 0.34, 0.32],
    [0.55, 0.52, 0.47, 0.44, 0.41, 0.38, 0.35, 0.33, 0.31],
    [0.50, 0.48, 0.44, 0.41, 0.38, 0.36, 0.34, 0.31, 0.29],
    [0.46, 0.44, 0.41, 0.39, 0.36, 0.34, 0.32, 0.30, 0.28],
    [0.42, 0.40, 0.38, 0.36, 0.34, 0.32, 0.30, 0.28, 0.26],
    [0.40, 0.38, 0.36, 0.34, 0.32, 0.30, 0.28, 0.26, 0.24],
    [0.38, 0.36, 0.34, 0.33, 0.31, 0.29, 0.27, 0.25, 0.23],
    [0.36, 0.34, 0.33, 0.32, 0.30, 0.28, 0.26, 0.24, 0.22]
]

RHO = [
    [-0.60, -0.55, -0.50, -0.48, -0.45, -0.42, -0.40, -0.35, -0.33],
    [-0.58, -0.54, -0.50, -0.47, -0.44, -0.41, -0.39, -0.34, -0.32],
    [-0.55, -0.52, -0.48, -0.46, -0.43, -0.40, -0.38, -0.33, -0.31],
    [-0.52, -0.50, -0.46, -0.44, -0.41, -0.38, -0.36, -0.32, -0.30],
    [-0.50, -0.48, -0.45, -0.43, -0.40, -0.37, -0.35, -0.31, -0.29],
    [-0.48, -0.46, -0.43, -0.41, -0.38, -0.36, -0.34, -0.30, -0.28],
    [-0.45, -0.44, -0.41, -0.39, -0.36, -0.34, -0.33, -0.29, -0.27],
    [-0.43, -0.42, -0.40, -0.38, -0.35, -0.33, -0.32, -0.28, -0.26],
    [-0.40, -0.39, -0.37, -0.36, -0.33, -0.32, -0.30, -0.27, -0.25],
    [-0.38, -0.37, -0.35, -0.34, -0.32, -0.31, -0.29, -0.26, -0.24],
    [-0.36, -0.35, -0.33, -0.32, -0.30, -0.29, -0.27, -0.25, -0.23],
    [-0.34, -0.33, -0.31, -0.30, -0.28, -0.27, -0.25, -0.23, -0.21]
]

SMILE_OPT_TENORS = (ql.Period(1, ql.Months),
                    ql.Period(3, ql.Months),
                    ql.Period(6, ql.Months),
                    ql.Period(1, ql.Years),
                    ql.Period(2, ql.Years),
                    ql.Period(3, ql.Years),
                    ql.Period(5, ql.Years),
                    ql.Period(7, ql.Years),
                    ql.Period(10, ql.Years),
                    ql.Period(15, ql.Years),
                    ql.Period(20, ql.Years),
                    ql.Period(30, ql.Years))

SMILE_SWAP_TENORS = (ql.Period(6, ql.Months),
                     ql.Period(1, ql.Years),
                     ql.Period(2, ql.Years),
                     ql.Period(3, ql.Years),
                     ql.Period(5, ql.Years),
                     ql.Period(7, ql.Years),
                     ql.Period(10, ql.Years),
                     ql.Period(20, ql.Years),
                     ql.Period(30, ql.Years))

ZERO_COUPON_DATA = (
    (ql.Period(1, ql.Days), 0.013),
    (ql.Period(1, ql.Years), 0.013),
    (ql.Period(2, ql.Years), 0.015),
    (ql.Period(3, ql.Years), 0.016),
    (ql.Period(4, ql.Years), 0.017),
    (ql.Period(5, ql.Years), 0.019),
    (ql.Period(10, ql.Years), 0.021),
    (ql.Period(15, ql.Years), 0.024),
    (ql.Period(20, ql.Years), 0.026),
    (ql.Period(30, ql.Years), 0.029))

NORM_VOL_MATRIX = ql.SwaptionVolatilityMatrix(
    CAL,
    ql.ModifiedFollowing,
    ATM_NORM_VOL_OPT_TENORS,
    ATM_NORM_VOL_SWAP_TENORS,
    ql.Matrix(ATM_NORM_VOLS),
    ql.Actual365Fixed(),
    False,
    ql.Normal)


def build_estr_swap_idx(
        projection_curve_handle):
    return ql.OvernightIndexedSwapIndex("ESTR", ql.Period(1, ql.Years), 2, ql.EURCurrency(),
                                        projection_curve_handle)


def build_nominal_term_structure(valuation_date, nominal_quotes):
    dates, rates = zip(*[(CAL.advance(valuation_date, x[0]), x[1])
                         for x in nominal_quotes])
    crv = ql.ZeroCurve(dates, rates, ql.Actual365Fixed())
    crv.enableExtrapolation()
    return crv


def sabr_parameters_guess(number_of_options, number_of_swaps, beta, nu, rho):
    n_elements = number_of_options * number_of_swaps
    guess = n_elements * [0]
    for i in range(number_of_options):
        for j in range(number_of_swaps):
            guess[i * number_of_swaps + j] = [
                ql.QuoteHandle(ql.SimpleQuote(0.1)),
                ql.QuoteHandle(ql.SimpleQuote(beta[i][j])),
                ql.QuoteHandle(ql.SimpleQuote(nu[i][j])),
                ql.QuoteHandle(ql.SimpleQuote(rho[i][j]))]
    return guess


def build_sabr_swaption_cube(
        volatility_matrix,
        spread_opt_tenors,
        spread_swap_tenors,
        strike_spreads,
        vol_spreads,
        beta,
        nu,
        rho,
        swap_index_base,
        short_swap_index_base=None,
        vega_weighted_smile_fit=False,
        is_parameter_fixed=(False, True, True, True),
        is_atm_calibrated=True):
    v_spreads = [[ql.makeQuoteHandle(v) for v in row]
                 for row in vol_spreads]
    guess = sabr_parameters_guess(
        len(spread_opt_tenors), len(spread_swap_tenors), beta, nu, rho)
    cube = ql.SabrSwaptionVolatilityCube(
        ql.SwaptionVolatilityStructureHandle(volatility_matrix),
        spread_opt_tenors,
        spread_swap_tenors,
        strike_spreads,
        v_spreads,
        swap_index_base,
        short_swap_index_base if short_swap_index_base else swap_index_base,
        vega_weighted_smile_fit,
        guess,
        is_parameter_fixed,
        is_atm_calibrated)
    cube.enableExtrapolation()
    return cube


class SwaptionVolatilityCube:
    def __init__(self):
        self.today = CAL.adjust(ql.Date.todaysDate())
        ql.Settings.instance().evaluationDate = self.today

        curve_handle = ql.RelinkableYieldTermStructureHandle()
        curve = build_nominal_term_structure(self.today, ZERO_COUPON_DATA)
        curve_handle.linkTo(curve)

        self.yts = curve_handle
        self.idx = ql.Estr(curve_handle)
        self.swap_idx = build_estr_swap_idx(self.idx)
        self.swap_engine = ql.DiscountingSwapEngine(curve_handle)

    def tearDown(self):
        ql.Settings.instance().evaluationDate = ql.Date()

    def _calculate_prem(
            self, cube, vol_type):
        opt_tenor = ql.Period(1, ql.Years)
        swap_tenor = ql.Period(10, ql.Years)
        exercise_date = cube.optionDateFromTenor(opt_tenor)
        fair_rate = cube.atmStrike(exercise_date, swap_tenor)
        vol = cube.volatility(exercise_date, swap_tenor, vol_type)
        start_date = CAL.advance(exercise_date, ql.Period(2, ql.Days))
        underlying = ql.MakeOIS(
            swap_tenor,
            self.idx,
            fair_rate,
            effectiveDate=start_date,
            terminationDate=None,
            paymentFrequency=ql.Annual,
            telescopicValueDates=True,
            dateGenerationRule=ql.DateGeneration.Backward,
            receiveFixed=True,
        )
        underlying.setPricingEngine(self.swap_engine)
        swaption = ql.Swaption(underlying, ql.EuropeanExercise(exercise_date))
        swaption.setPricingEngine(ql.BachelierSwaptionEngine(self.yts, ql.SwaptionVolatilityStructureHandle(cube)))
        premium = swaption.forwardPrice()
        return opt_tenor, swap_tenor, exercise_date, fair_rate, vol, premium

    def _test_premium(self):
        NORM_VOL_SPREADS = [[0.0] for _ in range(len(SMILE_OPT_TENORS) * len(SMILE_SWAP_TENORS))]
        sabr_cube = build_sabr_swaption_cube(
            NORM_VOL_MATRIX,
            SMILE_OPT_TENORS,
            SMILE_SWAP_TENORS,
            [0.0],
            NORM_VOL_SPREADS,
            BETA,
            NU,
            RHO,
            self.swap_idx)
        print(self._calculate_prem(sabr_cube, ql.Normal))


if __name__ == '__main__':
    SwaptionVolatilityCube()._test_premium()

LZ1153 avatar Sep 26 '25 03:09 LZ1153

@kp9991-git @lballabio do you think if we have a function called MakeOISwaption that has similar feature like telescopicValueDates will reduce the calibration time please?

LZ1153 avatar Oct 15 '25 05:10 LZ1153

I don't think it has anything to do with the swap schedule. If you call the function _test_premium() twice, the second time it's fast. It's just that on the first call it will calibrate the SABR cube. One possible optimisation that I see is to calibrate the cube partially on demand. Currently when you call it for the first time, it will calibrate all swap tenors and expiries. If you have a large cube but only need a handful of data points, this may not be necessary, particularly if some smiles take a while to converge. But perhaps you can talk to us about your use case? Are you building a cube to price a small number of swaptions, or is it an object that will need to be able to price any swaptions? If the latter, once built and calibrated, it should be quick. But if you are building it, pricing a small number of swaptions, then killing it, then yes there is a calibration overhead.

kp9991-git avatar Oct 15 '25 10:10 kp9991-git

Thanks for your input @kp9991-git . I would need to use it for both use case. Yea I noticed the subsequent call is quick. But the problem is if one of the vols moved, the cube will recalibrate as it is first call. Any workaround on this would be much appreciated.

LZ1153 avatar Oct 15 '25 14:10 LZ1153

Yes you hit a nail on the head with this comment. An EOD vol surface is different from a real-time vol surface. It sounds like you are using it for real-time pricing, right? In the EOD case you can live with some slowness because you build the object once. But when live vols feed into a vol surface, then there are two problems, or even there: how to build minimally only what's changed; how to implement throttling; how to deal with bid/offer. Anyway, in your case it's the first problem above. How is your application designed with respect to the vols? Do you have a live feed from BBG or something like that, then when you receive a tick, or if a user clicks a button, you refresh vols, feed into the vol cube, rebuild it, reprice a swaption, right? If that's the case, then yes I think calculating SABR smile slices on demand will help address this issue

kp9991-git avatar Oct 15 '25 18:10 kp9991-git

Yes, the use case is exactly what you mentioned - rebuild and reprice swaptions. I will try with your suggestion. Much appreciated on your inputs @kp9991-git .

LZ1153 avatar Oct 16 '25 13:10 LZ1153

This issue was automatically marked as stale because it has been open 60 days with no activity. Remove stale label or comment, or this will be closed in two weeks.

github-actions[bot] avatar Dec 16 '25 02:12 github-actions[bot]