Option pricing using the Black-Scholes model, without the formula | by Daniel Reti | Towards Data Science

Every university student taking a module on finance has seen the Black-Scholes-Merton option pricing formula. It is long, ugly, and confusing. It doesn’t even give an intuition for pricing options. The derivation of it is so difficult that Scholes and Merton received a Nobel prize for it in 1997 (Black died in 1995). It relies on the Feynman-Kac theorem and risk-neutral measures, but I will not get into it.
(https://en.wikipedia.org/wiki/Black%E2%80%93Scholes_equation).
Pricing an option can be done using the Black-Scholes partial differential equation (BS PDE). The BS PDE can be derived by applying Ito’s Lemma to geometric Brownian motion and then setting the necessary conditions to satisfy the continuous-time delta hedging.
We will solve this equation numerically, using Python. The main advantage of this method is that it bypasses very complicated analytical calculations with numerical methods, which are done by our computer.
As we are trying to solve the PDE numerically, we need to establish a few things before. Firstly, we will use it to value a European call option with strike price K. The method I will be using is called the finite difference method and it involves setting up a grid of points.
This grid will be used to simulate the PDE from point to point. The grid on the x-axis will represent the simulated times, which ranges from [0,1] and the y-axis will represent the possible stock prices, ranging from [S_min,S_max] . The result will be a 3D graph and our job is to determine the option price above each point on the grid.
To simulate on the grid we need to determine 3 boundaries (edge) conditions of the grid. In our case, we can do this for the top, the bottom, and the last boundary of the grid. We will also see later, that because of this we will simulate the equation backward. The bottom is the easiest, we will set S_min=0 . The top condition is a bit more tricky. It should be well above the option’s strike price K to ensure the option’s value V = max( S-K, 0) will always (with negligible probability of not happening p < 0.0001) payout S-K . We can do this by setting S_max 8 standard deviations away from the mean, as the stock price is log-normally distributed. So, if we take S_max=8sigma*(T-t)^0.5 , we have ensured that property.
The option value V at S_max can be deducted using a replication argument. If a derivative pays S-K at time t, we can replicate it by purchasing 1 unit of stock and putting e^(-r(1-t)*K it into a risk-free bank account. So this makes the value of the option for large S: V(t,S_max)=S_max — e^(-r(1-t)*K .
The final boundary condition will be the European call option’s payoff, as that will give the exact value for the option. So the three boundary conditions are:
We have now established the boundary conditions for the grid. Next, we need to discretize our space, which will allow us to use a central difference estimate for the derivatives of the BS PDE.
In the stock price direction (vertical) we introduce M points. To think about this, imagine that the possible range of S is [0,100] with M=100 . This will make 100 stock points with 1 at every integer. We do the same in the time direction (horizontal) with N steps. This will create a N+1 x M+1 matrix, which we can use to create derivative estimates.
With the discretized space, we can use central difference estimates for the derivatives of the option value (the delta and gamma from the greeks).
Plugging these into the Black-Scholes PDE, we get
This can be understood easily by visualizing what the above equation does. It basically takes 3 points and calculates a weighted average of those to arrive at a point 1 step forward in time.
By iterating the above process we can simulate the above grid by going one step at a time. Note, that as we know the final boundary condition and not the first, we will be actually be going back in time.
Applying the Euler-Maruyama scheme to the discretized Black-Scholes PDE, we get:
This is a system of ODEs, where V is the column vector of option prices at each timestep. Moreover, we need to add a vector to the above equation that contains the boundary conditions at time t: W_t , and then we can rewrite the equation in matrix notation, such that Lambda contains the multipliers. This equation now contains all the information we have and is called the explicit method. It uses the backward difference estimate for V concerning t, which is equivalent to the forward's difference if we were simulating forward it time (We are simulating backward).
So, to code it up we need functions for the boundary conditions.
import numpy as npimport scipy.sparseimport matplolib.pyplot as pltfrom mpl_toolkits.mplot3d import Axes3Ddef bottom_boundary_condition(K,T,S_min, r, t):    return np.zeros(t.shape)def top_boundary_condition(K,T,S_max, r, t):    return S_max-np.exp(-r*(T-t))*Kdef bottom_boundary_condition(K,T,S_min, r, t):    return np.maximum(S-K,0)
We also need functions to calculate the coefficients in Lamda . I wrote two functions for this, which I derived after doing some algebraic manipulations to the equation with the central difference estimates to isolate each V_i .
def compute_abc( K, T, sigma, r, S, dt, dS ):    a = -sigma**2 * S**2/(2* dS**2 ) + r*S/(2*dS)    b = r + sigma**2 * S**2/(dS**2)    c = -sigma**2 * S**2/(2* dS**2 ) - r*S/(2*dS)    return a,b,cdef compute_lambda( a,b,c ):    return scipy.sparse.diags( [a[1:],b,c[:-1]],offsets=[-1,0,1])def compute_W(a,b,c, V0, VM):     M = len(b)+1    W = np.zeros(M-1)    W[0] = a[0]*V0     W[-1] = c[-1]*VM     return W
Combining all this in a function that essentially populates the N x M matrix with option values, and returns the option value V , the times t and the stock prices S .
def price_call_explicit( K, T, r, sigma, N, M):    # Choose the shape of the grid    dt = T/N    S_min=0    S_max=K*np.exp(8*sigma*np.sqrt(T))    dS = (S_max-S_min)/M    S = np.linspace(S_min,S_max,M+1)    t = np.linspace(0,T,N+1)    V = np.zeros((N+1,M+1)) #...        # Set the boundary conditions    V[:,-1] = top_boundary_condition(K,T,S_max,r,t)    V[:,0] = bottom_boundary_condition(K,T,S_max,r,t)    V[-1,:] = final_boundary_condition(K,T,S) #...        # Apply the recurrence relation    a,b,c = compute_abc(K,T,sigma,r,S[1:-1],dt,dS)    Lambda =compute_lambda( a,b,c)     identity = scipy.sparse.identity(M-1)        for i in range(N,0,-1):        W = compute_W(a,b,c,V[i,0],V[i,M])        # Use `dot` to multiply a vector by a sparse matrix        V[i-1,1:M] = (identity-Lambda*dt).dot( V[i,1:M] ) - W*dt            return V, t, S
Plotting t and S against V , we get a very nice plot of the option payoff at every time-stock combination.
We can see that at t=1 the option value is exactly equal to its payoff, which is a great sanity check. Below you can see how the curve evolves into the option payoff at the final time. This is exactly what we want.
Pricing an option using the Black-Scholes PDE can be a very good intuition building example, but sadly it cannot really be used in practice. Mainly because it is slow to use and we have the formula to use. My above method can be made more robust by tuning the Crank-Nicholson method to simulate, which makes the process less sensitive.
Let me know if you would like to know more about the derivation, or a more in-depth review of the code or the Crank-Nicholson method.
I would like to add that I learned a lot from Dr. John Armstrong, my lecturer in financial maths at KCL. https://nms.kcl.ac.uk/john.armstrong/
If you are unfamiliar with the Black-Scholes model, have a look at this article to get a great introduction: https://medium.com/cantors-paradise/the-black-scholes-formula-explained-9e05b7865d8a