European Option Pricing with Binomial Trees¶

This notebook covers put-call parity and European option pricing with binomial trees, including ATM calls and puts, delta hedging, and related exercises in derivative pricing.

In [2]:
import numpy as np
import pandas as pd

# Given Parameters
S0 = 100
K = 100
r = 0.05
sigma = 0.20
T = 0.25
N = 100

dt = T/N
u = np.exp(sigma*np.sqrt(dt))
d = 1/u
p = (np.exp(r*dt)-d)/(u-d)
disc = np.exp(-r*dt)

Step 1: Put-Call Parity¶

Q5 – European Call & Put (Binomial Tree)

In [5]:
# Terminal stock prices
ST = np.array([S0*(u**j)*(d**(N-j)) for j in range(N+1)])

# Payoffs
call = np.maximum(ST-K,0)
put = np.maximum(K-ST,0)

# Backward induction
for i in range(N):
    call = disc*(p*call[1:] + (1-p)*call[:-1])
    put = disc*(p*put[1:] + (1-p)*put[:-1])

C_eur = round(call[0],2)
P_eur = round(put[0],2)

pd.DataFrame({
    "Option":["European Call","European Put"],
    "Price":[C_eur,P_eur]
})
Out[5]:
Option Price
0 European Call 4.61
1 European Put 3.36

Q5 (a) Since (S_0 = 100), the ATM strike is (K = 100). Process: build binomial tree, terminal payoffs (max(S_T - K, 0)) (call) and (max(K - S_T, 0)) (put), then backward induction with risk-neutral probability (p).

(b) Number of steps: A sufficiently large number of steps ((N = 100)) was chosen to ensure numerical convergence of the binomial tree while keeping computational complexity manageable. Increasing (N) improves approximation accuracy toward the continuous-time Black–Scholes value.

Q6 – Delta (European Options)

In [8]:
# Delta from tree: (V_up - V_down)/(S_up - S_down) at first time step
ST = np.array([S0*(u**j)*(d**(N-j)) for j in range(N+1)])
call_vals = np.maximum(ST-K, 0)
put_vals = np.maximum(K-ST, 0)

for i in range(N-1):  # stop one step early → 2 nodes at t = dt
    call_vals = disc*(p*call_vals[1:] + (1-p)*call_vals[:-1])
    put_vals = disc*(p*put_vals[1:] + (1-p)*put_vals[:-1])
    
# call_vals[0]=V_down, call_vals[1]=V_up at t=dt
S_up, S_down = S0*u, S0*d
delta_call = round((call_vals[1]-call_vals[0])/(S_up-S_down), 4)
delta_put = round((put_vals[1]-put_vals[0])/(S_up-S_down), 4)

pd.DataFrame({
    "Option":["European Call","European Put"],
    "Delta":[delta_call,delta_put]
})
Out[8]:
Option Delta
0 European Call 0.5693
1 European Put -0.4307

Q7 – Volatility Sensitivity (σ = 25%)

In [10]:
sigma_new = 0.25

u_new = np.exp(sigma_new*np.sqrt(dt))
d_new = 1/u_new
p_new = (np.exp(r*dt)-d_new)/(u_new-d_new)

ST_new = np.array([S0*(u_new**j)*(d_new**(N-j)) for j in range(N+1)])

call_new = np.maximum(ST_new-K,0)
put_new = np.maximum(K-ST_new,0)

for i in range(N):
    call_new = disc*(p_new*call_new[1:] + (1-p_new)*call_new[:-1])
    put_new = disc*(p_new*put_new[1:] + (1-p_new)*put_new[:-1])

C_new = round(call_new[0],2)
P_new = round(put_new[0],2)

pd.DataFrame({
    "Option":["Call","Put"],
    "Price (20%)":[C_eur,P_eur],
    "Price (25%)":[C_new,P_new],
    "Change":[round(C_new-C_eur,2),round(P_new-P_eur,2)]
})
Out[10]:
Option Price (20%) Price (25%) Change
0 Call 4.61 5.59 0.98
1 Put 3.36 4.34 0.98

Q8 – American Call & Put

In [12]:
# Rebuild tree
ST = np.array([S0*(u**j)*(d**(N-j)) for j in range(N+1)])
call = np.maximum(ST-K,0)
put = np.maximum(K-ST,0)

for i in range(N):
    ST = np.array([S0*(u**j)*(d**(N-1-i-j)) for j in range(N-i)])
    call = np.maximum(disc*(p*call[1:] + (1-p)*call[:-1]), ST-K)
    put = np.maximum(disc*(p*put[1:] + (1-p)*put[:-1]), K-ST)

C_am = round(call[0],2)
P_am = round(put[0],2)

pd.DataFrame({
    "Option":["American Call","American Put"],
    "Price":[C_am,P_am]
})
Out[12]:
Option Price
0 American Call 4.61
1 American Put 3.47

Q9 – Delta (American Options)

In [14]:
# American Delta from tree: option values at first time step (2 nodes)
ST_am = np.array([S0*(u**j)*(d**(N-j)) for j in range(N+1)])
call_am = np.maximum(ST_am-K, 0)
put_am = np.maximum(K-ST_am, 0)

for i in range(N-1):
    ST_step = np.array([S0*(u**j)*(d**(N-1-i-j)) for j in range(N-i)])
    call_am = np.maximum(disc*(p*call_am[1:] + (1-p)*call_am[:-1]), ST_step-K)
    put_am = np.maximum(disc*(p*put_am[1:] + (1-p)*put_am[:-1]), K-ST_step)
    
# call_am[0]=V_down, call_am[1]=V_up at t=dt
S_up_am, S_down_am = S0*u, S0*d
delta_call_am = round((call_am[1]-call_am[0])/(S_up_am-S_down_am), 4)
delta_put_am = round((put_am[1]-put_am[0])/(S_up_am-S_down_am), 4)

pd.DataFrame({
    "Option":["American Call","American Put"],
    "Delta":[delta_call_am,delta_put_am]
})
Out[14]:
Option Delta
0 American Call 0.5693
1 American Put -0.4498

Q10 – Volatility Sensitivity (American)

In [16]:
# American pricing with new volatility
ST_new = np.array([S0*(u_new**j)*(d_new**(N-j)) for j in range(N+1)])
call_new = np.maximum(ST_new-K,0)
put_new = np.maximum(K-ST_new,0)

for i in range(N):
    ST_new = np.array([S0*(u_new**j)*(d_new**(N-1-i-j)) for j in range(N-i)])
    call_new = np.maximum(disc*(p_new*call_new[1:] + (1-p_new)*call_new[:-1]), ST_new-K)
    put_new = np.maximum(disc*(p_new*put_new[1:] + (1-p_new)*put_new[:-1]), K-ST_new)

C_am_new = round(call_new[0],2)
P_am_new = round(put_new[0],2)

pd.DataFrame({
    "Option":["American Call","American Put"],
    "Price (20%)":[C_am,P_am],
    "Price (25%)":[C_am_new,P_am_new],
    "Change":[round(C_am_new-C_am,2),round(P_am_new-P_am,2)]
})
Out[16]:
Option Price (20%) Price (25%) Change
0 American Call 4.61 5.59 0.98
1 American Put 3.47 4.45 0.98

Step 2: Trinomial Tree¶

(same parameters: S0=100, r=5%, σ=20%, T=3 months)

Strike selection: Five strike prices were selected using moneyness levels 90%, 95%, 100%, 105%, and 110% of spot (S_0 = 100), giving (K = 90, 95, 100, 105, 110). For calls: Deep ITM to Deep OTM; for puts: Deep OTM to Deep ITM.

In [18]:
# Trinomial tree: parameters and pricing functions (N steps, recombining)
# dx = sigma*sqrt(2*dt), u = exp(dx), d = 1/u; match risk-neutral mean and variance
def trinomial_params(r, sigma, T, N):
    dt = T / N
    dx = sigma * np.sqrt(2 * dt)
    u = np.exp(dx)
    d = 1 / u
    nu = r - 0.5 * sigma**2
    p_u = 0.5 * ((sigma**2 * dt + nu**2 * dt**2) / (dx**2) + nu * dt / dx)
    p_d = 0.5 * ((sigma**2 * dt + nu**2 * dt**2) / (dx**2) - nu * dt / dx)
    p_m = 1 - p_u - p_d
    disc = np.exp(-r * dt)
    return u, d, p_u, p_m, p_d, disc, N

def price_trinomial(S0, K, r, sigma, T, N, option='call', style='european'):
    u, d, p_u, p_m, p_d, disc, _ = trinomial_params(r, sigma, T, N)
    # Terminal nodes: j = 0..2*N, stock = S0 * u^(j-N)
    j_nodes = np.arange(2*N + 1)
    S_T = S0 * (u ** (j_nodes - N))
    if option == 'call':
        V = np.maximum(S_T - K, 0.0)
    else:
        V = np.maximum(K - S_T, 0.0)
        
    # Backward induction
    for n in range(N - 1, -1, -1):
        n_nodes = 2 * n + 1
        S_n = S0 * (u ** (np.arange(n_nodes) - n))
        cont = disc * (p_d * V[:n_nodes] + p_m * V[1:n_nodes+1] + p_u * V[2:n_nodes+2])
        if style == 'american':
            if option == 'call':
                intrinsic = np.maximum(S_n - K, 0.0)
            else:
                intrinsic = np.maximum(K - S_n, 0.0)
            V = np.maximum(cont, intrinsic)
        else:
            V = cont.copy()
    return V[0]

# 5 strikes by moneyness: 90%, 95%, 100%, 105%, 110% (K/S0)
strikes = np.array([90, 95, 100, 105, 110])  # K = S0 * moneyness
moneyness_pct = strikes / S0 * 100

Q15 – European Call options (5 strikes)

In [20]:
# (a) Price European calls for 5 strikes (Deep OTM, OTM, ATM, ITM, Deep ITM)
eur_calls = np.array([price_trinomial(S0, K, r, sigma, T, N, option='call', style='european') for K in strikes])

# For a call: low K = ITM (S>K), high K = OTM. So K=90 Deep ITM, K=110 Deep OTM.
q15_table = pd.DataFrame({
    "Strike K": strikes,
    "Moneyness (K/S0)": moneyness_pct,
    "Type": ["Deep ITM", "ITM", "ATM", "OTM", "Deep OTM"],
    "European Call": np.round(eur_calls, 2)
})
q15_table
Out[20]:
Strike K Moneyness (K/S0) Type European Call
0 90 90.0 Deep ITM 11.67
1 95 95.0 ITM 7.72
2 100 100.0 ATM 4.61
3 105 105.0 OTM 2.48
4 110 110.0 Deep OTM 1.19

Q16 – European Put options (5 strikes)

In [22]:
# (a) Price European puts for the same 5 strikes
eur_puts = np.array([price_trinomial(S0, K, r, sigma, T, N, option='put', style='european') for K in strikes])

q16_table = pd.DataFrame({
    "Strike K": strikes,
    "Moneyness (K/S0)": moneyness_pct,
    "Type": ["Deep OTM", "OTM", "ATM", "ITM", "Deep ITM"],
    "European Put": np.round(eur_puts, 2)
})
q16_table
Out[22]:
Strike K Moneyness (K/S0) Type European Put
0 90 90.0 Deep OTM 0.55
1 95 95.0 OTM 1.54
2 100 100.0 ATM 3.37
3 105 105.0 ITM 6.18
4 110 110.0 Deep ITM 9.83

Q17 – American Call options (5 strikes)

In [24]:
# (a) Price American calls for the same 5 strikes
am_calls = np.array([price_trinomial(S0, K, r, sigma, T, N, option='call', style='american') for K in strikes])

q17_table = pd.DataFrame({
    "Strike K": strikes,
    "Moneyness (K/S0)": moneyness_pct,
    "Type": ["Deep ITM", "ITM", "ATM", "OTM", "Deep OTM"],
    "American Call": np.round(am_calls, 2),
    "European Call": np.round(eur_calls, 2),
    "Diff (Am - Eur)": np.round(am_calls - eur_calls, 2)
})
q17_table
Out[24]:
Strike K Moneyness (K/S0) Type American Call European Call Diff (Am - Eur)
0 90 90.0 Deep ITM 11.67 11.67 0.0
1 95 95.0 ITM 7.72 7.72 0.0
2 100 100.0 ATM 4.61 4.61 0.0
3 105 105.0 OTM 2.48 2.48 0.0
4 110 110.0 Deep OTM 1.19 1.19 0.0

Q18 – American Put options (5 strikes)

In [26]:
# (a) Price American puts for the same 5 strikes
am_puts = np.array([price_trinomial(S0, K, r, sigma, T, N, option='put', style='american') for K in strikes])

q18_table = pd.DataFrame({
    "Strike K": strikes,
    "Moneyness (K/S0)": moneyness_pct,
    "Type": ["Deep OTM", "OTM", "ATM", "ITM", "Deep ITM"],
    "American Put": np.round(am_puts, 2),
    "European Put": np.round(eur_puts, 2),
    "Diff (Am - Eur)": np.round(am_puts - eur_puts, 2)
})
q18_table
Out[26]:
Strike K Moneyness (K/S0) Type American Put European Put Diff (Am - Eur)
0 90 90.0 Deep OTM 0.56 0.55 0.01
1 95 95.0 OTM 1.58 1.54 0.04
2 100 100.0 ATM 3.48 3.37 0.11
3 105 105.0 ITM 6.43 6.18 0.25
4 110 110.0 Deep ITM 10.33 9.83 0.50

Step 3: Dynamic Delta Hedging and Asian Option¶

Data: (S_0 = 180), (r = 2%), (sigma = 25%), (T = 6) months, (K = 182) (Q25–Q26); Asian ATM uses (K = S_0 = 180).

Q25 – European Put, 3-step binomial tree

In [29]:
S0_q25, r_q25, sigma_q25, T_q25, K_q25 = 180, 0.02, 0.25, 0.5, 182
N3 = 3
dt3 = T_q25 / N3
u3 = np.exp(sigma_q25 * np.sqrt(dt3))
d3 = 1 / u3
p3 = (np.exp(r_q25 * dt3) - d3) / (u3 - d3)
disc3 = np.exp(-r_q25 * dt3)

# 3-step tree: S[n][j] = stock price at step n, node j (j = 0..n for binomial: j ups)
def build_binomial_3(S0, u, d, N):
    S = [[S0 * (u**j) * (d**(n-j)) for j in range(n+1)] for n in range(N+1)]
    return S

S_tree = build_binomial_3(S0_q25, u3, d3, N3)
# Put payoffs at maturity (step 3)
put_T = [max(K_q25 - S_tree[N3][j], 0) for j in range(N3+1)]

# Backward induction (European)
put_vals = put_T.copy()
for n in range(N3-1, -1, -1):
    put_vals = [disc3 * (p3 * put_vals[j+1] + (1-p3) * put_vals[j]) for j in range(n+1)]

P_eur_3step = round(put_vals[0], 2)
from IPython.display import display  # pyright: ignore[reportMissingImports]
display(pd.DataFrame({"Option": ["European Put (3-step)"], "Price": [P_eur_3step]}))

# Deltas at each node (for hedging): Delta[n][j] = (V_up - V_down) / (S_up - S_down) from step n
put_grid = [[0.0]*(n+1) for n in range(N3+1)]
for j in range(N3+1):
    put_grid[N3][j] = max(K_q25 - S_tree[N3][j], 0)
for n in range(N3-1, -1, -1):
    for j in range(n+1):
        put_grid[n][j] = disc3 * (p3 * put_grid[n+1][j+1] + (1-p3) * put_grid[n+1][j])

delta_grid = [[0.0]*(n+1) for n in range(N3+1)]
for n in range(N3):
    for j in range(n+1):
        S_up, S_down = S_tree[n+1][j+1], S_tree[n+1][j]
        V_up, V_down = put_grid[n+1][j+1], put_grid[n+1][j]
        delta_grid[n][j] = (V_up - V_down) / (S_up - S_down) if S_up != S_down else 0.0

# One path: e.g. Down -> Down -> Up (so we end at node j=1 at step 3). Path indices: (0,0)->(1,0)->(2,0)->(3,1)
path_j = [0, 0, 0, 1]  # node index at steps 0,1,2,3
path_S = [S_tree[n][path_j[n]] for n in range(N3+1)]
path_delta = [delta_grid[n][path_j[n]] for n in range(N3)]

# Cash account (seller: receives premium, shorts Delta shares so position = -Delta; rebalance each step)
# at t=0 receive P_eur_3step, hold Delta_0 shares (short if put: Delta_0 < 0). Cash_0 = Premium - Delta_0 * S_0
cash = P_eur_3step - path_delta[0] * path_S[0]
rows = [{"Step": 0, "S": path_S[0], "Delta": path_delta[0], "Cash (after rebal)": round(cash, 2), "Payoff (seller pays)": np.nan}]
for n in range(1, N3):
    cash = cash * np.exp(r_q25 * dt3)  # interest
    cash -= (path_delta[n] - path_delta[n-1]) * path_S[n]
    rows.append({"Step": n, "S": round(path_S[n], 4), "Delta": round(path_delta[n], 4), "Cash (after rebal)": round(cash, 2), "Payoff (seller pays)": np.nan})
cash = cash * np.exp(r_q25 * dt3)
payoff = max(K_q25 - path_S[N3], 0)
cash -= payoff
rows.append({"Step": N3, "S": round(path_S[N3], 4), "Delta": "-", "Cash (after rebal)": round(cash, 2), "Payoff (seller pays)": payoff})
q25_table = pd.DataFrame(rows)
q25_table
Option Price
0 European Put (3-step) 13.82
Out[29]:
Step S Delta Cash (after rebal) Payoff (seller pays)
0 0 180.0000 -0.472554 98.88 NaN
1 1 162.5352 -0.7447 143.45 NaN
2 2 146.7650 -1.0 181.39 NaN
3 3 162.5352 - 162.53 19.46477

Q26 – American Put, 25-step binomial (seller; delta hedging and cash account)

In [31]:
# Same data as Q25; 25 steps
N25 = 25
dt25 = T_q25 / N25
u25 = np.exp(sigma_q25 * np.sqrt(dt25))
d25 = 1 / u25
p25 = (np.exp(r_q25 * dt25) - d25) / (u25 - d25)
disc25 = np.exp(-r_q25 * dt25)

# Build full tree: S[n][j], American put value V[n][j], Delta[n][j]
S_am = [[S0_q25 * (u25**j) * (d25**(n-j)) for j in range(n+1)] for n in range(N25+1)]
V_am = [[0.0]*(n+1) for n in range(N25+1)]
for j in range(N25+1):
    V_am[N25][j] = max(K_q25 - S_am[N25][j], 0)
for n in range(N25-1, -1, -1):
    for j in range(n+1):
        cont = disc25 * (p25 * V_am[n+1][j+1] + (1-p25) * V_am[n+1][j])
        intrinsic = max(K_q25 - S_am[n][j], 0)
        V_am[n][j] = max(cont, intrinsic)
        
# Deltas at each node (a)
Delta_am = [[0.0]*(n+1) for n in range(N25+1)]
for n in range(N25):
    for j in range(n+1):
        Su, Sd = S_am[n+1][j+1], S_am[n+1][j]
        Vu, Vd = V_am[n+1][j+1], V_am[n+1][j]
        Delta_am[n][j] = (Vu - Vd) / (Su - Sd) if Su != Sd else 0.0

# (a) Show delta at each node for a subset (e.g. steps 0,1,2,3 and last few)
delta_subset = []
for n in [0, 1, 2, 3, N25-2, N25-1]:
    for j in range(min(n+1, 5)):
        delta_subset.append({"Step": n, "Node j": j, "S": round(S_am[n][j], 2), "Delta": round(Delta_am[n][j], 4)})
print("(a) Delta at selected nodes (subset):")
display(pd.DataFrame(delta_subset))
American_put_price_25 = round(V_am[0][0], 2)
display(pd.DataFrame({"Option": ["American Put (25-step)"], "Price": [American_put_price_25]}))
(a) Delta at selected nodes (subset):
Step Node j S Delta
0 0 0 180.00 -0.4756
1 1 0 173.75 -0.5608
2 1 1 186.48 -0.3951
3 2 0 167.71 -0.6479
4 2 1 180.00 -0.4786
5 2 2 193.19 -0.3163
6 3 0 161.89 -0.7331
7 3 1 173.75 -0.5675
8 3 2 186.48 -0.3947
9 3 3 200.14 -0.2423
10 23 0 79.82 -1.0000
11 23 1 85.67 -1.0000
12 23 2 91.95 -1.0000
13 23 3 98.68 -1.0000
14 23 4 105.91 -1.0000
15 24 0 77.05 -1.0000
16 24 1 82.69 -1.0000
17 24 2 88.75 -1.0000
18 24 3 95.26 -1.0000
19 24 4 102.23 -1.0000
Option Price
0 American Put (25-step) 13.04
In [32]:
# (b) One path: e.g. Down for 20 steps then Up for 5 (path ends at node 5 at maturity)
# Path: j=0 at steps 0..19, then j=1,2,3,4,5 at steps 20..25 (so we go 0,0,...,0,1,2,3,4,5)
path_steps_26 = list(range(N25+1))
path_j_26 = [0]*21 + [1,2,3,4,5]  # 21 downs then 5 ups -> end at node 5; len = 26
path_S_26 = [S_am[n][path_j_26[n]] for n in range(N25+1)]
path_delta_26 = [Delta_am[n][path_j_26[n]] for n in range(N25)]

cash26 = American_put_price_25 - path_delta_26[0] * path_S_26[0]
rows26 = [{"Step": 0, "S": round(path_S_26[0], 2), "Delta": round(path_delta_26[0], 4), "Cash (after rebal)": round(cash26, 2)}]
for n in range(1, N25):
    cash26 = cash26 * np.exp(r_q25 * dt25)
    cash26 -= (path_delta_26[n] - path_delta_26[n-1]) * path_S_26[n]
    rows26.append({"Step": n, "S": round(path_S_26[n], 2), "Delta": round(path_delta_26[n], 4), "Cash (after rebal)": round(cash26, 2)})
    
cash26 = cash26 * np.exp(r_q25 * dt25)
payoff26 = max(K_q25 - path_S_26[N25], 0)
cash26 -= payoff26
rows26.append({"Step": N25, "S": round(path_S_26[N25], 2), "Delta": "-", "Cash (after rebal)": round(cash26, 2), "Payoff (seller pays)": payoff26})
print("(b) Cash account along one path (Down x20 then Up x5 → end node 5):")
pd.DataFrame(rows26)
(b) Cash account along one path (Down x20 then Up x5 → end node 5):
Out[32]:
Step S Delta Cash (after rebal) Payoff (seller pays)
0 0 180.00 -0.4756 98.64 NaN
1 1 173.75 -0.5608 113.49 NaN
2 2 167.71 -0.6479 128.14 NaN
3 3 161.89 -0.7331 141.98 NaN
4 4 156.26 -0.8124 154.43 NaN
5 5 150.83 -0.8822 165.03 NaN
6 6 145.59 -0.94 173.51 NaN
7 7 140.54 -0.9844 179.81 NaN
8 8 135.65 -1.0 182.00 NaN
9 9 130.94 -1.0 182.08 NaN
10 10 126.39 -1.0 182.15 NaN
11 11 122.00 -1.0 182.22 NaN
12 12 117.77 -1.0 182.30 NaN
13 13 113.67 -1.0 182.37 NaN
14 14 109.73 -1.0 182.44 NaN
15 15 105.91 -1.0 182.51 NaN
16 16 102.23 -1.0 182.59 NaN
17 17 98.68 -1.0 182.66 NaN
18 18 95.26 -1.0 182.73 NaN
19 19 91.95 -1.0 182.81 NaN
20 20 88.75 -1.0 182.88 NaN
21 21 91.95 -1.0 182.95 NaN
22 22 95.26 -1.0 183.03 NaN
23 23 98.68 -1.0 183.10 NaN
24 24 102.23 -1.0 183.17 NaN
25 25 105.91 - 107.16 76.086072

Q27 – Asian ATM Put, 25-step binomial

(same data; K = S0 = 180)

In [34]:
# Asian ATM Put: K = S0 = 180. Path-dependent: value at (n,j) depends on running average A.
K_asian = S0_q25  # 180 ATM
M_avg = 35  # number of average buckets

# Average range: from S0*d^N to S0*u^N (wide enough for all paths)
a_min_global = S0_q25 * (d25**N25)
a_max_global = S0_q25 * (u25**N25)
a_grid = np.linspace(a_min_global, a_max_global, M_avg)

def interp_val(a, a_grid, V_vec):
    if a <= a_grid[0]: return V_vec[0]
    if a >= a_grid[-1]: return V_vec[-1]
    return np.interp(a, a_grid, V_vec)

# V_asian[n][j][:] = value at (n,j) for each discretized average
V_asian = [[np.zeros(M_avg) for j in range(n+1)] for n in range(N25+1)]
# At maturity: payoff = max(K - a, 0)
for m in range(M_avg):
    payoff_a = max(K_asian - a_grid[m], 0)
    for j in range(N25+1):
        V_asian[N25][j][m] = payoff_a

# Backward induction: at (n,j) with average a, we go to (n+1,j) with a_d and (n+1,j+1) with a_u
for n in range(N25-1, -1, -1):
    for j in range(n+1):
        S_here = S_am[n][j]
        S_down = S_am[n+1][j]
        S_up = S_am[n+1][j+1]
        for m in range(M_avg):
            a = a_grid[m]
            a_d = (a * (n + 1) + S_down) / (n + 2)
            a_u = (a * (n + 1) + S_up) / (n + 2)
            V_d = interp_val(a_d, a_grid, V_asian[n+1][j])
            V_u = interp_val(a_u, a_grid, V_asian[n+1][j+1])
            V_asian[n][j][m] = disc25 * (p25 * V_u + (1 - p25) * V_d)

# Initial value: at (0,0) the only average is S0 (so far only one price)
Asian_put_price_25 = round(interp_val(S0_q25, a_grid, V_asian[0][0]), 2)

# Deltas: at (n,j,a) Delta = (V_up - V_down)/(S_up - S_down) for the same a
Delta_asian = [[np.zeros(M_avg) for j in range(n+1)] for n in range(N25)]
for n in range(N25):
    for j in range(n+1):
        S_up, S_down = S_am[n+1][j+1], S_am[n+1][j]
        for m in range(M_avg):
            a = a_grid[m]
            a_u = (a * (n + 1) + S_up) / (n + 2)
            a_d = (a * (n + 1) + S_down) / (n + 2)
            V_u = interp_val(a_u, a_grid, V_asian[n+1][j+1])
            V_d = interp_val(a_d, a_grid, V_asian[n+1][j])
            if S_up != S_down:
                Delta_asian[n][j][m] = (V_u - V_d) / (S_up - S_down)
In [35]:
# One path for Asian: same path as Q26. Compute running average A along path.
path_A_27 = [path_S_26[0]]  # A[0] = S_0
for n in range(1, N25+1):
    path_A_27.append((path_A_27[-1] * n + path_S_26[n]) / (n + 1))

# Delta and value along path (interpolate from grid)
path_delta_asian = []
for n in range(N25):
    a_n = path_A_27[n]
    j_n = path_j_26[n]
    d = interp_val(a_n, a_grid, Delta_asian[n][j_n])
    path_delta_asian.append(d)

# Cash account (seller)
cash27 = Asian_put_price_25 - path_delta_asian[0] * path_S_26[0]
rows27 = [{"Step": 0, "S": round(path_S_26[0], 2), "Avg A": round(path_A_27[0], 2), "Delta": round(path_delta_asian[0], 4), "Cash": round(cash27, 2)}]
for n in range(1, N25):
    cash27 = cash27 * np.exp(r_q25 * dt25)
    cash27 -= (path_delta_asian[n] - path_delta_asian[n-1]) * path_S_26[n]
    rows27.append({"Step": n, "S": round(path_S_26[n], 2), "Avg A": round(path_A_27[n], 2), "Delta": round(path_delta_asian[n], 4), "Cash": round(cash27, 2)})
cash27 = cash27 * np.exp(r_q25 * dt25)
payoff_asian = max(K_asian - path_A_27[N25], 0)  # Asian payoff uses average, not terminal S
cash27 -= payoff_asian
rows27.append({"Step": N25, "S": round(path_S_26[N25], 2), "Avg A": round(path_A_27[N25], 2), "Delta": "-", "Cash": round(cash27, 2), "Payoff": round(payoff_asian, 4)})

from IPython.display import display  # pyright: ignore[reportMissingImports]
display(pd.DataFrame({"Option": ["Asian ATM Put (25-step, K=180)", "American Put (25-step, K=182)"], "Price": [Asian_put_price_25, American_put_price_25]}))
pd.DataFrame(rows27)
Option Price
0 Asian ATM Put (25-step, K=180) 7.25
1 American Put (25-step, K=182) 13.04
Out[35]:
Step S Avg A Delta Cash Payoff
0 0 180.00 180.00 -0.4438 87.13 NaN
1 1 173.75 176.87 -0.5547 106.44 NaN
2 2 167.71 173.82 -0.6484 122.19 NaN
3 3 161.89 170.84 -0.7119 132.52 NaN
4 4 156.26 167.92 -0.7403 137.01 NaN
5 5 150.83 165.07 -0.7393 136.92 NaN
6 6 145.59 162.29 -0.7187 133.97 NaN
7 7 140.54 159.57 -0.6873 129.61 NaN
8 8 135.65 156.91 -0.6511 124.76 NaN
9 9 130.94 154.32 -0.6134 119.87 NaN
10 10 126.39 151.78 -0.5753 115.10 NaN
11 11 122.00 149.30 -0.5371 110.48 NaN
12 12 117.77 146.87 -0.4988 106.02 NaN
13 13 113.67 144.50 -0.4605 101.71 NaN
14 14 109.73 142.18 -0.4222 97.55 NaN
15 15 105.91 139.92 -0.3839 93.53 NaN
16 16 102.23 137.70 -0.3456 89.65 NaN
17 17 98.68 135.53 -0.3073 85.90 NaN
18 18 95.26 133.41 -0.2689 82.29 NaN
19 19 91.95 131.34 -0.2305 78.79 NaN
20 20 88.75 129.31 -0.1922 75.41 NaN
21 21 91.95 127.61 -0.1538 71.91 NaN
22 22 95.26 126.21 -0.1153 68.28 NaN
23 23 98.68 125.06 -0.0769 64.52 NaN
24 24 102.23 124.15 -0.0385 60.61 NaN
25 25 105.91 123.44 - 4.08 56.5557

The Asian put price is lower than the vanilla American put because the payoff depends on the average stock price rather than the terminal price. Averaging reduces volatility of the payoff and therefore reduces option value.