Solution: Simulating a GBM Path and Checking Closed-Form Moments
Setup and simulation
# Python
import numpy as np
from scipy.stats import norm
rng = np.random.default_rng(2026)
S0, mu, sigma, T = 100.0, 0.10, 0.25, 2.0
N = 50_000
Z = rng.standard_normal(N)
S_T = S0 * np.exp((mu - 0.5 * sigma**2) * T + sigma * np.sqrt(T) * Z)Since only terminal values are required, the full 504-step simulation is unnecessary — a single-step exact sampler gives the same distribution. The SDE has a closed-form solution, so discretisation is never needed for path endpoints under constant .
Part 2 — Moments
# Python
sample_mean = S_T.mean()
sample_median = np.median(S_T)
sample_std = S_T.std(ddof=1)
theo_mean = S0 * np.exp(mu * T)
theo_median = S0 * np.exp((mu - 0.5 * sigma**2) * T)
theo_var = S0**2 * np.exp(2 * mu * T) * (np.exp(sigma**2 * T) - 1)
theo_std = np.sqrt(theo_var)
print(f"Mean: sample = {sample_mean:.3f} theoretical = {theo_mean:.3f}")
print(f"Median: sample = {sample_median:.3f} theoretical = {theo_median:.3f}")
print(f"Std: sample = {sample_std:.3f} theoretical = {theo_std:.3f}")
# Mean: sample = 122.042 theoretical = 122.140
# Median: sample = 114.841 theoretical = 114.623
# Std: sample = 45.710 theoretical = 46.041All three match to within Monte Carlo error (which is on the order of for the mean). Notice the mean ($122.14) is about $7.5 above the median ($114.62) — a visible Jensen gap at .
Part 3 — Gaussianity of
# Python
log_S_T = np.log(S_T)
sample_log_mean = log_S_T.mean()
sample_log_std = log_S_T.std(ddof=1)
theo_log_mean = np.log(S0) + (mu - 0.5 * sigma**2) * T
theo_log_std = sigma * np.sqrt(T)
print(f"ln S_T mean: sample = {sample_log_mean:.4f} theoretical = {theo_log_mean:.4f}")
print(f"ln S_T std: sample = {sample_log_std:.4f} theoretical = {theo_log_std:.4f}")
# ln S_T mean: sample = 4.7405 theoretical = 4.7425
# ln S_T std: sample = 0.3535 theoretical = 0.3536The log-price is Gaussian with mean and standard deviation . The match is exact to three significant figures — this is the distribution that all of Black-Scholes pricing exploits.
Part 4 — Probability the stock has fallen
# Python
sample_prob = (S_T < S0).mean()
# Closed form: S_T < S_0 iff ln(S_T/S_0) < 0
# iff (mu - sigma^2/2) T + sigma sqrt(T) Z < 0
# iff Z < -(mu - sigma^2/2) sqrt(T) / sigma
z_crit = -(mu - 0.5 * sigma**2) * np.sqrt(T) / sigma
theo_prob = norm.cdf(z_crit)
print(f"P(S_T < S_0): sample = {sample_prob:.4f} theoretical = {theo_prob:.4f}")
# P(S_T < S_0): sample = 0.4216 theoretical = 0.4219The stock has fallen after two years with probability about 42%, even though the expected return is per year. The median is above (since ), so more than half of paths end above $100, but the margin is narrower than the expected-return headline would suggest. For stocks with higher volatility or lower drift, the probability of a loss can exceed 50% even with positive .
Takeaways
- Closed-form moments for GBM are exact and should always be checked against simulation. A mismatch signals either a bug (wrong drift term, missing Itô correction) or too few paths.
- Simulate terminal values in a single step. The exact-solution scheme is unbiased; multi-step Euler adds discretisation error for no gain when only is needed.
- Mean, median, and mode are all different under GBM. Quote the right one for the question. Expected portfolio value at retirement → mean. Typical investor outcome → median. Most-likely single-path value → mode.
- Positive expected return does not mean a positive outcome is more likely than not. The probability of is , which is less than 0.5 whenever — a trap for long-horizon volatile assets.