The model: a sum of normal log returns
The inputs are those of case studies 2.4.6 and 2.6.5. Today's price is dollars, the horizon years, the interest rate and the volatility , both annual decimals with continuously compounded. is a standard normal random variable, with the density that case study 2.6.5 used as a weight.
The log return (case study 1.4.7) is
a linear function of , so it is normal (lesson 5.6.1), with mean and standard deviation .
Why the log return is normal
Case study 5.4.6 split the six months into 126 trading days and added up the variances of the daily log returns. Lesson 5.6.3 finishes the argument. Each day's log return is independent and , and the MGF of their sum is the 126th power of one day's MGF:
That is the MGF of , so by uniqueness (lesson 5.6.2) the six-month log return is exactly normal. Its standard deviation is : four times the horizon gives twice the spread.
Try it
The first line prints , and the daily volatility . The second checks the MGF identity at .
Then 20,000 seeded paths of 126 daily log returns, each path added up. In this run the sums have mean 0.0059 and standard deviation 0.1404, against 0.005 and 0.1414. The small mean is the harder number to pin down.
Skewness and excess kurtosis (lesson 5.3.3) come out near 0, as for a normal; adding 0.0 turns a -0.0 into 0.0.
The density of the price
The price rises with . By the distribution-function method of lesson 5.6.1, exactly when , so for
with the standard normal distribution function. For the density, use the monotone rule with and .
Rule: Density of the future price
The lognormal density of lesson 5.4.4, with the factor from the stretch of . It is 0 for : a price is always positive.
The density is 0.009598 at $80, 0.028192 at $100 and 0.010711 at $120, per dollar. It is not symmetric about $100: the price cannot fall below 0 but can rise without limit, so the right tail is longer.
From come the probabilities that case study 5.4.6 tabulated. , one minus its 0.782412, and , the of case study 2.6.5.
Try it
f is the rule; SciPy's lognorm gives the same values, with s set to and scale to the median . quad checks that the area is 1.
The last line gives the two probabilities from norm.cdf.
The mean, from the normal's MGF
The mean needs one line with lesson 5.6.2. Since , its mean is , and :
That is $101.5113. Case study 2.6.5 found it by completing the square inside an integral. The normal's MGF is that step done once, for every .
Rule: Mean of a lognormal
The mean that lesson 5.4.4 stated: the normal's MGF at . It is larger than by the factor .
The same function gives the spread. . Subtracting the squared mean leaves 208.1656, the variance lesson 5.4.4 stated, so the standard deviation is $14.4279, or .
Try it
The first line is the MGF route: , and their product times . quad then integrates for the mean directly.
The last line computes from , the variance, and the standard deviation two ways.
Why the is there
Prices in case study 2.6.5 are averages in a risk-neutral model: every asset grows on average like money in the bank. So the model must give . Then the discounted average, , is today's price $100.
A log return of , with no correction, would overshoot. The average of is , above , though averages 0. The mean would be , which discounts to 101.005: more than the $100 the stock costs today.
Subtracting half the variance from the log drift cancels the factor exactly. Lesson 5.7.1 states the general fact behind it, Jensen's inequality: for a convex , .
The cost of the correction: a median below the mean
Case study 5.4.6 watched the median fall behind the mean as the horizon grew. In the pricing model the mean is pinned at whatever is, while the median falls as grows. Six months out:
| volatility | 0.1 | 0.2 | 0.4 | 0.8 |
|---|---|---|---|---|
| median price | 101.26 | 100.50 | 97.53 | 86.50 |
| mean price | 101.51 | 101.51 | 101.51 | 101.51 |
| 0.5141 | 0.5282 | 0.5562 | 0.6114 |
At 80% volatility the typical outcome is a loss, to $86.50, while the mean is still $101.51. A few very large outcomes hold the mean up, and 61% of outcomes fall below it.
The last row is , as in lesson 5.4.4. The reason: the log of the mean, , is above the median log price, which is standard deviations.
Try it
The first line is the model without the correction: its mean and its discounted mean. The loop rebuilds the table.
The median and the mode
is an increasing function of , so quantiles pass straight through (lesson 5.6.1). The median of is , so the median price is . In the same way the 5% and 95% quantiles are , the $79.64 and $126.82 of case study 5.4.6.
The mode, where the density peaks, is ; it is stated here and checked on a grid below. So the mode, the median and the mean come in that order, all within $3.
Five years out they spread apart: the mode is $86.07, the median $105.13 and the mean $116.18. The price then ends below its mean with probability 0.5885.
The density of , six months and five years out. The price cannot fall below 0 but has a long right tail. So the mean sits above the median, the median above the mode, and the gaps grow with .
Try it
For each horizon the cell prints the mode, the median, the mean and . Then it finds the peak of the density on a grid with steps of 0.001, which lands on the mode.
A simulation check
Draw , compute , and every claim above can be checked by counting. In one seeded run of 200,000 draws, the mean is $101.53, the median $100.52 and the standard deviation $14.43.
The share at or below $90 is 0.2174, against 0.217588. The share below the mean is 0.5285, against 0.528186. The lowest of the 200,000 prices is $51.62: never negative.
Try it
The same seed draws the histogram in the figure above.
Case study 5.7.5 prices the call by averaging discounted payoffs over draws like these, and measures how far such an average can stray.
On the desk
Try it
The first two lines are the SciPy mistakes, each beside the right distribution.
The last line discounts the forecast mean with at the rate .
The distribution of a future price
Write price_summary(S0, r, sigma, T) and prob_below(level, S0, r, sigma, T) for the model of this case study.
price_summaryreturns a dict with keys"mean","median","mode"and"sd"of : , , and .prob_belowreturns , which is . A level of 0 or below gives 0.- With or , is certain to be . Then all three centres equal it and
sdis 0;prob_belowis 1 at or above it and 0 below. - Raise
ValueErrorif , or .
The tests check the six-month and five-year numbers above, the probabilities 0.217588 and 0.528186, and the edge cases.
See the tests
import math
def test_six_months():
"""Six months out: mean 101.5113, median 100.5013, mode 98.5112, sd 14.4279"""
got = price_summary(100, 0.03, 0.2, 0.5)
expect_close(got["mean"], 101.51130646157189, what="mean", rel=1e-9)
expect_close(got["median"], 100.5012520859401, what="median", rel=1e-9)
expect_close(got["mode"], 98.51119396030626, what="mode", rel=1e-9)
expect_close(got["sd"], 14.427945946151569, what="sd", rel=1e-7)
def test_five_years():
"""Five years out: mode 86.07, median 105.13, mean 116.18"""
got = price_summary(100, 0.03, 0.2, 5)
expect_close(got["mode"], 100 * math.exp(-0.15), what="mode", rel=1e-9)
expect_close(got["median"], 100 * math.exp(0.05), what="median", rel=1e-9)
expect_close(got["mean"], 100 * math.exp(0.15), what="mean", rel=1e-9)
def test_probabilities():
"""P(S_T <= 90) is 0.217588, and P(S_T <= mean) is N(v/2) = 0.528186"""
expect_close(prob_below(90, 100, 0.03, 0.2, 0.5), 0.21758753431780564, what="prob_below(90)", rel=1e-6)
expect_close(prob_below(100 * math.exp(0.015), 100, 0.03, 0.2, 0.5), 0.5281859888985083, what="prob_below(mean)", rel=1e-6)
expect_close(prob_below(100, 100, 0.03, 0.2, 0.5), 1 - 0.514101801652164, what="prob_below(100) = 1 - N(d2)", rel=1e-6)
def test_order():
"""With volatility, mode < median < mean, and the median has probability 1/2 below it"""
got = price_summary(50, 0.01, 0.35, 2)
assert got["mode"] < got["median"] < got["mean"], f"expected mode < median < mean, got {got}"
expect_close(prob_below(got["median"], 50, 0.01, 0.35, 2), 0.5, what="prob_below(median)", rel=1e-9)
def test_no_volatility():
"""With sigma = 0 the price is certain: S0 e^(rT) = 101.5113, sd 0, probabilities 0 or 1"""
got = price_summary(100, 0.03, 0.0, 0.5)
for key in ("mean", "median", "mode"):
expect_close(got[key], 100 * math.exp(0.015), what=key, rel=1e-12)
expect_close(got["sd"], 0.0, what="sd")
expect_close(prob_below(101.6, 100, 0.03, 0.0, 0.5), 1.0, what="prob_below just above the certain price")
expect_close(prob_below(101.4, 100, 0.03, 0.0, 0.5), 0.0, what="prob_below just below the certain price")
def test_at_expiry():
"""With T = 0 the price is today's, 100"""
got = price_summary(100, 0.03, 0.2, 0)
expect_close(got["median"], 100.0, what="median at T = 0", rel=1e-12)
expect_close(got["sd"], 0.0, what="sd at T = 0")
def test_non_positive_level():
"""A level of 0 or below has probability 0"""
expect_close(prob_below(0, 100, 0.03, 0.2, 0.5), 0.0, what="prob_below(0)")
expect_close(prob_below(-5, 100, 0.03, 0.2, 0.5), 0.0, what="prob_below(-5)")
def test_bad_inputs():
"""S0 <= 0, a negative volatility or a negative horizon raises ValueError"""
cases = [
("S0 = 0", lambda: price_summary(0, 0.03, 0.2, 0.5)),
("sigma = -0.2", lambda: price_summary(100, 0.03, -0.2, 0.5)),
("T = -1", lambda: prob_below(90, 100, 0.03, 0.2, -1)),
]
for name, call in cases:
try:
call()
except ValueError:
continue
raise AssertionError(f"expected ValueError for {name}")
Summary card
- With standard normal, , , . The log return is a sum of independent daily normals, so it is normal with variance (lesson 5.6.3).
- Density (lesson 5.6.1): for , the lognormal; .
- Mean from the normal's MGF (lesson 5.6.2): , so . gives the standard deviation .
- The cancels , so the discounted mean price is ; without it the model values the stock at $101.005.
- Mode below median below mean ; . The gaps grow with .
- Use
lognorm(s=v, scale=S0 * exp(m)), keep and annual, and treat or separately.