Transaction Cost Sensitivity Analysis (cantaro86)¶
Background¶
cantaro86_tc_sensitivity_demo.py Transaction Cost Option Pricing: Sensitivity Analyses and Key Insights
Credits¶
Based on notebook "4.1 Option pricing with transaction costs" from: cantaro86, "Financial Models Numerical Methods" (FMNM) https://github.com/cantaro86/Financial-Models-Numerical-Methods
Adapted as a SELF-CONTAINED educational module for the "Quant Finance with Python" course (Chapter 11 -- Hedging & Transaction Costs).
Topics covered¶
- Payoff modification under transaction costs (buy-side cost distortion).
- Writer vs buyer indifference prices across spot prices.
- Computational complexity of the DPZ binomial tree algorithm.
- Drift sensitivity: why the physical drift mu matters when hedging is imperfect (unlike the drift-free Black-Scholes world).
See also: cantaro86_transaction_cost_pricer.py for the full TC_pricer class and core algorithm description.
Code¶
```python
!/usr/bin/env python3¶
-- coding: utf-8 --¶
""" cantaro86_tc_sensitivity_demo.py Transaction Cost Option Pricing: Sensitivity Analyses and Key Insights
Credits¶
Based on notebook "4.1 Option pricing with transaction costs" from: cantaro86, "Financial Models Numerical Methods" (FMNM) https://github.com/cantaro86/Financial-Models-Numerical-Methods
Adapted as a SELF-CONTAINED educational module for the "Quant Finance with Python" course (Chapter 11 -- Hedging & Transaction Costs).
Topics covered¶
- Payoff modification under transaction costs (buy-side cost distortion).
- Writer vs buyer indifference prices across spot prices.
- Computational complexity of the DPZ binomial tree algorithm.
- Drift sensitivity: why the physical drift mu matters when hedging is imperfect (unlike the drift-free Black-Scholes world).
See also: cantaro86_transaction_cost_pricer.py for the full TC_pricer class and core algorithm description. """
import numpy as np import matplotlib.pyplot as plt from time import time as timer from scipy.stats import norm
============================================================================¶
We import the TC_pricer and helpers from the companion module.¶
If running standalone, place this file alongside¶
cantaro86_transaction_cost_pricer.py in the same directory.¶
============================================================================¶
try: from cantaro86_transaction_cost_pricer import ( TC_pricer, bs_call_price, terminal_no_option, terminal_writer, terminal_buyer, ) except ImportError: # Fallback: define minimal versions inline for standalone use import numpy.matlib
def terminal_no_option(x, y, cost_b, cost_s):
cost = np.zeros((len(x), len(y)))
for i in range(len(y)):
if y[i] <= 0:
cost[:, i] = (1 + cost_b) * y[i] * np.exp(x)
else:
cost[:, i] = (1 - cost_s) * y[i] * np.exp(x)
return cost
def terminal_writer(x, y, cost_b, cost_s, K):
cost = np.zeros((len(x), len(y)))
for i in range(len(x)):
for j in range(len(y)):
S_i = np.exp(x[i])
if y[j] < 0 and (1 + cost_b) * S_i <= K:
cost[i][j] = (1 + cost_b) * y[j] * S_i
elif y[j] >= 0 and (1 + cost_b) * S_i <= K:
cost[i][j] = (1 - cost_s) * y[j] * S_i
elif y[j] - 1 >= 0 and (1 + cost_b) * S_i > K:
cost[i][j] = (1 - cost_s) * (y[j] - 1) * S_i + K
elif y[j] - 1 < 0 and (1 + cost_b) * S_i > K:
cost[i][j] = (1 + cost_b) * (y[j] - 1) * S_i + K
return cost
def terminal_buyer(x, y, cost_b, cost_s, K):
cost = np.zeros((len(x), len(y)))
for i in range(len(x)):
for j in range(len(y)):
S_i = np.exp(x[i])
if y[j] < 0 and (1 + cost_b) * S_i <= K:
cost[i][j] = (1 + cost_b) * y[j] * S_i
elif y[j] >= 0 and (1 + cost_b) * S_i <= K:
cost[i][j] = (1 - cost_s) * y[j] * S_i
elif y[j] + 1 >= 0 and (1 + cost_b) * S_i > K:
cost[i][j] = (1 - cost_s) * (y[j] + 1) * S_i - K
elif y[j] + 1 < 0 and (1 + cost_b) * S_i > K:
cost[i][j] = (1 + cost_b) * (y[j] + 1) * S_i - K
return cost
class TC_pricer:
def __init__(self, S0, K, T, r, mu, sig, cost_b=0.0, cost_s=0.0, gamma=0.001):
self.S0 = S0
self.K = K
self.T = T
self.r = r
self.mu = mu
self.sig = sig
self.cost_b = cost_b
self.cost_s = cost_s
self.gamma = gamma
def price(self, N=500, TYPE="writer", verbose=False):
t_start = timer()
np.seterr(all="ignore")
x0 = np.log(self.S0)
T_vec, dt = np.linspace(0, self.T, N + 1, retstep=True)
delta = np.exp(-self.r * (self.T - T_vec))
dx = self.sig * np.sqrt(dt)
dy = dx
M = int(np.floor(N / 2))
y = np.linspace(-M * dy, M * dy, 2 * M + 1)
N_y = len(y)
med = np.where(y == 0)[0].item()
def F(xx, ll, nn):
return np.exp(self.gamma * (1 + self.cost_b) * np.exp(xx) * ll / delta[nn])
def G(xx, mm, nn):
return np.exp(-self.gamma * (1 - self.cost_s) * np.exp(xx) * mm / delta[nn])
for portfolio in ["no_opt", TYPE]:
x = np.array([
x0 + (self.mu - 0.5 * self.sig**2) * dt * N + (2 * i - N) * dx
for i in range(N + 1)
])
if portfolio == "no_opt":
Q = np.exp(-self.gamma * terminal_no_option(x, y, self.cost_b, self.cost_s))
elif portfolio == "writer":
Q = np.exp(-self.gamma * terminal_writer(x, y, self.cost_b, self.cost_s, self.K))
elif portfolio == "buyer":
Q = np.exp(-self.gamma * terminal_buyer(x, y, self.cost_b, self.cost_s, self.K))
for k in range(N - 1, -1, -1):
Q_new = (Q[:-1, :] + Q[1:, :]) / 2.0
x = np.array([
x0 + (self.mu - 0.5 * self.sig**2) * dt * k + (2 * i - k) * dx
for i in range(k + 1)
])
Buy = np.copy(Q_new)
Buy[:, :-1] = np.matlib.repmat(F(x, dy, k), N_y - 1, 1).T * Q_new[:, 1:]
Sell = np.copy(Q_new)
Sell[:, 1:] = np.matlib.repmat(G(x, dy, k), N_y - 1, 1).T * Q_new[:, :-1]
Q = np.minimum(np.minimum(Buy, Sell), Q_new)
if portfolio == "no_opt":
Q_no = Q[0, med]
else:
Q_yes = Q[0, med]
if TYPE == "writer":
price = (delta[0] / self.gamma) * np.log(Q_yes / Q_no)
else:
price = (delta[0] / self.gamma) * np.log(Q_no / Q_yes)
elapsed = timer() - t_start
if verbose:
return price, elapsed
return price
def bs_call_price(S0, K, T, r, sigma):
d1 = (np.log(S0 / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
d2 = d1 - sigma * np.sqrt(T)
return S0 * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
============================================================================¶
1. PAYOFF WITH TRANSACTION COSTS¶
============================================================================¶
def plot_payoff_with_tc(K=15, cost_b=0.01): """ Show how proportional transaction costs distort the call payoff.
With buy-side cost lambda, the effective payoff becomes:
max((1 + lambda) * S - K, 0)
The exercise threshold shifts from K to K / (1 + lambda).
Parameters
----------
K : float Strike price.
cost_b : float Proportional buy cost (lambda).
"""
S = np.linspace(K - 1, K + 1, 500)
payoff_zero = np.maximum(S - K, 0)
payoff_tc = np.maximum(S * (1 + cost_b) - K, 0)
plt.figure(figsize=(10, 5))
plt.plot(S, payoff_zero, color="blue", linewidth=2, label="Zero-cost payoff")
plt.plot(S, payoff_tc, color="red", linewidth=2, label=f"With TC (λ={cost_b})")
plt.axvline(x=K, color="grey", ls="--", alpha=0.5, label=f"K = {K}")
plt.axvline(x=K / (1 + cost_b), color="red", ls=":", alpha=0.5,
label=f"K/(1+λ) = {K / (1 + cost_b):.4f}")
plt.xlabel("S")
plt.ylabel("Payoff")
plt.title("Call Payoff: Zero Transaction Costs vs Proportional Costs")
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
============================================================================¶
2. WRITER vs BUYER PRICE ACROSS SPOT PRICES¶
============================================================================¶
def price_vs_spot(K=15, T=1, r=0.1, mu=0.1, sigma=0.25, gamma=0.0001, cost=0.05, N=400, S_range=None): """ Compare writer and buyer indifference prices across spot levels.
Key insight: the writer charges MORE than BS (compensation for
hedging costs), and the buyer pays LESS than BS (accounts for
imperfect replication). The BS price lies between them.
Parameters
----------
K : float Strike.
T : float Maturity.
r : float Risk-free rate.
mu : float Physical drift.
sigma : float Volatility.
gamma : float Risk aversion.
cost : float Proportional transaction cost (both sides).
N : int Binomial tree steps.
S_range : list Spot prices to scan.
Returns
-------
dict with spot prices, writer/buyer/zero-cost/BS prices.
"""
if S_range is None:
S_range = list(range(int(K - 10), int(K + 6)))
price_zero = []
price_writer = []
price_buyer = []
for S0 in S_range:
# Zero-cost prices
pricer = TC_pricer(S0, K, T, r, mu, sigma,
cost_b=0, cost_s=0, gamma=gamma)
price_zero.append(pricer.price(N=N, TYPE="writer"))
# Prices with transaction costs
pricer = TC_pricer(S0, K, T, r, mu, sigma,
cost_b=cost, cost_s=cost, gamma=gamma)
price_writer.append(pricer.price(N=N, TYPE="writer"))
price_buyer.append(pricer.price(N=N, TYPE="buyer"))
# BS reference
bs_prices = [bs_call_price(s, K, T, r, sigma) for s in S_range]
plt.figure(figsize=(10, 5))
plt.plot(S_range, price_zero, "bs-", label="Zero TC", markersize=5)
plt.plot(S_range, price_writer, "go-", label=f"Writer (cost={cost})", markersize=5)
plt.plot(S_range, price_buyer, "m*-", label=f"Buyer (cost={cost})", markersize=5)
plt.plot(S_range, bs_prices, "r--", linewidth=1.5, label="Black-Scholes")
plt.xlabel("Spot price S")
plt.ylabel("Option price")
plt.title(f"Indifference Prices vs Spot (K={K}, γ={gamma}, cost={cost})")
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
return {
"S_range": S_range,
"zero": price_zero,
"writer": price_writer,
"buyer": price_buyer,
"bs": bs_prices,
}
============================================================================¶
3. TIME COMPLEXITY ANALYSIS¶
============================================================================¶
def time_complexity(S0=15, K=15, T=1, r=0.1, mu=0.1, sigma=0.25, gamma=0.0001, N_values=None): """ Measure wall-clock time vs binomial tree size N.
The DPZ algorithm uses a tree in (log-price, stock-holding) space:
- At step k: (k+1) log-price nodes x (2*M+1) holding nodes
- M = floor(N/2), so total work ~ O(N^2 * N) per step
- With N backward steps: overall O(N^3)... but the holding grid
grows linearly with N, so effectively O(N^2) per pricing call.
Parameters
----------
S0, K, T, r, mu, sigma, gamma : float Model parameters.
N_values : list Tree sizes to test.
Returns
-------
dict with N_values, prices, times, and estimated exponent.
"""
if N_values is None:
N_values = [50 * 2**i for i in range(7)] # 50, 100, ..., 3200
pricer = TC_pricer(S0, K, T, r, mu, sigma, cost_b=0, cost_s=0, gamma=gamma)
prices = []
times = []
print(f" {'N':>6s} {'Price':>12s} {'Time (s)':>10s}")
print(f" {'-'*6} {'-'*12} {'-'*10}")
for N in N_values:
p, t = pricer.price(N=N, TYPE="writer", verbose=True)
prices.append(p)
times.append(t)
print(f" {N:>6d} {p:>12.6f} {t:>10.4f}")
# Estimate scaling exponent: time ~ N^alpha
if len(times) >= 2 and times[-1] > 0 and times[-2] > 0:
alpha = np.log2(times[-1] / times[-2])
print(f"\n Estimated exponent (last two points): alpha ≈ {alpha:.2f}")
print(f" (Expected: ~2 for O(N^2) complexity)")
return {"N_values": N_values, "prices": prices, "times": times}
============================================================================¶
4. DRIFT SENSITIVITY: WHY mu MATTERS WITH TRANSACTION COSTS¶
============================================================================¶
def drift_sensitivity(S0=15, K=15, T=1, r=0.1, sigma=0.25, gamma=1.0, cost=0.01, N=400, mu_values=None): """ Show that the physical drift mu affects option prices under transaction costs (unlike the drift-free Black-Scholes world).
In Black-Scholes, the drift cancels out via risk-neutral pricing.
With transaction costs and utility-based pricing, the drift matters
because:
1. Hedging is imperfect (not continuous).
2. The agent uses exponential utility, and the drift affects
the expected terminal wealth.
3. Higher drift makes the stock more attractive, affecting
the hedging strategy and thus the option's indifference price.
This effect is most visible with HIGH risk aversion (large gamma),
where the agent's utility is strongly sensitive to the drift.
Parameters
----------
S0, K, T, r, sigma : float Market parameters.
gamma : float Risk aversion (use high value, e.g. 1.0).
cost : float Proportional transaction cost.
N : int Tree steps.
mu_values : list Physical drift values to scan.
Returns
-------
dict with mu_values, writer prices, buyer prices.
"""
if mu_values is None:
mu_values = [0.01, 0.05, 0.1, 0.2, 0.3]
writer_prices = []
buyer_prices = []
print(f" Parameters: S0={S0}, K={K}, gamma={gamma}, cost={cost}")
print(f"\n {'mu':>8s} {'Writer':>10s} {'Buyer':>10s} {'Spread':>10s}")
print(f" {'-'*8} {'-'*10} {'-'*10} {'-'*10}")
for mu in mu_values:
pricer = TC_pricer(S0, K, T, r, mu, sigma,
cost_b=cost, cost_s=cost, gamma=gamma)
pw = pricer.price(N=N, TYPE="writer")
pb = pricer.price(N=N, TYPE="buyer")
writer_prices.append(pw)
buyer_prices.append(pb)
print(f" {mu:>8.3f} {pw:>10.4f} {pb:>10.4f} {pw - pb:>10.4f}")
bs_ref = bs_call_price(S0, K, T, r, sigma)
plt.figure(figsize=(10, 5))
plt.plot(mu_values, writer_prices, "go-", label="Writer", markersize=8)
plt.plot(mu_values, buyer_prices, "m*-", label="Buyer", markersize=8)
plt.axhline(y=bs_ref, color="r", ls="--", label=f"BS price = {bs_ref:.4f}")
plt.xlabel("Physical drift μ")
plt.ylabel("Indifference price")
plt.title(f"Drift Sensitivity (γ={gamma}, cost={cost})\n"
"Unlike BS, drift matters with transaction costs!")
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
return {"mu_values": mu_values, "writer": writer_prices, "buyer": buyer_prices}
============================================================================¶
5. GAMMA (RISK AVERSION) SENSITIVITY WITH VISUALISATION¶
============================================================================¶
def gamma_sensitivity(S0=15, K=15, T=1, r=0.1, mu=0.1, sigma=0.25, cost=0.01, N=400, gamma_values=None): """ Show how risk aversion gamma affects the bid-ask spread.
Higher gamma means the agent is more risk-averse, leading to:
- Higher writer price (demands more compensation).
- Lower buyer price (willing to pay less).
- Wider bid-ask spread.
Parameters
----------
S0, K, T, r, mu, sigma : float Market parameters.
cost : float Proportional transaction cost.
N : int Tree steps.
gamma_values : list Risk aversion levels to scan.
Returns
-------
dict with gamma_values, writer prices, buyer prices.
"""
if gamma_values is None:
gamma_values = [0.0001, 0.001, 0.01, 0.05, 0.1, 0.3, 0.5]
writer_prices = []
buyer_prices = []
print(f" Parameters: S0={S0}, K={K}, cost={cost}")
print(f"\n {'gamma':>10s} {'Writer':>10s} {'Buyer':>10s} {'Spread':>10s}")
print(f" {'-'*10} {'-'*10} {'-'*10} {'-'*10}")
for gamma in gamma_values:
pricer = TC_pricer(S0, K, T, r, mu, sigma,
cost_b=cost, cost_s=cost, gamma=gamma)
pw = pricer.price(N=N, TYPE="writer")
pb = pricer.price(N=N, TYPE="buyer")
writer_prices.append(pw)
buyer_prices.append(pb)
print(f" {gamma:>10.4f} {pw:>10.4f} {pb:>10.4f} {pw - pb:>10.4f}")
bs_ref = bs_call_price(S0, K, T, r, sigma)
plt.figure(figsize=(10, 5))
plt.plot(gamma_values, writer_prices, "go-", label="Writer", markersize=8)
plt.plot(gamma_values, buyer_prices, "m*-", label="Buyer", markersize=8)
plt.axhline(y=bs_ref, color="r", ls="--", label=f"BS price = {bs_ref:.4f}")
plt.xlabel("Risk aversion γ")
plt.ylabel("Indifference price")
plt.title(f"Gamma Sensitivity (cost={cost})\n"
"Higher risk aversion widens the bid-ask spread")
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
return {"gamma_values": gamma_values, "writer": writer_prices, "buyer": buyer_prices}
============================================================================¶
COMPREHENSIVE DEMO¶
============================================================================¶
def demo_all(): """Run all TC sensitivity demonstrations.""" S0, K, T, r, mu, sigma = 15.0, 15.0, 1.0, 0.1, 0.1, 0.25
# 1. Payoff distortion
print("=" * 60)
print("1. Payoff Distortion Under Transaction Costs")
print("=" * 60)
plot_payoff_with_tc(K=K, cost_b=0.01)
# 2. Price vs spot
print("\n" + "=" * 60)
print("2. Writer vs Buyer Indifference Prices Across Spot")
print("=" * 60)
price_vs_spot(K=K, T=T, r=r, mu=mu, sigma=sigma,
gamma=0.0001, cost=0.05, N=400)
# 3. Time complexity
print("\n" + "=" * 60)
print("3. Computational Complexity of the DPZ Algorithm")
print("=" * 60)
time_complexity(S0=S0, K=K, T=T, r=r, mu=mu, sigma=sigma,
gamma=0.0001, N_values=[50, 100, 200, 400, 800])
# 4. Gamma sensitivity with plot
print("\n" + "=" * 60)
print("4. Risk Aversion (γ) Sensitivity")
print("=" * 60)
gamma_sensitivity(S0=S0, K=K, T=T, r=r, mu=mu, sigma=sigma,
cost=0.01, N=400)
# 5. Drift sensitivity (key insight!)
print("\n" + "=" * 60)
print("5. Drift Sensitivity: Why μ Matters with Transaction Costs")
print("=" * 60)
print(" (Using high gamma = 1.0 to make the effect visible)")
drift_sensitivity(S0=S0, K=K, T=T, r=r, sigma=sigma,
gamma=1.0, cost=0.01, N=400)
print("\n" + "=" * 60)
print("SUMMARY OF KEY INSIGHTS")
print("=" * 60)
print(" 1. Transaction costs distort the payoff -- the effective exercise")
print(" threshold shifts from K to K/(1+λ).")
print(" 2. Writer charges more than BS; buyer pays less. The BS price")
print(" lies between the two indifference prices.")
print(" 3. The DPZ algorithm has O(N^2) complexity in the tree size N.")
print(" 4. Higher risk aversion (γ) widens the bid-ask spread, because")
print(" the more risk-averse agent demands more/pays less.")
print(" 5. Unlike Black-Scholes, the physical drift μ MATTERS under")
print(" transaction costs (especially with high γ), because hedging")
print(" is no longer continuous and the drift affects expected utility.")
============================================================================¶
MAIN¶
============================================================================¶
if name == "main": demo_all() ```
Exercises¶
Exercise 1. With proportional buy cost \(\lambda = 0.01\), the call exercise threshold shifts from \(K\) to \(K/(1+\lambda)\). For \(K = 15\), compute the new threshold and explain the economic intuition.
Solution to Exercise 1
New threshold: \(K/(1+\lambda) = 15/1.01 = 14.851\). Exercise occurs when \((1+\lambda)S > K\), i.e., \(S > K/(1+\lambda) = 14.851\). Intuition: the buyer must pay \((1+\lambda)S\) to acquire the stock (including transaction cost), so exercise is only profitable when this cost exceeds \(K\). The threshold shifts left, making exercise slightly easier.
Exercise 2. Explain why the writer price exceeds the BS price while the buyer price is below it when transaction costs are present.
Solution to Exercise 2
The writer demands compensation for hedging costs (which reduce their expected utility), so they charge more than BS. The buyer accounts for imperfect replication when valuing the option, so they pay less than BS. The BS price assumes costless continuous hedging; with costs, perfect replication is impossible, creating a bid-ask spread around the BS price.
Exercise 3. The drift \(\mu\) matters for option pricing under transaction costs, unlike in Black-Scholes. Explain why.
Solution to Exercise 3
In BS, continuous hedging eliminates all drift dependence. With transaction costs, hedging is discrete and imperfect, so the physical drift \(\mu\) affects the expected P&L of the hedging strategy. Higher \(\mu\) means the stock is more likely to appreciate, affecting the writer and buyer indifference prices differently through their utility-based optimization.
Exercise 4. Higher risk aversion \(\gamma\) widens the bid-ask spread. Explain this using the exponential utility \(U(W) = -e^{-\gamma W}\).
Solution to Exercise 4
Higher \(\gamma\) means greater curvature of the utility function, so the agent is more sensitive to wealth fluctuations from imperfect hedging. The writer demands a higher price to compensate for the utility cost of residual risk. The buyer offers less because the potential gains are worth less in utility terms. Both effects widen the spread. As \(\gamma \to 0\) (risk neutrality), both prices converge to the BS price.