Key Highlights:
- Geometric Brownian Motion (GBM) is the canonical continuous-time model for asset prices.
- GBM powers the Black–Scholes framework and Monte-Carlo risk engines.
- It entails most of the intuition practitioners carry about “what could the price do?”.
- The framework serves as fundamental bedrock of mathematical finance — advanced applications layer additional features on top of it.
python
# Setup
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
np.random.seed(42) # reproducibility (standard arbitrary number)
plt.rcParams["figure.dpi"] = 110
# ---- house style ---------------------------------------------------------
BG = "#F4F2E8" # Pale Sisal
INK = "#151515" # Anthracite
AQUA = "#1FFFFF" # accent
GREY = "#7A7A7A"
HEAD = {"fontname": "Switzer", "fontsize": 11, "fontweight": "bold", "color": INK}
plt.rcParams.update({
"font.family": "monospace",
"font.monospace": ["Courier Prime", "Courier New", "DejaVu Sans Mono"],
"font.size": 8,
"axes.titlesize": 11, "axes.labelsize": 8,
"xtick.labelsize": 7.5, "ytick.labelsize": 7.5, "legend.fontsize": 7.5,
"figure.facecolor": BG, "axes.facecolor": BG, "savefig.facecolor": BG,
"axes.edgecolor": GREY, "axes.labelcolor": GREY,
"xtick.color": GREY, "ytick.color": GREY,
"axes.spines.top": False, "axes.spines.right": False,
"axes.grid": True, "grid.alpha": 0.3, "grid.color": "#CCCCCC", "grid.linewidth": 0.5,
"legend.frameon": False,
"lines.linewidth": 2.0, "lines.markersize": 0,
})1.Introduction
A few simple facts to get up to speed:
- GBM assumes that percentage returns — not price changes — are random, normally distributed, and independent over time.
- Prices therefore stay positive and their terminal distribution is log-normal.
- 2 parameters fully describe the model — both estimable directly from historical data:
– the drift (expected annual log-growth plus half-variance)
– the volatility (annualised standard deviation of log returns)
2.Intuition
The logic before diving into equations:
- Returns compound, prices don't add
– A $10 move means something very different at SPY = 100 than at SPY = 600.
– What is comparable across price levels is the relative move.
– GBM makes randomness proportional to the current price: . - Why log-normal
– If each day multiplies the price by a small random gross return, the log-price is a sum of many small independent shocks.
– Sums of independent shocks are (approximately) normal.
– Normal log-price log-normal price: skewed right, floored at zero. - Drift vs. noise
– Over short horizons the noise term dominates: shrinks slower than as .
– Over long horizons drift wins.
– This is the same rule that governs plain Brownian motion — GBM simply wraps it in an exponential.
3.Theory
Below the theoretical foundation and respective equations:
- The Stochastic Differential Equation (SDE) and its solution
– A differential equation with a random term — so it yields a distribution of paths, not only one.
– is the drift (deterministic trend); is the diffusion (the noise). - Applying Itô's lemma to
– The correction comes from . - Exact discretisation
– Because the solution is closed-form, we can simulate without discretisation error at any step size .
The key implementations of those small functions: one draws per-step gross returns, the other compounds them into price paths.
python
# Implementation
def gbm_returns(mu, sigma, dt, n_steps, n_paths):
"""One-step gross returns under GBM (shape: n_steps x n_paths)."""
z = np.random.normal(size=(n_steps, n_paths))
return np.exp((mu - 0.5 * sigma**2) * dt + sigma * np.sqrt(dt) * z)
def gbm_paths(s0, mu, sigma, dt, n_steps, n_paths):
"""Price paths of shape (n_steps + 1, n_paths), starting at s0."""
rets = gbm_returns(mu, sigma, dt, n_steps, n_paths)
return s0 * np.vstack([np.ones(rets.shape[1]), rets]).cumprod(axis=0)4.Application
- Calibrate and from real data
– We pull daily SPY prices, estimate annualised drift and volatility from log returns, and let the data drive the simulation.
python
TICKER = "SPY"
START, END = "2018-01-01", "2024-12-31"
import yfinance as yf
px = (yf.download(TICKER, start=START, end=END, auto_adjust=True, progress=False)["Close"]
.squeeze().rename(TICKER).dropna())
log_ret = np.log(px / px.shift(1)).dropna()
s0 = float(px.iloc[-1])
sigma_hat = float(log_ret.std() * np.sqrt(252))
mu_hat = float(log_ret.mean() * 252 + 0.5 * sigma_hat**2) # drift of the SDE, not of log returns
print(f"{TICKER}: {len(px)} daily observations, last close = {s0:,.2f}")
print(f"mu_hat = {mu_hat:.2%} (annualised drift)")
print(f"sigma_hat = {sigma_hat:.2%} (annualised volatility)")output
SPY: 1760 daily observations, last close = 578.32
mu_hat = 14.75% (annualised drift)
sigma_hat = 19.54% (annualised volatility)- Simulate 1,000 5-year paths
– Grey lines are individual paths, the dashed line is the mean of the simulated distribution, and the band spans the 5th–95th percentile.
python
DT = 1 / 252
HORIZON = 252 * 5 # 5 years
N_PATHS = 1_000
paths = gbm_paths(s0, mu_hat, sigma_hat, DT, HORIZON, N_PATHS)
t_ax = np.arange(paths.shape[0]) / 252
p5, p95 = np.percentile(paths, [5, 95], axis=1)
fig, ax = plt.subplots(figsize=(10, 5))
ax.fill_between(t_ax, p5, p95, color=AQUA, alpha=0.35, lw=0, zorder=1,
label="5\u201395% band")
ax.plot(t_ax, paths[:, :200], color="#BBBBBB", lw=0.35, alpha=0.45, zorder=2)
ax.plot(t_ax, paths.mean(axis=1), color=INK, lw=2.2, ls="--", zorder=3,
label="Mean path")
ax.set_title(f"{TICKER}: {N_PATHS:,} GBM paths, 5 years "
f"(mu={mu_hat:.1%}, sigma={sigma_hat:.1%})", **HEAD)
ax.set_xlabel("Years"); ax.set_ylabel("Simulated price"); ax.legend()
plt.tight_layout(); plt.show()
- Terminal distribution — is it log-normal?
– Verifies the code: the simulated histogram should match the closed-form density — if it doesn't, the ½σ² term is usually the culprit.
– Proves the claim: section 2 asserts prices end up log-normal; this is where you see the right skew rather than take it on faith.
– The practical lesson: the mean sits above the median, so the “average” projected outcome is not the typical one — most paths land below it.
python
from scipy import stats
T = HORIZON / 252
s_T = paths[-1]
x = np.linspace(s_T.min(), s_T.max(), 400)
pdf = stats.lognorm.pdf(x, s=sigma_hat*np.sqrt(T),
scale=s0*np.exp((mu_hat - 0.5*sigma_hat**2)*T))
fig, ax = plt.subplots(figsize=(10, 4))
ax.hist(s_T, bins=60, density=True, color=AQUA, alpha=0.55,
edgecolor=INK, linewidth=0.25, label="Simulated $S_T$")
ax.plot(x, pdf, color=INK, lw=2.2, label="Theoretical log-normal")
ax.axvline(s_T.mean(), color=INK, lw=1.8, ls="--", label=f"Mean {s_T.mean():,.0f}")
ax.axvline(np.median(s_T), color=INK, lw=1.8, label=f"Median {np.median(s_T):,.0f}")
ax.set_title(f"{TICKER}: terminal price distribution after {T:.0f} years", **HEAD)
ax.set_xlabel("Price"); ax.legend()
plt.tight_layout(); plt.show()- Sanity check: switch the drift off
– With the process becomes a martingale — no expected gain — so the mean terminal price must land back at .
– Zeroing one parameter isolates the rest: this catches a missing correction that a full simulation might hide.
– How to read the result: the sample mean wobbles around with standard error . Within ~2 SE is noise; a persistent gap in one direction that doesn't shrink as grows is a bug.
python
paths_nd = gbm_paths(s0, 0.0, sigma_hat, DT, HORIZON, N_PATHS)
print(f"Mean terminal price (no drift): {paths_nd[-1].mean():,.2f} vs S0 = {s0:,.2f}")output
Mean terminal price (no drift): 588.06 vs S0 = 578.325.Conclusion
Strengths
- Analytically tractable — closed-form solutions (Black–Scholes) make it the natural baseline
- Positive prices, log-normal terminal distribution — matches the basic stylised fact that prices can't go negative
- Only two parameters — both estimable directly from historical log returns
- Cheap to simulate — vectorised NumPy generates millions of paths in seconds
Weaknesses & Limitations
- Constant volatility — realilty has vol clusters and spikes
- No jumps — crashes like March 2020 are far outside its reach
- Normal log-returns — real returns have fat tails
- Independent increments — momentum and mean-reversion exist
Applications in Practice
- Baseline for option pricing and Monte-Carlo risk engines
- Scenario cones for wealth projections
- Base model against which fancier models must justify their complexity
Alternatives & Extensions
- Stochastic volatility — Heston
- Jump-diffusion — Merton
- GARCH-family models — for clustered volatility
