Skip to content
All library documents

Hull–White Swaption Calibration and Initial Curve Matching

Article Quant Q&A · Author: Hilbert

Summary

The document presents a QuantLib setup for calibrating a one-factor Hull–White short-rate model to a EUR overnight-indexed discount curve and a normal-volatility surface for three-month Euribor swaptions. It describes fitting the model’s mean-reversion and volatility parameters by minimizing pricing errors across swaption helpers, then comparing model-implied zero-coupon bond discount factors with those from the input curve.

The author reports calibrated parameters and says the resulting bond prices do not match the initial curve, despite expecting the model’s time-dependent drift to reproduce it. The code and input tables give context for diagnosing the discrepancy, but the document contains only the question: it includes no answer or demonstrated fix. It therefore illustrates a calibration and curve-consistency problem rather than establishing a reliable implementation. Details such as curve conventions, dates, volatility data quality, and the bond-pricing formula would need review before drawing conclusions.

Key ideas

  • The example calibrates a one-factor Hull–White model to swaption normal volatilities.
  • Swaption helpers are priced against a market curve and used to fit mean reversion and volatility.
  • The author expects the model’s drift adjustment to reproduce the initial term structure.
  • The reported zero-coupon bond discount factors do not match the input curve, and the document gives no resolution.

Tags

Full text
# 1 Factor Hull And White Swaption Calibration


# 1 Factor Hull And White Swaption Calibration












I'm trying to calibrate a Hull and White model with constant volatility, mean reversion and theta such that the model can reproduce the initial Term Structure. I'm using this python code adapted from "The QuantLib Python Cookbook"

Here is the initial term structure (EUR OIS curve)

jours means days and taux means rate

```
jours;taux
1;-0.00472
7;-0.00472
14;-0.00471
30;-0.004719
60;-0.0047875
90;-0.00481
120;-0.004835
150;-0.00487
180;-0.00489
210;-0.00491
240;-0.004931
270;-0.004952
300;-0.00497
330;-0.0049875
360;-0.005
540;-0.005089
720;-0.0051225
900;-0.0051325
1080;-0.0050975
1440;-0.004946
1800;-0.004665
2160;-0.004275
2520;-0.003843
2880;-0.003375
3240;-0.0028865
3600;-0.00238
3960;-0.001885
4320;-0.00142
5400;-0.00027
7200;0.000655
9000;0.000775
10800;0.00056
12600;0.000351
14400;0.0001485
18000;-0.0002425
36000;-0.0002425
```

And the normal volatility surface for the Euribor 3M Swaptions

```
Expir.;1;2;3;4;5;7;10;12;15;20;25;30
30;0.02;0.03;0.03;0.03;0.03;0.03;0.03;0.03;3.86;9.6;9.32;7.2
90;0.02;0.03;0.03;0.03;0.03;0.03;0.03;0.04;5.51;10.11;9.49;7.32
180;0.02;0.03;0.03;0.03;0.03;0.03;0.03;0.03;6.66;10.83;9.71;7.48
270;0.02;0.02;0.03;0.03;0.03;0.03;0.03;0.02;7.12;11.23;9.91;7.64
360;0.02;0.02;0.03;0.03;0.03;0.03;0.02;0.02;6.77;11.59;10.1;7.78
720;0.02;0.02;0.02;0.03;0.02;0.03;0.02;6.04;11.68;12.81;10.76;8.31
1080;0.02;0.02;0.02;0.02;0.03;0.02;6.19;10.95;14.2;13.76;11.27;8.74
1440;0.01;0.02;0.02;0.02;0.02;4.38;12.72;15.45;15.79;14.36;11.56;8.99
1800;0.01;0.02;0.02;0.02;4.3;10.85;15.72;17.16;16.85;14.62;11.63;9.04
2160;0.01;0.02;3.23;8.08;13.52;15.65;18.2;18.73;17.67;14.72;11.56;8.97
2520;2.84;7.32;11.34;14.02;15.79;18.06;19.29;19.27;17.8;14.42;11.21;8.66
2880;9.74;13.93;17.01;18.52;19.41;20.43;20.33;19.72;17.71;13.97;10.74;8.22
3240;16.2;18.46;20.21;20.96;21.36;21.56;20.52;19.44;17.09;13.19;10.03;7.56
3600;21.37;20.4;20.94;21.45;21.73;21.56;19.92;18.54;16.02;12.12;9.09;6.69
4320;24.92;22.86;22.6;22.28;21.81;20.7;17.96;16.16;13.41;9.66;6.97;4.43
5400;22.36;20.06;19.29;18.42;17.45;15.61;12.43;10.6;8.18;5.25;2.98;0.48
7200;12.04;10.06;8.88;7.77;6.72;4.97;2.75;1.55;1.1;0.01;0.01;0.01
```

Here is the code I'm using

```
import QuantLib as ql
from QuantLib import *
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from collections import namedtuple
from pprint import pprint
import math
from scipy.interpolate import interp1d

zc = pd.read_csv('zc_auj.csv',sep=';')
matrice_vol = pd.read_csv('vol_auj.csv',sep = ';')

today = ql.Date(12,11,2020)
settlement = today+1
ql.Settings_instance().evaluationDate = today
spot_dates = [today + ql.Period(i,ql.Days) for i in zc['jours']]
spot_rates = [i for i in zc['taux']]
day_count = ql.Thirty360()
calendar = ql.France()
interpolation = ql.Linear()
compounding = ql.Compounded
compounding_frequency = ql.Annual
displacement = 0

spot_curve =ql.ZeroCurve(spot_dates,
                         spot_rates,
                         day_count,
                         calendar,
                         interpolation,
                         compounding,
                         compounding_frequency)

term_structure = ql.YieldTermStructureHandle(spot_curve)

index = ql.Euribor3M(term_structure)

CalibrationData = namedtuple("CalibrationData",
                             "start, length, volatility")

data = []
for i,v in enumerate(range(1,matrice_vol.shape[0]+1,1)):
    for j,w in enumerate(range(matrice_vol.shape[1]-1)):
        data.append(CalibrationData(int(matrice_vol.iloc[i][0]),int(matrice_vol.columns[j+1]),matrice_vol.iloc[i,j+1]/100))

    
def create_swaption_helpers(data, index, term_structure, engine):
    swaptions = []
    fixed_leg_tenor = ql.Period(3, ql.Months)
    fixed_leg_daycounter = ql.Actual360()
    floating_leg_daycounter = ql.Actual360()
    for d in data:
        vol_handle = ql.QuoteHandle(ql.SimpleQuote(d.volatility))
        helper = ql.SwaptionHelper(ql.Period(d.start,ql.Days),
                                   ql.Period(d.length,ql.Years),
                                   vol_handle,
                                   index,
                                   fixed_leg_tenor,
                                   fixed_leg_daycounter,
                                   floating_leg_daycounter,
                                   term_structure,
                                   ql.BlackCalibrationHelper.RelativePriceError,
                                   ql.nullDouble(),
                                   100,
                                   ql.Normal,
                                   0)
        helper.setPricingEngine(engine)
        swaptions.append(helper)
    return swaptions

def calibration_report(swaptions, data):
    columns = ["Model Price", "Market Price", "Implied Vol", "Market Vol", "Rel Error Price", "Rel Error Vols"]
    report_data = []
    cum_err = 0.0
    cum_err2 = 0.0
    for i, s in enumerate(swaptions):
        model_price = s.modelValue()
        market_vol = data[i].volatility
        black_price = s.blackPrice(market_vol)
        rel_error = model_price/black_price - 1.0
        implied_vol = s.impliedVolatility(model_price,1e-7, 1000, 0, 1)
        rel_error2 = implied_vol/market_vol-1.0
        cum_err += rel_error*rel_error
        cum_err2 += rel_error2*rel_error2
        report_data.append((model_price, black_price, implied_vol,market_vol, rel_error, rel_error2))
    
    print ("Cumulative Error Price: %7.5f" % math.sqrt(cum_err))
    print ("Cumulative Error Vols : %7.5f" % math.sqrt(cum_err2))
    return pd.DataFrame(report_data,columns= columns, index=['']*len(report_data))

model = ql.HullWhite(term_structure)
engine = ql.JamshidianSwaptionEngine(model)
swaptions = create_swaption_helpers(data,index,term_structure,engine)

optimization_method = ql.LevenbergMarquardt(10e-8,1.0e-8,1.0e-8)
end_criteria = ql.EndCriteria(10000, 100, 1e-6, 1e-8, 1e-8)
model.calibrate(swaptions, optimization_method, end_criteria)
a, sigma = model.params()
print ("a = %6.5f, sigma = %6.5f" % (a, sigma))

zeta = calibration_report(swaptions, data)
```

As a backtest I'm trying to price zero_coupon bonds using the calibrated model (as I mentionned theta such that I can exactly match the initial yield curve) but my results don't match.

When I execute the code I get a = 0.03319, sigma = 0.00023

Here's a plot of dicount factors derived from the Term_structure and those computed using the Hull and White formula for zero coupon bonds

Can someone tell me what I'm doing wrong ?

Thank you, Hilbert

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.