In this article we cover the Black-Scholes model for modeling a a divideng-paying stock. We will verify the basic property of the model by looking at the realized profit and loss (P/L) of a trader that is short an option and performs delta hedging.

The Python environment is quite simple and requires a few standard packages.

$ python -mv venv venv
$ ./venv/Scripts/activate
$ pip install numpy matplotlib seaborn scipy pandas

We assume that the stock process $S(t)$ follows the dynamics

\[\frac{dS(t)}{S(t)} = (r - q) dt + \sigma dW(t),\]

with $S(0) = S_0$ the initial value, $r$ the deterministic discount factor, $q$ the deterministic dividend yield, and $dW(t)$ a Wiener process. We assume $r$, $q$, and $\sigma$ to be constant values, even if it is easy to extend the results to time-varying (yet deterministic) functions.

from collections import namedtuple
import matplotlib.pylab as plt
import numpy as np
import pandas as pd
from scipy.stats import norm
import seaborn as sns

To keep things simple, we put together all the components of the model into the class Model. All the features of the vanilla contract are in the Vanilla class, where $N$ is the notional of the contract (that is, the number of shares that the client wants to buy or sell), K is the strike, T the expiry, and is_call is a boolean to distinguish put and call options. All times are in fractions of the year, starting from $t=0$.

Model = namedtuple('Model', 'r, q, σ')
Vanilla = namedtuple('Vanilla', 'N, K, T, is_call')
Φ = norm.cdf

It is well-known that the value of a vanilla option at time $t$ is given by the formula

\[C = e^{-r T} \omega \left[ F \, \Phi(ω d_+) - K \, \Phi(ω d_-)) \right]\]

where $K$ is the strike of the option, $\tau = T -t$ is the time to expiry, $F = S(t) e^{(r - q) τ}$ is the forward at time $T$, $\omega$ is 1 for a call and -1 for a put, $\Phi$ the cumulative function of the normal distribution, and

\[\begin{aligned} d_+ & = \frac{\log\frac{F}{K} + \frac{1}{2} σ^2 τ}{σ \sqrt{τ}} \\ d_- & = d_+ - σ * \sqrt{τ}. \end{aligned}\]
def compute_price(t, S_t, r, q, σ, N, K, T, is_call):
    τ = T - t
    ω = 1.0 if is_call else -1.0
    if τ == 0.0:
        return N * max(ω * (S_t - K), 0.0)
    F = S_t * np.exp((r - q) * τ)
    df = np.exp(-r * τ)
    d_plus = (np.log(F / K) + 0.5 * σ**2 * τ) / σ / np.sqrt(τ)
    d_minus = d_plus - σ * np.sqrt(τ)
    return N * ω * df * (F * Φ(ω * d_plus) - K * Φ(ω * d_minus))

To perform delta hedging, a trader needs to build the amount of shares given by the delta of the option, which reads

\[\Delta = ω e^{-r \tau} (F \, Φ(ω d_+) - K \, Φ(ω d_-)).\]
def compute_delta(t, S_t, r, q, σ, N, K, T, is_call):
    τ = T - t
    ω = 1.0 if is_call else -1.0
    if τ == 0.0:
        return 1.0 if ω * (S_t - K) > 0 else 0.0
    F = S_t * np.exp((r - q) * τ)
    d_plus = (np.log(F / K) + 0.5 * σ**2 * τ) / σ / np.sqrt(τ)
    return N * ω * np.exp(-q * τ) * Φ(ω * d_plus)

We need to generate paths that are realization of the Black-Scholes model, as done by the generate_paths() function.

def generate_paths(t_0, S_0, r, q, σ, T, num_paths, num_steps):
    assert T > t_0
    retval = [np.ones((num_paths,)) * S_0]
    X = np.log(retval[0])
    schedule = np.linspace(t_0, T, num_steps + 1)
    W = norm.rvs(size=(num_steps, num_paths))
    t = 0.0
    for i in range(num_steps):
        Δt = schedule[i + 1] - schedule[i]
        t = t + Δt
        sqrt_Δt = np.sqrt(Δt)
        X = X + (r - q - 0.5 * σ**2) * Δt + σ * sqrt_Δt * W[i, :]
        retval.append(np.exp(X))
    return schedule, np.vstack(retval)

The procedure is the following: the trader sells the option and adds the premium to the P/L; then at every time $t$ they purchase $\Delta(t)$ options at spot $S(t)$, costing $S(t) \Delta(t)$, which is the amount we need to subtract to the P/L. At $t + \delta t$ they need to purchase $\Delta(t + \delta t) - \Delta(t)$ options, and so on till expiry. In the interval $[t, t + \delta t)$ the cash accrues the interest $r$, while the shares accrues $q \Delta(t) S(t)$ in dividends. At expiry, the owner of the call will get one share if $S(T) > K$ and will pay $K$, or nothing if $S(T) \le K$; the behavior at expiry for a put option is similar. From the classical theory we would expect a zero P/L over all paths when hedging happens continuously, or a small difference otherwise.

def analyze(t_0, S_0, model, vanilla, num_steps, num_paths):
    ts, paths = generate_paths(t_0, S_0, *model, vanilla.T, num_steps, num_paths)

    pnls = []

    for i in range(paths.shape[1]):
        # compute the PNL over each path
        path = paths[:, i]
        # we are short the option
        premium = compute_price(t_0, S_0, *model, *vanilla)
        cash, shares = premium, 0.0
        # perform hedging on each time interval
        for t, t_plus_Δt, S_t in zip(ts[:-1], ts[1:], path[:-1]):
            Δt = t_plus_Δt - t
            # delta hedge at the beginning of the interval
            new_shares = compute_delta(t, S_t, *model, *vanilla)
            # hedging cost
            cash -= (new_shares - shares) * S_t
            shares = new_shares
            # interests and dividends
            cash += cash * (np.exp(model.r * Δt) - 1) + shares * (np.exp(model.q * Δt) - 1) * S_t

        # at expiry, sell the shares to the client if the option in-the-money
        T, S_T = ts[-1], path[-1]
        if vanilla.is_call and S_T > vanilla.K:
            # sell N shares at value K
            cash += vanilla.K * vanilla.N
            shares -= vanilla.N
        elif not vanilla.is_call and S_T < vanilla.K:
            # buy N shares at value K
            cash -= vanilla.K * vanilla.N
            shares += vanilla.N
        # liquidate all remaining positions, we must have zero shares at the end
        cash += shares * S_T
        pnls.append(cash)

    pnls = np.array(pnls) * np.exp(-model.r * (T -t_0))
    normalized_pnls = pnls / premium
    return normalized_pnls

We will analyze two cases: a call option first, then a put option. We take $S_0=100$ and a volatility of 25%. For a fixed number of paths (1,000), we divide the time $[0, T]$ into $n$ interval and perform delta hedging at each point. We would expect to have a P/L distribution that is centered around zero yet a bit scattered (symmetrically), with average errors getting smaller and smaller as $n\rightarrow \infty$. We have plotted the normalized P/L, defined as P/L divided by the option premium.

t_0, S_0 = 0.0, 100.0
model = Model(r=0.0, q=0.2, σ=0.25)
vanilla = Vanilla(N=1.0, K=S_0, T=1.0, is_call=True)
num_paths = 1_000
data = {}
for num_steps in [4, 8, 16, 32, 64]:
    normalized_pnls = analyze(t_0, S_0, model, vanilla, num_paths, num_steps)
    print(f"{num_steps: 2d} steps: E[PNL] = {normalized_pnls.mean():.2f}, V[PNL] = {normalized_pnls.std():.2f}")
    data[f'N={num_steps}'] = normalized_pnls
 4 steps: E[PNL] = 0.06, V[PNL] = 1.15
 8 steps: E[PNL] = 0.06, V[PNL] = 0.84
 16 steps: E[PNL] = 0.04, V[PNL] = 0.58
 32 steps: E[PNL] = 0.01, V[PNL] = 0.42
 64 steps: E[PNL] = -0.00, V[PNL] = 0.30
data = pd.DataFrame(data)
sns.kdeplot(data)
plt.xlim(-2, 2)
plt.axvline(x=0, linestyle='dashed', color='grey');

png

t_0, S_0 = 0.0, 100.0
model = Model(r=0.05, q=0.03, σ=0.25)
vanilla = Vanilla(N=1.0, K=S_0, T=1.0, is_call=False)
num_paths = 4_000
data = {}
for num_steps in [4, 8, 16, 32, 64]:
    normalized_pnls = analyze(t_0, S_0, model, vanilla, num_paths, num_steps)
    print(f"{num_steps: 2d} steps: E[PNL] = {normalized_pnls.mean():.2f}, V[PNL] = {normalized_pnls.std():.2f}")
    data[f'N={num_steps}'] = normalized_pnls
 4 steps: E[PNL] = 0.00, V[PNL] = 0.46
 8 steps: E[PNL] = -0.00, V[PNL] = 0.32
 16 steps: E[PNL] = -0.01, V[PNL] = 0.24
 32 steps: E[PNL] = -0.00, V[PNL] = 0.17
 64 steps: E[PNL] = 0.00, V[PNL] = 0.12
data = pd.DataFrame(data)
sns.kdeplot(data)
plt.xlim(-2, 2)
plt.axvline(x=0, linestyle='dashed', color='grey');

png