Barrier Call (Joint Law Pricing)¶
Background¶
Barrier Option Pricing via the Joint Law of (W_t, M_t)
This script prices up-and-out call options using three methods:
- Closed-form via the image method (reflection principle)
- Numerical integration of the joint density f_{M_T, W_T}
- Monte Carlo simulation with barrier monitoring
The joint density of the running maximum M_T and terminal value W_T of standard Brownian motion is:
f_{M_T, W_T}(m, w) = 2(2m - w) / (T * sqrt(2piT)) * exp(-(2m - w)^2 / (2T))
for m >= 0 and w <= m.
Code¶
```python """ Barrier Option Pricing via the Joint Law of (W_t, M_t)
This script prices up-and-out call options using three methods: 1. Closed-form via the image method (reflection principle) 2. Numerical integration of the joint density f_{M_T, W_T} 3. Monte Carlo simulation with barrier monitoring
The joint density of the running maximum M_T and terminal value W_T of standard Brownian motion is:
f_{M_T, W_T}(m, w) = 2(2m - w) / (T * sqrt(2*pi*T)) * exp(-(2m - w)^2 / (2T))
for m >= 0 and w <= m. """
import numpy as np from scipy.stats import norm from scipy.integrate import dblquad import matplotlib.pyplot as plt
=============================================================================¶
1. Black-Scholes Vanilla Call Price¶
=============================================================================¶
def bs_call_price(S, K, T, r, sigma): """Standard Black-Scholes European call price.""" if T <= 0: return max(S - K, 0.0) d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) d2 = d1 - sigma * np.sqrt(T) return S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
=============================================================================¶
2. Closed-Form: Up-and-Out Call via Image Method¶
=============================================================================¶
def up_and_out_call_closed_form(S0, K, H, T, r, sigma): """ Closed-form price of an up-and-out call option.
C_UO = C_BS(S0, K) - (S0/H)^(2*lambda - 2) * C_BS(H^2/S0, K)
where lambda = r / sigma^2 + 1/2.
Parameters
----------
S0 : float – Initial stock price
K : float – Strike price
H : float – Upper barrier (H > S0)
T : float – Time to maturity (years)
r : float – Risk-free rate
sigma : float – Volatility
Returns
-------
float – Up-and-out call price
"""
if S0 >= H:
return 0.0 # Already knocked out
lam = r / sigma**2 + 0.5
C_vanilla = bs_call_price(S0, K, T, r, sigma)
C_image = bs_call_price(H**2 / S0, K, T, r, sigma)
C_uo = C_vanilla - (S0 / H)**(2 * lam - 2) * C_image
return max(C_uo, 0.0)
=============================================================================¶
3. Joint Density of (M_T, W_T) for Standard Brownian Motion¶
=============================================================================¶
def joint_density_MW(m, w, T): """ Joint density f_{M_T, W_T}(m, w) for standard Brownian motion.
f(m, w) = 2(2m - w) / (T * sqrt(2*pi*T)) * exp(-(2m - w)^2 / (2T))
Valid for m >= 0 and w <= m.
Parameters
----------
m : float – Running maximum value
w : float – Terminal Brownian motion value
T : float – Time horizon
Returns
-------
float – Joint density value
"""
if m < 0 or w > m:
return 0.0
u = 2 * m - w
return u / (T * np.sqrt(2 * np.pi * T)) * 2 * np.exp(-u**2 / (2 * T))
=============================================================================¶
4. Numerical Integration via Joint Density¶
=============================================================================¶
def up_and_out_call_joint_density(S0, K, H, T, r, sigma, n_w=200, n_m=200): """ Price an up-and-out call by numerically integrating over the joint density of (M_T, W_T).
C_UO = e^{-rT} * int int (S_T(w) - K)^+ * f_{M_T, W_T}(m, w) dm dw
subject to m < b (barrier not hit).
Parameters
----------
S0 : float – Initial stock price
K : float – Strike price
H : float – Upper barrier
T : float – Time to maturity
r : float – Risk-free rate
sigma : float – Volatility
n_w : int – Grid points in w-direction
n_m : int – Grid points in m-direction
Returns
-------
float – Up-and-out call price (numerical)
"""
if S0 >= H:
return 0.0
# Barrier in Brownian motion space
b = (1.0 / sigma) * np.log(H / S0)
drift = (r - 0.5 * sigma**2) * T
# w range: need S_T > K, i.e., w > (log(K/S0) - drift) / sigma
w_min_payoff = (np.log(K / S0) - drift) / sigma
w_min = max(w_min_payoff, -6 * np.sqrt(T)) # Ensure reasonable range
w_max = b # w cannot exceed b (since w <= m < b)
if w_min >= w_max:
return 0.0
# Create grid
w_vals = np.linspace(w_min, w_max, n_w)
dw = (w_max - w_min) / (n_w - 1) if n_w > 1 else 0.0
price = 0.0
for w in w_vals:
# m range: max(0, w) <= m < b
m_lo = max(0.0, w)
m_hi = b
if m_lo >= m_hi:
continue
m_vals = np.linspace(m_lo, m_hi, n_m)
dm = (m_hi - m_lo) / (n_m - 1) if n_m > 1 else 0.0
# Stock price at terminal time
S_T = S0 * np.exp(drift + sigma * w)
payoff = max(S_T - K, 0.0)
if payoff <= 0:
continue
# Integrate over m using trapezoidal rule
density_vals = np.array([joint_density_MW(m, w, T) for m in m_vals])
inner_integral = np.trapz(density_vals, m_vals)
price += payoff * inner_integral * dw
return np.exp(-r * T) * price
def up_and_out_call_scipy(S0, K, H, T, r, sigma): """ Price an up-and-out call using scipy.integrate.dblquad for higher accuracy.
Parameters
----------
S0 : float – Initial stock price
K : float – Strike price
H : float – Upper barrier
T : float – Time to maturity
r : float – Risk-free rate
sigma : float – Volatility
Returns
-------
float – Up-and-out call price
float – Estimated integration error
"""
if S0 >= H:
return 0.0, 0.0
b = (1.0 / sigma) * np.log(H / S0)
drift = (r - 0.5 * sigma**2) * T
w_min_payoff = (np.log(K / S0) - drift) / sigma
def integrand(m, w):
S_T = S0 * np.exp(drift + sigma * w)
payoff = max(S_T - K, 0.0)
return payoff * joint_density_MW(m, w, T)
def m_lower(w):
return max(0.0, w)
def m_upper(w):
return b
result, error = dblquad(
integrand,
w_min_payoff, b, # w limits
m_lower, m_upper, # m limits (functions of w)
epsabs=1e-10,
epsrel=1e-10
)
return np.exp(-r * T) * result, error
=============================================================================¶
5. Monte Carlo Simulation¶
=============================================================================¶
def up_and_out_call_monte_carlo(S0, K, H, T, r, sigma, n_paths=100000, n_steps=252, seed=42): """ Monte Carlo price of an up-and-out call with discrete barrier monitoring.
Parameters
----------
S0 : float – Initial stock price
K : float – Strike price
H : float – Upper barrier
T : float – Time to maturity
r : float – Risk-free rate
sigma : float – Volatility
n_paths : int – Number of simulation paths
n_steps : int – Number of time steps per path
seed : int – Random seed
Returns
-------
float – Monte Carlo price estimate
float – Standard error
"""
rng = np.random.default_rng(seed)
dt = T / n_steps
drift_dt = (r - 0.5 * sigma**2) * dt
vol_sqrt_dt = sigma * np.sqrt(dt)
# Simulate log-price paths
Z = rng.standard_normal((n_paths, n_steps))
log_S = np.log(S0) + np.cumsum(drift_dt + vol_sqrt_dt * Z, axis=1)
# Check barrier condition (max over path)
max_log_S = np.max(log_S, axis=1)
not_knocked_out = max_log_S < np.log(H)
# Terminal payoff
S_T = np.exp(log_S[:, -1])
payoffs = np.maximum(S_T - K, 0.0) * not_knocked_out
# Discounted price
disc_payoffs = np.exp(-r * T) * payoffs
price = np.mean(disc_payoffs)
std_err = np.std(disc_payoffs) / np.sqrt(n_paths)
return price, std_err
=============================================================================¶
6. Brownian Bridge Correction for Monte Carlo¶
=============================================================================¶
def up_and_out_call_mc_bridge(S0, K, H, T, r, sigma, n_paths=100000, n_steps=252, seed=42): """ Monte Carlo with Brownian bridge correction for continuous barrier monitoring.
Between each pair of discrete time points, the probability that
the continuous path crosses the barrier is:
P(max S in [t_i, t_{i+1}] > H | S_{t_i}, S_{t_{i+1}})
= exp(-2 * log(H/S_i) * log(H/S_{i+1}) / (sigma^2 * dt))
if both S_i, S_{i+1} < H, and 1 otherwise.
Parameters
----------
S0 : float – Initial stock price
K : float – Strike price
H : float – Upper barrier
T : float – Time to maturity
r : float – Risk-free rate
sigma : float – Volatility
n_paths : int – Number of simulation paths
n_steps : int – Number of time steps per path
seed : int – Random seed
Returns
-------
float – Monte Carlo price with bridge correction
float – Standard error
"""
rng = np.random.default_rng(seed)
dt = T / n_steps
drift_dt = (r - 0.5 * sigma**2) * dt
vol_sqrt_dt = sigma * np.sqrt(dt)
Z = rng.standard_normal((n_paths, n_steps))
log_S = np.zeros((n_paths, n_steps + 1))
log_S[:, 0] = np.log(S0)
for i in range(n_steps):
log_S[:, i + 1] = log_S[:, i] + drift_dt + vol_sqrt_dt * Z[:, i]
log_H = np.log(H)
# Survival probability via Brownian bridge
survival_prob = np.ones(n_paths)
for i in range(n_steps):
S_i = log_S[:, i]
S_ip1 = log_S[:, i + 1]
# If either endpoint exceeds barrier, knocked out
crossed = (S_i >= log_H) | (S_ip1 >= log_H)
survival_prob[crossed] = 0.0
# Brownian bridge correction for non-crossed paths
not_crossed = ~crossed & (survival_prob > 0)
if np.any(not_crossed):
a = log_H - S_i[not_crossed]
b_val = log_H - S_ip1[not_crossed]
p_cross = np.exp(-2.0 * a * b_val / (sigma**2 * dt))
# Generate uniform to decide if barrier was crossed
U = rng.uniform(size=np.sum(not_crossed))
knocked = U < p_cross
idx = np.where(not_crossed)[0]
survival_prob[idx[knocked]] = 0.0
S_T = np.exp(log_S[:, -1])
payoffs = np.maximum(S_T - K, 0.0) * (survival_prob > 0)
disc_payoffs = np.exp(-r * T) * payoffs
price = np.mean(disc_payoffs)
std_err = np.std(disc_payoffs) / np.sqrt(n_paths)
return price, std_err
=============================================================================¶
7. Visualization¶
=============================================================================¶
def plot_joint_density(T=1.0, b=1.5, n_grid=200): """ Plot the joint density f_{M_T, W_T}(m, w) with the barrier constraint m < b.
Parameters
----------
T : float – Time horizon
b : float – Barrier level in Brownian motion space
n_grid : int – Grid resolution
"""
w_vals = np.linspace(-3 * np.sqrt(T), b, n_grid)
m_vals = np.linspace(0, b, n_grid)
W, M = np.meshgrid(w_vals, m_vals)
# Evaluate density (respecting w <= m constraint)
Z = np.zeros_like(W)
for i in range(n_grid):
for j in range(n_grid):
if W[i, j] <= M[i, j]:
Z[i, j] = joint_density_MW(M[i, j], W[i, j], T)
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Contour plot
ax = axes[0]
cf = ax.contourf(W, M, Z, levels=30, cmap='viridis')
ax.set_xlabel(r'$W_T$ (terminal value)', fontsize=12)
ax.set_ylabel(r'$M_T$ (running maximum)', fontsize=12)
ax.set_title(r'Joint Density $f_{M_T, W_T}(m, w)$ with Barrier $m < b$', fontsize=13)
ax.axhline(y=b, color='red', linestyle='--', linewidth=2, label=f'Barrier $b = {b:.1f}$')
ax.plot(w_vals, w_vals, 'w--', linewidth=1, alpha=0.5, label=r'$w = m$ boundary')
ax.legend(fontsize=10)
plt.colorbar(cf, ax=ax, label='Density')
# Marginal density of W_T given M_T < b
ax = axes[1]
marginal_w = np.zeros(n_grid)
for j, w in enumerate(w_vals):
m_lo = max(0.0, w)
if m_lo < b:
m_range = np.linspace(m_lo, b, 100)
densities = [joint_density_MW(m, w, T) for m in m_range]
marginal_w[j] = np.trapz(densities, m_range)
# Compare with unconstrained normal density
normal_density = norm.pdf(w_vals, loc=0, scale=np.sqrt(T))
ax.plot(w_vals, marginal_w, 'b-', linewidth=2, label=r'$f_{W_T | M_T < b}(w)$')
ax.plot(w_vals, normal_density, 'k--', linewidth=1.5, alpha=0.6,
label=r'$\phi(w; 0, T)$ (unconstrained)')
ax.fill_between(w_vals, marginal_w, alpha=0.2, color='blue')
ax.set_xlabel(r'$W_T$', fontsize=12)
ax.set_ylabel('Density', fontsize=12)
ax.set_title(r'Marginal of $W_T$ Constrained by $M_T < b$', fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('joint_density_barrier.png', dpi=150, bbox_inches='tight')
plt.show()
def plot_price_comparison(S0=100, K=100, T=1.0, r=0.05, sigma=0.2): """ Compare up-and-out call prices across barrier levels using all methods.
Parameters
----------
S0 : float – Initial stock price
K : float – Strike price
T : float – Time to maturity
r : float – Risk-free rate
sigma : float – Volatility
"""
barriers = np.linspace(S0 * 1.05, S0 * 2.0, 20)
prices_closed = []
prices_numerical = []
prices_mc = []
mc_errors = []
print(f"{'Barrier':>10} {'Closed-Form':>12} {'Joint Density':>14} {'Monte Carlo':>12} {'MC Std Err':>10}")
print("-" * 62)
for H in barriers:
# Closed-form
p_cf = up_and_out_call_closed_form(S0, K, H, T, r, sigma)
prices_closed.append(p_cf)
# Joint density (scipy)
p_jd, _ = up_and_out_call_scipy(S0, K, H, T, r, sigma)
prices_numerical.append(p_jd)
# Monte Carlo with bridge
p_mc, se_mc = up_and_out_call_mc_bridge(S0, K, H, T, r, sigma,
n_paths=50000, n_steps=252)
prices_mc.append(p_mc)
mc_errors.append(se_mc)
print(f"{H:10.2f} {p_cf:12.6f} {p_jd:14.6f} {p_mc:12.6f} {se_mc:10.6f}")
# Vanilla BS price for reference
vanilla_price = bs_call_price(S0, K, T, r, sigma)
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Price comparison
ax = axes[0]
ax.plot(barriers, prices_closed, 'b-', linewidth=2, label='Closed-Form (Image)')
ax.plot(barriers, prices_numerical, 'r--', linewidth=2, label='Joint Density (Scipy)')
ax.errorbar(barriers, prices_mc, yerr=np.array(mc_errors) * 1.96, fmt='go',
markersize=4, capsize=3, label='Monte Carlo (Bridge)')
ax.axhline(y=vanilla_price, color='gray', linestyle=':', linewidth=1,
label=f'Vanilla BS = {vanilla_price:.4f}')
ax.set_xlabel('Barrier Level $H$', fontsize=12)
ax.set_ylabel('Option Price', fontsize=12)
ax.set_title('Up-and-Out Call Price vs Barrier Level', fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
# Pricing error (relative to closed-form)
ax = axes[1]
errors_jd = np.array(prices_numerical) - np.array(prices_closed)
errors_mc = np.array(prices_mc) - np.array(prices_closed)
ax.plot(barriers, errors_jd, 'r-o', markersize=4, linewidth=1.5,
label='Joint Density - Closed Form')
ax.plot(barriers, errors_mc, 'g-s', markersize=4, linewidth=1.5,
label='Monte Carlo - Closed Form')
ax.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
ax.set_xlabel('Barrier Level $H$', fontsize=12)
ax.set_ylabel('Pricing Error', fontsize=12)
ax.set_title('Pricing Error Relative to Closed-Form', fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('barrier_price_comparison.png', dpi=150, bbox_inches='tight')
plt.show()
=============================================================================¶
8. Main Execution¶
=============================================================================¶
if name == "main": # Parameters S0 = 100.0 # Initial stock price K = 100.0 # Strike price H = 130.0 # Upper barrier T = 1.0 # Time to maturity (1 year) r = 0.05 # Risk-free rate sigma = 0.20 # Volatility
print("=" * 65)
print(" Barrier Option Pricing via Joint Law of (W_t, M_t)")
print("=" * 65)
print(f"\n S0 = {S0}, K = {K}, H = {H}, T = {T}, r = {r}, sigma = {sigma}\n")
# Method 1: Closed-form
price_cf = up_and_out_call_closed_form(S0, K, H, T, r, sigma)
print(f" [1] Closed-Form (Image Method): {price_cf:.6f}")
# Method 2: Joint density (manual grid)
price_grid = up_and_out_call_joint_density(S0, K, H, T, r, sigma, n_w=300, n_m=300)
print(f" [2] Joint Density (Grid 300x300): {price_grid:.6f}")
# Method 3: Joint density (scipy)
price_scipy, err_scipy = up_and_out_call_scipy(S0, K, H, T, r, sigma)
print(f" [3] Joint Density (Scipy dblquad): {price_scipy:.6f} (err ~ {err_scipy:.2e})")
# Method 4: Monte Carlo (discrete monitoring)
price_mc, se_mc = up_and_out_call_monte_carlo(S0, K, H, T, r, sigma,
n_paths=200000, n_steps=252)
print(f" [4] Monte Carlo (discrete, 200K): {price_mc:.6f} (SE = {se_mc:.6f})")
# Method 5: Monte Carlo with Brownian bridge
price_bb, se_bb = up_and_out_call_mc_bridge(S0, K, H, T, r, sigma,
n_paths=200000, n_steps=252)
print(f" [5] Monte Carlo (bridge, 200K): {price_bb:.6f} (SE = {se_bb:.6f})")
# Reference: Vanilla BS price
vanilla = bs_call_price(S0, K, T, r, sigma)
print(f"\n Vanilla BS Call Price: {vanilla:.6f}")
print(f" Barrier Discount (UO/Vanilla): {price_cf / vanilla:.4f}")
print("\n" + "=" * 65)
print(" In-Out Parity Check")
print("=" * 65)
price_ui = vanilla - price_cf
print(f" Up-and-In Call = Vanilla - UO: {price_ui:.6f}")
print(f" Sum (UI + UO): {price_ui + price_cf:.6f}")
print(f" Vanilla: {vanilla:.6f}")
print(f" Parity Error: {abs(price_ui + price_cf - vanilla):.2e}")
# Visualizations
print("\n Generating plots...")
plot_joint_density(T=T, b=(1.0 / sigma) * np.log(H / S0))
plot_price_comparison(S0=S0, K=K, T=T, r=r, sigma=sigma)
print("\n Done.")
```
Exercises¶
Exercise 1. Write the joint density of the running maximum \(M_T\) and terminal value \(W_T\) of standard Brownian motion.
Solution to Exercise 1
For \(m \ge 0\) and \(w \le m\): \(f_{M_T, W_T}(m, w) = \frac{2(2m - w)}{T\sqrt{2\pi T}}\exp\!\bigl(-\frac{(2m-w)^2}{2T}\bigr)\). This is derived from the reflection principle.
Exercise 2. Explain the image (reflection) method for pricing up-and-out barrier options.
Solution to Exercise 2
The reflection principle maps paths crossing barrier \(H\) to reflected paths starting at \(H^2/S_0\). The up-and-out price is the vanilla price minus the reflected-path contribution, weighted by the factor \((S_0/H)^{2(r/\sigma^2 - 1/2)}\).
Exercise 3. Compare the three pricing methods in the code: closed-form, numerical integration, and Monte Carlo.
Solution to Exercise 3
Closed-form is exact (fastest, most accurate). Numerical integration of the joint density achieves \(10^{-10}\) accuracy with adaptive quadrature. Monte Carlo has \(O(1/\sqrt{N})\) error and is the least accurate but most flexible (works for non-standard barriers).
Exercise 4. Give an example of an option whose pricing requires the joint distribution of both \(\max_t S_t\) and \(S_T\).
Solution to Exercise 4
A partial lookback call pays \(\max(S_T - \alpha\max_t S_t, 0)\), combining the terminal price with the running maximum. A bonus certificate paying \(\max(S_T, B)\) if \(\min_t S_t > H\) also depends on both extremes. These require the full joint distribution.