Skip to content

Building a Binomial Option Pricer and Checking Black–Scholes Convergence

Original question

How do I build a binomial option pricing model in Python and compare its results with Black-Scholes?
Build a recombining Cox–Ross–Rubinstein tree for the stock, evaluate the European payoff at every terminal node, and roll those payoffs back using discounted risk-neutral expectations. Compare the resulting price at several step counts with Black–Scholes using the same spot, strike, maturity, rate and volatility.

Roll the payoff backwards through a recombining grid

For step length h=τ/nh=\tau/n, set u=eσhu=e^{\sigma\sqrt h}, d=1/ud=1/u, R=erhR=e^{rh}, and p=(R−d)/(u−d)p=(R-d)/(u-d). The identity pu+(1−p)d=Rpu+(1-p)d=R makes the discounted stock a martingale under these probabilities. With jj up moves after ii steps, the stock is Si,j=S0ujdi−jS_{i,j}=S_0u^jd^{i-j}. A European call has Vn,j=(Sn,j−K)+V_{n,j}=(S_{n,j}-K)^+. Its backward recursion is
Vi,j=R−1[(1−p)Vi+1,j+pVi+1,j+1].V_{i,j}=R^{-1}\left[(1-p)V_{i+1,j}+pV_{i+1,j+1}\right].

A single vector is enough for the option values because each backward step only needs the next vector. The straightforward method costs O(n2)O(n^2) time and O(n)O(n) memory. If pp is outside (0,1)(0,1), inspect the input assumptions and step size before rolling back.

At an interior node you already know both successor option values because you work from maturity backwards. Multiply each by its risk-neutral weight, add them and discount that single-period expectation. The index jj identifies the number of up moves so the down successor uses the same jj and the up successor uses j+1j+1. The node arrangement is why one rolling array is enough. If you update that array in place, go from low jj to high jj so both entries on the right-hand side still belong to the next time level.

Why the first refinements can cross the benchmark

Take S0=K=100S_0=K=100, τ=1\tau=1, r=5%r=5\%, and σ=20%\sigma=20\%. With one step, u≈1.2214u\approx1.2214 and d≈0.8187d\approx0.8187. The call pays about 22.1422.14 in the up state and zero in the down state, giving a price near 12.1612.16. With two steps the price is about 9.549.54, on the other side of the Black–Scholes value of 10.4510.45. At 100 steps the price is about 10.4310.43. Watching just the first two rows might suggest deterioration, but the run at many step counts tells a different story.

A pricer and its convergence check

import numpy as np from scipy.stats import norm def crr_call(spot, strike, maturity, rate, vol, steps): h = maturity / steps u = np.exp(vol * np.sqrt(h)) d = 1 / u gross = np.exp(rate * h) p = (gross - d) / (u - d) if not 0 < p < 1: raise ValueError("Tree needs down < gross return < up.") ups = np.arange(steps + 1) values = np.maximum(spot * u**ups * d**(steps - ups) - strike, 0) for _ in range(steps): values = ((1 - p) * values[:-1] + p * values[1:]) / gross return values[0] benchmark = 100 * (norm.cdf(0.35) - np.exp(-0.05) * norm.cdf(0.15)) for steps in (1, 2, 10, 25, 100, 400): price = crr_call(100, 100, 1, 0.05, 0.20, steps) print(steps, round(price, 5), round(price - benchmark, 5))
Compare the continuous-model reference in the options pricer before diagnosing a discrepancy in the tree.

Interactive example

Watch a binomial tree approach the Black–Scholes price

A Cox–Ross–Rubinstein tree prices a one-year European call with spot and strike 100, volatility 20%, a 5% risk-free rate and zero dividends.

20-step tree 10.3513 · Black–Scholes 10.4506
Binomial call prices oscillating toward the Black–Scholes benchmark10111215.7510.515.320Tree stepsCall value
  • CRR tree
  • Black–Scholes

References and next steps