In this post, you will learn how to calibrate American options in C++ with modern methods and open-source tools, so that calibration will be really fast with source code open to review.

Source code itself is located in gituliar/tastyhedge repo on GitHub, which you can build and run it on Linux or Windows. This repo also contains all necessary market data to reproduce discussed examples.

Calibration is a process of fitting parameters of the model to the market data. The model itself is used to quantify risks due to the market moves. This is an inevitable step in building advanced hedging and trading strategies.

Black-Scholes model with early exercise is our main focus. It’s suitable for pricing equity options, which are mostly of American style. Models with the early-exercise feature have no analytical solution and usually are solved with time-consuming numerical methods. We’ll use a boundary-interpolation method, which is available in QuantLib. This modern method is very fast and is essential for anyone interested in quantitative finance.

In my previous post, I discussed in details another method for pricing American options – finite-difference method. This method is much slower, however more universal and can be used to solve a broader range of problems, similar to Monte-Carlo. Every seasoned quant I met is familiar with this method.

Introduction

Trading options is possible without a risk management strategy. You don’t necessary need to understand a pricing model and all risk measures it calculates (like delta, vega, gamma, etc.). Option prices are available in the trading app that is provided by your broker. You can buy or even sell options pretty much like you do with stocks.

A naive approach like this, will eventually lead to a disaster very soon. Hence, if your intention goes beyond betting on a stock market with options, you’d better hedge your portfolio and keep under control your risk limits.

Volatility is the only parameter of the Black-Scholes model. We consider to use this model for risk management as option prices alone can’t quantify the market risk. Your broker will likely provide implied volatility data.

Prerequisite

Imagine for a moment that we want to run a brokerage business. Apart from the main service – to execute client orders – we also want to provide volatility data, so that our clients can manage risk of their positions and survive in the whirl of the financial markets. In addition, we might consider to manage market risk of our own positions if we decide to take some risk too.

Market data is provided by the exchange. This includes option prices, volume, open interest, etc. Perfectly, we’d like to get this data in real-time, however for the calibration exercise we are fine with historical data.

Tesla is good candidates to test our approach. It is liquid and pays no dividends, which simplifies the calibration process. As a dataset, let’s take Tesla options on 2023-05-01 with a 5-min interval. There are 3'634 options in every snapshot or, 283'360 options in total.

Interest rate is another input to the Black-Scholes model. Its origin largely depends on how we going to hedge the interest-rate risk. As this is not our main focus for the moment, let’s take freely available Daily Treasury Par Yield Curve Rates from the U.S. Treasury.

The dataset with all mentioned market data is located in gituliar/tastyhedge/mds.

Risk analytics is the last piece we need to run our brokerage firm. This sort of programs is proprietary and expensive, however we can build it ourselves. After all, that’s what this post is about, so let’s dive in.

Step 1: European Calibration

The first step in our approach is to calibrate European options. The idea is to start with the European volatility and adjust it repeatedly until it replicates the American price. For more details see Step 3.

European calibration has no closed-form solution. Fortunately, there is a very efficient numerical algorithm: Let’s Be Rational by Peter Jaeckel. Its reference implementation is available in C++ and other languages. We will use it like this:

 1/// File: src/Analytics/Model_BlackScholes.cpp
 2
 3Error
 4calibrateEuropean(
 5  f64    v,     //  option price
 6  f64    s,     //  stock price
 7  f64    k,     //  strike
 8  f64    dte,   //  days to expiration
 9  f64    r,     //  interest rate
10  f64    q,     //  dividend rate
11  Parity w,     //  put / call
12  f64&   z)     //  implied volatility
13{
14  f64 t = dte / kDaysInYear;
15  f64 k_ = k * exp(-t * r);
16
17  z = implied_volatility_from_a_transformed_rational_guess(v, s, k_, t, w);
18
19  return "";
20}

Performance

2'800'000 opt/s is how many European options I’m able to calibrate on my machine with AMD Ryzen 9 CPU. You may wonder whether this is a lot or not ?

The options market has about 1'500'000 options listed on 5'000 stocks. Hence, we can calibrate the entire market in just 1/2 of a second on a single CPU core. Of course, this is not a nanosecond scale, required for high-frequency trading, however it’s more than enough for most hedging and trading strategies.

Advanced statistic with per-call distribution time of calibrateEuropean, collected with Tracy profiler, looks as following:

Step 1: Statistics

Step 2: American Pricing

The second step is to adjust our initial estimate to a desired tolerated error. You’ll see how to do this in the next step. For now we need to learn how to efficiently price American options.

American pricing is costly because of the early-exercise feature. Fortunately, there is a modern boundary-interpolation method by Andersen et al. It’s available in QuantLib and is probably the fastest method to price American options.

QuantLib is an advanced library with many features, so we need to perform some preparation steps prior to calling the pricing algorithm:

 1/// File: src/Analytics/Model_BlackScholes.cpp
 2
 3Error
 4priceAmerican(f64 s, f64 k, f64 dte, f64 z, f64 r, f64 q, Parity w, f64& v)
 5{
 6  /// Anchor + Maturity
 7  ///
 8  auto anchor = ql::Date(31, ql::Jul, 1944);
 9  auto act365 = ql::Actual365Fixed();
10  auto maturity = anchor + std::ceil(dte);
11
12  ql::Settings::instance().evaluationDate() = anchor;
13
14  /// Option Data
15  ///
16  ql::Option
17    w_ = (w == kParity_Call) ? ql::Option::Call : ql::Option::Put;
18
19  ql::Handle<ql::YieldTermStructure>
20    r_ = make_shared<ql::FlatForward>(anchor, r, act365);
21
22  ql::Handle<ql::YieldTermStructure>
23    q_ = make_shared<ql::FlatForward>(anchor, q, act365);
24
25  ql::Handle<ql::Quote>
26    s_ = make_shared<ql::SimpleQuote>(s);
27
28  ql::Handle<ql::BlackVolTermStructure>
29    z_ = make_shared<ql::BlackConstantVol>(anchor, ql::TARGET(), z, act365);
30
31  /// Black-Scholes Model
32  ///
33  auto bsm = make_shared<ql::BlackScholesMertonProcess>(s_, q_, r_, z_);
34  auto engine = make_shared<ql::QdFpAmericanEngine>(
35    bsm, ql::QdFpAmericanEngine::fastScheme());
36
37  auto payoff = make_shared<ql::PlainVanillaPayoff>(w_, k);
38  auto americanExercise = make_shared<ql::AmericanExercise>(anchor, maturity);
39  ql::VanillaOption americanOption(payoff, americanExercise);
40
41  americanOption.setPricingEngine(engine);
42
43  /// Boundary-Interpolation Pricer
44  ///
45  try {
46      v = americanOption.NPV();
47  }
48
49  /// Error Handling
50  ///
51  catch (...) {
52      std::exception_ptr ep = std::current_exception();
53      try {
54          std::rethrow_exception(ep);
55      }
56      catch (std::exception& e) {
57          return "priceAmerican : "s + e.what();
58      }
59  }
60
61  return "";
62}

Performance

45'000 opt/s is how many American options I can price on the same machine. It’s not as impressive as 2'800'000 opt/s for European calibration. But it’s about 100x faster than pricing with the finite-difference method. See my post on pricing American options on CPU and GPU for detailed benchmarks.

Advanced statistic with per-call distribution time of priceAmerican, collected with Tracy profiler, looks as following:

Step 2: Statistics

Step 3: American Calibration

The third step, and the final one, is to adjust our initial estimate repeatedly until it replicates the American price to the desired tolerance. As option prices are quoted with $0.01 step, we can safely tolerate the error within that range.

Newton’s method is a classical algorithm to numerically find roots of a real-valued function. In our case, the function is a difference between the model and market prices of the option, while unknown variable is implied volatility.

The final implementation looks as:

 1/// File: src/Analytics/Model_BlackScholes.cpp
 2
 3Error
 4calibrateAmerican(f64 v, f64 s, f64 k, f64 dte, f64 r, f64 q, Parity w, f64& z)
 5{
 6  Error err;
 7
 8  /// Initial guess
 9  ///
10  if (auto err = calibrateEuropean(v, s, k, dte, r, q, w, z); !err.empty())
11    return "calibrateAmerican : " + err;
12
13  /// Newton's solver
14  ///
15  f64 v_ = v;
16  s16 n = 16;
17  while (n-- > 0 && !std::isnan(z)) {
18    if (auto err = priceAmerican(s, k, dte, z, r, q, w, v_); !err.empty())
19      return "calibrateAmerican : " + err;
20    if (std::isnan(v))
21      break;
22
23    const f64 tolerance = 0.005;
24    if (std::abs(v - v_) < tolerance)
25      /// Solution found
26      return "";
27
28    /// Boundary-Interpolation Pricer
29    ///
30    f64 vUp;
31    const f64 dz = 0.0001;
32    if (err = priceAmerican(s, k, dte, z + dz, r, q, w, vUp); !err.empty())
33      break;
34
35    /// Finite-difference derivative
36    ///
37    f64 dvdz = (vUp - v_) / dz;
38    z -= (v_ - v) / dvdz;
39  }
40
41  /// No solution
42  ///
43  z = NaN;
44  return err;
45}

Performance

16'500 opt/s is how many American options I can calibrate on my machine. Effectively, we make 3 pricing calls per calibration. It’s 170x slower than European calibration with Let’s Be Rational by Jaeckel, but much faster comparing to the finite-difference.

Advanced statistic with per-call distribution time of calibrateAmerican, collected with Tracy profiler is shown below.

The distribution indicates that:

  • Deep in- and out-the-money options are cheap to calibrate, as the volatility is the same for European and American cases, hence the initial guess is already an answer, see the biggest spike around 10 us region (to the left of the median time).
  • At-the-money options, on the other hand, require several adjustment steps, hence more priceAmerican calls. See spikes around 100 us region:

Step 3: Statistics

Conclusion

In order to build advanced hedging and trading strategies, portfolio managers need to quantify market risk of their portfolios. This is what pricing models are used for.

In this post, we saw how to calibrate the Black-Scholes model to Tesla American option prices and yield curve rates using C++ and modern quantitative methods and demonstrated how it performs on the modern AMD Ryzen 9 CPU.

Our approach allows to calibrate 16'500 opt/s on a single CPU core. At this speed we can calibrate mid prices for the entire market of 1'500'000 options listed on 5'000 stocks in just 45 s.

Advanced statistic for various functions, collected with Tracy profiler, looks like this:

Summary