# QuantLib - Calibrating Hull White one-factor on negative interest rates

I have been working with the QuantLib Python package for some days now. Currently, I am working on calibrating a Hull White one-factor model for short rates. I am calibrating the model on the yield-curve and on swaption volatilities.

Given the current market circumstances, a large part of the yield curve is negative. This causes some issues with calibrating the Hull White one-factor model since the lognormal distribution cannot be negative. After some searching I found the possibility of specifying a shift. In the code below my SwaptionHelper initialization is shown including the shift.

Please note I am currently using a yield curve, however, when using a flatforward of -0.00478 I get similar results.

However, using the shift results in unrealistic output for a (a=0.0000389, sigma=0.0222).

I have been searching a lot for an answer, but cannot find decent information for situations with a negative rate. If anyone would be able to explain the usage of a shift or do a quick check on my code to see if anything is wrong would be so helpful.

helper = ql.SwaptionHelper(
ql.Period(int(maturity), ql.Years),
ql.Period(int(tenor), ql.Years),
volatility,
index,
fixedLegTenor,
fixedLegDayCounter,
floatingLegDayCounter,
term_structure,
ql.BlackCalibrationHelper.RelativePriceError,
ql.nullDouble(),
1.0,
ql.ShiftedLognormal,
0.05 #shift to make rates non-negative
)


Full code:

import csv
from QuantLib.QuantLib import SwaptionHelper
import matplotlib.pyplot as plt
import pandas as pd
import QuantLib as ql

zero_curve = pd.read_csv('ZeroCurve.csv', delimiter=';')
dates = []
rates = []

for i in range(0,len(zero_curve)):
rates.append(float(zero_curve['Rate'][i]))
dates.append(ql.Date(int(zero_curve['Mat_Day'][i]),int(zero_curve['Mat_Month'][i]),int(zero_curve['Mat_Year'][i])))

return dates, rates

# Read the swaption volatilities from the csv file.
swaption_vols = pd.read_csv('SwaptionVol.csv', delimiter=';', index_col=0)
dates, rates = load_csv_input()

curve = ql.ZeroCurve(dates, rates, ql.Actual365Fixed())
term_structure = ql.YieldTermStructureHandle(curve)

model = ql.HullWhite(term_structure)
#engine = ql.TreeSwaptionEngine(model, 25)
engine = ql.JamshidianSwaptionEngine(model)
#engine = ql.G2SwaptionEngine(model, 10, 400)

index = ql.Euribor1Y(term_structure)
fixedLegTenor = ql.Period('1Y')
fixedLegDayCounter = ql.Actual360()
floatingLegDayCounter = ql.Actual360()

swaptions = []
ql.Settings.instance().evaluationDate = ql.Date(1, 10, 2020)
for maturity in swaption_vols.index:
for tenor in swaption_vols.columns:
volatility = ql.QuoteHandle(ql.SimpleQuote(swaption_vols.at[maturity,tenor]))
helper = ql.SwaptionHelper(
ql.Period(int(maturity), ql.Years),
ql.Period(int(tenor), ql.Years),
volatility,
index,
fixedLegTenor,
fixedLegDayCounter,
floatingLegDayCounter,
term_structure,
ql.BlackCalibrationHelper.RelativePriceError,
ql.nullDouble(),
1.0,
ql.ShiftedLognormal,
0.2 #shift to make rates non-negative
)
helper.setPricingEngine(engine)
swaptions.append(helper)

optimization_method = ql.LevenbergMarquardt(1.0e-8,1.0e-8,1.0e-8)
end_criteria = ql.EndCriteria(500000, 1000, 1e-6, 1e-8, 1e-8)
model.calibrate(swaptions, optimization_method, end_criteria)
params = model.params()
print(params)


Update

I have changed the volatility type from lognormal to normal, since the data contains normal volatilities. So this should be able to work with the negative values.

Now I am running into an issue where the calibration only works if I use a small selection of volatilities. My complete volatility matrix is 10x10 (1, 2, 3, 4, 5, 10, 15, 20, 25, and 30Y maturity and tenors). However, if I use more than the first 5x5 I get a "root not bracketed" error.

• I do not understand your remark "since the lognormal distribution cannot be negative." The short rate in the HW model is normal and can be negative. Lognormal are bond prices and these are positive?
– g g
Nov 10, 2020 at 13:46
• You're right, the HW model is indeed normal. However, as far as I know, the calculations for the swaptions include a lognormal distribution. Nov 10, 2020 at 13:54
• I suppose I'll first further check the plausibility of the calculated Hull White parameters by constructing the implied volatility surface Nov 10, 2020 at 14:07
• @Michielap perhaps you meant that your market swaption vols are obtained with Black's formula, i.e. the data in SwaptionVol.csv is lognormal. Is that the case? Maybe it helps also if you could share the data. Nov 10, 2020 at 14:29
• I assumed that they were lognormal. However, I reached out to the provider of the data and apparently they are actually normal. So I am going to look into that first to see if that will change my results. Thanks for pointing me in that direction. Nov 10, 2020 at 14:42

When building a SwaptionHelper, you have to tell QuantLib what kind of volatility you are inputting. There are three options: Black Vol, Shifted Black Vol and Normal Vol.

Since you don't have black vol for most of the swaption surface (EUR) because of the negative forwards, you can either use shifted Black Vol or Normal Vol.

In the example you are using shifted black vol but you have a shift of 20%! Your shifted vols should have a respective shift value but I doubt it is 20%. ICAP for example quotes shifted black vols with a shift of 2% for EUR rates.

Without doing any code, I would guess if you correct your shift to the appropriate value you'll get better results. You can compare the model values with the market values for your helpers.

• Hi David, thanks a lot for your help. I actually managed to find that my data consists of normal volatilities. Correct me if I'm wrong but I think that should mean that I can remove the shift since normal can handle negative values. I updated my script to show the differences between model and market values. Nov 10, 2020 at 15:27
• Correct, just use ql.Normal instead of ql.ShiftedLognormal as the volatity type and don't input any shift... Nov 10, 2020 at 15:29
• Thanks David! I just wrote a brief update on my question. I keep getting a "root not bracketed" error when using the full (10x10) volatility surface. Only for smaller (5x5) surfaces the calibration yields a result. Nov 10, 2020 at 15:32
• Are you inputting the normal vol correctly? for example a normal vol of 34 refers to 34 basis points which would be inputed as 34 / 10000 Nov 10, 2020 at 15:36
• I have the volatility as percentages, so for maturity and tenor of 1 year it is 0.1816. I use those directly as input for the swaptionhelper. Nov 10, 2020 at 15:38