SigmaField
Mathematical Foundations for Quant Finance
0 of 256 lessons
      1. 6.5.1 The explicit scheme and stabilityConcept · 35 min
      2. 6.5.2 Implicit schemes and Crank–NicolsonConcept · 35 min
      3. 6.5.3 Practice set: finite differencesPractice set · 20 min
      4. 6.5.4 Case study: pricing a European option on a gridCase study · 35 min

Level 6, Differential equations 6.5 Finite differences for PDEs Lesson 4 of 4

Case studyLesson 6.5.4 in Level 6, Differential equations. About 35 minutes.

Case study: pricing a European option on a grid

From the equation to a grid

Case study 6.3.4 stated the Black–Scholes equation for the value V(S,t)V(S, t) of an option on a stock with price SS at time tt:

Vt+12σ2S2VSS+rSVS−rV=0V_t + \tfrac{1}{2}\sigma^2S^2V_{SS} + rSV_S - rV = 0

At expiry TT a call is worth its payoff, V(S,T)=max⁡(S−K,0)V(S, T) = \max(S - K, 0): a terminal condition (lesson 6.3.2). The inputs are case study 2.4.6's: strike K=100K = 100, T=0.5T = 0.5 years, rate r=0.03r = 0.03 and volatility σ=0.2\sigma = 0.2, both yearly decimals, with the stock at 100 today.

Case study 6.4.4 counted time backwards from expiry, τ=T−t\tau = T - t, so that Vt=−VτV_t = -V_\tau. Then the payoff becomes a starting profile:

Vτ=12σ2S2VSS+rSVS−rVV(S,0)=max⁡(S−K,0)\begin{gathered} V_\tau = \tfrac{1}{2}\sigma^2S^2V_{SS} + rSV_S - rV \\[4pt] V(S, 0) = \max(S - K, 0) \end{gathered}

This runs forward in τ\tau from the payoff, as lesson 6.5.1's rod ran forward from its first profile. A grid can work with SS directly, without 6.4.4's change of variables: it only needs the coefficients at each grid point.

Take grid prices Si=i ΔSS_i = i\,\Delta S for i=0,…,Mi = 0, \ldots, M, up to Smax⁡=M ΔSS_{\max} = M\,\Delta S, and NN time steps of Δτ=TN\Delta\tau = \frac{T}{N}. Replace VSSV_{SS} by the second difference and VSV_S by the central difference (lesson 2.8.1). Because Si=i ΔSS_i = i\,\Delta S, the factors of ΔS\Delta S cancel: Si2ΔS2=i2\frac{S_i^2}{\Delta S^2} = i^2 and Si2ΔS=i2\frac{S_i}{2\Delta S} = \frac{i}{2}.

Rule: The Black–Scholes equation on a grid

(LV)i=aiVi−1+biVi+ciVi+1ai=12σ2i2−12r ibi=−σ2i2−rci=12σ2i2+12r iVn+1−12Δτ LVn+1=Vn+12Δτ LVn\begin{gathered} (LV)_i = a_i V_{i-1} + b_i V_i + c_i V_{i+1} \\[4pt] a_i = \tfrac{1}{2}\sigma^2 i^2 - \tfrac{1}{2}r\,i \\[2pt] b_i = -\sigma^2 i^2 - r \\[2pt] c_i = \tfrac{1}{2}\sigma^2 i^2 + \tfrac{1}{2}r\,i \\[6pt] V^{n+1} - \tfrac{1}{2}\Delta\tau\,LV^{n+1} = V^n + \tfrac{1}{2}\Delta\tau\,LV^n \end{gathered}

(LV)i(LV)_i is the right side of the equation at grid price SiS_i. The last line is Crank–Nicolson (lesson 6.5.2): the average of LVLV 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, i=100i = 100, the second-difference weight times Δτ\Delta\tau is 12σ2i2 Δτ\frac{1}{2}\sigma^2 i^2\,\Delta\tau, which plays the part of lesson 6.5.1's rr.

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 τ\tau:

  • At S=0S = 0. A stock worth 0 stays at 0, so the call never pays: V(0,τ)=0V(0, \tau) = 0. The equation agrees: at S=0S = 0 it reads Vτ=−rVV_\tau = -rV.
  • At Smax⁡S_{\max}. Far above the strike, the call is almost sure to be exercised, so it is worth the stock less the discounted strike: V(Smax⁡,τ)=Smax⁡−Ke−rτV(S_{\max}, \tau) = S_{\max} - Ke^{-r\tau}. By put–call parity (case study 3.3.4), the error is the put's value there.
  • A put has the mirror conditions: V(0,τ)=Ke−rτV(0, \tau) = Ke^{-r\tau} and V(Smax⁡,τ)=0V(S_{\max}, \tau) = 0.

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.

Smax⁡S_{\max} 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 σT=0.1414\sigma\sqrt{T} = 0.1414 (case study 5.6.5). Four times that above the strike is 100e4×0.1414≈176100e^{4 \times 0.1414} \approx 176.

Smax⁡S_{\max}120150200300
grid price at 1006.3468836.3675136.3675136.367513
error−0.0241-0.0241−0.0035-0.0035−0.0035-0.0035−0.0035-0.0035
boundary value off by0.56870.00720.00000.0000

The last row is the true call price at Smax⁡S_{\max} minus the boundary value. At 120 it is 57 cents off, and the price at 100 is 2.4 cents low. From 150 up, Smax⁡S_{\max} no longer changes the price at 100 to 6 decimals.

Pricing the call

Take Smax⁡=200S_{\max} = 200 with M=200M = 200, so ΔS=1\Delta S = 1, and N=100N = 100 steps of Δτ=0.005\Delta\tau = 0.005. 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 S=100S = 100, 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 SjS_j with case study 2.7.4's delta–gamma estimate, using the grid's own delta and gamma (next section):

V(S)≈Vj+Δj (S−Sj)+12Γj (S−Sj)2V(S) \approx V_j + \Delta_j\,(S - S_j) + \tfrac{1}{2}\Gamma_j\,(S - S_j)^2

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 aia_i, bib_i and cic_i 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 12σ2i2 Δτ\frac{1}{2}\sigma^2 i^2\,\Delta\tau at the strike, and the price at 103.3.

⌘/Ctrl + Enter runs the cell

Try it

The first line prints 100e4σT100e^{4\sigma\sqrt{T}} and how many standard deviations σT\sigma\sqrt{T} the price 120 lies above the strike. Then the boundary table, with grid_price from above and ΔS=1\Delta S = 1; its last column is the true price at Smax⁡S_{\max} minus the boundary value there.

⌘/Ctrl + Enter runs the cell

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

Δ≈Vi+1−Vi−12 ΔSΓ≈Vi+1−2Vi+Vi−1ΔS2\begin{gathered} \Delta \approx \frac{V_{i+1} - V_{i-1}}{2\,\Delta S} \\[6pt] \Gamma \approx \frac{V_{i+1} - 2V_i + V_{i-1}}{\Delta S^2} \end{gathered}

The central and second differences of lesson 2.8.1, applied to the grid's values today, at τ=T\tau = T. They give delta and gamma at every node from the one solve.

At S=100S = 100 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 N=10N = 10 steps, so the ratio 12σ2i2 Δτ\frac{1}{2}\sigma^2 i^2\,\Delta\tau at the strike is 10:

priceerrorgamma at 99at 100at 101
plain Crank–Nicolson6.3177−0.0533-0.0533−0.0728-0.07280.1846−0.0727-0.0727
with Rannacher's start6.3661−0.0050-0.00500.02860.02700.0274

Plain Crank–Nicolson's gamma swings between −0.0728-0.0728 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.

⌘/Ctrl + Enter runs the cell
Check yourself
A colleague prices the call on a grid with 10 Crank–Nicolson time steps and reports gamma 0.1846 at the strike, but −0.0728-0.0728 one dollar below it. What is the best fix?

Grid refinement and its cost

Halve ΔS\Delta S and Δτ\Delta\tau together, keeping Smax⁡=200S_{\max} = 200 and Δτ=ΔS200\Delta\tau = \frac{\Delta S}{200}:

ΔS\Delta SΔτ\Delta\taugrid values M×NM \times Npriceerrorerror ratio
40.021,2506.313970−5.71×10−2-5.71 \times 10^{-2}
20.015,0006.356929−1.41×10−2-1.41 \times 10^{-2}4.05
10.00520,0006.367513−3.51×10−3-3.51 \times 10^{-3}4.01
0.50.002580,0006.370150−8.78×10−4-8.78 \times 10^{-4}4.00
0.250.00125320,0006.370808−2.19×10−4-2.19 \times 10^{-4}4.00

Each halving divides the error by 4, as Crank–Nicolson's Δτ2+ΔS2\Delta\tau^2 + \Delta S^2 promises, and multiplies the work by 4. The error is within a cent from ΔS=1\Delta S = 1 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: 4×6.367513−6.3569293=6.371041\frac{4 \times 6.367513 - 6.356929}{3} = 6.371041, only 1.3×10−51.3 \times 10^{-5} 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 12σ2i2 Δτ\frac{1}{2}\sigma^2 i^2\,\Delta\tau grows like i2i^2, so the top of the grid sets the limit, about Δτ≤1σ2M2\Delta\tau \le \frac{1}{\sigma^2M^2}: at least σ2M2T=800\sigma^2M^2T = 800 steps for M=200M = 200. 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 ΔS=2\Delta S = 2 and 1.

explicit_price steps with lesson 6.5.1's scheme on the same grid. True means the values grew past 10610^6: unstable.

⌘/Ctrl + Enter runs the cell

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 K−SiK - S_i.

Rule: American option on a grid

Vin+1←max⁡(Vin+1, payoff(Si))V_i^{n+1} \leftarrow \max\left(V_i^{n+1},\ \text{payoff}(S_i)\right)

Applied at every grid price after every step. At S=0S = 0 the put is exercised at once, so its boundary value is KK, not Ke−rτKe^{-r\tau}; at Smax⁡S_{\max} it is 0.

grid M×NM \times NEuropean putAmerican putearly exercise worth
200 × 1004.87875.00390.1252
400 × 2004.88135.00780.1265
800 × 4004.88205.00910.1271
1,600 × 8004.88225.00950.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.

⌘/Ctrl + Enter runs the cell
Check yourself
A grid with Smax⁡=120S_{\max} = 120 prices the call at 6.3469, against 6.3675 with Smax⁡=200S_{\max} = 200 and the same steps. Why?
Exercise

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 M+1M + 1 stock prices from 0 to S_max, with NN time steps; S_max is twice the strike when None. It returns (price, delta, gamma) at S0.

  1. Build the weights aia_i, bib_i, cic_i of this case study and take the steps with solve_banded. Use this case study's boundary values for a call or a put.
  2. Use Rannacher's start: two implicit steps of Δτ2\frac{\Delta\tau}{2}, then N−1N - 1 Crank–Nicolson steps.
  3. Read delta and gamma off the grid at the node SjS_j nearest S0. Return the price Vj+Δjh+12Γjh2V_j + \Delta_j h + \frac{1}{2}\Gamma_j h^2, delta Δj+Γjh\Delta_j + \Gamma_j h and gamma Γj\Gamma_j, where h=S0−Sjh = S_0 - S_j.
  4. Raise ValueError if S0, K, T, sigma or S_max is not positive, or if kind is not "call" or "put". Raise it if M<3M < 3 or N<1N < 1 or either is not whole, and if S0 is not between ΔS\Delta S and Smax⁡−ΔSS_{\max} - \Delta S.

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 τ=T−t\tau = T - t, the Black–Scholes equation runs forward from the payoff: Vτ=12σ2S2VSS+rSVS−rVV_\tau = \frac{1}{2}\sigma^2S^2V_{SS} + rSV_S - rV. On the grid Si=i ΔSS_i = i\,\Delta S its right side has weights ai=12σ2i2−12ria_i = \frac{1}{2}\sigma^2 i^2 - \frac{1}{2}ri, bi=−σ2i2−rb_i = -\sigma^2 i^2 - r, ci=12σ2i2+12ric_i = \frac{1}{2}\sigma^2 i^2 + \frac{1}{2}ri, and Crank–Nicolson solves one tridiagonal system per step.
  • Call boundaries: V=0V = 0 at S=0S = 0 and V=Smax⁡−Ke−rτV = S_{\max} - Ke^{-r\tau} at Smax⁡S_{\max}, set several standard deviations of the log price above the strike. A put has Ke−rτKe^{-r\tau} (American: KK) and 0.
  • The shared call: 6.367513 with ΔS=1\Delta S = 1 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 max⁡(V,payoff)\max(V, \text{payoff}) after every step: 5.0091 on an 800 × 400 grid, against the tree's 5.0098, exercised below about 81.

Free further reading

Optional. This lesson covers what you need; these add more examples and practice. All are free to read.