OS-004 · Monte Carlo

Monte Carlo Option Pricing Simulation

The fair price of a European option is the expected payoff at expiry, discounted, with the expectation taken under the risk-neutral model. Monte Carlo pricing simulates that expectation directly — here in a case where the Black–Scholes formula gives the exact answer, so every simulation error is visible.

Interactive model

OS-004 · option-pricing · seeded
in the moneyout of the moneypayoff (right scale)strike K
running price ± 2 SEBlack–Scholes
Parameters
Continuously compounded
Simulated terminal prices
Same seed, same run — here, in simulation.js and in simulation.py.
Monte Carlo price
10.6060
Standard error
0.1468
95% interval, low
10.318
95% interval, high
10.894
Black–Scholes price
10.4506
Error vs Black–Scholes
0.1554
Paths ending in the money
56.8%

Example run: default parameters, seed 42.

Pricing by simulation

Under the risk-neutral measure the stock follows geometric Brownian motion with drift equal to the risk-free rate r. A European option only cares about the price at maturity T, and that price can be sampled exactly in one step, with no time grid:

ST = S0 · exp((r − σ²/2)T + σ√T · Z),   Z ~ N(0, 1)

The price is the discounted mean payoff over n simulated values, max(ST − K, 0) for a call and max(K − ST, 0) for a put:

Ĉ = e−rT · (1/n) · Σ payoff(ST(i)),   SE = e−rT · s / √n

where s is the sample standard deviation of the payoffs. The drift is r, not the stock's expected return: pricing by replication makes the real-world drift irrelevant, one of the central results of Black and Scholes. The histogram shows the lognormal spread of ST; only the orange bars contribute to the price, weighted by the payoff line. Out-of-the-money options have few paths that pay, which is why their relative error is large — raise the strike of a call to see it.

The Black–Scholes check

For this model the expectation has a closed form. With Φ the standard normal distribution function,

d1 = [ln(S0/K) + (r + σ²/2)T] / (σ√T),   d2 = d1 − σ√T
C = S0Φ(d1) − Ke−rTΦ(d2),   P = Ke−rTΦ(−d2) − S0Φ(−d1)

The lab evaluates Φ through the complementary error function (a power series below 2.5 and a continued fraction above, accurate to about 10−15), so the reference price is exact to far more digits than the simulation can reach. The Monte Carlo error then behaves like any sample mean: it shrinks as 1/√n, and the 95% interval should contain the Black–Scholes price for about 19 seeds in 20. Φ(d2) is also the risk-neutral probability that a call finishes in the money (Φ(−d2) for a put), which the last readout estimates.

Nobody needs simulation for a plain European option. The point of checking against the formula is trust: the same code, with the payoff replaced, prices options that have no formula — averages over a path (Asian options), barriers, baskets of several assets — which is why Boyle introduced the method in 1977.

Antithetic variates

If Z is standard normal, so is −Z. The antithetic estimator uses each draw twice, averaging the payoffs at Z and −Z, and treats the pair average as one sample:

Var[(f(Z) + f(−Z))/2] = ½ · (Var f(Z) + Cov(f(Z), f(−Z)))

Compared at equal cost — the same number of simulated prices — it wins whenever that covariance is negative, which holds for any payoff that is monotone in Z, such as a call or a put. With the default parameters and 400,000 simulated prices, it cuts the standard error by about 29% for the call and 23% for the put. For a payoff that is symmetric in Z, such as a straddle near the money, it can do worse than plain sampling. Switch the Variance reduction control and compare the standard error readout at the same seed.

Example result

For an at-the-money call (S₀ = K = 100, r = 5%, σ = 20%, T = 1 year) with 10,000 paths and seed 42, the Monte Carlo price is 10.6060 with a standard error of 0.1468, a 95% interval from 10.318 to 10.894. The Black–Scholes price is 10.4506, so the error is 0.1554, and 56.8% of the simulated prices finish in the money.

Output of option-pricing with option_type=call s0=100 strike=100 rate=0.05 sigma=0.2 maturity=1 paths=10000 variance_reduction=none seed=42
Monte Carlo price10.6060
Standard error0.1468
95% interval, low10.318
95% interval, high10.894
Black–Scholes price10.4506
Error vs Black–Scholes0.1554
Paths ending in the money56.8%

Implementation

The page runs simulation.js from the package below; the Python file is a line-by-line port with the same random-number generator, so a given seed produces the same result in both languages. The excerpts show the core of each.

JavaScript — simulation.js
// Risk-neutral GBM, sampled exactly at maturity: S_T = S₀·exp((r − σ²/2)T + σ√T·Z).
// The price is the discounted mean payoff, e^(−rT)·E[payoff(S_T)].
export const payoff = (type, s, k) => (type === 'put' ? Math.max(k - s, 0) : Math.max(s - k, 0));

export function simulate(st, count, rng, onPrice) {
  const { p, drift, vol } = st;
  const end = Math.min(st.m, st.i + count);
  while (st.i < end) {
    const z = rng.normal();
    const s1 = p.s0 * Math.exp(drift + vol * z);
    let y = payoff(p.option_type, s1, p.strike);
    let prices = [s1];
    if (p.variance_reduction === 'antithetic') {
      // Reuse −Z: the pair average has lower variance when the payoff is monotone in Z.
      const s2 = p.s0 * Math.exp(drift - vol * z);
      y = (y + payoff(p.option_type, s2, p.strike)) / 2;
      prices = [s1, s2];
    }
    for (const s of prices) { st.prices++; if (payoff(p.option_type, s, p.strike) > 0) st.itm++; if (onPrice) onPrice(s); }
    st.i++;
    const d = y - st.mean; // Welford running mean / variance of the (pair-averaged) payoff
    st.mean += d / st.i;
    st.m2 += d * (y - st.mean);
  }
}

export function summarize(st) {
  const price = st.df * st.mean;
  const se = st.i > 1 ? st.df * Math.sqrt(st.m2 / (st.i - 1) / st.i) : null;
  const { p } = st;
  const bs = blackScholes(p.option_type, p.s0, p.strike, p.rate, p.sigma, p.maturity);
  return {
    price,
    std_error: se,
    ci_low: se === null ? null : price - 1.96 * se,
    ci_high: se === null ? null : price + 1.96 * se,
    bs_price: bs,
    error: Math.abs(price - bs),
    itm_fraction: st.prices ? st.itm / st.prices : 0,
    simulated_prices: st.prices,
    covers: se === null ? null : Math.abs(price - bs) <= 1.96 * se,
  };
}
Python — simulation.py
def payoff(kind, s, k):
    return max(k - s, 0) if kind == "put" else max(s - k, 0)


def price_option(p, rng, every=None, rows=None):
    """Discounted mean payoff under risk-neutral GBM, S_T = S0*exp((r - s^2/2)T + s*sqrt(T)*Z)."""
    kind, s0, k = p["option_type"], p["s0"], p["strike"]
    anti = p["variance_reduction"] == "antithetic"
    m = p["paths"] // 2 if anti else p["paths"]
    drift = (p["rate"] - p["sigma"] * p["sigma"] / 2) * p["maturity"]
    vol = p["sigma"] * math.sqrt(p["maturity"])
    df = math.exp(-p["rate"] * p["maturity"])
    mean = m2 = 0.0
    itm = prices = 0
    for i in range(1, m + 1):
        z = rng.normal()
        s1 = s0 * math.exp(drift + vol * z)
        y = payoff(kind, s1, k)
        sims = [s1]
        if anti:  # reuse -Z: lower variance when the payoff is monotone in Z
            s2 = s0 * math.exp(drift - vol * z)
            y = (y + payoff(kind, s2, k)) / 2
            sims = [s1, s2]
        for s in sims:
            prices += 1
            if payoff(kind, s, k) > 0:
                itm += 1
        d = y - mean  # Welford running mean / variance
        mean += d / i
        m2 += d * (y - mean)
        if rows is not None and (i % every == 0 or i == m):
            se = df * math.sqrt(m2 / (i - 1) / i) if i > 1 else ""
            rows.append([prices, df * mean, se])
    se = df * math.sqrt(m2 / (m - 1) / m) if m > 1 else None
    return df * mean, se, itm / prices if prices else 0, prices

Run the full model from a terminal:

Shell
node simulation.js --option-type call --s0 100 --seed 42
python simulation.py --option-type call --s0 100 --seed 42

Download the model

option-pricing.zip6 files · 14.3 KBDownload .zip
Files in option-pricing.zip
FileContentsSize
README.mdHow to run it, parameters, example result2.7 KB
model.jsonParameters with ranges and defaults, outputs, the seed-42 example3.3 KB
simulation.jsThe model used on this page plus a command-line runner (Node 18+)9.5 KB
simulation.pyPython 3.9+ port, standard library only8.4 KB
example.csvResult table from the example run8.5 KB
LICENSE.txtMIT License1.0 KB

Assumptions & limitations

  • Educational model, not investment advice. The page demonstrates a numerical method. It does not value any real contract or recommend any trade.
  • Black–Scholes world. Constant interest rate and volatility, lognormal prices, no dividends, no transaction costs, continuous trading. Real volatility changes over time and differs by strike (the volatility smile), and real returns have fatter tails than the lognormal model.
  • European exercise only. The payoff depends on ST alone. American options, which can be exercised early, need regression-based methods such as Longstaff–Schwartz on top of the simulation.
  • Normal-theory interval. The 95% interval uses the central limit theorem; for deep out-of-the-money options with few paying paths the payoff distribution is very skewed and the interval is less reliable.
  • Pseudo-random normals. Box–Muller draws from a seeded generator; see pseudo-random numbers and seeds.

References

  1. Black, F. & Scholes, M. (1973). The pricing of options and corporate liabilities. Journal of Political Economy, 81(3), 637–654. doi.org/10.1086/260062
  2. Boyle, P. P. (1977). Options: A Monte Carlo approach. Journal of Financial Economics, 4(3), 323–338. doi.org/10.1016/0304-405X(77)90005-8
  3. Glasserman, P. (2003). Monte Carlo Methods in Financial Engineering. Springer.