From the equation to a grid
Case study 6.3.4 stated the Black–Scholes equation for the value of an option on a stock with price at time :
At expiry a call is worth its payoff, : a terminal condition (lesson 6.3.2). The inputs are case study 2.4.6's: strike , years, rate and volatility , both yearly decimals, with the stock at 100 today.
Case study 6.4.4 counted time backwards from expiry, , so that . Then the payoff becomes a starting profile:
This runs forward in from the payoff, as lesson 6.5.1's rod ran forward from its first profile. A grid can work with directly, without 6.4.4's change of variables: it only needs the coefficients at each grid point.
Take grid prices for , up to , and time steps of . Replace by the second difference and by the central difference (lesson 2.8.1). Because , the factors of cancel: and .
Rule: The Black–Scholes equation on a grid
is the right side of the equation at grid price . The last line is Crank–Nicolson (lesson 6.5.2): the average of at the old and new times. Its left side is a tridiagonal system, one solve_banded per step.
The weights change from row to row, but the pattern is lesson 6.5.2's. At the strike, , the second-difference weight times is , which plays the part of lesson 6.5.1's .
Boundary conditions
Each step needs the two end values, as in lesson 6.5.1. Case study 6.3.4 found them, written here with :
- At . A stock worth 0 stays at 0, so the call never pays: . The equation agrees: at it reads .
- At . Far above the strike, the call is almost sure to be exercised, so it is worth the stock less the discounted strike: . By put–call parity (case study 3.3.4), the error is the put's value there.
- A put has the mirror conditions: and .
Case study 6.2.4 found that a wrong value at a far boundary drags the whole curve, and fixed it by putting the true value there. The grid makes the same choice: its far value is the one the call nearly has, not 0 or the payoff.
must be far enough above the strike for the second condition to hold. Over six months the log of the stock price spreads by about (case study 5.6.5). Four times that above the strike is .
| 120 | 150 | 200 | 300 | |
|---|---|---|---|---|
| grid price at 100 | 6.346883 | 6.367513 | 6.367513 | 6.367513 |
| error | ||||
| boundary value off by | 0.5687 | 0.0072 | 0.0000 | 0.0000 |
The last row is the true call price at minus the boundary value. At 120 it is 57 cents off, and the price at 100 is 2.4 cents low. From 150 up, no longer changes the price at 100 to 6 decimals.
Pricing the call
Take with , so , and steps of . The strike and the spot, both 100, are grid prices.
The pricer starts with Rannacher's start (lesson 6.5.2): two implicit steps of half the length, then Crank–Nicolson. The next section shows why.
The grid gives 6.367513 at , against the formula's 6.371028: 0.0035 low, within half a cent. The remaining error comes from the grid spacing, and the refinement section below shows it shrinking.
The grid holds a value only at its nodes. For a spot between them, step from the nearest node with case study 2.7.4's delta–gamma estimate, using the grid's own delta and gamma (next section):
At 103.3 this gives 8.396155, against the formula's 8.399483: off by 0.0033, the grid's own error.
Try it
grid_price builds , and and starts from the payoff. It takes two implicit half steps (theta = 1), then Crank–Nicolson steps (theta = 0.5). Each step sets the end values, adds their terms to the right side, and solves.
The cell prints the price at 100 with its error, the ratio at the strike, and the price at 103.3.
Try it
The first line prints and how many standard deviations the price 120 lies above the strike. Then the boundary table, with grid_price from above and ; its last column is the true price at minus the boundary value there.
Delta and gamma from the grid
Case study 2.8.4 found delta and gamma by bumping the stock price and repricing. On a grid the neighbours are already there: the values at 99 and 101 are a $1 bump each way, at no extra cost.
Rule: Greeks from the grid
The central and second differences of lesson 2.8.1, applied to the grid's values today, at . They give delta and gamma at every node from the one solve.
At the grid gives delta 0.570080 and gamma 0.027791, against the formula's 0.570158 and 0.027772. The figure shows gamma at every node beside the formula.
Too few time steps
Lesson 6.5.2 warned that Crank–Nicolson with large steps leaves a zig-zag at a kink, and a payoff has one at the strike. Take only steps, so the ratio at the strike is 10:
| price | error | gamma at 99 | at 100 | at 101 | |
|---|---|---|---|---|---|
| plain Crank–Nicolson | 6.3177 | 0.1846 | |||
| with Rannacher's start | 6.3661 | 0.0286 | 0.0270 | 0.0274 |
Plain Crank–Nicolson's gamma swings between and 0.1846, exactly at the strike where the spot sits. With 5 steps its price is 6.5925, 22 cents high. Rannacher's start brings the 5-step price to 6.3580, within 1.3 cents.
One solve gives the value and the Greeks at every grid price. The payoff's corner rounds off as the time to expiry grows, as heat does; with too few time steps, gamma zig-zags at the strike.
Try it
grid_price is the one above. The first line prints the formula's delta and gamma, and the ratio at the strike for 10 steps. The second reads delta and gamma at 100 off the default grid.
The loop runs 5 and 10 steps without and with Rannacher's start, and prints the price, its error and gamma at 98 to 102.
Grid refinement and its cost
Halve and together, keeping and :
| grid values | price | error | error ratio | ||
|---|---|---|---|---|---|
| 4 | 0.02 | 1,250 | 6.313970 | ||
| 2 | 0.01 | 5,000 | 6.356929 | 4.05 | |
| 1 | 0.005 | 20,000 | 6.367513 | 4.01 | |
| 0.5 | 0.0025 | 80,000 | 6.370150 | 4.00 | |
| 0.25 | 0.00125 | 320,000 | 6.370808 | 4.00 |
Each halving divides the error by 4, as Crank–Nicolson's promises, and multiplies the work by 4. The error is within a cent from on. Delta and gamma settle too, to 0.570153 and 0.027773 on the finest grid.
Because the error falls by 4 each time, lesson 2.8.2's Richardson extrapolation applies: , only from the formula.
Set that against the other methods. Case study 5.11.4's tree with 1,000 steps has 501,501 nodes and an error of 0.0014; the grid with 80,000 values does better, at 0.00088.
Case study 5.7.5's Monte Carlo price, 6.3765 with a standard error of 0.0095 from a million draws, is far rougher still.
The explicit scheme of lesson 6.5.1 is no shortcut here. Its ratio grows like , so the top of the grid sets the limit, about : at least steps for . With 400 steps it blows up; with 800 it gives 6.3684.
Try it
The loop builds the refinement table with grid_price from above, then applies Richardson extrapolation to the grids with and 1.
explicit_price steps with lesson 6.5.1's scheme on the same grid. True means the values grew past : unstable.
An American put
Case study 5.11.4 priced an American put on a tree by taking, at each node, the larger of holding and exercising. A grid does the same at each time step: after each Crank–Nicolson solve, replace every value by the larger of it and the payoff .
Rule: American option on a grid
Applied at every grid price after every step. At the put is exercised at once, so its boundary value is , not ; at it is 0.
| grid | European put | American put | early exercise worth |
|---|---|---|---|
| 200 × 100 | 4.8787 | 5.0039 | 0.1252 |
| 400 × 200 | 4.8813 | 5.0078 | 0.1265 |
| 800 × 400 | 4.8820 | 5.0091 | 0.1271 |
| 1,600 × 800 | 4.8822 | 5.0095 | 0.1274 |
The European column reaches the Black–Scholes put, 4.8822, as the call did. The American column creeps up on the tree's 5.0098, more slowly. Its distance from the tree falls from 0.0059 to 0.0020, 0.0007 and 0.0003: about 2 to 3 times per halving, not 4.
The reason is a new corner. Where the put switches from holding to exercising, its value meets the payoff, and that point moves as time passes. On the 800-price grid the highest price where the put is exercised today is 81.25, matching the tree's critical price of about 81.
Try it
grid_price from above, now with kind="put". For each grid the cell prints the European and American puts, their difference and the distance from the tree's 5.0098. The last number is the highest grid price below the strike where the American value equals the payoff.
The first line is the Black–Scholes put, by put–call parity.
A Crank–Nicolson option pricer
Write grid_price(S0, K, T, r, sigma, kind="call", S_max=None, M=200, N=100). It prices a European call or put on a grid of stock prices from 0 to S_max, with time steps; S_max is twice the strike when None. It returns (price, delta, gamma) at S0.
- Build the weights , , of this case study and take the steps with
solve_banded. Use this case study's boundary values for a call or a put. - Use Rannacher's start: two implicit steps of , then Crank–Nicolson steps.
- Read delta and gamma off the grid at the node nearest
S0. Return the price , delta and gamma , where . - Raise
ValueErrorifS0,K,T,sigmaorS_maxis not positive, or ifkindis not"call"or"put". Raise it if or or either is not whole, and ifS0is not between and .
The tests use the call of this case study, the same call with only 5 time steps, and put–call parity. They also try a spot between nodes and three other options.
See the tests
import math
from scipy.stats import norm
def bs(S, K, T, r, sigma, kind="call"):
d1 = (math.log(S / K) + (r + sigma**2 / 2) * T) / (sigma * math.sqrt(T))
call = S * norm.cdf(d1) - K * math.exp(-r * T) * norm.cdf(d1 - sigma * math.sqrt(T))
return call if kind == "call" else call - S + K * math.exp(-r * T)
def expect_value_error(call, why):
try:
call()
except ValueError:
return
raise AssertionError(f"expected ValueError: {why}")
def test_shared_call():
"""The 6-month call prices within 1 cent of 6.3710, with delta within 0.001 of 0.570158 and gamma within 0.0001 of 0.027772"""
price, delta, gamma = grid_price(100, 100, 0.5, 0.03, 0.2)
expect_close(price, bs(100, 100, 0.5, 0.03, 0.2), what="price", abs_tol=0.01, rel=0)
expect_close(delta, 0.570158, what="delta", abs_tol=1e-3, rel=0)
expect_close(gamma, 0.027772, what="gamma", abs_tol=1e-4, rel=0)
def test_case_study_grid():
"""With the default grid (S up to 200 in steps of 1, 100 time steps) the price is the case study's 6.367513"""
expect_close(grid_price(100, 100, 0.5, 0.03, 0.2)[0], 6.367513, what="price on the default grid", abs_tol=1e-4, rel=0)
def test_few_time_steps():
"""With only 5 time steps, Rannacher's start keeps the price within 2 cents and gamma within 0.005"""
price, delta, gamma = grid_price(100, 100, 0.5, 0.03, 0.2, N=5)
expect_close(price, bs(100, 100, 0.5, 0.03, 0.2), what="price with N = 5", abs_tol=0.02, rel=0)
expect_close(gamma, 0.027772, what="gamma with N = 5", abs_tol=0.005, rel=0)
def test_put_call_parity():
"""On the same grid, call minus put equals S0 − K e^(−rT) to within 1e-5"""
call = grid_price(100, 100, 0.5, 0.03, 0.2, "call")[0]
put = grid_price(100, 100, 0.5, 0.03, 0.2, "put")[0]
expect_close(call - put, 100 - 100 * math.exp(-0.015), what="call − put", abs_tol=1e-5, rel=0)
def test_spot_between_nodes():
"""A spot of 103.3, between nodes, prices within 1 cent of the formula"""
expect_close(grid_price(103.3, 100, 0.5, 0.03, 0.2)[0], bs(103.3, 100, 0.5, 0.03, 0.2), what="price at S0 = 103.3", abs_tol=0.01, rel=0)
def test_other_options():
"""A put with strike 90, a call deep in the money and a one-year put at 40% volatility each price within 1 cent"""
expect_close(grid_price(100, 90, 0.5, 0.03, 0.2, "put")[0], bs(100, 90, 0.5, 0.03, 0.2, "put"), what="put, K = 90", abs_tol=0.01, rel=0)
expect_close(grid_price(150, 100, 0.5, 0.03, 0.2)[0], bs(150, 100, 0.5, 0.03, 0.2), what="call, S0 = 150", abs_tol=0.01, rel=0)
expect_close(grid_price(100, 110, 1, 0.05, 0.4, "put", S_max=400, M=400)[0], bs(100, 110, 1, 0.05, 0.4, "put"),
what="put, K = 110, T = 1, σ = 0.4", abs_tol=0.01, rel=0)
def test_bad_inputs():
"""Non-positive inputs, an unknown kind, too small a grid, a fractional step count or a spot off the grid raise ValueError"""
expect_value_error(lambda: grid_price(0, 100, 0.5, 0.03, 0.2), "S0 = 0")
expect_value_error(lambda: grid_price(100, 100, 0, 0.03, 0.2), "T = 0")
expect_value_error(lambda: grid_price(100, 100, 0.5, 0.03, -0.2), "a negative volatility")
expect_value_error(lambda: grid_price(100, 100, 0.5, 0.03, 0.2, "digital"), "'digital' is not call or put")
expect_value_error(lambda: grid_price(100, 100, 0.5, 0.03, 0.2, M=2), "M = 2 leaves one inside point")
expect_value_error(lambda: grid_price(100, 100, 0.5, 0.03, 0.2, N=10.5), "10.5 time steps")
expect_value_error(lambda: grid_price(250, 100, 0.5, 0.03, 0.2), "S0 = 250 lies beyond S_max = 200")
Summary card
- With time to expiry , the Black–Scholes equation runs forward from the payoff: . On the grid its right side has weights , , , and Crank–Nicolson solves one tridiagonal system per step.
- Call boundaries: at and at , set several standard deviations of the log price above the strike. A put has (American: ) and 0.
- The shared call: 6.367513 with and 100 steps, within half a cent of 6.3710. The error falls by 4 per halving, and Richardson extrapolation gives 6.371041.
- Delta and gamma come from differences of neighbouring grid values: 0.570080 and 0.027791. Too few time steps make gamma zig-zag at the strike; Rannacher's start removes it.
- An American put takes after every step: 5.0091 on an 800 × 400 grid, against the tree's 5.0098, exercised below about 81.