Programming

Options Pricing

In this section we will create some code for pricing vanilla and exotic options using several methods such as Black-Scholes pricing and Monte Carlo methods.

First, let's define a abstract base class which all option payoffs will inherit. We specify an abstract base class by inheriting the built-in class ABC.

from abc import ABC, abstractmethod

class PayOff(ABC):
    
    @abstractmethod
    def __call__(self, spot: float) -> float:
        # PayOff function goes here.
        pass

We cannot create a PayOff object on it's own - what the abstract base class does here is enforce an interface. The @abstractmethod decorator enforces that the __call__ function must be instantiated for all classes that inherit PayOff. If __call__ is not instantiated an error will be thrown. All classes that inherit PayOff will therefore use __call__ to return the payoff of the option given the spot value at expiry. Enforcing this interface is useful because when we come to building the option pricing classes, we will access the derivative's payoff using __call__. We can therefore plug in different payoff classes to the option pricer and it will continue to work as expected.

The __call__ function is called similarly to how a function is called, using the syntax

call_payoff = CallPayOff(strike=100)
call_payoff(spot=105)  # Returns: 5.0

Let's now instantiate the CallPayOff above and PutPayOff.

class CallPayOff(PayOff):
    
    def __init__(self, strike: float) -> None:
        self._strike = strike
    
    def __call__(self, spot: float) -> float:
        return max(spot - self.strike, 0.0)
    
    @property
    def strike(self) -> float:
        return self._strike
    

class PutPayOff(PayOff):
    
    def __init__(self, strike: float) -> None:
        self._strike = strike
    
    def __call__(self, spot: float) -> float:
        return max(self.strike - spot, 0.0)
    
    @property
    def strike(self) -> float:
        return self._strike

We define the strike attribute as self._strike and define the strike as a property method. The property method enables the user to call the method using the syntax self.strike rather than self.strike(). That is, a property method behaves like a class attribute. This particular implementation is the Pythonic way to make a data member read-only. Fully read-only members (that is, private attributes) are not included as a feature in Python. So, in theory we could reassign self._strike in the class, changing the data member. Setting the attribute with an underscore before the name is a convention in Python that indicates the attribute is intended for internal use within the class. It's a form of weak private access modifier, meaning that while the attribute is accessible from outside the class, it's meant to signal to other developers that it should be treated as a private attribute and not accessed directly.

Black-Scholes Pricing

Our first method for options pricing is to simply calculate the analytical result for the Black-Scholes price of call and put options. The class accepts the Black-Scholes parameters and computes the call or put price, which we saw in the Options Pricing course are defined as $$ C(S, t) = SN(d_1) - K e^{-r\tau} N(d_2) $$ and $$ P(S, t) = -S N(-d_1) + Ke^{-r\tau} N(-d_2) $$ respectively, with $d_1$ and $d_2$ defined as $$ d_1 = \frac{\log(S/K) + (r + \frac{1}{2}\sigma^2)\tau}{\sigma \sqrt{\tau}} $$ $$ d_2 = d_1 - \sigma \sqrt{\tau}. $$

from scipy.stats import norm
import numpy as np

class BlackScholesOptionPricer:
    
    def __init__(
        self,
        payoff: PayOff,
        expiry: float,
        volatility: float,
        risk_free_rate: float,
    ) -> None:
        self.payoff = payoff
        self.expiry = expiry
        self.volatility = volatility
        self.risk_free_rate = risk_free_rate
        
    def get_price(
        self,
        spot: float,
    ) -> float:
        strike = self.payoff.strike
        volatility_drift = 0.5 * np.power(self.volatility, 2)
        sqrt_variance = self.volatility * np.sqrt(self.expiry)
        d1_numerator = np.log(spot/strike) + (self.risk_free_rate + volatility_drift) * self.expiry
        d1 = d1_numerator / sqrt_variance
        d2 = d1 - sqrt_variance
        if isinstance(self.payoff, CallPayOff):
            return spot * norm.cdf(d1) - strike * np.exp(- self.risk_free_rate * self.expiry) * norm.cdf(d2)
        elif isinstance(self.payoff, PutPayOff):
            return strike * np.exp(-self.risk_free_rate * self.expiry) * norm.cdf(-d2) - spot * norm.cdf(-d1)

Below is an example of a code snippet that uses the option pricer to price an at-the-money call option, with $\tau=1$, $K=100$, $\sigma=0.05$ and $r=0.03$.

black_scholes_option_pricer = BlackScholesOptionPricer(
    payoff=CallPayOff(
        strike=100,
    ),
    expiry=1,
    volatility=0.05,
    risk_free_rate=0.03,
)
black_scholes_option_pricer.get_price(spot=100)
# Returns: 3.786114
Monte Carlo Simulation

Monte Carlo simulation involves simulating many evolutions of the underlying asset until the expiry time, calculating the payoff of the derivative each time. We saw in the options pricing course that we can consider the price of a derivative as the discounted expected payoff. Therefore, we calculate the price by taking the mean of the simulated payoffs and discounting the risk-free rate.

Monte Carlo simulation has several advantages to using an analytical result. Firstly, many types of option do not have a closed-form analytical solution to the price, such as certain types of American option where the option can be exercised at any time before expiry. Secondly, existing analytical solutions rely on specific models and assumptions for the underlying asset. For example, the Black-Scholes model assumes that the underlying asset obeys geometric Brownian motion. Using Monte Carlo simulation enables us to select any model for the underlying asset and obtain a price.

class MonteCarloOptionPricer:
    
    def __init__(
        self,
        payoff: PayOff,
        expiry: float,
        volatility: float,
        risk_free_rate: float,
    ) -> None:
        self.payoff = payoff
        self.expiry = expiry
        self.volatility = volatility
        self.risk_free_rate = risk_free_rate
        
    def get_price(
        self, 
        spot: float, 
        num_simulations: int = 10000,
    ) -> float:
        expiry = self.expiry
        volatility = self.volatility
        risk_free_rate = self.risk_free_rate
        payoff = self.payoff
        
        drift = (risk_free_rate - 0.5 * volatility ** 2) * expiry
        diffusion = volatility * np.sqrt(expiry) * np.random.randn(num_simulations)
        stock_prices = spot * np.exp(drift + diffusion)
        payoffs = np.array([self.payoff(stock_price) for stock_price in stock_prices])
        
        option_price = np.exp(-risk_free_rate * expiry) * np.mean(payoffs)
        return option_price

get_price() samples the terminal probability distribution of the underlying asset num_simulations times, calculating the payoff each time and storing it. Consequently, this implementation can be used to price any derivative that depends only on the current spot value and the expiry time. The code below calculates the option price for the same option we priced before.

monte_carlo_option_pricer = MonteCarloOptionPricer(
    payoff=CallPayOff(
        strike=100,
    ),
    expiry=1,
    volatility=0.05,
    risk_free_rate=0.03,
)
monte_carlo_option_pricer.get_price(spot=100)
# Returns: 3.778158

We see that the option price is in close agreement to the option price we calculated according to the analytical result of the Black-Scholes model.

Binary options are a type of exotic option which have a payoff of 1 when the option is in the money and 0 when the option is out of the money. Analogous to vanilla calls and puts, there exist binary call options, where the payoff is 1 when the asset price is greater than the strike price at expiry, and binary put options, where the payoff is 1 when the asset price is below the strike price at expiry.

We can simply create classes for the payoff of the binary options and plug them in to the MonteCarloOptionPricer.

class BinaryCallPayOff(PayOff):
    
    def __init__(self, strike: float) -> None:
        self._strike = strike
    
    def __call__(self, spot: float) -> float:
        return float(spot > self.strike)
    
    @property
    def strike(self) -> float:
        return self._strike
    

class BinaryPutPayOff(PayOff):
    
    def __init__(self, strike: float) -> None:
        self._strike = strike
        
    def __call__(self, spot: float) -> float:
        return float(spot < self.strike)
    
    @property
    def strike(self) -> float:
        return self._strike
Converting a boolean value to a float will return 1.0 if the boolean is True, and 0.0 if the boolean is False.

monte_carlo_option_pricer = MonteCarloOptionPricer(
    payoff=BinaryCallPayOff(
        strike=100,
    ),
    expiry=1,
    volatility=0.05,
    risk_free_rate=0.03,
)
monte_carlo_option_pricer.get_price(spot=100)
# Returns: 0.703864
Path-Dependent Derivatives

Path-dependent derivatives are financial derivatives whose value is not just determined by the final price of the underlying asset at expiry, but also by the path of asset price over the life of the derivative. In other words, the entire price history of the underlying asset can influence the derivative's payoff. Now, we will create a Monte Carlo simulator capable of pricing any path-dependent derivative. Barrier options are path-dependent exotic options. Their payoff depends on whether the underlying asset's price hits a certain price level, known as the barrier, during the option's life. There are call and put barrier options, and for each category there exist four subtypes.

  • Up-and-In options become active if the asset price goes up and hits the barrier.
  • Up-and-Out options become void if the asset price goes up and hits the barrier.
  • Down-and-In options become active if the asset price goes down and hits the barrier.
  • Down-and-Out options become void if the asset price goes down and hits the barrier.
To price barrier options we first need to adjust our payoff class. The payoff __call__ function will accept a price path from now to expiry and determine the option price from the path.

class PathDependentPayOff(ABC):
    
    @abstractmethod
    def __call__(self, path: np.array) -> float:
        pass

Let's now produce a Monte Carlo option pricer which will generate num_simulations paths. For each path, we will generate steps_per_path number of samples of the underlying asset along the path. This parameter therefore defines the resolution of the generated price path up to expiry. We can then plug each path into the PathDependentPayOff classes, taking the average payoff across all simulations and discounting it to get the derivative price. Below is an example implementation of the Monte Carlo simulator for pricing path-dependent options.

class MCPathDependentOptionPricer:
    
    def __init__(
        self,
        payoff: PathDependentPayOff,
        expiry: float,
        volatility: float,
        risk_free_rate: float,
        steps_per_path: int = 100,  # Number of steps in the path
    ) -> None:
        self.payoff = payoff
        self.expiry = expiry
        self.volatility = volatility
        self.risk_free_rate = risk_free_rate
        self.steps_per_path = steps_per_path
        
    def get_price(
        self, 
        spot: float, 
        num_simulations: int = 10000,
    ) -> float:
        dt = self.expiry / self.steps_per_path
        drift = (self.risk_free_rate - 0.5 * self.volatility ** 2) * dt
        diffusion = self.volatility * np.sqrt(dt)

        random_shocks = np.random.randn(num_simulations, self.steps_per_path)

        price_paths = np.exp(drift + diffusion * random_shocks)
        initial_prices = np.full((num_simulations, 1), spot)
        price_paths = np.hstack([initial_prices, price_paths])
        price_paths = price_paths.cumprod(axis=1)

        payoffs = np.array([self.payoff(path) for path in price_paths])
        average_payoff = np.mean(payoffs)
        discounted_price = np.exp(-self.risk_free_rate * self.expiry) * average_payoff

        return discounted_price

Below are the PayOff classes for the four subtypes of barrier call options.

class UpAndInBarrierCallPayOff(PathDependentPayOff):
    
    def __init__(
        self, 
        strike: float,
        barrier: float,
    ) -> None:
        self._strike = strike
        self._barrier = barrier
    
    def __call__(self, path: np.array) -> float:
        return (path >= self.barrier).any() * max(path[-1] - self.strike, 0)
    
    @property
    def strike(self) -> float:
        return self._strike
    
    @property
    def barrier(self) -> float:
        return self._barrier
    

class UpAndOutBarrierCallPayOff(PathDependentPayOff):
    
    def __init__(
        self, 
        strike: float,
        barrier: float,
    ) -> None:
        self._strike = strike
        self._barrier = barrier
        
    def __call__(self, path: np.array) -> float:
        return (path < self.barrier).all() * max(path[-1] - self.strike, 0)
    
    @property
    def strike(self) -> float:
        return self._strike
    
    @property
    def barrier(self) -> float:
        return self._barrier
    

class DownAndInBarrierCallPayOff(PathDependentPayOff):
    
    def __init__(
        self,
        strike: float,
        barrier: float,
    ) -> None:
        self._strike = strike
        self._barrier = barrier
        
    def __call__(self, spot: np.array) -> float:
        return (path <= self.barrier).any() * max(path[-1] - self.strike, 0)
    
    @property
    def strike(self) -> float:
        return self._strike
    
    @property
    def barrier(self) -> float:
        return self._barrier
    

class DownAndOutBarrierCallPayOff(PathDependentPayOff):
    
    def __init__(
        self,
        strike: float,
        barrier: float,
    ) -> None:
        self._strike = strike
        self._barrier = barrier
        
    def __call__(self, spot: np.array) -> float:
        return (path >= self.barrier).all() * max(path[-1] - self.strike, 0)
    
    @property
    def strike(self) -> float:
        return self._strike
    
    @property
    def barrier(self) -> float:
        return self._barrier

Let's now use one of the PayOff classes we have created in an example. Below is an example using this pricer to determine the value of an up-and-in barrier call option, with barrier equal to 105. That is, the asset price must hit 105 before the option is active, and if the asset never hits 105 then the payoff is zero. If the option is active then it simply behaves as a vanilla call option.

mc_path_dependent_option_pricer = MCPathDependentOptionPricer(
    payoff=UpAndInBarrierCallPayOff(
        strike=100,
        barrier=105,
    ),
    expiry=1,
    volatility=0.05,
    risk_free_rate=0.03,
    steps_per_path=1000,
)
stock_prices = mc_path_dependent_option_pricer.get_price(100)
# Returns: 3.423053

We see that despite the Black-Scholes parameters are the same, the addition of a barrier reduces the option price.

The PayOff classes are very similar to those for barrier call options. The conditions for the barrier option to be active are void are identical. We simply need to change the payoff functions for when the option is active.

class UpAndInBarrierPutPayOff(PathDependentPayOff):
    
    def __init__(
        self, 
        strike: float,
        barrier: float,
    ) -> None:
        self._strike = strike
        self._barrier = barrier
    
    def __call__(self, path: np.array) -> float:
        return (path >= self.barrier).any() * max(self.strike - path[-1], 0)
    
    @property
    def strike(self) -> float:
        return self._strike
    
    @property
    def barrier(self) -> float:
        return self._barrier
    

class UpAndOutBarrierCallPayOff(PathDependentPayOff):
    
    def __init__(
        self, 
        strike: float,
        barrier: float,
    ) -> None:
        self._strike = strike
        self._barrier = barrier
        
    def __call__(self, path: np.array) -> float:
        return (path < self.barrier).all() * max(self.strike - path[-1], 0)
    
    @property
    def strike(self) -> float:
        return self._strike
    
    @property
    def barrier(self) -> float:
        return self._barrier
    

class DownAndInBarrierCallPayOff(PathDependentPayOff):
    
    def __init__(
        self,
        strike: float,
        barrier: float,
    ) -> None:
        self._strike = strike
        self._barrier = barrier
        
    def __call__(self, spot: np.array) -> float:
        return (path <= self.barrier).any() * max(self.strike - path[-1], 0)
    
    @property
    def strike(self) -> float:
        return self._strike
    
    @property
    def barrier(self) -> float:
        return self._barrier
    

class DownAndOutBarrierCallPayOff(PathDependentPayOff):
    
    def __init__(
        self,
        strike: float,
        barrier: float,
    ) -> None:
        self._strike = strike
        self._barrier = barrier
        
    def __call__(self, spot: np.array) -> float:
        return (path >= self.barrier).all() * max(self.strike - path[-1], 0)
    
    @property
    def strike(self) -> float:
        return self._strike
    
    @property
    def barrier(self) -> float:
        return self._barrier