Expanding the Binomial Option Pricing Model

Posted on Thu 29 March 2018 in Finance

This post will be the last post, at least for the time being, in the series discussing the binomial model for pricing options. In the previous post we implemented this model in Python in order to find prices for basic European call options. In this post, we'll expand the implementation so that it can be used to price a wider variety of options, including American options.

Pricing European Put Options

To begin, let's take a second look at the implementation we found in the previous post to price call options.

import math

def binomial_call(S, K, T, r, vol, N):
    dt = T/N
    u =  math.exp(vol * math.sqrt(dt))
    d = 1/u
    p = (math.exp(r * dt) - d)/(u - d)
    C = {}
    for m in range(0, N+1):
            C[(N, m)] = max(S * (u ** (2*m - N)) - K, 0)
    for k in range(N-1, -1, -1):
        for m in range(0,k+1):
            C[(k, m)] = math.exp(-r * dt) * (p * C[(k+1, m+1)] + (1-p) * C[(k+1, m)])
    return C[(0,0)]

How would we need to change this function to price a put option instead? Recall that a call option gives the right to buy the underlying stock for a particular price, while a put option gives the right to sell for a particular price. The only real difference between the two is the payoff function. If either type of option has a strike price of \(K\), and if we use \(S_T\) to denote the spot price of the stock at expiration, then the call option is worth

\begin{equation*} C = \max(S_T-K,0) \end{equation*}

at expiration, since the option allows one to purchase the stock for \(K\) and immediately resell in the market for \(S_T\) for a profit of \(S_T-K\), and one would only exercise this option if \(S_T-K > 0\). Similarly, the payoff of a put option is

\begin{equation*} P = \max(K-S_T,0) \end{equation*}

since, if one held the option, they could purchase the stock for \(S_T\) in the market and resell for \(K\) using the option for a profit of \(K-S_T\), and again this option is only exercised if \(K-S_T > 0\). To modify the code above to price a put option, we only need to change the part where we compute the value of the option at expiry to use this other payoff function. The resulting function is below.

def binomial_put(S, K, T, r, vol, N):
    dt = T/N
    u =  math.exp(vol * math.sqrt(dt))
    d = 1/u
    p = (math.exp(r * dt) - d)/(u - d)
    C = {}
    for m in range(0, N+1):
        C[(N, m)] = max(K - S * (u ** (2*m - N)), 0) #New payoff function for put options
    for k in range(N-1, -1, -1):
        for m in range(0,k+1):
            C[(k, m)] = math.exp(-r * dt) * (p * C[(k+1, m+1)] + (1-p) * C[(k+1, m)])
    return C[(0,0)]

The above code works perfectly well, but what if we had a third type of options trade with a third payoff? For example, we could consider an options trading strategy called a straddle. Such a trade consists of buying both a call option and a put option on a stock, with the strike price for both equal to the price of the stock today. What is such a trade good for? If a trader owns a straddle and the price of the stock goes up, then they can exercise the call option and receive the change in price. On the other had, if the price goes down, they can exercise the put option instead. This isn't actually a free money machine like it sounds - the trader has to pay some amount to purchase the straddle, and so they'll only make a profit if the change in the price of the stock is greater than the price they paid for the straddle. Therefore a trader who buys a straddle is betting that the stock will make a large change in its value over the life of the option, but can't decide whether that change will be an increase or decrease.

How could one find the price of a straddle? Well, a straddle consists of a call and a put, so one method to price a straddle would be to use the above functions to find the price of the call and put separately and simply add them together. A second option, though, is to consider the payoff function of the straddle. It's not hard to see that the payoff is simply

\begin{equation*} \text{Straddle} = |S_T - K| \end{equation*}

where \(K\) is the strike price on the two options, assumed to be the value of the stock today. We could therefore write a third function to price straddles, modifying the function in the final payoff step to be the payoff function for straddles. This method will give the same price as the one we'd get by pricing the put and call options in the straddle separately, but will run roughly twice as fast since it will require constructing only one binomial pricing tree instead of two.

Of course, a straddle is but one example of any number of possible options strategies that can consist of long and short positions in multiple options with varying types and strike prices. Many of these have fun names like a strangle, an iron condor, or, most bizarrely, the jade lizard. Since all of these strategies are combinations of simpler call and put options, we could price them by finding the price of all of the components and taking the sum. This is more computationally expensive than it needs to be, though, especially for complicated strategies that could involve many options. The easier pricing method is to observe that each of these strategies also has a single function that determines the payoff of the option from the spot price of the asset at expiration. (It's the sum of the payoff functions of all of the options involved in the strategy.) Instead of writing tens or even hundreds of different functions for all the various different type of options trades, we'll instead modify the function we've defined above so that we can easily change the payoff associated to the option.

The resulting function is defined below. We also give some functions to produce the payoff function of a call, put, and straddle.

def binomial_model(S, T, r, vol, payoff, N):
    """
    payoff is a function that describes the payoff of the option,
        at expiry, as a function of the value of the underlying
    """
    dt = T/N
    u =  math.exp(vol * math.sqrt(dt))
    d = 1/u
    p = (math.exp(r * dt) - d)/(u - d)
    C = {}
    for m in range(0, N+1):
            C[(N, m)] = payoff(S * (u ** (2*m - N)))
    for k in range(N-1, -1, -1):
        for m in range(0,k+1):
            C[(k, m)] = math.exp(-r * dt) * (p * C[(k+1, m+1)] + (1-p) * C[(k+1, m)])
    return C[(0,0)]

def call_payoff_with_strike(K):
    return lambda S: max(S-K,0)

def put_payoff_with_strike(K):
    return lambda S: max(K-S,0)

def straddle_payoff_with_strike(K):
    return lambda S: abs(S-K)

As a simple check, note that our new method to price a call gives the same answer as our old function for pricing the same option. (The various parameters below were chosen arbitrarilty.)

print(binomial_call(100, 115, 0.25, 0.015, 0.2, 500))
print(binomial_model(100, 0.25, 0.015, 0.2, call_payoff_with_strike(115), 500))
0.42805363986800904
0.42805363986800904

Our new functions are also consistent, in that the price of a straddle is the sum of the call and put prices.

call = binomial_model(100, 0.25, 0.015, 0.2, call_payoff_with_strike(100), 500)
put = binomial_model(100, 0.25, 0.015, 0.2, put_payoff_with_strike(100), 500)
straddle = binomial_model(100, 0.25, 0.015, 0.2, straddle_payoff_with_strike(100), 500)
print(call + put)
print(straddle)
7.962206120543319
7.9622061205433186

The above code suggests that it may be clarifying to rewrite our code using an object-oriented approach - instead of taking the payoff function itself as an argument in our function, we could define an Option class that would include a method to compute the payoff of the option. Before we begin making those larger changes, though, we'll also consider what changes we need to make to our implementation to be able to price American options.

Pricing American Options

Recall that while European options can only be exercised on one particular day, American options can be exercised at any time before they expire. Since American options give their holders more rights than European options, American options should be worth at least as much as their European counterparts. But how much more: how much is the ability to exercise the option early worth?

This is related to what is called the intrinsic value of an option, which is defined to be the value the option would have if it were exercised now, regardless of whether or not we're actually able to exercise the option at this time. For example, if you own a European call option on a stock with strike price $100 that expires in 3 months, and the stock price right now is $105, then the intrinsic value of the option today is $5, even though the option can't actually be exercised right now.

It is important to note that the intrinsic value of the option is not the same as the price of the option today - for example, in the situation we just described, if we us the same values for the risk-free rate and volatility that we've assumed in our examples above then the price of the option given by the model (using \(N = 500\) steps) is

binomial_model(105, 0.25, 0.015, 0.2, call_payoff_with_strike(100), 500)
7.318672372367908

Note that this value is actually higher than the intrinsic value of $5 - we'll discuss this point more later.

For a European call option, the idea of intrinsic value doesn't make a big difference in the actual pricing, since these options can only be exercised at a specific time. An American option, though, can always be exercised to gain the intrinsic value. To find the value of an American option at any particular time, we need to consider both the expected future value of the option (as we have done for European options) and the intrinsic value. The actual value of the option at that time is then the larger of these two values.

We can easily modify our earlier code to accommodate this change. In the main loop where we work backward through the tree, we have already calculated the theoretical future value of the option. We can simply add an extra line of code to compute the intrinsic value of the option, and then take the maximum to find the value of the option at each node. Of course, we only want to make this change for pricing American options, and so this aspect of the calculation needs to be something controlled by the Option class in the object-oriented approach we're about to implement.

An Object-Oriented Implementation

Our model has three components that are fairly distinct, namely a stock, an option on that stock, and a binomial model to find the price of that option. Each of these concepts will have its own corresponding class. The simplest class is the one that will describe a stock. In our model, the stock really has only two values associated to it - today's spot price and the stock's volatility - and so the definition of this class is very simple.

class Stock:
    def __init__(self, spot, vol):
        self.spot = spot
        self.vol = vol

With such little information, we don't actually need the overhead of defining this stock class, but it's worth it to separate the stock from the concept of an option since options can be written on many underlying assets.

As far as the option is concerned, we have a few pieces of information that define the option itself - the underlying asset, the expiration date of the option, and the payouts associated to the option. To consider the distinction between American and European options, we describe the payout of the option using two different functions, one that gives the final value at expiration and another function for the payout associated to early exercise. A base class from which our other option types will inherit is defined below.

class Option:
    def __init__(self, underlying, expiry):
        self.underlying = underlying
        self.expiry = expiry

    def final_payoff(self, spot):
        raise NotImplementedError("Final option payoff is not defined")

    def early_payoff(self, spot):
        raise NotImplementedError("Early exercise payoff is not defined")

We can then implement subclasses that give the specific behavior of American and European call and put options. These options have an extra parameter associated to them, the strike price of the option. For European call options, the final_payoff function is the same as what we've used before, and the early_payoff function just returns 0 since the option cannot be exercised early.

class EuroCall(Option):
    def __init__(self, underlying, expiry, strike):
        super().__init__(underlying, expiry)
        self.strike = strike

    def final_payoff(self, spot):
        return max(spot - self.strike,0)

    def early_payoff(self, spot):
        return 0

class EuroPut(Option):
    def __init__(self, underlying, expiry, strike):
        super().__init__(underlying, expiry)
        self.strike = strike

    def final_payoff(self, spot):
        return max(self.strike - spot,0)

    def early_payoff(self, spot):
        return 0

We can also define classes to represent American options. These are just like European options, except the early_payoff function is the same as the final_payoff function.

class AmerCall(EuroCall):
    def early_payoff(self, spot):
        return self.final_payoff(spot)

class AmerPut(EuroPut):
    def early_payoff(self, spot):
        return self.final_payoff(spot)

This framework is flexible enough to allow for many other types of options beyond combinations of calls and puts. For example, instead of American options, where the final_payoff and early_payoff functions are the same, we could consider options that have an entirely different payoff if exercised early, perhaps with a some kind of early-exercise penalty. We could also easily add a time parameter to the early_payoff function, and consider more complicated options that could, for example, only be exercised on certain days, or have different strike prices over the life of the option.

Finally, we define a class that contains the actual binomial pricing method. This class takes in the option and the risk-free rate as an input, and then includes a method that runs the binomial model for a given number of steps to find its price. Since our model will now need to take into account the possible intermediate values of the stock price in order to price American options, we define an extra function to make that computation. Otherwise, the code is very similar to what we have had before, except some of the parameters are now considered as attributes of various objects instead of as function arguments.

class BinomialModel:
    def __init__(self, option, r):
        self.option = option
        self.r = r

    def price(self, N=500):
        dt = self.option.expiry/N
        u =  math.exp(self.option.underlying.vol * math.sqrt(dt))
        d = 1/u
        p = (math.exp(self.r * dt) - d)/(u - d)

        # Computes the price of the underlying asset k steps into the tree with m up movements
        def S(k,m):
            return self.option.underlying.spot * (u ** (2*m-k))

        C = {}
        for m in range(0, N+1):
            C[(N, m)] = self.option.final_payoff(S(N,m))
        for k in range(N-1, -1, -1):
            for m in range(0,k+1):
                future_value = math.exp(-self.r * dt) * (p * C[(k+1, m+1)] + (1-p) * C[(k+1, m)])
                exercise_value = self.option.early_payoff(S(k,m))
                C[(k, m)] = max(future_value, exercise_value)
        return C[(0,0)]

Just to make sure we haven't introduced any errors, we can check to make sure that our new code finds the same prices as before. Consider the example we computed before:

binomial_call(100, 115, 0.25, 0.015, 0.2, 500)
0.42805363986800904

Using our new code, which now requires a few more lines to write, we have

test_stock = Stock(100, 0.2)
euro = EuroCall(test_stock,0.25,115)
BinomialModel(euro,0.015).price()
0.42805363986800904

Thankfully, it appears we haven't broken anything in refactoring our code to make it object-oriented.

American vs. European Options

American options, as we've discussed above, are essentially European options along with the extra right to exercise early. It seems this extra right should be worth something, and that an American option should be worth more than its European counterpart. Is that actually the case?

In particular, with can price an American call option with the same parameters as the European option we priced at the end of the last section:

amer = AmerCall(test_stock,0.25,115)
BinomialModel(amer,0.015).price()
0.42805363986800904

This price is the same as the price for the European option, and we just argued that the American option should be worth more. Did we make a mistake in our model?

It turns out that the early exercise rights of an American option do not necessarily have any value. Looking at our code, we see that the American option will only be worth more than the European option if there are times when the "exercise value" (which is also the intrinsic value of the option) is worth more than the expected future value of the option. For American call options, this will never actually occur if the risk-free interest rate is positive. (This is actually only true for stocks that don't pay dividends, which are the only stocks we've been considering so far. If the stock pays a dividend during the life of the option, then it may be worth it to exercise the call option early so that one can hold the stock and receive the dividend payment.)

To see this, consider a portfolio that consists of one share of the asset underlying the call option along with a obligation to pay \(K\) dollars on the expiration date of the option (i.e., in order to repay a risk-free loan). Let \(S_t\) denote the value of the stock at a given time, and \(Z_t\) denote the value of the loan. If interest rates are positive, we have that \(Z_t < Z_T\) for all \(t < T\). Let \(C_t\) denote the value of the option at time \(t\), and let \(T\) denote the expiration time of the option.

At expiration, we have that \(Z_T = K\) and so the value of the portfolio is \(S_T-Z_T = S_T - K \leq \max(S_T-K,0) = C_T\). Therefore by no-arbitrage principles we have that \(S_t - Z_t \leq C_t\) for all earlier times. Since interest rates are positive, we have \(S_t-Z_t > S_t-K\), and so combining these inequalities gives that

\begin{equation*} S_t - K < S_t - Z_t \leq C_t \end{equation*}

that is, the value of a call option at a particular time is always greater than the intrinsic value of the option. In particular, an investor is never better off by exercising an American call option early, and so the extra rights of an American call option are worthless. If an investor holds the option and wanted to cash out, it would always be better for them to sell the option than to exercise it. Note that our argument was based on no-arbitrage principles, and therefore doesn't rely on any of the special assumptions about price movement of the underlying asset in our particular binomial model.

American put options really are different from European puts, though. There are times when it is better to exercise an American put option early, and so American put options can be worth strictly more than their European counterparts. For example, sticking with the same stock and option parameters we've been considering, but pricing put options instead, gives

amerp = AmerPut(test_stock,0.25,115)
print(BinomialModel(amerp,0.015).price())
europ = EuroPut(test_stock,0.25,115)
print(BinomialModel(europ,0.015).price())
15.183131872004736
14.997611223826567

Note that the American option is in fact worth more. One possible way we could improve our code would be to add a method to the BinomialModel class that could find the nodes in the tree that correspond to situations in which early exercise of the option is preferred.

Conclusion

And that's it! It's taken a fair amount of work, but we've managed to build a fairly efficient model for pricing a number of different types of options on simple stocks. Even better, we did this without any hardcore mathematics - our model uses nothing more complicated than basic probability, and all of the hard work is done by a computer in simulation.

Of course, there are still plenty of ways that our model could be improved. For example, we've taken the volatility of the underlying stock as a given, but where does this number actually come from? We've also ignored dividends that might be paid on the stock, transaction costs, and tax considerations. But the underlying theory of the model, and our specific implementation of it in Python, is flexible enough that we could continue to make modifications to address these concerns. If you'd like to do that, you can check out a consolidated and lightly-commented version of the final implementation on my Github page.