Black and Scholes's d1¶
Black and Scholes's d1 for a European option on a stock paying no dividend, d1 = (ln(S / K) + r T + s^2 / 2) / s, with s = sigma sqrt(T / P) the volatility over the option's life: Hull's (ln(S / K) + (r + sigma^2 / 2) T) / (sigma sqrt(T)).
S and K are in the game's money unit (the kernel is generic over it), r is continuous a year, T in years. sigma is the standard deviation of the log return over a period P the game states (a year for Hull's sigma a year): a volatility per root year is a unit Trait cannot spell, so it is the dimensionless spread over its period, as lusine.stochastic.step_volatility's is. No time or no volatility left divides by nothing: that guard is the game's. Source: Black & Scholes, The Pricing of Options and Corporate Liabilities, J. Political Economy 81 (1973); Hull, Options, Futures, and Other Derivatives, 9th ed. (Pearson, 2014), ch. 15. Checked against a reference in numpy, which has its own tests.
- Source
- Black & Scholes, J. Political Economy 81 (1973); Hull, Options, Futures, and Other Derivatives, 9th ed. (2014), ch. 15.
- Proof
- reference, invariant.
d1.trait
namespace lusine.finance;
float d1(float<_> spot, float<_> strike, float<year^-1> rate, float volatility, float<year> period, float<year> years)
{
let s = volatility * sqrt(years / period);
return (log(spot / strike) + rate * years + 0.5 * s * s) / s;
}
(ln(S / K) + (r + sigma^2 / 2) T) / (sigma sqrt(T)), with the volatility over a period it is given.
import os, sys
root = os.getcwd()
while not os.path.isfile(os.path.join(root, 'docsite.json')):
root = os.path.dirname(root)
sys.path.insert(0, os.path.join(root, 'tools'))
import numpy as np
import matplotlib.pyplot as plt
from scipy.special import ndtr
from scipy.stats import norm
from lusine import trait_run
EPS = float(np.finfo(np.float32).eps) # Trait's float is binary32
YEAR = 31557600.0 # lusine's year row, the Julian year in seconds: the host speaks base units
The kernel¶
The law as the library states it, and as this page runs it.
print(trait_run.source('finance/d1.trait'))
// lusine.finance.d1: Black and Scholes's d1 for a European option on a stock paying no dividend,
// d1 = (ln(S / K) + r T + s^2 / 2) / s, with s = sigma sqrt(T / P) the volatility over the option's life: Hull's
// (ln(S / K) + (r + sigma^2 / 2) T) / (sigma sqrt(T)). S and K are in the game's money unit (the kernel is generic over
// it), r is continuous a year, T in years. sigma is the standard deviation of the log return over a period P the game
// states (a year for Hull's sigma a year): a volatility per root year is a unit Trait cannot spell, so it is the
// dimensionless spread over its period, as lusine.stochastic.step_volatility's is. No time or no volatility left
// divides by nothing: that guard is the game's.
// Source: Black & Scholes, The Pricing of Options and Corporate Liabilities, J. Political Economy 81 (1973); Hull,
// Options, Futures, and Other Derivatives, 9th ed. (Pearson, 2014), ch. 15.
// Checked against a reference in numpy, which has its own tests.
namespace lusine.finance;
float d1(float<_> spot, float<_> strike, float<year^-1> rate, float volatility, float<year> period, float<year> years)
{
let s = volatility * sqrt(years / period);
return (log(spot / strike) + rate * years + 0.5 * s * s) / s;
}
The program¶
A lattice of options, one an item, the kernel bound over them: the prices in euros, the rate a year, the volatility over a period (a year here, Hull's), the life in years. The host speaks the base units, so a time is stored in seconds and a rate per second, and d1 is dimensionless. kernel below is that call; closed is the independent account, Black-Scholes in float64 with scipy's exact normal distribution, at the parameters as the device holds them. held rounds them so.
CALL = False
BS = """namespace bs;
lusine.version version = { { number = 1 } };
struct option {
host float<EUR> spot; host float<EUR> strike; host float<year^-1> rate; host float volatility;
host float<year> period; host float<year> years;
float d1;
};
lattice<option, 1> options(spacing = 1 m, boundary = wall);
void main()
{
options.d1 = lusine.finance.d1(options.spot, options.strike, options.rate, options.volatility,
options.period, options.years);
}
"""
print(BS)
def held(call, spot, strike, rate, volatility, years, period=1.0):
"""The parameters as the device holds them, in the host's units (euros, a year, years): each rounded to binary32 in
the base units it is stored in."""
f = lambda v, scale=1.0: np.float32(np.asarray(v, dtype=float) * scale).astype(float) / scale
return (np.asarray(call, dtype=bool), f(spot), f(strike), f(rate, 1 / YEAR), f(volatility), f(years, YEAR),
f(period, YEAR))
def kernel(call, spot, strike, rate, volatility, years, period=1.0):
"""The kernel's d1 for each option: prices in euros, the rate a year, the volatility over `period` years and the
life in years, stored in the base units (seconds); the result back in dimensionless, one an option (a
single option's alone)."""
n = max(np.size(a) for a in (call, spot, strike, rate, volatility, years, period))
run = trait_run.Program(['finance/d1.trait'], BS, {'bs.options': [n]})
for field, v in (('spot', spot), ('strike', strike), ('rate', np.asarray(rate) / YEAR),
('volatility', volatility), ('period', np.asarray(period) * YEAR),
('years', np.asarray(years) * YEAR)):
run.store('bs.options', field, np.broadcast_to(np.asarray(v), (n,)))
out = run.steps('bs.main', 1, [('bs.options', 'd1')])[0][0]
return out if n > 1 else float(out[0])
def closed(call, spot, strike, rate, volatility, years, period=1.0):
"""The independent account: Black-Scholes in float64 with scipy's exact normal distribution, at the parameters
the device holds."""
c, S, K, r, v, T, P = held(call, spot, strike, rate, volatility, years, period)
s = v * np.sqrt(T / P)
a = (np.log(S / K) + (r + 0.5 * (v * v / P)) * T) / s
b = a - s
df = K * np.exp(-r * T)
return a
rng = np.random.default_rng(391)
N = 1000
SPOT, STRIKE = rng.uniform(20, 200, N), rng.uniform(20, 200, N)
RATE, VOL, LIFE = rng.uniform(-0.02, 0.15, N), rng.uniform(0.05, 0.9, N), rng.uniform(0.02, 5.0, N)
CALLS = rng.random(N) < 0.5
namespace bs;
lusine.version version = { { number = 1 } };
struct option {
host float<EUR> spot; host float<EUR> strike; host float<year^-1> rate; host float volatility;
host float<year> period; host float<year> years;
float d1;
};
lattice<option, 1> options(spacing = 1 m, boundary = wall);
void main()
{
options.d1 = lusine.finance.d1(options.spot, options.strike, options.rate, options.volatility,
options.period, options.years);
}
Reference: Hull's example through scipy's N¶
Hull's printed values (Options, Futures, and Other Derivatives, 9th ed., 2014) are quoted from sources that were opened, the book itself not: Example 15.6 (S0 = 42, K = 40, r = 10%, sigma = 20%, six months) as QFRM's BS_Simple documentation cites it, and ch. 19's running example (S0 = 49, K = 50, r = 5%, sigma = 20%, 20 weeks) as the black-scholes-greeks README quotes it. Each is held to half a unit in the last digit printed. d1 is not printed in a source opened, so it is held through the prices it gives with d2 = d1 - sigma sqrt(T) and scipy's exact N.
a = kernel(None, 42.0, 40.0, 0.10, 0.20, 0.5)
b = a - 0.2 * np.sqrt(0.5)
df = 40.0 * np.exp(-0.05)
c, p = 42.0 * ndtr(a) - df * ndtr(b), df * ndtr(-b) - 42.0 * ndtr(-a)
assert abs(c - 4.76) <= 0.005 and abs(p - 0.81) <= 0.005, (c, p)
print(f'd1 {a:.5f}: call {c:.4f} (4.76), put {p:.4f} (0.81)')
d1 0.76926: call 4.7594 (4.76), put 0.8086 (0.81)
Reference: scipy's Black-Scholes¶
A thousand options: each d1 within 1e-6 (1 + |d1|) of the float64 account.
a = kernel(None, SPOT, STRIKE, RATE, VOL, LIFE)
want = closed(True, SPOT, STRIKE, RATE, VOL, LIFE)
err = np.abs(a - want) / (1 + np.abs(want))
assert np.max(err) <= 1e-6, np.max(err)
print(f'largest error {np.max(err):.2e} of 1 + |d1|')
largest error 3.72e-07 of 1 + |d1|
Invariant: the period the volatility is over¶
A trading day's volatility, sigma / sqrt(252) over a 252nd of a year, gives the year's d1.
year = kernel(None, SPOT, STRIKE, RATE, VOL, LIFE)
day = kernel(None, SPOT, STRIKE, RATE, VOL / np.sqrt(252.0), LIFE, period=1 / 252)
gap = np.abs(day - year) / (1 + np.abs(year))
assert np.max(gap) <= 2e-6, np.max(gap)
print(f'the same d1 within {np.max(gap):.1e} of 1 + |d1|')
the same d1 within 2.4e-07 of 1 + |d1|