The equation, and four obstacles
Case study 6.3.4 stated the equation for the value of an option on a stock at price , at time in years:
Here is the continuous interest rate and the volatility, both annual decimals. A call with strike and expiry has the terminal condition . The inputs are those of case study 2.4.6: , , , , and today with .
Next to , the equation has four differences, and each change below removes one.
- Its condition is at the end, , not at the start.
- Its coefficients and change with the stock price.
- It has terms in and that the heat equation lacks.
- Its diffusion term has a factor , not 1.
Step 1: time to expiry
Case study 6.3.4 found that the equation runs backwards in time, so it needs a condition at expiry. Measure time backwards from expiry instead: , the time to expiry in years. Then (lesson 6.3.2), and
This runs forwards in , as heat does (lesson 6.4.1). The payoff becomes a starting profile at , and today is .
Step 2: the log of the price
Measure the price on a log scale relative to the strike: , so . A price of 100 is , the strike itself; 110 is and 90 is . Lesson 6.2.2 used the same logarithm on the equidimensional equation, whose coefficients also grow with powers of the variable.
Write . The chain rule (lessons 2.3.3 and 4.2.2) gives the partials in , with :
- 1
- 2
- 3
Substituting these into the equation of step 1 gives an equation with constant coefficients:
Prices from 0 to infinity become all of , from to : the endless rod of lesson 6.4.2. The payoff becomes .
Try it
W is the Black–Scholes formula written in and . The loop takes central differences (lesson 2.8.1) at three points and prints next to the right side of the new equation.
They agree to 4 decimal places: 7.0738, 3.9329 and 4.4488.
Step 3: a factor that removes two terms
The terms in and remain. Write for two numbers and still to choose. The product rule gives each partial as times a bracket:
Put these into the equation of step 2 and divide by . The terms add up to , and the right makes that 0. The right then cancels every term in , leaving only .
Rule: From Black–Scholes to heat
has no units; is a rate per year, like . For the shared inputs, and .
Step 4: a clock for heat
is a heat equation with diffusivity per year. As in lesson 6.4.1, change the clock to ; then . Today, with half a year to go, .
has no units, because is per year and is in years. The whole half-year is only 0.01 of heat-equation time: a short spell of smoothing.
The payoff becomes a kink
At , , so the starting profile is
using and . It is 0 for , and its slope just right of 0 is . So near the strike it is lesson 6.4.2's kink , bending up a little more further out.
| 0.05 | 0.1 | 0.2 | 0.5 | |
|---|---|---|---|---|
| 0.051916 | 0.107833 | 0.232754 | 0.735098 | |
| kink | 0.05 | 0.1 | 0.2 | 0.5 |
The slope is 1 for every input, since . So every call becomes a kink-like profile; only how much it bends up depends on and .
Try it
SymPy puts into step 2's equation, divides by , and solves for the that removes and the that removes . With both, what is left is . The constants cancel, so they are left out.
Then the numbers for the shared inputs, a central-difference check of at two points, and the table of .
Solving with the heat kernel
Now lesson 6.4.2 applies. With today,
and the price is . At , , so .
The bell has width . That is , the standard deviation of the six-month log return in case study 5.6.5. In lesson 6.4.2's reading, is averaged over a random shift of : here, the stock's random log return to expiry.
quad from the corner at 0 to ten widths above gives , and the price is 6.371028. The kink alone would give : the extra upward bend of makes 1.147 times as large.
| 80 | 90 | 100 | 110 | 120 | |
|---|---|---|---|---|---|
| 0 | 0.09531 | 0.182322 | |||
| 0.003761 | 0.020819 | 0.064714 | 0.138549 | 0.234497 | |
| price by heat | 0.3915 | 2.1043 | 6.371 | 13.319 | 22.0575 |
| Black–Scholes | 0.3915 | 2.1043 | 6.371 | 13.319 | 22.0575 |
The two rows agree to within at every price. Three months later, at , is 0.005 and the call is worth 4.3576 at and 11.5715 at , again matching the formula.
The same problem in two coordinates. In and the payoff is a kink-like profile that the heat equation smooths for a time of only 0.01. Mapped back with , the smoothed profile is the call's value today.
Try it
u0 is the starting profile and heat the kernel. u_heat integrates from the corner at 0, or from ten widths below if that is higher, to ten widths above.
The first line prints , the price, the kink's and their ratio. The loop prints the table, and True where the heat price is within of the formula. The last loop prices at , with and .
Back to the Black–Scholes formula
The integral can also be done by hand. Above 0, is two exponentials, with and . Lesson 6.4.2 gave each one cut at 0:
Multiply by and add up the exponents. Since , they simplify for every and :
- First piece: and , so it is .
- Second piece: and , so it is .
- Inside : and .
The last line makes the arguments and . So the heat solution is
the formula of case study 2.4.6. Today and , and the two pieces are 57.01581 and 50.644782: the stock part and the strike part of case study 2.6.5.
Try it
SymPy checks the three facts in the list for every and . The first line should print 0 and , the second two 0s.
Then the two pieces for the shared inputs, with quad beside the cut-exponential rule, and the arguments of .
The heat route and case study 2.6.5's route are the same calculation. That case study averaged the payoff over the normal log return. Here the heat kernel does the averaging, and the factor supplies the discounting and the drift.
On the desk
Black and Scholes found their formula this way, by reducing their equation to the heat equation. Using the change of variables in code brings its own traps.
The put, from the same equation
The put pays . Steps 1 to 4 are unchanged; only the starting profile changes, to , which is positive for . The heat integral gives 4.882222, case study 5.11.4's European put. Put–call parity holds: .
A factor that can overflow
The factor is mild here, but explodes as shrinks. At , , so contains . That overflows a float for above about 0.59, a stock price 1.81 times the strike.
The cure is to multiply before exponentiating. Inside the integral, is , and on the bell's window is tiny. Written that way, a call with at 0.5% volatility prices at 101.488806 instead of crashing.
Try it
The put by heat, its formula price and . Then the low-volatility call: , the where passes the largest float, and that edge as a multiple of the strike. A separate overflows at , while the combined integrand gives the price, next to its floor .
The last loop prints and the bell's width for the two unit mistakes of the note below.
Price an option through the heat equation
Write price_by_heat(S, K, T, r, sigma, kind="call"). It returns the price of a European call or put by steps 1 to 4 and the heat kernel.
- Compute , , and .
- Integrate with
quad, where the payoff is for the call (for ) and for the put (for ). Use a window of 12 widths around , cut at 0. - At return the payoff. Raise
ValueErrorfor , , , or akindother than"call"and"put".
The tests compare with the Black–Scholes formula for the shared call and put and three other options, one of them at 0.5% volatility, where a separate overflows.
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))
d2 = d1 - sigma * math.sqrt(T)
if kind == "call":
return S * norm.cdf(d1) - K * math.exp(-r * T) * norm.cdf(d2)
return K * math.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1)
def test_shared_call():
"""The 6-month at-the-money call is worth 6.3710"""
expect_close(price_by_heat(100, 100, 0.5, 0.03, 0.2), _bs(100, 100, 0.5, 0.03, 0.2), what="price_by_heat(100, 100, 0.5, 0.03, 0.2)", rel=1e-8)
def test_shared_put():
"""The matching put is worth 4.8822"""
expect_close(price_by_heat(100, 100, 0.5, 0.03, 0.2, "put"), _bs(100, 100, 0.5, 0.03, 0.2, "put"), what="price_by_heat(..., 'put')", rel=1e-8)
def test_out_of_the_money_call():
"""A strike-120 call is worth 0.8914"""
expect_close(price_by_heat(100, 120, 0.5, 0.03, 0.2), _bs(100, 120, 0.5, 0.03, 0.2), what="price_by_heat(100, 120, 0.5, 0.03, 0.2)", rel=1e-8)
def test_other_inputs():
"""A 1-year put with S = 80, K = 100, r = 5% and 35% volatility is worth 21.1650"""
expect_close(price_by_heat(80, 100, 1, 0.05, 0.35, "put"), _bs(80, 100, 1, 0.05, 0.35, "put"), what="price_by_heat(80, 100, 1, 0.05, 0.35, 'put')", rel=1e-8)
def test_low_volatility():
"""At 0.5% volatility a call with S = 200, K = 100 is worth 101.4888, with no overflow"""
try:
value = price_by_heat(200, 100, 0.5, 0.03, 0.005)
except OverflowError:
raise AssertionError("OverflowError: with a = 1/2 - r/sigma^2 = -1199.5, combine e^(a(x - y)) inside the integral")
expect_close(value, _bs(200, 100, 0.5, 0.03, 0.005), what="price_by_heat(200, 100, 0.5, 0.03, 0.005)", rel=1e-8)
def test_at_expiry():
"""At T = 0 the price is the payoff: 5 for a call at 105 and for a put at 95"""
expect_close(price_by_heat(105, 100, 0, 0.03, 0.2), 5.0, what="call at expiry")
expect_close(price_by_heat(95, 100, 0, 0.03, 0.2, "put"), 5.0, what="put at expiry")
def test_bad_inputs():
"""A zero volatility or an unknown kind raises ValueError"""
for args, kind in [((100, 100, 0.5, 0.03, 0.0), "call"), ((100, 100, 0.5, 0.03, 0.2), "straddle")]:
try:
price_by_heat(*args, kind=kind)
except ValueError:
continue
raise AssertionError(f"expected ValueError for sigma={args[4]}, kind={kind!r}")
Summary card
- turns the terminal condition into a starting profile; gives constant coefficients: .
- with and leaves ; with , . For the shared call, , and .
- The call's payoff becomes , a kink with slope 1 at the strike.
- The heat kernel's width is the spread of the log return. The heat integral gives 6.3710, and by hand it gives .
- In code, combine inside the integral: is huge at low volatility. Keep and in annual units, and return the payoff at expiry.