Using QuantLib interactively
Hello, dear reader.
Today’s post was originally published in the September 2023 issue of Wilmott Magazine. The full source file and the required data are available on my Tutorial page, together with code from other articles and, when available, either the articles themselves or corresponding blog posts like this one.
Subscribe to my Substack to receive my posts in your inbox, or follow me on Twitter or LinkedIn if you want to be notified of new posts, or subscribe via RSS if you’re the tech type: the buttons for all that are in the footer. Also, I’m available for training, both online and (when possible) on-site: visit my Training page for more information.
Using QuantLib interactively
Or, the joys of quick feedback
This time, I thought we could have a look specifically at why you would want to use QuantLib from Python. The advantages, besides a simpler syntax than, say, C++, are interactivity (no compile-link-run cycle) and the possibility to use the larger Python ecosystem; for instance, Jupyter, which provides a notebook-like environment where Python code can be mixed with descriptive text and with figures, usually generated by the code itself. It can be used for interactive data exploration and calculation, as well as for demonstrations: for instance, I published a series of videos on YouTube in which I use Jupyter notebooks to showcase QuantLib.
What are we looking at?
The code below is from another Jupyter notebook that I wrote, and shows how to use QuantLib to fit a credit curve to a set of bond prices. I used it, for instance, in a training I did for a team at a large and famous institution that will remain unnamed. Now, why am I telling you this? It’s not just for bragging rights, or to astutely hint at the fact that I’m indeed available for training teams. It’s because of something that happened during the training, and that I’m not going to spoil right now. A master of suspense, that’s what they call me. Onwards with the code!
Required modules
Not surprisingly, the first required module is QuantLib. The part of the
library which is exported to Python is contained in a single module, due
to the way we’re generating the wrapper code. The name is, of course,
QuantLib; for convenience, I usually shorten it to ql
following common
Python practice. Hence, the line import QuantLib as ql
that appears at
the top of this code and of my other notebooks.
import QuantLib as ql
import csv
from matplotlib import pyplot as plt
from matplotlib.dates import YearLocator
from matplotlib.ticker import PercentFormatter
def new_plot():
f = plt.figure(figsize=(8, 5))
ax = f.add_subplot(1, 1, 1)
ax.xaxis.grid(True, "major", color="lightgray")
ax.yaxis.grid(True, "major", color="lightgray")
ax.xaxis.set_major_locator(YearLocator(10))
return f, ax
The other modules are the csv
module from the Python standard library,
used to read and write CSV files, and matplotlib
, not a standard
module but one of the modules most commonly used for plotting. I’m going
to gloss over the details of the new_plot
function that follows the
import statements; when called, it creates a new, empty Matplotlib plot
and configures its x and y grids. Its calling code will then add data to
the returned plot.
Global evaluation date
today = ql.Date(21, ql.September, 2021)
ql.Settings.instance().evaluationDate = today
Not a lot to see here. As I mentioned in previous notebooks, QuantLib has a global evaluation date; here I’m setting it. A day may come when I show what happens when we change the evaluation date; but it is not this day.
Read bond data from a CSV file
This part of the code reads the data file shown in the box; each row corresponds to a bond and contains its start date, its maturity date, the coupon rate it pays, and a quoted price. For simplicity, all bonds will have the same frequency and day-count convention; but we might have specified those in this file, too.
def from_iso(date):
return ql.Date(date, "%Y-%m-%d")
def apply_types(row):
return [
from_iso(row[0]),
from_iso(row[1]),
float(row[2]),
float(row[3]),
]
with open("2023-09.csv") as f:
reader = csv.reader(f)
next(reader) # skip header
data = [apply_types(row) for row in reader]
data.sort(key=lambda r: r[1])
The csv
module in the Python standard library helps with parsing and
quotation (if any) but only reads strings and doesn’t try to infer
types; therefore we need a couple of helper functions to read the data
in the correct formats and types. The first, from_iso
, converts a date
written in ISO format into a QuantLib date object; other formats can be
parsed as well, by specifying their format string. The second,
apply_types
, takes one row (parsed as a list of strings) and converts
each element into the proper type. The final loop applies it to each
row. We’re also sorting data by bond maturity; it’s not required for
fitting, but it makes it easier to plot data.
Create bonds and helpers
Now we finally get into some QuantLib code; namely, we create the bonds that we’ll use for fitting the discount curve. For each row in the data, we create a coupon schedule based on the given start and maturity date (plus, as I mentioned, a few assumptions about the frequency, calendar and business-day convention; they might be in the data instead) and with the schedule, the corresponding coupon-paying bond. As we loop over the rows, we keep two lists: one contains the bonds themselves, and the other contains so-called bond helpers, which wrap the bonds and their quoted price and will be passed to the curve constructor as part of the objective function of the fit.
helpers = []
bonds = []
for start, maturity, coupon, price in data:
schedule = ql.Schedule(
start,
maturity,
ql.Period(1, ql.Years),
ql.TARGET(),
ql.ModifiedFollowing,
ql.ModifiedFollowing,
ql.DateGeneration.Backward,
False,
)
bond = ql.FixedRateBond(
3,
100.0,
schedule,
[coupon / 100.0],
ql.Actual360(),
ql.ModifiedFollowing,
)
bonds.append(bond)
helpers.append(ql.BondHelper(ql.QuoteHandle(ql.SimpleQuote(price)), bond))
Lastly, we create a pricing engine and set it to all bonds; once we have a fitted discount curve, we’ll pass it to its handle, now still empty, and use it to check the resulting bond prices.
discount_curve = ql.RelinkableYieldTermStructureHandle()
bond_engine = ql.DiscountingBondEngine(discount_curve)
for b in bonds:
b.setPricingEngine(bond_engine)
Fit to a few curve models
The library implements a few parametric models such as Nelson-Siegel; in this example we’ll also use exponential splines, cubic B splines, and Svensson. The models are stored in a Python dictionary so that they can be easily retrieved based on a tag. A couple of them take parameters, but you’ll forgive me if I ignore them for brevity; they are documented in the library.
methods = {
"Nelson/Siegel": ql.NelsonSiegelFitting(),
"Exp. splines": ql.ExponentialSplinesFitting(True),
"B splines": ql.CubicBSplinesFitting(
[
-30.0,
-20.0,
0.0,
5.0,
10.0,
15.0,
20.0,
25.0,
30.0,
40.0,
50.0,
],
True,
),
"Svensson": ql.SvenssonFitting(),
}
The fitting is performed by the FittedBondDiscountCurve
class, which
takes a list of bond helpers and the parametric model to calibrate, plus
a few other parameters. As I mentioned, each bond helper contains a bond
and its quoted price; the fitting process iterates over candidate values
for the model parameters, reprices each of the bonds based on the
resulting discount factors, and tries to minimize the difference between
the resulting prices and the passed quotes.
tolerance = 1e-8
max_iterations = 5000
day_count = ql.Actual360()
curves = {
tag: ql.FittedBondDiscountCurve(
today,
helpers,
day_count,
methods[tag],
tolerance,
max_iterations,
)
for tag in methods
}
Plot the curves
And now, we start to use to our advantage the plotting capabilities of Matplotlib. Together with the interactivity of the notebook, this gives us an environment where we can try out things, visualize data, and receive immediate feedback. For instance, the next bit of code plots the curves we just fitted; it creates a new plot, defines a set of monthly dates starting from today and spanning the next 30 years, and for each of the curves it extracts and plots the corresponding zero rates.
f, ax = new_plot()
ax.yaxis.set_major_formatter(PercentFormatter(1.0))
styles = iter(["-", "--", ":", "-."])
dates = [today + ql.Period(i, ql.Months) for i in range(12 * 30 + 1)]
for tag in curves:
rates = [
curves[tag].zeroRate(d, day_count, ql.Continuous).rate() for d in dates
]
ax.plot_date(
[d.to_date() for d in dates],
rates,
next(styles),
label=tag,
)
ax.legend(loc="best");
The figure above shows the result. The curves are a diverse bunch, to say the least: Nelson-Siegel and Svensson look sensible; exponential splines are almost flat and, compared to the others, look like the fit failed and we got some best-effort result; and cubic B splines are too wavy for my tastes. How can we get a better sense of their quality?
Plot the prices
With more visualization, of course. The quoted prices can be read from
the data; they’re the last column. The bond prices implied by any of the
curves are also not hard to get—do you remember we created a discount
engine and set it to each bond? This pays off now: we can link its
discount handle to the desired curve and ask each bond for its price;
that’s what the prices
function does. The error
function calls the
latter and returns the difference between calculated and quoted prices.
quoted_prices = [row[-1] for row in data]
def prices(tag):
discount_curve.linkTo(curves[tag])
return [b.cleanPrice() for b in bonds]
def errors(tag):
return [q - p for p, q in zip(prices(tag), quoted_prices)]
The bit of code that follows the functions plots the errors for each curve as function of the bond maturities. As you might have expected from the previous figure, Nelson-Siegel and Svensson yield smaller errors. Of the two, Svensson looks better in general but especially at longer maturities.
f, ax = new_plot()
maturities = [r[1].to_date() for r in data]
markers = iter([".", "+", "x", "1"])
for tag in curves:
ax.plot_date(
maturities,
errors(tag),
next(markers),
label=tag,
)
ax.legend(loc="best");
If we restrict ourselves to just one curve (let’s single out Svensson here as the best candidate) we can visualize the calibration errors in yet another way; that’s what the next plot does. Instead of the errors, it plots both quoted and calculated prices against maturities. The figure shows a fit which is not great overall, with noticeable errors for most bonds and a few much larger errors that don’t seem to follow any particular pattern.
f, ax = new_plot()
ps = prices("Svensson")
qs = quoted_prices
ax.plot_date(maturities, qs, "P", label="quoted")
ax.plot_date(maturities, ps, "o", label="Svensson")
ax.legend(loc="best")
for m, p, q in zip(maturities, ps, qs):
ax.plot_date([m, m], [p, q], "-", color="grey")
Another point of view
So, what happened during the training I mentioned? It’s not unusual that, while I bring to the table my inside knowledge about QuantLib, the other people in the room are better quants. And when people with different expertise get together, good things usually happen. This time, one of the trainees said what you, too, might be thinking: “we should probably look at yields.”
And, thanks to Python and Jupyter, it took all of two minutes to
visualize them. The yield corresponding to a quoted price can be
calculated by passing it to the bondYield
method of the corresponding
bond; and after linking the desired discount curve, calling the same
method without a target price will return the yield corresponding to the
calculated price (the method is called simply yield
in C++, but that’s
a reserved keyword in Python, so we had to rename it while we export
it.)
The result was enlightening. Most of the yields were on some kind of curve, but we had a few obvious outliers and they were pulling the fit away from the rest of the bonds.
quoted_yields = [
b.bondYield(p, day_count, ql.Compounded, ql.Annual)
for b, p in zip(bonds, quoted_prices)
]
def yields(tag):
discount_curve.linkTo(curves[tag])
return [b.bondYield(day_count, ql.Compounded, ql.Annual) for b in bonds]
f, ax = new_plot()
ax.yaxis.set_major_formatter(PercentFormatter(1.0))
ys = yields("Svensson")
qys = quoted_yields
ax.plot_date(maturities, qys, ".", label="quoted")
ax.plot_date(maturities, ys, "x", label="Svensson")
ax.legend(loc="best");
Remove outliers
Fortunately, it was also easy to improve the fit. Given the yields we calculated, we filtered out the helpers whose calculated yield differed by more than 50 basis points from the yield implied by the quoted price. We then created a new curve, passing only the filtered helpers.
ys = yields("Svensson")
qys = quoted_yields
filtered_helpers = [
h for h, y1, y2 in zip(helpers, ys, qys) if abs(y1 - y2) < 0.005
]
curves["Svensson (new)"] = ql.FittedBondDiscountCurve(
today,
filtered_helpers,
day_count,
ql.SvenssonFitting(),
tolerance,
max_iterations,
)
Improved results
Again, it took almost no time to see the results. The rest of the code recreates the plots for yields…
f, ax = new_plot()
ax.yaxis.set_major_formatter(PercentFormatter(1.0))
ys = yields("Svensson")
ys2 = yields("Svensson (new)")
qys = quoted_yields
ax.plot_date(maturities, qys, ".", label="quoted")
ax.plot_date(maturities, ys2, "x", label="Svensson (new)")
ax.legend(loc="best");
…and prices; both show a definite improvement of the quality of the fit. The mood of everyone involved in that session was also markedly improved.
f, ax = new_plot()
ps = prices("Svensson (new)")
qs = quoted_prices
ax.plot_date(maturities, qs, "P", label="quoted")
ax.plot_date(maturities, ps, "o", label="Svensson (new)")
ax.legend(loc="best")
for m, p, q in zip(maturities, ps, qs):
ax.plot_date([m, m], [p, q], "-", color="grey")
Do you want to try?
To summarize: this kind of fast feedback and interactivity is the reason why, during these last years, using QuantLib in Python has become my go-to choice for exploration and troubleshooting. I hope this article will inspire you to give it a try; if you want to jump right into it without local installations, you can do it from your browser.
Go to the QuantLib-SWIG repository on GitHub (the home of the Python, Java, C# and R wrappers for QuantLib) and under the list of files you’ll see a README file, with a row of colorful badges displayed after the title. Click on the one that says “launch binder”, wait until the environment is built (it might take a while; the binder project has limited funds and resources and is looking for sponsors) and you’ll find yourself connected to a running Jupyter instance with a number of QuantLib examples that you can run and modify. You might have to right-click on the examples and select “Open with notebook” to get the runnable version.
Have fun, and see you next time!