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.
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.
__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.
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