Monte Carlo Simulation
How to answer questions about an uncertain future by simulating thousands of possible futures, and how sure you can be of the answer.
Introduction
After this module you can
- explain what a Monte Carlo simulation does, and run one with a fixed seed so that anyone can repeat it
- write the Monte Carlo estimate as an average over paths, and show that it is right on average
- compute the standard error of an estimate, and explain why a hundred times as many paths make it only ten times smaller
- build a 95 % interval for an average and for a chance, and say when it can be trusted
- read the median, other percentiles and the chance of a loss off simulated paths, and estimate how precise they are
- check a simulation against the exact answers of its model
- use antithetic pairs to shrink the error without more paths, and say when this helps
The core idea
Nobody knows how an investment will develop over the next years. A Monte Carlo simulation plays through thousands of possible futures to show what you can expect and how wide the range of outcomes is. First you make assumptions, for example that the investment earns 8 % a year on average and swings by about 20 % a year. Then the computer builds one path, year by year. For each year it draws a random return around the 8 %: one year might bring plus 27 %, the next minus 9 %, the next plus 4 %. Multiplying these yearly returns one after the other gives where this path ends after 30 years. The computer repeats this 10,000 times with new random numbers, so you get 10,000 different futures under the same assumptions. From all paths together you read off what is typical, how bad a bad case is and how likely a loss is. The answer can only be as good as the assumptions behind it.
The idea in one example
Put $10,000 into an investment for 30 years. How much might you end with, and how likely is a loss? A Monte Carlo simulation answers this with the method from the core idea. It got its name in the 1940s from the casino of Monte Carlo, a place that lives on chance.
Figure 1 shows the result for 10,000 paths. Most of them end well below the average, because a few very lucky paths pull it up.
The numbers in Figure 1, such as the typical outcome of $60,814 and the 5 % chance of a loss, are estimates. They come from one run of 10,000 random paths. A second run draws new random returns for every year, so every path and every number comes out a little different. The exact typical outcome of this model is $60,496, so this run is about $300 off. The rest of this module shows how large that error is and how to repeat a run exactly. It also checks the simulation against exact answers and shows a trick that makes the results more precise with the same number of paths.
Key Points
Many possible futures
Each run of the model is called a path. The model is the rule behind every path: here, a random return for each year around the assumed average.
Read off what matters
Sort the 10,000 end values. The middle one is the median, the typical outcome. The value with 10 % of the paths below it is the 10th percentile, a bad case, and the value with 90 % below it is the 90th percentile, a good case. The share of paths that end below the start is the chance of a loss. Each of these is a count or an average over the paths.
The average and the typical future
Gains and losses multiply (module 1), so a few very lucky paths end extremely high, while most paths gather lower down. The average is pulled up by those few. In the simulation of Figure 1, 70.97 % of the paths end below the average of $111,469, the same effect as the volatility drag of module 10.
More paths, more precision
With 100 paths the median could have come out anywhere from about $42,000 to $87,000, depending on the luck of the draws, as Figure 7 shows. With 10,000 paths it is far more precise. The error shrinks slowly, though: a hundred times as many paths make the numbers ten times as precise, the same rule as for the spread of a random walk (module 6).
A seed makes a run repeatable
A computer makes its random numbers with a fixed rule that starts from one number, the seed. The same seed gives exactly the same numbers and so the same paths, which lets anyone repeat and check a result. The simulation of Figure 1 uses the seed 17, fixed before the first run.
Only as good as the model
The 8 % and the 20 % are assumptions, and so is the bell shape of the yearly returns. More paths make the answer more precise for this model, and they cannot tell whether the model fits the real world. Module 17 shows how real returns break the bell shape.
The Math + Python
The double arrow ⟹ reads “and therefore”, the sign √ stands for the square root and the sign ≈ means “about equal”.
- SE=the standard error of the estimate
- σX=the spread of one path result
- s=the measured spread of the results
- M=the number of paths
- Var=the variance
- θM=the Monte Carlo estimate from M paths
One simulated path is a guess with a large error. The average of many paths is much more precise, and its error shrinks with the square root of the number of paths.
Worked example, step by step
10,000 paths of $10,000 over 30 years (μ = 8 %, σ = 20 %)
1. Growth rate of the log returns: g = 0.08 − 0.20 × 0.20 / 2 = 0.06
2. Each path draws 30 yearly log returns, adds them up and grows $10,000 by e to that sum
3. Average of the 10,000 final values: $111,469
4. Spread of one path s = $167,084, so SE = $167,084 / 100 = $1,671
- μ=the average return per year
- σ=the volatility per year
- g=the growth rate of the log returns per year
- e=the number e, about 2.718, the base of the natural logarithm
The same calculation, implemented in Python
Download monte_carlo_basic.pyimport numpy as np # numpy draws the random numbers
start, years, paths = 10_000, 30, 10_000 # $10,000, 30 years, 10,000 paths
mu, sigma = 0.08, 0.20 # average return and volatility a year
rng = np.random.default_rng(17) # fixed seed: the same paths every run
g = mu - sigma ** 2 / 2 # the growth rate of the log returns
log_ret = rng.normal(g, sigma, (paths, years)) # a random log return per path and year
final = start * np.exp(log_ret.sum(axis=1)) # what $10,000 grows into on each path
avg = final.mean() # the Monte Carlo estimate, an average
s = final.std(ddof=1) # the spread of one path result
se = s / paths ** 0.5 # the standard error, s over root of M
print(f"spread of one path: ${s:,.0f}") # print the spread s
print(f"average ${avg:,.0f} +/- ${se:,.0f}") # the estimate and its standard error
Output
spread of one path: $167,084
average $111,469 +/- $1,671Going deeper: the math step by step
For readers who want the full math: every step derived, with the full code behind the page at the end.
1. One path of the model
Call the start W0 = $10,000 and the value after t years Wt, with T = 30 years in all. Each year the log of the value moves by a log return Yt (module 1), drawn at random from a bell (module 7) with the mean g and the standard deviation σ (sigma), the volatility. The model assumes that the years are independent of each other, and that g and σ stay the same and are known. Log returns add up over time (module 1), and exp(y) is e to the power y. The sign Σ means “add all of these”, here for t from 1 to T.
- WT=the value after T years
- W0=the value at the start
- exp=the number e to the power of the bracket
- Σ=add up the terms for every value of the index below the sign
- t=the number of a year
- T=the number of years
- Yt=the log return of year t
- g=the mean of the yearly log return
- μ=the average return per year
- σ=the volatility per year
The model of Figure 1 assumes an average return μ (mu) of 8 % a year, compounded continuously as in module 10, and σ = 20 %. Here μ is defined so that eμ is the expected growth factor of a year, which is what “compounded continuously” means. With this μ the rule of module 10 holds exactly, so the log returns grow at g = μ − σ2/2 = 0.08 − 0.02 = 0.06 on average. As simple returns, a year then has the average growth factor e0.08 = 1.0833, an average simple return of 8.33 %, and the median simple return e0.06 − 1 = 6.18 %. A sum of independent bells is again a bell, and its means and variances add up (module 6). So ln(WT/W0) follows a bell with the mean gT = 1.8 and the standard deviation σ√T = 0.2 × √30 = 1.0954, where the sign √ stands for the square root.
2. The Monte Carlo estimate
Run the model M times with fresh random numbers, and read one number Xm off path m, such as its final value. Call the true value we want θ (theta), here the expected final value E[X]. The Monte Carlo estimate θM, with the subscript M for the number of paths, is the plain average of the path results.
- θM=the Monte Carlo estimate from M paths
- M=the number of paths
- Σ=add up the terms for every value of the index below the sign
- m=the number of a path
- Xm=the result of path m
- E=the expected value, the long-run average
- θ=the true value the estimate looks for
The expected value of a sum is the sum of the expected values (module 3), so the estimate has no bias: it is right on average. The law of large numbers of module 3 pulls it closer to θ as M grows. Example: the 10,000 final values of Figure 1 average $111,469.
3. The exact answers of this model
This model can also be solved with pen and paper, which lets us check the simulation. The percentile xu at the share u is the value with the share u of the outcomes below it, so the 10th percentile has u = 0.1 and the median u = 0.5. Φ(z), the capital Greek letter phi, is the share of the standard bell below z (module 7), and zu is the point with Φ(zu) = u, for example z0.9 = 1.2816. The exponential keeps the order of values, so every percentile of the log turns into a percentile of the value. Module 10 showed that a bell Y with the mean a and the standard deviation c has E[eY] = ea + c2/2, and here a + c2/2 = gT + σ2T/2 = μT.
- xu=the percentile at the share u
- W0=the value at the start
- e=the number e, about 2.718, the base of the natural logarithm
- g=the mean of the yearly log return
- T=the number of years
- zu=the point of the standard bell with the share u below it
- σ=the volatility per year
- P=the probability of what stands in the brackets
- WT=the value after T years
- Φ=the share of the standard bell curve below the point in the brackets
- E=the expected value, the long-run average
- μ=the average return per year
Example: the median is 10,000 e1.8 = $60,496 (simulated $60,814), the 10th percentile 10,000 e1.8 − 1.2816 × 1.0954 = $14,861 (simulated $14,636) and the 90th percentile $246,276 (simulated $246,456). The chance of a loss is Φ(−1.8/1.0954) = Φ(−1.6432) = 5.02 % (simulated 5.00 %, 500 paths), and the average is 10,000 e2.4 = $110,232 (simulated $111,469). The share of outcomes below the average is Φ(σ√T/2) = Φ(0.5477) = 70.81 % (simulated 70.97 %). Every simulated number lies near its exact value, and step 4 says how near it should be.
4. The standard error
How far is θM from θ? Call σX the standard deviation of one path result. The paths are independent, so the variance of their sum is M times σX2 (module 6), and dividing by M divides the variance by M2. The standard error SE is the standard deviation of the estimate, the typical distance between the estimate and the true value. The double arrow ⟹ reads “and therefore”, and the sign ≈ means “about equal”.
- Var=the variance
- θM=the Monte Carlo estimate from M paths
- M=the number of paths
- σX=the spread of one path result
- SE=the standard error of the estimate
- s=the measured spread of the results
The true σX is usually unknown, so a run measures its own standard deviation s, computed with M − 1 as in module 8, and reports s/√M. Example: the paths of Figure 1 have s = $167,084, so SE = $167,084/√10,000 = $1,671. The variance formula of module 10 gives the exact σX = E[WT] × √(eσ2T − 1) = $167,904 and the exact SE $1,679. Four times as many paths halve the error: 40,000 paths give $840. A hundred times as many make it ten times smaller: 100 paths give $16,790 and 1,000,000 paths $168.
5. A 95 % interval
By the central limit theorem of module 7, the average of many paths is close to a bell around θ with the standard deviation SE. A bell holds 95 % of its area within 1.96 standard deviations of its middle (module 7). So the interval from θM − 1.96 SE to θM + 1.96 SE holds θ in about 95 % of all runs. The sign ± means “plus or minus” and writes both ends at once.
- θM=the Monte Carlo estimate from M paths
- SE=the standard error of the estimate
Example: $111,469 ± 1.96 × $1,671 runs from $108,194 to $114,743, and it holds the exact $110,232. The estimate lies (111,469 − 110,232)/1,671 = +0.74 standard errors above it. The bell of the central limit theorem needs enough paths, the more so the more lopsided the results are. Exercise 4 repeats a simulation 1,000 times: for the final value the interval holds the truth in 89.9 % of the runs with 100 paths and in 94.0 % with 1,000, for the symmetric log in 94.2 % and 94.4 %.
6. A chance is an average too
A chance is the average of a flag that is 1 when the event happens and 0 otherwise. Call the true chance p and its estimate from M paths pM. A flag times itself is the flag again, so the expected square of a flag is p, and the variance rule of module 6 gives it the variance p − p2 = p(1 − p). Step 4 then gives the standard error, and divided by p it shows the error as a share of the chance itself.
- SE=the standard error of the estimate
- pM=the chance estimated from M paths
- p=the true chance
- M=the number of paths
Example: the simulation of Figure 3 estimates the chance of a loss as 5.00 %, with SE = √(0.05 × 0.95/10,000) = 0.218 %. The 95 % interval runs from 4.57 % to 5.43 % and holds the exact 5.02 %. As a share of the chance the error is 4.4 %. A chance of 1 % from 10,000 paths rests on only about 100 hits, and its error is 9.9 % of its size. For the same relative error, the number of paths must grow about like 1/p, so rare events, such as the very bad days of module 18, need many paths.
7. The error of a percentile
A percentile moves when paths cross it. At the true percentile xu, the share of paths below it is an estimated chance with the standard error √(u(1 − u)/M) of step 6. Let f be the density of the outcomes, the height of the curve whose area gives the chances (module 7). Moving a value by a small step δ (delta) changes the share below it by about f(xu) δ, so an error in the share turns into an error in the value of about the share error divided by f(xu). For the median of the log, a bell with the standard deviation σ√T, the height in the middle is 1/(σ√T × √(2π)), where π = 3.14159 is the circle number, and u = 1/2. That gives the second formula.
- SE=the standard error of the estimate
- xu=the percentile at the share u
- f=the density of the outcomes
- u=the share of the outcomes below the percentile
- M=the number of paths
- ln=the natural logarithm
- π=the number pi, about 3.1416
- σ=the volatility per year
- T=the number of years
Example: σ√T = 1.0954, so the median of 10,000 paths is uncertain by 1.37 %, the median of 1,000 paths by 4.34 % and the median of 100 paths by 13.73 %. The run of Figure 1 agrees, as Figure 7 shows: the medians of 100 groups of 100 of its paths spread by 13.64 %, from $41,680 to $86,829, the medians of 10 groups of 1,000 by 5.31 %, a rough value from only ten groups, and the median of all 10,000 lies 0.52 % above the exact $60,496.
8. Seeds, and checking the error with other runs
A computer’s random numbers come from a fixed rule that starts at the seed, so they are called pseudo-random. The same seed gives the same paths, and a different seed, or the same generator used again, gives an independent run. Independent runs are a direct check of the standard error: the distance of an estimate from the truth, counted in standard errors, behaves like a draw from the standard bell. The bars | | stand for the size of a number without its sign.
- z=the distance of the estimate from the truth, in standard errors
- θM=the Monte Carlo estimate from M paths
- θ=the true value the estimate looks for
- SE=the standard error of the estimate
- P=the probability of what stands in the brackets
Example: the seed 17 run again gives every path exactly as before. The seeds 1 to 5 give medians from $58,702 to $62,463, between −2.19 and +2.33 standard errors from the exact median, averages from $108,005 to $111,644 and chances of a loss from 4.66 % to 5.24 %. Two of the five medians lie a little more than two standard errors away. A single run lands that far out with a chance of about 5 %, so two of five happen by luck alone in about 2 % of such sets of runs, and exercise 2 checks the error over 100 runs. A seed is fixed before the run and never picked after a look at the results, because trying seeds until one looks good turns luck into a false result.
9. Antithetic pairs
The error can also shrink with smarter random numbers. Each path is driven by draws Z from the standard bell, and the mirrored draws −Z are exactly as likely. Use both and count the pair as one value, the average of the results X(Z) and X(−Z). This is the method of antithetic pairs, the simplest of the methods that reduce the variance of an estimate. With the correlation ρ (rho) of the two halves of a pair (module 6), the variance of the sum of two equal variances gives the formula below. M paths make M/2 pairs.
- Var=the variance
- X=the result of one path
- Z=a draw from the standard bell
- σX=the spread of one path result
- ρ=the correlation of the two halves of a pair
- SEpairs=the standard error with mirrored pairs
- M=the number of paths
For the final value W0ea + cZ and its mirror, the correlation is ρ = −e−c2, since their covariance e2a(1 − ec2) divided by the variance e2a + c2(ec2 − 1) leaves −e−c2. With c2 = σ2T = 1.2 this is −0.301, and the error shrinks by √(1 − 0.301) = 0.836, from $1,679 to $1,404 for 10,000 paths. Part 8 of the full code measures a correlation of −0.300 and a standard error of $1,384. For the log of the final value, a straight line in Z, ρ = −1 and the pairs give the exact 1.8 every time. For the chance of a loss the gain is tiny, as exercise 5 shows. Pairs help most for results that move steadily with the random numbers.
Full code behind this page
Every figure and number on this page, in one file to download.
It runs on its own with Python 3 and numpy, and a fixed seed makes it print the same numbers on every run.
Show the full codeHide the full code
The full code behind this page: every number of this lesson.
# QuantFinanceToGo, module 16: Monte Carlo Simulation. The lesson program.
# Runs on its own with Python 3 and numpy. Every path in it is simulated.
# For learning only, nothing here is investment advice.
import math
from statistics import NormalDist
import numpy as np
bell = NormalDist() # the standard bell curve of module 7
start, years, paths = 10000, 30, 10000
mu, sigma = 0.08, 0.20 # assumed average and volatility a year
g = mu - sigma ** 2 / 2 # growth rate of module 10
t_sd = sigma * math.sqrt(years) # sd of ln(W/W0) after 30 years
# 1) The model of one path
print("1) The model: yearly log returns from a bell with mean g and sd sigma")
print(f" g = {mu} - {sigma}^2 / 2 = {g:.2f} after {years} years: mean of "
f"ln(W/W0) {g * years:.1f}, sd {t_sd:.4f}")
# 2) The simulation: 10,000 paths of 30 years
rng = np.random.default_rng(17) # fixed seed, same paths on every run
log_ret = rng.normal(g, sigma, (paths, years))
value = start * np.exp(log_ret.cumsum(axis=1))
final = value[:, -1]
low, mid, high = np.percentile(final, [10, 50, 90])
print("2) The simulation: 10,000 simulated futures of $10,000")
print(f" median ${mid:,.0f} 10th percentile ${low:,.0f} 90th ${high:,.0f}")
print(f" chance of a loss {(final < start).mean():.2%} "
f"({int((final < start).sum())} paths) average ${final.mean():,.0f}")
print(f" paths below the average: {(final < final.mean()).mean():.2%}")
# 3) The exact answers of the same model
z90 = bell.inv_cdf(0.9)
exact_median = start * math.exp(g * years)
exact_mean = start * math.exp(mu * years)
print("3) Exact answers of the model (no random numbers)")
exact_low = exact_median * math.exp(-z90 * t_sd)
exact_high = exact_median * math.exp(z90 * t_sd)
print(f" median ${exact_median:,.0f} 10th ${exact_low:,.0f} "
f"90th ${exact_high:,.0f}")
print(f" chance of a loss {bell.cdf(-g * years / t_sd):.2%} average "
f"${exact_mean:,.0f} below the average {bell.cdf(t_sd / 2):.2%}")
# 4) Standard error and 95 % interval of the average
s = final.std(ddof=1)
se = s / math.sqrt(paths)
sd_exact = exact_mean * math.sqrt(math.exp(t_sd ** 2) - 1)
print("4) The average and its standard error SE = s / sqrt(M)")
print(f" s ${s:,.0f} SE ${se:,.0f} 95 % interval "
f"${final.mean() - 1.96 * se:,.0f} to ${final.mean() + 1.96 * se:,.0f}")
print(f" exact sd ${sd_exact:,.0f} exact SE ${sd_exact / math.sqrt(paths):,.0f}"
f" estimate minus exact: {(final.mean() - exact_mean) / se:+.2f} SE")
for m in (100, 40000, 1000000):
print(f" with {m:>9,} paths the exact SE is ${sd_exact / math.sqrt(m):,.0f}")
# 5) The chance of a loss and its standard error
p_loss = (final < start).mean()
se_p = math.sqrt(p_loss * (1 - p_loss) / paths)
print("5) The chance of a loss as an average of 0/1 flags")
print(f" {p_loss:.2%} with SE {se_p:.3%}: 95 % interval "
f"{p_loss - 1.96 * se_p:.2%} to {p_loss + 1.96 * se_p:.2%}")
# 6) The error of the median: groups of the same 10,000 paths
print("6) Medians of groups: sd of their logs, theory sqrt(pi/2) * sd / sqrt(n)")
for n in (100, 1000):
med = np.median(final.reshape(paths // n, n), axis=1)
spread = np.log(med).std(ddof=1)
theory = math.sqrt(math.pi / 2) * t_sd / math.sqrt(n)
print(f" {paths // n:>3} groups of {n:>5,}: spread {spread:6.2%} theory "
f"{theory:6.2%} from ${med.min():,.0f} to ${med.max():,.0f}")
se_med =math.sqrt(math.pi / 2) * t_sd / math.sqrt(paths)
print(f" all 10,000: {math.log(mid / exact_median):+.2%} off the exact median, "
f"theory {se_med:.2%}")
# 7) Seeds: the same seed repeats, other seeds scatter
print("7) The same seed twice, then the seeds 1 to 5")
again = np.random.default_rng(17)
again_log = again.normal(g, sigma, (paths, years))
again_final = start * np.exp(again_log.cumsum(axis=1))[:, -1]
print(f" seed 17 again: median ${np.median(again_final):,.0f}, every path the "
f"same: {bool(np.array_equal(again_final, final))}")
seeds = [np.random.default_rng(1), np.random.default_rng(2), np.random.default_rng(3),
np.random.default_rng(4), np.random.default_rng(5)]
for number, r in enumerate(seeds, start=1):
f = start * np.exp(r.normal(g, sigma, (paths, years)).cumsum(axis=1))[:, -1]
off = math.log(np.median(f) / exact_median) / se_med
print(f" seed {number}: median ${np.median(f):,.0f} ({off:+.2f} SE) "
f"average ${f.mean():,.0f} loss {(f < start).mean():.2%}")
# 8) Antithetic pairs: every draw z is used again as -z
z = rng.standard_normal((paths // 2, years))
up = start * np.exp((g + sigma * z).sum(axis=1))
down = start * np.exp((g - sigma * z).sum(axis=1))
pair = (up + down) / 2
rho = np.corrcoef(up, down)[0, 1]
rho_exact = -math.exp(-t_sd ** 2) # correlation of e^(a + bZ) and e^(a - bZ)
anti_exact = sd_exact * math.sqrt((1 + rho_exact) / 2) / math.sqrt(paths // 2)
print("8) 5,000 antithetic pairs, 10,000 paths in all")
print(f" average ${pair.mean():,.0f} SE "
f"${pair.std(ddof=1) / math.sqrt(paths // 2):,.0f} correlation in a pair "
f"{rho:+.3f}, exact {rho_exact:+.3f}")
print(f" exact SE: plain ${sd_exact / math.sqrt(paths):,.0f}, antithetic "
f"${anti_exact:,.0f}")
# 9) The chance of a loss after 1, 10 and 30 years
print("9) Below the start after t years: simulated and exact")
for t in (1, 10, 30):
print(f" t = {t:>2}: {(value[:, t - 1] < start).mean():.2%} exact "
f"{bell.cdf(-g * t / (sigma * math.sqrt(t))):.2%}")
1) The model: yearly log returns from a bell with mean g and sd sigma
g = 0.08 - 0.2^2 / 2 = 0.06 after 30 years: mean of ln(W/W0) 1.8, sd 1.0954
2) The simulation: 10,000 simulated futures of $10,000
median $60,814 10th percentile $14,636 90th $246,456
chance of a loss 5.00% (500 paths) average $111,469
paths below the average: 70.97%
3) Exact answers of the model (no random numbers)
median $60,496 10th $14,861 90th $246,276
chance of a loss 5.02% average $110,232 below the average 70.81%
4) The average and its standard error SE = s / sqrt(M)
s $167,084 SE $1,671 95 % interval $108,194 to $114,743
exact sd $167,904 exact SE $1,679 estimate minus exact: +0.74 SE
with 100 paths the exact SE is $16,790
with 40,000 paths the exact SE is $840
with 1,000,000 paths the exact SE is $168
5) The chance of a loss as an average of 0/1 flags
5.00% with SE 0.218%: 95 % interval 4.57% to 5.43%
6) Medians of groups: sd of their logs, theory sqrt(pi/2) * sd / sqrt(n)
100 groups of 100: spread 13.64% theory 13.73% from $41,680 to $86,829
10 groups of 1,000: spread 5.31% theory 4.34% from $57,049 to $66,399
all 10,000: +0.52% off the exact median, theory 1.37%
7) The same seed twice, then the seeds 1 to 5
seed 17 again: median $60,814, every path the same: True
seed 1: median $58,702 (-2.19 SE) average $108,005 loss 5.24%
seed 2: median $61,128 (+0.76 SE) average $111,644 loss 5.05%
seed 3: median $61,034 (+0.64 SE) average $111,485 loss 4.66%
seed 4: median $62,463 (+2.33 SE) average $109,853 loss 4.71%
seed 5: median $60,217 (-0.34 SE) average $109,294 loss 4.84%
8) 5,000 antithetic pairs, 10,000 paths in all
average $108,396 SE $1,384 correlation in a pair -0.300, exact -0.301
exact SE: plain $1,679, antithetic $1,404
9) Below the start after t years: simulated and exact
t = 1: 38.52% exact 38.21%
t = 10: 16.78% exact 17.14%
t = 30: 5.00% exact 5.02%Real World
Where it is used
- Portfolio risk: risk models simulate the possible losses of a portfolio over a short horizon, for example for the value at risk of module 18.
- Option pricing: options whose price has no closed formula are priced by averaging simulated payoffs, and module 31 checks a formula this way.
- Financial planning: planning tools for savings and retirement show a range of simulated outcomes, such as a median and a bad case.
- Portfolios over many years: simulations with rules for rebalancing show the spread of final wealth and the chance of missing a target, as in module 48.
- Faster simulation: methods that reduce the variance, such as antithetic pairs, are standard and buy precision more cheaply than extra paths, the topic of module 50.
Best practice
- Results are usually reported with their standard error or an interval, together with the seed and the number of paths, so that others can repeat them.
- A simulation is only as good as its inputs: the assumed average, the volatility and the shape of the returns decide what comes out.
Common mistakes
- Reporting an estimate without its standard error. The average of $111,469 carries an error of $1,671, and from 100 paths it would carry about $16,790.
- Expecting more paths to fix the model. The standard error measures only the luck of the draws. If real returns have fat tails (module 17), a million paths of a bell answer the wrong question very precisely.
- Picking the seed after seeing the results. Five seeds already give medians from $58,702 to $62,463. Choosing the most pleasing one turns luck into a false result.
- Treating two runs with the same seed as independent. Two generators with the seed 2 gave the same median of $63,401.51 twice, so the second run adds no information.
- Reading the average as the typical outcome. 70.97 % of the paths of Figure 1 end below their average of $111,469, and the median is $60,814.
- Trusting a 95 % interval from few paths of a lopsided result. With 100 paths the interval of the final value held the truth in only 89.9 % of 1,000 runs.
- Using too few paths for a rare event. A chance of 1 % from 10,000 paths is uncertain by 9.9 % of its size.
- Counting an antithetic pair as two independent paths. The two halves are correlated, so each pair is one value, and the standard error comes from the pair values.
Questions
Recap
- A Monte Carlo simulation runs a model many times with random numbers and reads every answer off the paths.
- The estimate θM is the average of the M path results, and it is right on average.
- Its standard error is σX/√M, estimated by s/√M: four times the paths halve it, a hundred times make it ten times smaller.
- θM ± 1.96 SE holds the truth in about 95 % of runs, once there are enough paths for the result at hand.
- A chance is the average of a 0/1 flag, with the standard error √(p(1 − p)/M), and rare events need many paths.
- A percentile is uncertain by the error of a share divided by the density there, √(π/2) σ√T/√M for the median of the log.
- A fixed seed makes a run repeatable, and independent runs check the standard error.
- Antithetic pairs shrink the error by √(1 + ρ), which helps when the result moves steadily with the random numbers.
Exercises
Three short calculations first, with pen, paper and a calculator, then five programs with rising difficulty. Try each one before you open its solution.
1The exact median to check a simulationEasy
A simulation puts $10,000 into the model of step 1 for 10 years, with an average return of 7 % a year, compounded continuously, and swings of 20 % a year. Which median should its paths show?
Show solutionHide solution
1. Growth of the log: 0.07 − 0.20 × 0.20/2 = 0.07 − 0.02 = 0.05 a year
2. Over 10 years: 0.05 × 10 = 0.5
3. Median: 10,000 × e0.5 = 10,000 × 1.648721 = $16,487.21
Half of the simulated paths should end below about $16,487, so a run whose median lands far from it has too few paths or a mistake.
2How precise a simulated average isEasy
A run of 40,000 paths gives an average final value of $52,000, and the final values of single paths have a standard deviation of $80,000. What is the standard error of the average, and what is its 95 % interval?
Show solutionHide solution
1. The square root of 40,000 is 200
2. Standard error: 80,000/200 = $400
3. Half the width of the interval: 1.96 × 400 = $784
4. Interval: 52,000 − 784 to 52,000 + 784 = $51,216 to $52,784
Although single paths scatter by $80,000, the average of 40,000 of them is pinned down to within about $800.
3How precise a simulated median isMedium
A run of 2,500 paths of the model of step 1 over 25 years, with swings of 20 % a year, reports a median. By about how many percent is this median uncertain?
Show solutionHide solution
1. Spread of the log after 25 years: 0.20 times the square root of 25 = 0.20 × 5 = 1.0
2. Divided by the square root of 2,500: 1.0/50 = 0.02
3. Half of pi: 3.141593/2 = 1.570796, and its square root is 1.253314
4. Error of the log of the median: 1.253314 × 0.02 = 0.0251, about 2.5 %
The median of 2,500 paths is typically off by about 2.5 % of its size, so a gap of 1 % from the exact median is no sign of a mistake.
In Python
Each file runs as it is and prints that it is not solved yet. Fill in the lines marked TODO, run the file again and compare your numbers with the solution.
1A chance from thrown diceEasy
Throw two simulated dice M times and estimate the chance that their sum is at least 10, which is exactly 6/36 = 1/6. Write share_and_se(flags), run it for M = 100, 10,000 and 1,000,000, and count how many standard errors each estimate lies from 1/6.
- M=the number of paths
Exercise 1, a chance from thrown dice.
# QuantFinanceToGo, module 16: Monte Carlo Simulation. Exercise 1 (easy).
# Runs on its own with Python 3 and numpy. Fill in the lines marked TODO.
#
# Throw two dice M times and estimate the chance that their sum is at least
# 10. The exact chance is 6/36 = 1/6: (4,6), (5,5), (6,4), (5,6), (6,5), (6,6).
# a) Write share_and_se(flags): the share of True values and its standard
# error sqrt(p (1 - p) / M), where M is the number of flags.
# b) Run it for M = 100, 10,000 and 1,000,000 throws.
# c) How far is each estimate from 1/6, counted in standard errors?
import math
import numpy as np
rng = np.random.default_rng(1) # fixed seed
def share_and_se(flags):
# TODO: return (share, standard error) as two floats
return None
if share_and_se(np.array([True, False])) is None:
print("Exercise 1 is not solved yet. Fill in the lines marked TODO.")
else:
for m in (100, 10000, 1000000):
dice = rng.integers(1, 7, size=(m, 2)) # whole numbers 1 to 6
share, se = share_and_se(dice.sum(axis=1) >= 10)
print(f"M = {m:>9,}: estimate {share:.4%} SE {se:.4%} "
f"off by {(share - 1 / 6) / se:+.2f} SE")
Show solutionHide solution
The estimates are 18.0000 % with an SE of 3.8419 %, 16.4400 % with 0.3706 % and 16.7142 % with 0.0373 %. They lie +0.35, −0.61 and +1.27 standard errors from the exact 16.67 %, all well inside two. Each step of a hundred times more throws makes the error ten times smaller.
Solution to exercise 1.
# QuantFinanceToGo, module 16: Monte Carlo Simulation. Solution to exercise 1.
# Runs on its own with Python 3 and numpy.
import math
import numpy as np
rng = np.random.default_rng(1) # fixed seed
def share_and_se(flags):
# a share is the average of 0/1 flags, so its SE is sqrt(p (1 - p) / M)
flags = np.asarray(flags, dtype=float)
p = float(flags.mean())
return p, math.sqrt(p * (1 - p) / flags.size)
for m in (100, 10000, 1000000):
dice = rng.integers(1, 7, size=(m, 2)) # whole numbers 1 to 6
share, se = share_and_se(dice.sum(axis=1) >= 10)
print(f"M = {m:>9,}: estimate {share:.4%} SE {se:.4%} "
f"off by {(share - 1 / 6) / se:+.2f} SE")
M = 100: estimate 18.0000% SE 3.8419% off by +0.35 SE M = 10,000: estimate 16.4400% SE 0.3706% off by -0.61 SE M = 1,000,000: estimate 16.7142% SE 0.0373% off by +1.27 SE
2Seeds and repeated runsEasy
Write median_of_run(rng, paths), which simulates final values of the model of step 1 and returns their median. Run it with two generators that share the seed 2, then use one of them again, and finally make 100 runs of 1,000 paths and compare the spread of the logs of their medians with step 7.
Exercise 2, seeds and repeated runs.
# QuantFinanceToGo, module 16: Monte Carlo Simulation. Exercise 2 (easy).
# Runs on its own with Python 3 and numpy. Fill in the lines marked TODO.
#
# The model: $10,000 over 30 years, yearly log returns from a bell
# with the mean g = 0.08 - 0.2^2 / 2 = 0.06 and the sd 0.2.
# a) Write median_of_run(rng, paths): simulate paths final values with the
# generator rng and return their median.
# b) Two generators with the same seed: do they give the same median?
# c) One generator used again gives a new run. Make 100 runs of 1,000 paths
# and compare the spread of the logs of their medians with the theory
# sqrt(pi / 2) * 0.2 * sqrt(30) / sqrt(1000).
import math
import numpy as np
start, years, g, sigma = 10000, 30, 0.06, 0.2
rng_a = np.random.default_rng(2) # two generators, same seed
rng_b = np.random.default_rng(2)
def median_of_run(rng, paths):
# TODO: draw (paths, years) log returns, add them up per path,
# turn them into final values and return their median
return None
if median_of_run(np.random.default_rng(0), 10) is None:
print("Exercise 2 is not solved yet. Fill in the lines marked TODO.")
else:
first, twin = median_of_run(rng_a, 1000), median_of_run(rng_b, 1000)
print(f"seed 2 in two generators: ${first:,.2f} and ${twin:,.2f}")
print(f"the first generator again: ${median_of_run(rng_a, 1000):,.2f}")
logs = np.log([median_of_run(rng_a, 1000) for _ in range(100)])
theory = math.sqrt(math.pi / 2) * sigma * math.sqrt(years) / math.sqrt(1000)
print(f"100 runs of 1,000 paths: medians from ${math.exp(logs.min()):,.0f} "
f"to ${math.exp(logs.max()):,.0f}")
print(f"spread of their logs {logs.std(ddof=1):.2%} theory {theory:.2%}")
Show solutionHide solution
Both generators with the seed 2 give $63,401.51, exactly the same. The first generator used again gives $58,902.18, a new and independent run. The 100 runs of 1,000 paths have medians from $52,655 to $66,796, and the logs of their medians spread by 4.13 %, close to the 4.34 % of step 7.
Solution to exercise 2.
# QuantFinanceToGo, module 16: Monte Carlo Simulation. Solution to exercise 2.
# Runs on its own with Python 3 and numpy.
import math
import numpy as np
start, years, g, sigma = 10000, 30, 0.06, 0.2
rng_a = np.random.default_rng(2) # two generators, same seed
rng_b = np.random.default_rng(2)
def median_of_run(rng, paths):
# every call takes the next random numbers of rng: a new, independent run
log_ret = rng.normal(g, sigma, (paths, years))
final = start * np.exp(log_ret.sum(axis=1))
return float(np.median(final))
first, twin = median_of_run(rng_a, 1000), median_of_run(rng_b, 1000)
print(f"seed 2 in two generators: ${first:,.2f} and ${twin:,.2f}")
print(f"the first generator again: ${median_of_run(rng_a, 1000):,.2f}")
logs = np.log([median_of_run(rng_a, 1000) for _ in range(100)])
theory = math.sqrt(math.pi / 2) * sigma * math.sqrt(years) / math.sqrt(1000)
print(f"100 runs of 1,000 paths: medians from ${math.exp(logs.min()):,.0f} "
f"to ${math.exp(logs.max()):,.0f}")
print(f"spread of their logs {logs.std(ddof=1):.2%} theory {theory:.2%}")
seed 2 in two generators: $63,401.51 and $63,401.51 the first generator again: $58,902.18 100 runs of 1,000 paths: medians from $52,655 to $66,796 spread of their logs 4.13% theory 4.34%
3Percentiles and the chance of a loss over timeMedium
Simulate 10,000 paths of the model of step 1 with the seed 3. Write exact_percentile(t, u) and exact_loss(t) from step 3, and compare the 10th percentile, the median and the chance of a loss after 1, 5, 10, 20 and 30 years with the simulation. Then compare the ratio of the 90th to the 10th percentile.
Exercise 3, percentiles and the chance of a loss over time.
# QuantFinanceToGo, module 16: Monte Carlo Simulation. Exercise 3 (medium).
# Runs on its own with Python 3 and numpy. Fill in the lines marked TODO.
#
# 10,000 simulated paths of $10,000 (8 % a year, swings of 20 %), read after
# 1, 5, 10, 20 and 30 years.
# a) Write exact_percentile(t, u): after t years ln(W / W0) follows a bell
# with the mean g t and the sd sigma sqrt(t), so the percentile at the
# share u is W0 exp(g t + z sigma sqrt(t)), z = NormalDist().inv_cdf(u).
# b) Write exact_loss(t): the chance to end below W0, which is
# NormalDist().cdf(-g t / (sigma sqrt(t))).
# c) Compare both with the simulated paths, and watch the ratio of the 90th
# to the 10th percentile grow with the square root of time.
import math
from statistics import NormalDist
import numpy as np
start, years, paths, g, sigma = 10000, 30, 10000, 0.06, 0.2
rng = np.random.default_rng(3) # fixed seed
value = start * np.exp(rng.normal(g, sigma, (paths, years)).cumsum(axis=1))
def exact_percentile(t, u):
# TODO: start * exp(g t + z sigma sqrt(t)) with z of the bell at u
return None
def exact_loss(t):
# TODO: the bell's share below -g t / (sigma sqrt(t))
return None
if exact_percentile(1, 0.5) is None or exact_loss(1) is None:
print("Exercise 3 is not solved yet. Fill in the lines marked TODO.")
else:
print(" t 10th sim / exact median sim / exact loss sim / exact")
for t in (1, 5, 10, 20, 30):
lo, mid = np.percentile(value[:, t - 1], [10, 50])
print(f"{t:>2} {lo:>8,.0f} / {exact_percentile(t, 0.1):>8,.0f}"
f" {mid:>8,.0f} / {exact_percentile(t, 0.5):>8,.0f}"
f" {(value[:, t - 1] < start).mean():6.2%} / {exact_loss(t):6.2%}")
for t in (1, 4, 9, 16, 30):
hi, lo = np.percentile(value[:, t - 1], [90, 10])
print(f"t = {t:>2}: 90th / 10th = {hi / lo:5.2f} exact "
f"{exact_percentile(t, 0.9) / exact_percentile(t, 0.1):5.2f}")
Show solutionHide solution
After 30 years the simulation gives a 10th percentile of $15,414 against the exact $14,861, a median of $61,034 against $60,496 and a chance of a loss of 4.66 % against 5.02 %. The chance of a loss falls from 37.56 % after one year to 4.66 % after 30, close to the exact 38.21 % and 5.02 %. The ratio of the 90th to the 10th percentile grows from 1.68 after one year to 16.42 after 30, against the exact 1.67 and 16.57, since the width of the log grows with √t.
- t=the number of a year
Solution to exercise 3.
# QuantFinanceToGo, module 16: Monte Carlo Simulation. Solution to exercise 3.
# Runs on its own with Python 3 and numpy.
import math
from statistics import NormalDist
import numpy as np
start, years, paths, g, sigma = 10000, 30, 10000, 0.06, 0.2
rng = np.random.default_rng(3) # fixed seed
value = start * np.exp(rng.normal(g, sigma, (paths, years)).cumsum(axis=1))
def exact_percentile(t, u):
# the log grows by g t on average and spreads by sigma sqrt(t)
z = NormalDist().inv_cdf(u)
return start * math.exp(g * t + z * sigma * math.sqrt(t))
def exact_loss(t):
# below the start means ln(W / W0) below 0
return NormalDist().cdf(-g * t / (sigma * math.sqrt(t)))
print(" t 10th sim / exact median sim / exact loss sim / exact")
for t in (1, 5, 10, 20, 30):
lo, mid = np.percentile(value[:, t - 1], [10, 50])
print(f"{t:>2} {lo:>8,.0f} / {exact_percentile(t, 0.1):>8,.0f}"
f" {mid:>8,.0f} / {exact_percentile(t, 0.5):>8,.0f}"
f" {(value[:, t - 1] < start).mean():6.2%} / {exact_loss(t):6.2%}")
for t in (1, 4, 9, 16, 30):
hi, lo = np.percentile(value[:, t - 1], [90, 10])
print(f"t = {t:>2}: 90th / 10th = {hi / lo:5.2f} exact "
f"{exact_percentile(t, 0.9) / exact_percentile(t, 0.1):5.2f}")
t 10th sim / exact median sim / exact loss sim / exact 1 8,214 / 8,218 10,654 / 10,618 37.56% / 38.21% 5 7,682 / 7,610 13,560 / 13,499 24.83% / 25.12% 10 8,282 / 8,102 18,161 / 18,221 17.03% / 17.14% 20 10,725 / 10,552 33,295 / 33,201 8.60% / 8.99% 30 15,414 / 14,861 61,034 / 60,496 4.66% / 5.02% t = 1: 90th / 10th = 1.68 exact 1.67 t = 4: 90th / 10th = 2.81 exact 2.79 t = 9: 90th / 10th = 4.63 exact 4.65 t = 16: 90th / 10th = 7.66 exact 7.77 t = 30: 90th / 10th = 16.42 exact 16.57
4How often a 95 % interval holdsMedium
Write interval(values), the average plus and minus 1.96 standard errors. Repeat a simulation of M paths 1,000 times, for M = 100 and 1,000, and count how often the interval holds the exact average of the final value, 10,000 e2.4, and how often the interval of the log holds the exact 1.8.
- M=the number of paths
Exercise 4, how often a 95 % interval holds.
# QuantFinanceToGo, module 16: Monte Carlo Simulation. Exercise 4 (medium).
# Runs on its own with Python 3 and numpy. Fill in the lines marked TODO.
#
# How often does a 95 % interval hold the true value? In the model of step 1,
# ln(W / W0) after 30 years follows a bell with the mean 1.8 and the
# sd 0.2 sqrt(30), so one draw per path is enough. The exact average final
# value is 10,000 e^(0.08 * 30).
# a) Write interval(values): the average plus and minus 1.96 standard
# errors, with the standard error s / sqrt(M) and s computed with M - 1.
# b) Repeat a simulation of M paths 1,000 times, for M = 100 and 1,000, and
# count how often the interval holds the exact average of the final value
# and how often the interval of ln(W / W0) holds the exact 1.8.
import math
import numpy as np
rng = np.random.default_rng(4) # fixed seed
start, mean_log, sd_log = 10000, 1.8, 0.2 * math.sqrt(30)
exact_final = start * math.exp(0.08 * 30)
def interval(values):
# TODO: return (low, high), the average -/+ 1.96 standard errors
return None
if interval(np.array([1.0, 2.0, 3.0])) is None:
print("Exercise 4 is not solved yet. Fill in the lines marked TODO.")
else:
for m in (100, 1000):
hit_final = hit_log = 0
for _ in range(1000):
logs = rng.normal(mean_log, sd_log, m)
low, high = interval(start * np.exp(logs))
hit_final += low <= exact_final <= high
low, high = interval(logs)
hit_log += low <= mean_log <= high
print(f"M = {m:>5,}: the interval of the final value holds it in "
f"{hit_final / 1000:.1%}, the interval of the log in "
f"{hit_log / 1000:.1%}")
Show solutionHide solution
With 100 paths the interval of the final value holds the truth in only 89.9 % of the runs, while the interval of the log holds it in 94.2 %. With 1,000 paths the two reach 94.0 % and 94.4 %. The final value is very lopsided, a few huge paths decide its average, and the average of 100 such values is still far from a bell. The log is a bell already, so its interval is right from the start.
Solution to exercise 4.
# QuantFinanceToGo, module 16: Monte Carlo Simulation. Solution to exercise 4.
# Runs on its own with Python 3 and numpy.
import math
import numpy as np
rng = np.random.default_rng(4) # fixed seed
start, mean_log, sd_log = 10000, 1.8, 0.2 * math.sqrt(30)
exact_final = start * math.exp(0.08 * 30)
def interval(values):
# the central limit theorem makes the average about a bell around the truth
values = np.asarray(values, dtype=float)
se = values.std(ddof=1) / math.sqrt(values.size)
return values.mean() - 1.96 * se, values.mean() + 1.96 * se
for m in (100, 1000):
hit_final = hit_log = 0
for _ in range(1000):
logs = rng.normal(mean_log, sd_log, m)
low, high = interval(start * np.exp(logs))
hit_final += low <= exact_final <= high
low, high = interval(logs)
hit_log += low <= mean_log <= high
print(f"M = {m:>5,}: the interval of the final value holds it in "
f"{hit_final / 1000:.1%}, the interval of the log in "
f"{hit_log / 1000:.1%}")
M = 100: the interval of the final value holds it in 89.9%, the interval of the log in 94.2% M = 1,000: the interval of the final value holds it in 94.0%, the interval of the log in 94.4%
5Antithetic pairs against plain drawsHard
Write plain(func, z) and antithetic(func, z), and compare 10,000 plain draws with 5,000 antithetic pairs for the final value, its log and the chance of a loss. Then repeat both 200 times and measure the real spread of each estimate.
Exercise 5, antithetic pairs against plain draws.
# QuantFinanceToGo, module 16: Monte Carlo Simulation. Exercise 5 (hard).
# Runs on its own with Python 3 and numpy. Fill in the lines marked TODO.
#
# Antithetic pairs: every standard bell draw z is used twice, as z and -z.
# In the model of step 1, W / W0 = exp(1.8 + 0.2 sqrt(30) z) after 30 years.
# a) Write plain(func, z): the average of func(z) and its standard error.
# b) Write antithetic(func, z): the average of the pair values
# (func(z) + func(-z)) / 2 and their standard error, one value per pair.
# c) Compare both with the same number of paths, 10,000 plain draws against
# 5,000 pairs, for the final value, its log and the chance of a loss.
# Then repeat both 200 times and measure the real spread of each estimate.
import math
import numpy as np
rng = np.random.default_rng(5) # fixed seed
start, mean_log, sd_log = 10000, 1.8, 0.2 * math.sqrt(30)
tasks = [("final value", ",.0f", lambda z: start * np.exp(mean_log + sd_log * z)),
("log of W / W0", ".4f", lambda z: mean_log + sd_log * z),
("chance of a loss", ".4f", lambda z: (mean_log + sd_log * z < 0) * 1.0)]
def plain(func, z):
# TODO: (average of func(z), its standard error)
return None
def antithetic(func, z):
# TODO: pair values (func(z) + func(-z)) / 2, then (average, standard error)
return None
line = tasks[1][2]
if plain(line, np.ones(4)) is None or antithetic(line, np.ones(4)) is None:
print("Exercise 5 is not solved yet. Fill in the lines marked TODO.")
else:
for name, spec, func in tasks:
a, se_a = plain(func, rng.standard_normal(10000))
b, se_b = antithetic(func, rng.standard_normal(5000))
print(f"{name:<17} plain {a:{spec}} (SE {se_a:{spec}}) antithetic "
f"{b:{spec}} (SE {se_b:{spec}})")
for name, spec, func in tasks:
runs_a = [plain(func, rng.standard_normal(10000))[0] for _ in range(200)]
runs_b = [antithetic(func, rng.standard_normal(5000))[0] for _ in range(200)]
print(f"{name:<17} spread of 200 runs: plain {np.std(runs_a, ddof=1):{spec}}"
f" antithetic {np.std(runs_b, ddof=1):{spec}}")
Show solutionHide solution
For the final value the pairs report an SE of $1,406 against $1,674, the factor 0.84 of step 9, and over 200 runs they really spread by $1,355 against $1,717. For the log the pairs give exactly 1.8000 every time, since a straight line cancels in each pair. For the chance of a loss both report an SE of 0.0022, and over 200 runs the pairs spread by 0.0024 against 0.0023 for plain draws, no gain at all. A flag of a rare event barely moves with most draws, so mirrored draws hardly help it.
Solution to exercise 5.
# QuantFinanceToGo, module 16: Monte Carlo Simulation. Solution to exercise 5.
# Runs on its own with Python 3 and numpy.
import math
import numpy as np
rng = np.random.default_rng(5) # fixed seed
start, mean_log, sd_log = 10000, 1.8, 0.2 * math.sqrt(30)
tasks = [("final value", ",.0f", lambda z: start * np.exp(mean_log + sd_log * z)),
("log of W / W0", ".4f", lambda z: mean_log + sd_log * z),
("chance of a loss", ".4f", lambda z: (mean_log + sd_log * z < 0) * 1.0)]
def plain(func, z):
# one value per draw, SE = s / sqrt(M)
x = func(z)
return float(x.mean()), float(x.std(ddof=1) / math.sqrt(x.size))
def antithetic(func, z):
# the two halves of a pair are not independent, so the pair is one value
x = (func(z) + func(-z)) / 2
return float(x.mean()), float(x.std(ddof=1) / math.sqrt(x.size))
for name, spec, func in tasks:
a, se_a = plain(func, rng.standard_normal(10000))
b, se_b = antithetic(func, rng.standard_normal(5000))
print(f"{name:<17} plain {a:{spec}} (SE {se_a:{spec}}) antithetic "
f"{b:{spec}} (SE {se_b:{spec}})")
for name, spec, func in tasks:
runs_a = [plain(func, rng.standard_normal(10000))[0] for _ in range(200)]
runs_b = [antithetic(func, rng.standard_normal(5000))[0] for _ in range(200)]
print(f"{name:<17} spread of 200 runs: plain {np.std(runs_a, ddof=1):{spec}}"
f" antithetic {np.std(runs_b, ddof=1):{spec}}")
final value plain 113,128 (SE 1,674) antithetic 110,737 (SE 1,406) log of W / W0 plain 1.8020 (SE 0.0109) antithetic 1.8000 (SE 0.0000) chance of a loss plain 0.0498 (SE 0.0022) antithetic 0.0532 (SE 0.0022) final value spread of 200 runs: plain 1,717 antithetic 1,355 log of W / W0 spread of 200 runs: plain 0.0110 antithetic 0.0000 chance of a loss spread of 200 runs: plain 0.0023 antithetic 0.0024
Interview prep
Questions on this module in the style of quant interviews, written in my own words and ordered from easy to hard. Write down your own answer first, then open the worked answer and compare.
EasyQ1A Monte Carlo estimate from 10,000 paths has a standard error of 0.4. How many paths do you need for a standard error of 0.1?
Show answerHide answer
You need 160,000 paths. SE = σX/√M, so an error four times smaller needs √M four times larger and M sixteen times larger: 16 × 10,000 = 160,000.
- SE=the standard error of the estimate
- σX=the spread of one path result
- M=the number of paths
EasyQ2Why does a simulation fix its seed?
Show answerHide answer
A fixed seed makes every run give the same numbers, so anyone can repeat and check them. With the same seed the program draws the same random numbers and the same paths. To see how much a result depends on luck, run it again with other seeds, chosen before looking at the results.
MediumQ3How can you estimate π with random numbers, and how precise is the estimate from a million points?
Show answerHide answer
Count the share of random points of the unit square that fall inside the quarter circle, and multiply by 4. The quarter circle has the area π/4, so the share estimates p = π/4 = 0.7854. Its standard error is √(p(1 − p)/M), and 4 times that is 1.6422/√M. A million points give π to about 0.0016.
- π=the number pi, about 3.1416
- p=the true chance
- M=the number of paths
MediumQ4A run of 10,000 paths shows an average final value far above the median. How can that be?
Show answerHide answer
A few very lucky paths pull the average up, while the median only looks at the middle. Gains and losses multiply, so the final values are lopsided with a long tail of very high values. In the simulation of Figure 1 the average is $111,469 and the median $60,814, and 70.97 % of the paths end below the average.
MediumQ5A simulation of 10,000 paths finds a 1 % chance. How precise is that, and how many paths would give it to 1 % of its size?
Show answerHide answer
The estimate is uncertain by about 10 % of its size, and 990,000 paths would bring that down to 1 %. SE = √(0.01 × 0.99/10,000) = 0.000995, which is 9.95 % of 0.01. The relative error is √((1 − p)/(pM)), so 1 % needs M = 0.99/(0.01 × 0.012) = 990,000.
- SE=the standard error of the estimate
- p=the true chance
- M=the number of paths
HardQ6Derive the standard error of a Monte Carlo average.
Show answerHide answer
Independent paths add their variances, and the average divides the variance by M squared. The sum ΣXm of M independent path results has the variance MσX2, and dividing the sum by M divides the variance by M2. That leaves σX2/M, and its square root σX/√M is the standard error. In practice σX is replaced by the measured s.
- σX=the spread of one path result
- M=the number of paths
HardQ7When do antithetic pairs help, and by how much for the final value of $10,000 after 30 years at 8 % with swings of 20 % a year?
Show answerHide answer
They help when the result moves steadily with the random draws, and for the final value they cut the error by about 16 %. A pair has the variance σX2(1 + ρ)/2, so M paths as pairs have the error σX√(1 + ρ)/√M. For ea + cZ the correlation with its mirror is −e−c2 = −e−1.2 = −0.301, and √(1 − 0.301) = 0.836. For a flag of a rare event ρ is close to 0 and the pairs gain almost nothing.
- σX=the spread of one path result
- ρ=the correlation of the two halves of a pair
- M=the number of paths
- e=the number e, about 2.718, the base of the natural logarithm
- c=the standard deviation of Y
HardQ8A simulation says the chance of a loss is 5.00 % with a standard error of 0.22 %. What does the interval from 4.57 % to 5.43 % cover, and what does it leave out?
Show answerHide answer
It covers the luck of the random draws and leaves out every error of the model. Within the model, about 95 % of such intervals hold the true chance, and this one holds the exact 5.02 %. It assumes yearly returns from a bell with a known average of 8 % and a known volatility of 20 %. If real returns have fatter tails or the inputs are wrong, the true chance can lie far outside, and no number of paths shows that.
Mini projectA Monte Carlo report over five horizons
A small project that grows out of the code of this module. The starter file simulates 10,000 paths of $10,000 in the model of step 1 with the seed 1, and prints the 10th percentile, the median, the 90th percentile, the chance of a loss and the average with its standard error after 1, 5, 10, 20 and 30 years. Grow it step by step.
- 1Move the report into your engine as a method that takes the paths and a list of horizons.
- 2Add a standard error to every percentile with the batch method, and add the 95 % interval of every chance.
- 3Add a yearly saving of a fixed amount, paid in at the end of each year, and compare the report with and without it.
- 4Add antithetic pairs and report how much they shrink each standard error.
- 5Write a short text that explains every column, and say which numbers depend most on the assumed 8 % and 20 %.
Project starter, a Monte Carlo report over five horizons.
# QuantFinanceToGo, module 16: Monte Carlo Simulation. Project starter: a report.
# Runs on its own with Python 3 and numpy. Every path in it is simulated.
# The lesson lists the next steps to grow this file into your own project.
import math
import numpy as np
rng = np.random.default_rng(1) # fixed seed
start, years, paths = 10000, 30, 10000
mu, sigma = 0.08, 0.20 # assumed, per year
log_ret = rng.normal(mu - sigma ** 2 / 2, sigma, (paths, years))
value = start * np.exp(log_ret.cumsum(axis=1)) # one row per path
def horizon_report(t):
final = value[:, t - 1]
low, mid, high = np.percentile(final, [10, 50, 90])
se_mean = final.std(ddof=1) / math.sqrt(paths)
loss = (final < start).mean()
print(f"{t:>5} {low:>9,.0f} {mid:>9,.0f} {high:>10,.0f} {loss:>7.2%}"
f" {final.mean():>10,.0f} {se_mean:>7,.0f}")
print(f"Monte Carlo report: ${start:,}, {paths:,} paths, average {mu:.0%} "
f"and volatility {sigma:.0%} a year")
print(f"{'years':>5} {'10th':>9} {'median':>9} {'90th':>10} {'loss':>7}"
f" {'average':>10} {'SE':>7}")
for t in (1, 5, 10, 20, 30):
horizon_report(t)
Monte Carlo report: $10,000, 10,000 paths, average 8% and volatility 20% a year
years 10th median 90th loss average SE
1 8,238 10,582 13,726 38.41% 10,817 22
5 7,577 13,438 23,811 25.75% 14,823 71
10 8,045 18,090 40,019 16.98% 21,906 149
20 10,331 32,918 103,043 9.33% 48,449 536
30 14,704 58,702 244,354 5.24% 108,005 1,588What to show
- The code and the printed report, with every number explained in one sentence.
- Why the 30-year row repeats the seed 1 run of part 7 of the full code: a median of $58,702, an average of $108,005 and a chance of a loss of 5.24 %.
- Why the chance of a loss falls from 38.41 % after one year to 5.24 % after 30, while the standard error of the average grows from $22 to $1,588.
Honest limits
- All paths are simulated from assumed numbers, so the report forecasts nothing about any real investment.
- The model has a bell shape, independent years and a fixed average and volatility. Real returns have fat tails and calm and wild phases.
- Costs, taxes and inflation are left out.
All files of this lesson
Every file runs on its own with Python 3 and numpy. None of them reads other files or asks for input, and every simulation uses a fixed seed.
- monte_carlo_basic.pyPython implementation: 10,000 simulated paths, their average and its standard error.
- example.pyThe full code behind this page: every number of this lesson.
- exercise_1.pyExercise 1, a chance from thrown dice.
- solution_1.pySolution to exercise 1.
- exercise_2.pyExercise 2, seeds and repeated runs.
- solution_2.pySolution to exercise 2.
- exercise_3.pyExercise 3, percentiles and the chance of a loss over time.
- solution_3.pySolution to exercise 3.
- exercise_4.pyExercise 4, how often a 95 % interval holds.
- solution_4.pySolution to exercise 4.
- exercise_5.pyExercise 5, antithetic pairs against plain draws.
- solution_5.pySolution to exercise 5.
- project_starter.pyProject starter, a Monte Carlo report over five horizons.