The future stock price
The Black–Scholes model is derived in the Stochastic Calculus course and used throughout the Derivatives Modelling course. Here we take one claim of it as given: the stock price at expiry is
Here is a standard normal number: any real value, weighted by the density of lesson 2.6.3. Each value of is one possible future, and says how much weight it gets.
The inputs follow the desk's conventions. is today's price and the strike, both in dollars. is in years, and and are annual decimals, with continuously compounded (case study 1.4.6).
Where the option starts to pay
A call pays at expiry: the ramp of lesson 1.1.2. It pays nothing unless . Setting and solving for with logs (lesson 1.4.4) gives the corner , where
These and are the ones in the formula of case study 2.4.6. For our inputs , so the call pays off in every future with above that: a little more than half of them.
The price as an integral
The model's price is the payoff averaged over all futures, each weighted by , then discounted by to today.
Rule: Option price as an integral
An improper integral over the whole real line (lesson 2.6.3). The payoff is zero for every below , so the integral really runs from to .
The payoff for each future , and the payoff weighted by . The weighted curve's area, 6.4673, discounted by , is the price $6.3710. Big payoffs far out barely count, because is tiny there.
Try it
The first line checks the corner: , where is exactly 100.
Then three prices: the formula, quad over the whole line, and quad split at the corner. The whole-line integral gives 6.37102797 against 6.37102794: quad handles the corner of less precisely. Split at , the integrand is smooth and agrees to 14 digits.
Where and come from
Above the payoff is , so the price splits into two integrals over : a stock part and a strike part.
The strike part
is the area under the bell above . The bell is symmetric (lesson 2.6.3), so that equals the area below , which is .
So the strike part is . Here is the model's chance that the call ends in the money, above the strike.
The stock part, by substitution
- 1
- 2
- 3
- 4
- 5
The stock part is . Together, : the formula of case study 2.4.6.
Step 3 over the whole line gives one more check. The shifted bell has area 1, so the model's average future price is : the stock grows on average at the rate .
Try it
The loop checks step 3 at three values of : both sides agree.
Then the two parts by quad against and , their difference (the price), and the average future price.
On the desk
has no formula, so it is computed
is an area under , and lesson 2.6.3 showed it has no formula in familiar functions. Every pricing library computes it numerically. SciPy's norm.cdf uses tuned approximations accurate to about 16 significant digits, close to the limit of a float.
So "the Black–Scholes formula" is exact algebra wrapped around two numerical integrals. Pricing by quad directly, as above, is the same calculation done more slowly; it is useful for payoffs with no formula.
Cutting the infinite integral off
Any numerical method must stop somewhere. Cutting the call integral at instead of leaves out the tail beyond .
| cut at | 2 | 3 | 4 | 6 | 8 |
|---|---|---|---|---|---|
| price left out ($) | 0.91 | 0.080 | 0.0026 |
Step 3 shows where the weight sits. The stock part's integrand is a bell centred at , not at 0, so the tail starts later when is large.
For a 5-year option with , and the price is $65.6312. Cutting at 4 then leaves out $1.3, about 2%. Measure the cut from instead: 6 beyond it leaves out less than $0.0000001 for both options.
Try it
call prices by quad from up to top, which defaults to infinity. It writes in the form of step 3: multiplied out directly, overflows a float at large before can shrink it.
The loops print what each cut leaves out, in scientific notation. The second is the 5-year, 80% volatility option; its cut at 4 leaves out 1.3, written 1.3e+00.
Tails and precision
A float keeps about 16 significant digits. So , a tiny number found by subtracting from 1, loses digits as grows.
At , is , which rounds to exactly 1, and 1 - norm.cdf(9) gives 0. By symmetry the same tail is , computed with no subtraction; SciPy also offers norm.sf(x) for . Worse, 1 - quad(norm.pdf, -math.inf, 9) returns a small negative number: a negative probability.
Desks meet this in far out-of-the-money options. The put integral, done the same way, gives .
For a strike of 40, writing as gets the fifth digit of the price wrong. Such tiny prices feed ratios, such as implied volatilities, where every digit counts.
Try it
The loop compares with and norm.sf(x) at = 5, 8 and 9. At 8 the subtraction is already 7% off; at 9 it gives 0.
Then the far tail by quad both ways, and the strike-40 put both ways.
Bad inputs and units
- At expiry, , the formulas for and divide by zero. There is nothing left to average: the price is the payoff, .
- With there is no randomness, and the price tends to , about $1.488806 here. itself divides by zero, so code must treat it separately.
- Volatility as a decimal. Typing for 20% prices the call at $100.0, the whole stock.
- Time in years. Typing for six months gives $27.1509 instead of $6.3710.
- Positive prices. needs and ; a zero or negative input raises an error.
Try it
bs_call is the formula of case study 2.4.6. With Python raises ZeroDivisionError.
Then a tiny against its limit , and the two unit mistakes.
Price a call and a put by integration
Write call_by_integral(S0, K, T, r, sigma) and put_by_integral(S0, K, T, r, sigma). Each computes the price as an integral with quad, split at .
- For the call, the payoff is positive above , so integrate from to .
- For the put, the payoff is positive below , so integrate from to .
- At expiry, , return the payoff itself: for the call and for the put.
The tests compare with the Black–Scholes formula, check put–call parity, , and try .
See the tests
import math
from scipy.stats import norm
def _bs_call(S0, K, T, r, sigma):
d1 = (math.log(S0 / K) + (r + sigma**2 / 2) * T) / (sigma * math.sqrt(T))
return S0 * norm.cdf(d1) - K * math.exp(-r * T) * norm.cdf(d1 - sigma * math.sqrt(T))
def test_call_matches_formula():
"""The 6-month at-the-money call is worth about 6.3710, as the formula says"""
expect_close(call_by_integral(100, 100, 0.5, 0.03, 0.2), _bs_call(100, 100, 0.5, 0.03, 0.2), what="call_by_integral", rel=1e-9)
def test_out_of_the_money_call():
"""A strike-120 call matches the formula, about 0.8914"""
expect_close(call_by_integral(100, 120, 0.5, 0.03, 0.2), _bs_call(100, 120, 0.5, 0.03, 0.2), what="call, K = 120", rel=1e-8)
def test_put_call_parity():
"""Call minus put equals S0 − K e^(−rT)"""
c = call_by_integral(80, 75, 1.0, 0.04, 0.3)
p = put_by_integral(80, 75, 1.0, 0.04, 0.3)
expect_close(c - p, 80 - 75 * math.exp(-0.04), what="c − p", rel=1e-8)
def test_put_positive():
"""A put is worth something: the at-the-money put is about 4.8822"""
expect_close(put_by_integral(100, 100, 0.5, 0.03, 0.2), 4.882221902473731, what="put_by_integral", rel=1e-8)
def test_at_expiry():
"""At expiry (T = 0) each option is worth its payoff: a call 10 when S0 = 110, a put 10 when S0 = 90"""
expect_close(call_by_integral(110, 100, 0.0, 0.03, 0.2), 10.0, what="call_by_integral at T = 0, S0 = 110")
expect_close(call_by_integral(90, 100, 0.0, 0.03, 0.2), 0.0, what="call_by_integral at T = 0, S0 = 90")
expect_close(put_by_integral(90, 100, 0.0, 0.03, 0.2), 10.0, what="put_by_integral at T = 0, S0 = 90")
Summary card
- An option's price is its discounted average payoff: , with .
- The payoff has a corner at ; split the integral there.
- The strike part is , where is the model's chance of ending in the money.
- Completing the square and the substitution turn the stock part into .
- has no formula and is computed numerically. Use , not , for far tails.
- Cut an infinite integral about 6 beyond the centre of its integrand, here. Treat and separately, and keep and in annual decimals and years.