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.

Difficulty
IncludesPythonMini project

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.

Figure 1. 10,000 simulated futures of $10,000 over 30 years, with an average return of 8 % a year and swings of 20 % a year. The typical future ends at $60,814, one in ten ends below $14,636 and one in ten above $246,456, and 5 % end with a loss.

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.

Figure 2. The range between a bad case and a good case widens as the years go by, because the swings of the years add up.

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.

Figure 3. Even with a good average return, some futures still end below the start. Counting those paths gives the chance of a loss.

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
The standard error is the spread of one result divided by the square root of the number of 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.py
import 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,671

Going 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.

Figure 4. The chance of a loss after t years, exact from the formula above with t in place of T, and from the 10,000 paths of Figure 1: 38.5 %, 16.8 % and 5.0 % after 1, 10 and 30 years, against the exact 38.21 %, 17.14 % and 5.02 %. The mean of the log grows with t and its spread only with √t, so a loss gets rarer the longer the money stays invested.

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.

Figure 5. The average of the first M paths of Figure 1, with the band of 1.96 standard errors to each side from step 5. A single lucky path moves the average by tens of thousands of dollars early on. After 10,000 paths it reads $111,469 against the exact $110,232.

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.

Figure 6. How precisely 10,000 and 1,000,000 paths measure a chance p, as a share of p. The chance of a loss of 5 % is known to 4.4 % of its size, a 1 % event only to 9.9 %, and with 10,000 paths a 0.01 % event hardly at all.

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.

Figure 7. Medians of groups of 100 and of 1,000 paths and of all 10,000, against the exact median of the model. A hundred times as many paths shrink the spread ten times.

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
example.pyComplete fileDownload example.py

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%}")
Output
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.pyComplete fileDownload exercise_1.py

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_1.pyComplete fileDownload solution_1.py

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")
Output
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.pyComplete fileDownload exercise_2.py

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_2.pyComplete fileDownload solution_2.py

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%}")
Output
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.pyComplete fileDownload exercise_3.py

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_3.pyComplete fileDownload solution_3.py

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}")
Output
 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.pyComplete fileDownload exercise_4.py

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_4.pyComplete fileDownload solution_4.py

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%}")
Output
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.pyComplete fileDownload exercise_5.py

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_5.pyComplete fileDownload solution_5.py

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}}")
Output
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.

  1. 1Move the report into your engine as a method that takes the paths and a list of horizons.
  2. 2Add a standard error to every percentile with the batch method, and add the 95 % interval of every chance.
  3. 3Add a yearly saving of a fixed amount, paid in at the end of each year, and compare the report with and without it.
  4. 4Add antithetic pairs and report how much they shrink each standard error.
  5. 5Write a short text that explains every column, and say which numbers depend most on the assumed 8 % and 20 %.
project_starter.pyComplete fileDownload project_starter.py

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)
Output
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,588

What 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.