Hello again.

Today’s post was originally published as an article in the May 2023 issue of Wilmott Magazine, which was dedicated to the 50th anniversary of the Black-Scholes model. It is reposted here without modifications, except that the code is here interleaved with the text instead of in a separate box.

The full source file is 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.

The Black-Scholes model in QuantLib

I’m usually fine with changing plans, mostly because I don’t believe them in the first place; I’ve been long known to quote the old adage attributed to Eisenhower that plans are worthless but planning is everything. I won’t confirm nor deny that the reason was to try and avoid giving estimates on projects.

Which is a long-winded way to say that I had planned another column for this month, but I happily jumped on the bandwagon when I was told by our esteemed editor that this issue of the magazine would celebrate the 50th anniversary of the Black-Scholes model. The model is provided in QuantLib, of course, and after 50 years from publication it is still pretty useful—a fact that bodes well for all those who, like myself, are even older than that.

Black-Scholes implementations in the library

The Black-Scholes model was one of the first we added to QuantLib because European options were a textbook example we wanted to have when we started; and later on, we kept coming back to it one way or another as a benchmark. Adding a Monte Carlo framework to the library? Let’s price European options under Black-Scholes and check that prices are right. Adding a finite-difference framework? The same—twice, because we revamped finite differences at some point. Coding trees? You guessed it, let’s try a Black-Scholes binomial tree and check European option prices.

So, how is the Black-Scholes model used in the library? Let me count the ways.

And let’s get the includes out of the way first:

#include <ql/exercise.hpp>
#include <ql/instruments/europeanoption.hpp>
#include <ql/pricingengines/vanilla/analyticdividendeuropeanengine.hpp>
#include <ql/pricingengines/vanilla/analyticeuropeanengine.hpp>
#include <ql/pricingengines/vanilla/binomialengine.hpp>
#include <ql/pricingengines/vanilla/fdblackscholesvanillaengine.hpp>
#include <ql/pricingengines/vanilla/mceuropeanengine.hpp>
#include <ql/quotes/simplequote.hpp>
#include <ql/settings.hpp>
#include <ql/termstructures/volatility/equityfx/blackconstantvol.hpp>
#include <ql/termstructures/yield/flatforward.hpp>
#include <ql/time/calendars/target.hpp>
#include <ql/time/daycounters/actual360.hpp>
#include <iostream>

Analytic formulas

Of course, as I mentioned, the library can price European options using the Black-Scholes formula.

The setup is a bit more cumbersome than you might expect; for example, we need a full-fledged risk-free rate curve and volatility surface (even if flat, as in this case), from which the engine will pick the values corresponding to the maturity and strike of the option.

int main() {

   using namespace QuantLib;

   auto valueDate = Date(3, May, 2023);
   Settings::instance().evaluationDate() = valueDate;

   auto strike = 95.0;
   auto maturityDate = Date(3, August, 2023);

   EuropeanOption option(
       ext::make_shared<PlainVanillaPayoff>(Option::Call,
                                            strike),
       ext::make_shared<EuropeanExercise>(maturityDate));


   auto underlyingValue =
       ext::make_shared<SimpleQuote>(96.4);
   auto riskFreeCurve = ext::make_shared<FlatForward>(
       valueDate, 0.01, Actual360());
   auto volSurface = ext::make_shared<BlackConstantVol>(
       valueDate, TARGET(), 0.15, Actual360());

   auto bsProcess = ext::make_shared<BlackScholesProcess>(
       Handle<Quote>(underlyingValue),
       Handle<YieldTermStructure>(riskFreeCurve),
       Handle<BlackVolTermStructure>(volSurface));

   option.setPricingEngine(
       ext::make_shared<AnalyticEuropeanEngine>(bsProcess));

   std::cout << option.NPV() << std::endl; // prints 3.7858

As you see, we’re a long way from a simple function taking the inputs of the Black-Scholes formula and returning the resulting value. As I tried to show in previous columns, though, this setup gives us the possibility to perform operations such as changing or shifting the curves and have the value of the option change accordingly. And if you were to dig into the code of the engine, of course, you would ultimately find a class, BlackCalculator, that implements the formula based on those simpler inputs.

Now, the acute reader—that legendary creature—might have noticed that we’re using a class called BlackScholesProcess, not BlackScholesModel. This is a historical artifact; I’ll get to it later. What I’ll quickly note, instead, is that BlackScholesProcess is just one of a family of classes that share a common underlying implementation; the library implements the Black-Scholes process as the particular case of a generalized Black-Scholes-Merton process where the dividend yield is null. Other specific cases of the same generic process include, for instance, the Black process and the Garman-Kohlhagen model.

To close this section, I’ll mention that the Black-Scholes model is the base for a number of other analytic formulas implemented in the library. The same AnalyticEuropeanEngine class used above can also manage cash-or-nothing or asset-or-nothing payoffs; the AnalyticDividendEuropeanEngine can accommodate discrete dividends, as also shown in the listing; and the library provides analytic engines for a baker’s dozen of more or less exotic instruments such as Asian options of various kinds, barrier and double barrier options, lookback options, chooser options, exchange options, and others that you can find in the store of that old friend of Wilmott magazine, the Collector.

   auto dividends =
       DividendVector({Date(24, May, 2023)}, {1.5});

   option.setPricingEngine(
       ext::make_shared<AnalyticDividendEuropeanEngine>(
           bsProcess, dividends));

   std::cout << option.NPV() << std::endl; // prints 2.94026

Finite-differences methods

The model was also plugged in our finite-difference frameworks; plural, since the original implementation is currently being phased out by a newer one. In the listing, you can see the corresponding engine being used in its simpler form; it is also possible to customize parameters such as, for instance, the number of points in the finite-difference grid, or other finer points of the method. In this case, the Black-Scholes model is coded into classes such as FdmBlackScholesOp and FdmBlackScholesMesher: respectively, the finite-differences operator used during the calculation and the helper object used to set up the grid over which the calculation will be performed.

   option.setPricingEngine(
       ext::make_shared<FdBlackScholesVanillaEngine>(
           bsProcess));

   std::cout << option.NPV() << std::endl; // prints 3.78768

As for the analytic case, this is not the only way the model is used. Just to mention a few extensions, the same engine can price American options; other engines based on the same model modify the calculation so that it can be used to price, for instance, barrier options; and other engines exist that couple the Black-Scholes model for the value of the underlying and, say, the Hull-White model for rates.

Monte Carlo simulations

Looking at the next bit of listing, you’ll see next that it instantiates—possibly with an unfamiliar syntax—a Monte Carlo engine. Like for finite differences, the library provides a general framework in which specific models can be used. In this case, the object providing the specialized behavior for the Black-Scholes model is the same instance of BlackScholesProcess we’ve been using all along. As the implementation of a stochastic process, it has methods that the Monte Carlo framework can call to obtain the information it needs during path generation, such as the drift and diffusion terms at any given point in the path.

   option.setPricingEngine(
       MakeMCEuropeanEngine<LowDiscrepancy>(bsProcess)
           .withAntitheticVariate()
           .withSteps(1)
           .withSamples(32767));

   std::cout << option.NPV() << std::endl; // prints 3.78531

Again, this is the most basic usage of the framework. The same process can be used to price other kinds of options (Asian ones, for instance), and multiple Black-Scholes processes can be correlated and used in a higher-dimensional simulation to price, say, basket options.

Binomial trees

Finally, the next bit of code instantiates an engine based on an underlying binomial tree. Again, the framework is generic and the model is plugged in via a specific BlackScholesLattice class. There are a few available choices for the way the tree is built; in this listing, I’m using a method developed by the late Mark Joshi, whom I like to remember as a contributor to our library.

   auto timeSteps = 100;
   option.setPricingEngine(
       ext::make_shared<BinomialVanillaEngine<Joshi4>>(
           bsProcess, timeSteps));

   std::cout << option.NPV() << std::endl; // prints 3.75728
}

As you came to expect by now, the Black-Scholes process can be used in a few different ways in this framework. The same binomial engine shown in the listing can price American options, for instance; and I’ll also mention that a binomial Black-Scholes tree also underlies the implementation of a Tsiveriotis-Fernandes engine for pricing convertible bonds—though that method is known to have drawbacks; I’d be glad to have contributions in that area covering more modern pricing methods.

What do we get out of this?

Of course, the above is just a quick catalog of ways the Black-Scholes model is used, and was meant to showcase its lasting impact; I could hardly show any details in 1500 words. For a whole lot of those details, you can have a look at Implementing QuantLib where all those framework are described in full. However, I do have some parting remarks and some small hope that they might be food for thought for a quantitative developer.

According to Bjarne Stroustrup, there are only two kinds of languages: the ones people complain about and the ones nobody uses. Well, si parva licet componere magnis, QuantLib is a library that people, myself included, do complain about. If you get around to read Implementing QuantLib, you’ll see that I criticized plenty of things, and our implementation of the Black-Scholes process is one of those. Why, you say?

Geological layers

Good question. As I mentioned at the beginning of the column, the Black-Scholes model was one of the first things we implemented, and this might make it prone to technical debt. The early phases of a project, by definition, is when we know the least about it—in the case of a library such as QuantLib, for instance, about how it will be used and in which way it will grow—and this will lead to non-optimal choices.

Later on, when we understand things better, we should go back to the code and change it so that it reflects our new understanding. If we don’t, we make it more awkward to build further code on top of the existing one. This, and not some sense of purity, is why we talk of paying back technical debt. Of course, this doesn’t always happen, and for all kinds of good reasons.1

An example? We initially wrote the BlackCalculator class so that it doesn’t take the usual parameters such as the volatility and the risk-free rate; instead, it takes equivalent but more unusual ones, such as the standard deviation of the expected distribution and the discount factor. It probably looked like a neat idea at the time, but it made it a bit harder to understand.

Another example? The generalized Black-Scholes process is a jack of too many trades. It started as something to use in Monte Carlo simulations; but since it was already laying there, we got lazy and started using it as a convenient way to pass around the bunch of data it contains. The generalization itself has some tricky corner cases: search for “quantlib notebooks rho for the black process” and you’ll find a YouTube video in which I go over one such case.

How would I do it now?

Well, I would write analytic formulas in the way people, myself included, expect so they are easier to read and verify. Or we could write a general BlackScholesModel class, and it could be able to create a leaner, more efficient process for a given Monte Carlo simulation.

Now, you could correctly point out that we’re dealing with software, not masonry, so we could change it—and I would agree. The tricky part would be to do it in a way that doesn’t break code that people have written on top of QuantLib. This usually involves writing new functionality as additions, deprecating the old functionality, and waiting a whole bunch of releases before finally removing it. In the case of something as widely used as the Black-Scholes process, though, this might cause more work than it’s worth, for QuantLib developers and users alike.

So, as usual, I don’t have a plan. However, you’re welcome to have a look at the library code and try your hand at contributing some changes. I’ll be happy to look at your pull requests. See you next time!

  1. As I write this, in early February 2023, the hot bit of news is that Marie Kondo now has three kids and no longer tidies her home like she used to. Good for her, I say.