The Heston model is given by the following equations in a risk-neutral measure

\[\begin{aligned} \frac{dS(t)}{S(t)} & =\mu dt + \sqrt{V(t)} dW_S(t) \\ dV(t) & = \kappa(\theta−V(t)) dt + \zeta \sqrt{V(t)} dW_V(t) \\ < dW_S(t), dW_V(t)> & =ρdt, \end{aligned}\]

with $\mu = r - q$.

It is easy to see that the process for the inverted spot $U(t) = S(t)^{-1}$ is still described by the Heston model. Using Itô’s lemma on $U(t) = f(S(t))$, with

\[\begin{aligned} f(s) & = \frac{1}{s} \\ % f'(s) & = -\frac{1}{s^2} \\ % f''(s) & = \frac{2}{s^3}, \\ \end{aligned}\]

gives

\[dU(t) = \left( -\mu S(t) \frac{1}{S(t)^2} + \frac{1}{2} V(t) S(t)^2 \frac{2}{S(t)^3} \right)dt - \sqrt{V(t)}S(t)\frac{1}{S(t)^2} dW_S(t)\]

and therefore

\[\frac{dU(t)}{U(t)} = \left( -\mu + V(t) \right) dt - \sqrt{V(t)}dW_S(t).\]

We can change the measure by using

\[dW_U(t) = -dW_S(t) + \sqrt{V(t)} dt\]

such that the appropriately discounted spot process becomes a martingale. We still need to adjust the second process as $dW_S(t)$ and $dW_V(t)$ are correlated. To do that, we first use

\[dW_V(t) = \rho dW_S(t) + \sqrt{1 - \rho^2} dW_V^\perp(t),\]

where $dW_S(t)$ and $dW_V^\perp(t)$ are independent, then use our definition of $dW_U(t)$ above to obtain

\[dW_V(t) = - \rho dW_U(t) + \rho\sqrt{V(t)}dt + \sqrt{1 - \rho^2} dW_V^\perp(t)\]

such that

\[\begin{aligned} dV(t) & = \kappa(\theta - V(t))dt + \zeta \sqrt{V(t)} dW_V(t) \\ % & = \kappa(\theta - V(t))dt + \zeta \sqrt{V(t)} \left( -\rho dW_U(t) + \rho \sqrt{V(t)} dt + \sqrt{1 - \rho^2} dW_V^\perp(t) \right) \\ % & = \kappa \theta dt - \kappa V(t) dt + \zeta \rho V(t) dt + \zeta \sqrt{V(t)}\left( -\rho dW_U(t) + \sqrt{1 - \rho^2} dW_V^\perp(t) \right) \\ % & = \left( \kappa \theta - (\kappa - \zeta \rho) V(t) \right) dt + \zeta \sqrt{V(t)} dW_{\bar{V}}(t) \\ % & = (\kappa \theta - \bar\kappa V(t)) dt + \zeta \sqrt{V(t)} dW_{\bar{V}}(t) \\ % & = \bar\kappa \left( \frac{\kappa}{\bar\kappa}\theta - V(t) \right) dt + \zeta \sqrt{V(t)} dW_{\bar{V}}(t) \\ % & = \bar\kappa(\bar\theta - V(t)) dt + \zeta \sqrt{V(t)} dW_{\bar{V}}(t), \end{aligned}\]

with

\[\begin{aligned} <dW_U(t), dW_{\bar{V}}(t)> & = -\rho dt \\ % \bar\kappa & = \kappa - \zeta \rho \\ % \bar\theta & = \frac{\kappa}{\bar \kappa} \theta. \end{aligned}\]

Clearly, if $\rho = 0$, we just have $\bar\kappa = \kappa$, $\bar\theta = \theta$, meaning that in this case the $V(t)$ process is unchanged.

from dataclasses import dataclass
import matplotlib.colors as mcolors
import matplotlib.pylab as plt
from mpl_toolkits.mplot3d.axes3d import Axes3D
import numpy as np
import scipy.integrate
from scipy.optimize import root_scalar
from scipy.stats import norm
Φ = norm.cdf

The Contract class wraps the basic ingredients of a vanilla contract – the maturity T, the strike K, and a boolean flag to indicate whether it is a call or a put.

@dataclass
class Contract:
    K: float
    T: float
    is_call: bool

The Model class defines a Heston model and provides the methods to price a vanilla contract with the closed-form approximation and Monte Carlo. The closed-form approximation requires the numerical integration of an indefinite integral, done using scipy.integrate.quad_vec, while for Monte Carlo pricing we first generate a number of paths with the Heston dynamics and then evaluate in expectation the value of the vanilla payoff at expiry (which is then discounted to time 0).

@dataclass
class Model:
    S_0: float
    r: float
    q: float
    V_0: float
    κ: float
    θ: float
    ζ: float
    ρ: float

    def __str__(self):
        return f'S_0={self.S_0}, V_0={self.V_0}, κ={self.κ}, θ={self.θ}, ζ={self.ζ}, ρ={self.ρ}'
    
    def phi(self, u, tau):
        alpha_hat = -0.5 * u * (u + 1j)
        beta = self.κ - 1j * u * self.ζ * self.ρ
        gamma = 0.5 * self.ζ ** 2
        d = np.sqrt(beta**2 - 4 * alpha_hat * gamma)
        g = (beta - d) / (beta + d)
        h = np.exp(-d*tau)
        A_ = (beta-d)*tau - 2*np.log((g*h-1) / (g-1))
        A = self.κ * self.θ / (self.ζ**2) * A_
        B = (beta - d) / (self.ζ**2) * (1 - h) / (1 - g*h)
        return np.exp(A + B * self.V_0)

    def integral(self, K, T):
        integrand = (lambda u: 
            np.real(np.exp((1j*u + 0.5)*K)*self.phi(u - 0.5j, T))/(u**2 + 0.25))

        i, _ = scipy.integrate.quad_vec(integrand, 0, np.inf)
        return i

    def compute_forward(self, T):
        return self.S_0 * np.exp((self.r - self.q) * T)
    
    def compute_price(self, contract: Contract):
        K, T = contract.K, contract.T
        a = np.log(self.S_0 / K) + (self.r-self.q) * T
        i = self.integral(a, T)
        call_price = self.S_0 * np.exp(-self.q * T) - K * np.exp(-self.r * T) / np.pi*i
        if contract.is_call:
            return call_price
        else:
            F = np.exp((self.r - self.q) * T) * self.S_0
            return call_price - np.exp(-self.r * T) * (F - K)
    
    def generate_paths(self, schedule, num_paths):
        num_steps = len(schedule) - 1
        S_all, V_all = [np.ones((num_paths,)) * self.S_0], [np.ones((num_paths,)) * self.V_0]
        X = np.log(S_all[0])
        V = V_all[0]
        W_X = np.random.randn(num_steps, num_paths)
        W_V_ortho = np.random.randn(num_steps, num_paths)
        W_V = self.ρ * W_X + np.sqrt(1 - self.ρ**2) * W_V_ortho

        for i in range(num_steps):
            Δt = schedule[i + 1] - schedule[i]
            sqrt_Δt = np.sqrt(Δt)
            X = X + (self.r - self.q - 0.5 * V) * Δt + np.sqrt(V) * sqrt_Δt * W_X[i]
            V = V + self.κ * (self.θ - V) * Δt + self.ζ * np.sqrt(V) * sqrt_Δt * W_V[i]
            V = np.maximum(V, 0)
            S_all.append(np.exp(X))
            V_all.append(V)
        
        return np.vstack(S_all), np.vstack(V_all)

    def compute_mc_price(self, S_T, contract: Contract):
        df = np.exp(-self.r * contract.T)
        ω = 1.0 if contract.is_call else -1.0
        Π = df * np.maximum(ω * (S_T - contract.K), 0)
        mc_price, mc_std = Π.mean(), Π.std() / np.sqrt(len(S_T))
        return mc_price, mc_std

We compare the closed-form prices with the ones given by the Monte Carlo method as a check.

model = Model(S_0=100.0, r=0.02, q=0.01, V_0=0.04, κ=1.0, θ=0.04, ζ=0.2, ρ=-0.8)
K_all = np.linspace(0.8 * model.S_0, 1.2 * model.S_0, 11)
S_all = None
analytic_prices, mc_lower_prices, mc_upper_prices = [], [], []
for K in K_all:
    contract = Contract(K, 1.0, False)
    if S_all is None:
        S_all, _ = model.generate_paths(np.linspace(0, contract.T, 201), 10_000)
    analytic_prices.append(model.compute_price(contract))
    mc_price, mc_std_dev = model.compute_mc_price(S_all[-1], contract)
    mc_lower_prices.append(mc_price - 1.96 * mc_std_dev)
    mc_upper_prices.append(mc_price + 1.96 * mc_std_dev)
plt.plot(K_all, analytic_prices, '--o', label='Analytic price')
plt.fill_between(K_all, mc_lower_prices, mc_upper_prices, alpha=0.5, color='orange', label='MC 95% Confidence Interval')
plt.legend();

png

It is customary to quote vanilla prices using implied volatilities, that is the volatility that is required by the Black-Scholes formula in order to generate the same price. This means that we use closed-form approximation above to compute the Heston price, then with a root search algorithm we compute the implied volatility. The procedure is repeated over several strikes and maturities and gives rise to the so-called volatility surface.

def compute_black_scholes_price(S_0, r, q, σ, contract):
    K, T, is_call = contract.K, contract.T, contract.is_call
    ω = 1.0 if is_call else -1.0
    if T == 0.0:
        return max(ω * (S_0 - K), 0.0)
    df = np.exp(-r * T)
    F = S_0 * np.exp((r - q) * T)
    d_plus = (np.log(F / K) + 0.5 * σ**2 * T) / σ / np.sqrt(T)
    d_minus = d_plus - σ * np.sqrt(T)
    return ω * df * (F * Φ(ω * d_plus) - K * Φ(ω * d_minus))
def compute_implied_vol(S_0, r, q, σ_0, contract, target_price):
    def inner(σ):
        return compute_black_scholes_price(S_0, r, q, σ, contract) - target_price
    try:
        result = root_scalar(inner, x0=σ_0, bracket=[1e-3, 0.5])
    except:
        return np.nan
    return np.nan if not result.converged else result.root
def compute_smile(model, T, factor=3, num_points=101):
    F = model.compute_forward(T)
    contract = Contract(K=F, T=T, is_call=True)
    price_ATM = model.compute_price(contract)
    σ_ATM = compute_implied_vol(model.S_0, model.r, model.q, 0.5, contract, price_ATM)
    width = factor * σ_ATM * np.sqrt(T)

    σs = []
    Ks = np.linspace(F * np.exp(-width), F * np.exp(width), num_points)
    for K in Ks:
        contract = Contract(K=K, T=T, is_call=K > F)
        price = model.compute_price(contract)
        σ = compute_implied_vol(model.S_0, model.r, model.q, 0.5, contract, price)
        σs.append(σ)
    
    return Ks, σs

We plot the volatility surface using a 3D plot, with strikes on the X-axis, maturities on the Y-axis, and the implied volatility itself on the Z-axis.

X, Y, Z, C = [], [], [], []
colors = list(mcolors.TABLEAU_COLORS.values())
model = Model(S_0=100.0, r=0.02, q=0.01, V_0=0.04, κ=1.0, θ=0.04, ζ=0.2, ρ=-0.1)
for color, T in zip(colors, [1 / 12, 2 / 12, 6 / 12, 1, 2, 3, 4, 5]):
    Ks, σs = compute_smile(model, T)
    X.append([T] * len(Ks))
    Y.append(Ks)
    Z.append(σs)
    C.append([color] * len(Ks))
fig, ax = plt.subplots(subplot_kw={'projection': '3d'}, figsize=(8, 8))
ax.plot_surface(np.array(X), np.array(Y), np.array(Z), facecolors=C)
ax.view_init(elev=20, azim=10, roll=0)
ax.set_xlabel('T')
ax.set_ylabel('K')
ax.set_zlabel('$σ_{impl}$')
ax.set_title(model);

png

The picture above is nice yet a bit difficult to interpret, and often without convenient view angles can be confusing. A better way is to replace strikes with the so-called simple delta, defined as

\[\delta_{simple}(K, T) = \Phi\left( \frac{\log(F / K)}{\sigma \sqrt{T}} \right),\]

where $\Phi$ is the cumulative density function of the normal distribution, $T$ the maturity of the option, $F$ the forward at $T$, and $K$ the strike. By definition $\delta_{simple}$ is a number between 0 and 1 for all maturities and gives a convenient way of adimensionalize the moneyness $F/K$. One such surface is computed below.

def compute_vol_on_delta_grid(T, d_grid, num_points):
    F = model.compute_forward(T)
    Ks, σs = compute_smile(model, T, factor=9, num_points=num_points)
    ds = Φ(np.log(Ks / F) / σs / np.sqrt(T))
    σs = np.interp(d_grid, ds, σs)
    # remove nans at the extremes, replace them with flat interpolation
    n = len(σs)
    for i in range(n // 2, 0, -1):
        if np.isnan(σs[i - 1]):
            σs[i - 1] = σs[i]
    for i in range(n // 2, n - 1):
        if np.isnan(σs[i + 1]):
            σs[i + 1] = σs[i]
    return σs
model = Model(S_0=1.0, r=0.05, q=0.03, V_0=0.1**2, κ=0.25, θ=0.2**2, ζ=0.5, ρ=-0.5)
d_grid = np.linspace(0.01, 0.99, 101)
X, Y, Z = [], [], []
T_all = [m / 52 for m in range(1, 53)]
for T in T_all:
    σs = compute_vol_on_delta_grid(T, d_grid, 101)
    X.append([T] * len(σs))
    Y.append(d_grid)
    Z.append(σs)
fig, (ax0, ax1) = plt.subplots(figsize=(12, 5), ncols=2)
for y, z in zip(Y, Z):
    ax0.plot(z, y)
ax0.set_xlabel('Implied Vol'); ax0.set_ylabel('Simple Delta')
ax1.contour(X, Y, Z, colors='k', levels=25, alpha=0.5)
ax1.contourf(X, Y, Z, cmap='binary', levels=25)
ax1.set_xlabel('T'); ax1.set_ylabel('Simple Delta')
fig.tight_layout()

png