Black-Scholes vs CRR Binomial Tree: Analytical vs Numerical¶

This notebook benchmarks the closed-form Black-Scholes formula against the Cox-Ross-Rubinstein binomial tree across three dimensions:

  1. Convergence — CRR pricing error as a function of the number of steps N
  2. Runtime — wall-clock time for BS vs CRR at various N
  3. Stability — behaviour near zero time-to-expiry and at deep in/out-of-the-money strikes
  4. Richardson extrapolation — averaging N and N+1 steps to reduce the CRR odd-even oscillation
In [1]:
import sys
sys.path.insert(0, '../src')

import time
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.express as px
from plotly.subplots import make_subplots

from options_pricing.models.black_scholes import bs_price
from options_pricing.models.binomial import crr_price
from options_pricing.greeks.analytical import bs_greeks

# Reference ATM parameters
S, K, T, r, sigma = 100.0, 100.0, 1.0, 0.05, 0.20
BS_CALL = bs_price(S, K, T, r, sigma, 'call')
BS_PUT  = bs_price(S, K, T, r, sigma, 'put')
print(f'BS Call = {BS_CALL:.6f}')
print(f'BS Put  = {BS_PUT:.6f}')
BS Call = 10.450584
BS Put  = 5.573526

1. Convergence of CRR to Black-Scholes¶

As N increases, the CRR tree discretises the continuous GBM path more finely and the price converges to the BS closed form. The convergence is $O(1/N)$ in general, but the error oscillates between odd and even N due to the discrete nature of the binomial lattice.

In [2]:
N_values = list(range(10, 501, 10))
crr_calls = [crr_price(S, K, T, r, sigma, N, 'call', 'european') for N in N_values]
errors    = [abs(c - BS_CALL) for c in crr_calls]

fig = make_subplots(rows=1, cols=2,
                    subplot_titles=['CRR Call Price vs N', 'Absolute Error vs N'])

fig.add_trace(go.Scatter(x=N_values, y=crr_calls, name='CRR price',
                         line=dict(color='#636EFA')), row=1, col=1)
fig.add_hline(y=BS_CALL, line_dash='dash', line_color='red',
              annotation_text='BS exact', row=1, col=1)

fig.add_trace(go.Scatter(x=N_values, y=errors, name='|CRR − BS|',
                         line=dict(color='#EF553B')), row=1, col=2)

fig.update_xaxes(title_text='N (steps)', row=1, col=1)
fig.update_xaxes(title_text='N (steps)', row=1, col=2)
fig.update_yaxes(title_text='Call Price', row=1, col=1)
fig.update_yaxes(title_text='Absolute Error', type='log', row=1, col=2)
fig.update_layout(template='plotly_white', showlegend=True)
fig.show()

print(f'N=50:   error = {abs(crr_price(S,K,T,r,sigma,50,"call") - BS_CALL):.5f}')
print(f'N=200:  error = {abs(crr_price(S,K,T,r,sigma,200,"call") - BS_CALL):.5f}')
print(f'N=1000: error = {abs(crr_price(S,K,T,r,sigma,1000,"call") - BS_CALL):.5f}')
N=50:   error = 0.03989
N=200:  error = 0.00999
N=1000: error = 0.00200

2. Runtime Comparison¶

The BS formula is $O(1)$ and completes in under 10 µs. The CRR tree is $O(N^2)$ in the naive matrix implementation but $O(N)$ in the vectorised single-vector form used here.

In [3]:
N_bench = [50, 100, 200, 500, 1000, 2000]
reps = 200

# BS runtime
t0 = time.perf_counter()
for _ in range(reps * 10):
    bs_price(S, K, T, r, sigma, 'call')
bs_us = (time.perf_counter() - t0) / (reps * 10) * 1e6

# CRR runtimes
crr_ms = []
for N in N_bench:
    t0 = time.perf_counter()
    for _ in range(reps):
        crr_price(S, K, T, r, sigma, N, 'call', 'european')
    crr_ms.append((time.perf_counter() - t0) / reps * 1e3)

df_rt = pd.DataFrame({'N': N_bench, 'CRR (ms)': crr_ms})
df_rt['Speedup vs BS'] = [ms * 1e3 / bs_us for ms in crr_ms]
print(f'BS runtime: {bs_us:.2f} µs')
print(df_rt.to_string(index=False))

fig2 = px.line(df_rt, x='N', y='CRR (ms)', markers=True,
               title='CRR Runtime vs N', template='plotly_white',
               labels={'CRR (ms)': 'Runtime (ms)'})
fig2.show()
BS runtime: 45.61 µs
   N  CRR (ms)  Speedup vs BS
  50  0.112317       2.462630
 100  0.220167       4.827308
 200  0.466011      10.217613
 500  1.391761      30.515364
1000  2.607561      57.172624
2000  5.698046     124.933713

3. Stability at Extreme Parameters¶

We check three edge cases where numerical methods can struggle:

  • Short-dated ATM (T = 0.02y ≈ 5 trading days): gamma is very high, CRR needs more steps
  • Deep OTM (S/K = 0.5): prices are near zero; relative error can be large even when absolute error is small
  • Deep ITM (S/K = 2.0): for a call, price approaches the forward value
In [4]:
cases = [
    ('ATM short-dated (T=0.02)',  100.0, 100.0, 0.02),
    ('ATM medium (T=0.25)',       100.0, 100.0, 0.25),
    ('OTM call (S=60, K=100)',     60.0, 100.0, 1.0),
    ('ITM call (S=150, K=100)',   150.0, 100.0, 1.0),
    ('Far OTM (S=40, K=100)',      40.0, 100.0, 1.0),
]

rows = []
for label, s, k, t in cases:
    bs_val  = bs_price(s, k, t, r, sigma, 'call')
    crr_200 = crr_price(s, k, t, r, sigma, 200, 'call', 'european')
    crr_500 = crr_price(s, k, t, r, sigma, 500, 'call', 'european')
    rows.append({
        'Case': label,
        'BS': round(bs_val, 6),
        'CRR N=200': round(crr_200, 6),
        'CRR N=500': round(crr_500, 6),
        'Err N=200': round(abs(crr_200 - bs_val), 6),
        'Err N=500': round(abs(crr_500 - bs_val), 6),
    })

pd.DataFrame(rows).set_index('Case')
Out[4]:
BS CRR N=200 CRR N=500 Err N=200 Err N=500
Case
ATM short-dated (T=0.02) 1.178457 1.177048 1.177893 0.001410 0.000564
ATM medium (T=0.25) 4.614997 4.610008 4.613001 0.004989 0.001996
OTM call (S=60, K=100) 0.054437 0.052724 0.054083 0.001713 0.000354
ITM call (S=150, K=100) 54.970140 54.969801 54.970041 0.000339 0.000099
Far OTM (S=40, K=100) 0.000019 0.000016 0.000018 0.000003 0.000002

4. Richardson Extrapolation¶

The odd-even oscillation in CRR error can be reduced by averaging the N-step and (N+1)-step prices (Broadie & Detemple 1996). This halves the error for roughly the same computational cost.

In [5]:
N_values_re = list(range(10, 201, 5))
errors_plain = [abs(crr_price(S, K, T, r, sigma, N, 'call') - BS_CALL)
                for N in N_values_re]
errors_re    = [abs(0.5 * (crr_price(S, K, T, r, sigma, N, 'call') +
                           crr_price(S, K, T, r, sigma, N + 1, 'call')) - BS_CALL)
                for N in N_values_re]

fig3 = go.Figure()
fig3.add_trace(go.Scatter(x=N_values_re, y=errors_plain, name='CRR (plain)',
                          line=dict(color='#636EFA')))
fig3.add_trace(go.Scatter(x=N_values_re, y=errors_re, name='CRR (Richardson avg)',
                          line=dict(color='#00CC96', dash='dot')))
fig3.update_layout(
    title='CRR Convergence: Plain vs Richardson Extrapolation',
    xaxis_title='N (steps)', yaxis_title='Absolute Error',
    yaxis_type='log', template='plotly_white'
)
fig3.show()

print('Error at N=100:')
plain = abs(crr_price(S, K, T, r, sigma, 100, 'call') - BS_CALL)
re    = abs(0.5 * (crr_price(S, K, T, r, sigma, 100, 'call') +
                   crr_price(S, K, T, r, sigma, 101, 'call')) - BS_CALL)
print(f'  Plain:               {plain:.6f}')
print(f'  Richardson avg:      {re:.6f}')
print(f'  Improvement factor:  {plain / re:.1f}x')
Error at N=100:
  Plain:               0.019972
  Richardson avg:      0.001300
  Improvement factor:  15.4x

5. American vs European Put (Early-Exercise Premium)¶

For a non-dividend-paying stock, the American call equals the European call. For puts, the American price is strictly greater — the early-exercise premium reflects the time value of receiving the intrinsic value now rather than waiting to expiry.

In [6]:
spot_range = np.linspace(60, 140, 60)
eur_puts = [crr_price(s, K, T, r, sigma, 300, 'put', 'european') for s in spot_range]
ame_puts = [crr_price(s, K, T, r, sigma, 300, 'put', 'american') for s in spot_range]
premium  = [a - e for a, e in zip(ame_puts, eur_puts)]

fig4 = make_subplots(rows=1, cols=2,
                     subplot_titles=['Put Prices vs Spot', 'Early-Exercise Premium'])
fig4.add_trace(go.Scatter(x=spot_range, y=eur_puts, name='European put',
                          line=dict(color='#636EFA')), row=1, col=1)
fig4.add_trace(go.Scatter(x=spot_range, y=ame_puts, name='American put',
                          line=dict(color='#EF553B', dash='dot')), row=1, col=1)
fig4.add_trace(go.Scatter(x=spot_range, y=premium, name='Premium',
                          line=dict(color='#00CC96'), fill='tozeroy'), row=1, col=2)
fig4.update_xaxes(title_text='Spot (S)')
fig4.update_yaxes(title_text='Price', row=1, col=1)
fig4.update_yaxes(title_text='Premium', row=1, col=2)
fig4.update_layout(template='plotly_white')
fig4.show()

Summary¶

Aspect Black-Scholes CRR Binomial (N=200)
Price accuracy (European) Exact |CRR − BS| < 0.01
American exercise ✗ not supported ✓ exact via backward induction
Greek computation Analytical, O(1) Finite-difference, 5 extra pricer calls
Runtime (ATM) < 10 µs ~1–5 ms
Deep OTM / T → 0 Numerically stable Oscillates; smoothed by Richardson avg

Recommendation: Use Black-Scholes for European options in interactive dashboards and Monte Carlo Greeks (speed-critical paths). Use CRR with N ≥ 200 (or Richardson averaging) for American options and when comparing analytical vs numerical accuracy.