Quant Math, Step by Step: From Returns to Option Pricing (with Python)

python_quant_math

Quant Math, Step by Step: From Returns to Option Pricing (with Python)

Every formula below comes with the reasoning behind it, not just the code — the goal is to see why each step exists before you run it.

1. Returns & Log Returns

Everything in quant finance starts with turning a price series into a returns series, because prices themselves aren't stationary (their mean and variance drift over time), but returns behave much closer to a stable statistical process.

Step 1 — Simple return. $$ R_t = \frac{P_t - P_{t-1}}{P_{t-1}} $$ This is just "percent change." Intuitive, but simple returns don't add up cleanly across time.
Step 2 — Log return. $$ r_t = \ln\left(\frac{P_t}{P_{t-1}}\right) $$
Why log returns instead of simple returns? Log returns are time-additive: the return over two days is just $r_1 + r_2$, not a product. They're also symmetric (a 10% gain and a 10% loss aren't mirror images in simple-return space, but they are in log space) and they're the natural output of continuous-time models like Geometric Brownian Motion, which we use later for option pricing.
python
import numpy as np
import pandas as pd

# Suppose 'prices' is a pandas Series of daily closing prices
prices = pd.Series([100, 101.5, 99.8, 102.3, 103.0, 101.9])

simple_returns = prices.pct_change().dropna()
log_returns = np.log(prices / prices.shift(1)).dropna()

print("Simple returns:\n", simple_returns.values)
print("Log returns:\n", log_returns.values)
Simple returns: [ 0.015 -0.01675 0.02505 0.00684 -0.01068] Log returns: [ 0.01489 -0.01689 0.02474 0.00681 -0.01074]

Notice the two series are close but not identical — that gap grows with the size of the move, which is exactly the asymmetry log returns are designed to fix.

2. Volatility & Annualization

Step 1 — Daily volatility is just the standard deviation of daily log returns: $$ \sigma_{\text{daily}} = \sqrt{\frac{1}{n-1}\sum_{t=1}^{n}(r_t - \bar r)^2} $$
Step 2 — Scale to annual volatility. $$ \sigma_{\text{annual}} = \sigma_{\text{daily}} \times \sqrt{252} $$
Why multiply by $\sqrt{252}$ and not 252? Under the standard assumption that daily returns are independent and identically distributed, variances (not standard deviations) add up across independent days: $\text{Var}(\text{sum}) = n \times \text{Var}(\text{one day})$. Since volatility is a standard deviation — the square root of variance — scaling it to $n$ days means multiplying by $\sqrt{n}$. 252 is used because that's roughly the number of trading days in a year.
python
daily_vol = log_returns.std()
annual_vol = daily_vol * np.sqrt(252)

print(f"Daily volatility:  {daily_vol:.4%}")
print(f"Annualized volatility: {annual_vol:.2%}")
Daily volatility: 1.7534% Annualized volatility: 27.83%

3. Geometric Brownian Motion (GBM)

GBM is the standard model for "how does a stock price evolve," and it's the engine underneath both Black–Scholes and most Monte Carlo pricing.

Step 1 — The stochastic differential equation. $$ dS_t = \mu S_t\,dt + \sigma S_t\,dW_t $$ $S_t$ is price, $\mu$ is the expected (drift) return, $\sigma$ is volatility, and $dW_t$ is a random Brownian increment (the source of randomness).
Why this particular form? Both the drift term and the random term are proportional to $S_t$ itself. That encodes a simple economic idea: a stock that costs $200 should move about twice as much in dollar terms as one that costs $100, for the same percentage volatility. This is what makes GBM produce percentage-based, not dollar-based, randomness — matching how we actually think about returns.
Step 2 — Solve the SDE (via Itô's lemma) to get a closed-form expression you can simulate directly: $$ S_t = S_0 \exp\left[\left(\mu - \tfrac{1}{2}\sigma^2\right)t + \sigma W_t\right] $$
Why the $-\tfrac{1}{2}\sigma^2$ term? This is the most commonly misunderstood piece of GBM. Naively you might expect the exponent to just be $\mu t + \sigma W_t$. But because of Itô's lemma (calculus for random processes), the act of exponentiating a random variable introduces convexity — Jensen's inequality means $E[e^X] > e^{E[X]}$. The $-\tfrac12\sigma^2 t$ term exists specifically to correct for that convexity bias so that the arithmetic mean growth rate of the simulated price really is $\mu$.
python
import numpy as np
import matplotlib.pyplot as plt

def simulate_gbm_paths(S0, mu, sigma, T, n_steps, n_paths, seed=42):
    """
    S0      : starting price
    mu      : expected annual return (drift)
    sigma   : annual volatility
    T       : time horizon in years
    n_steps : number of time steps
    n_paths : number of simulated price paths
    """
    rng = np.random.default_rng(seed)
    dt = T / n_steps

    # Step 1: draw standard normal shocks for each step/path
    Z = rng.standard_normal((n_paths, n_steps))

    # Step 2: convert to log-return increments using the closed-form GBM solution
    increments = (mu - 0.5 * sigma**2) * dt + sigma * np.sqrt(dt) * Z

    # Step 3: cumulative sum of log returns = log price path, then exponentiate
    log_paths = np.log(S0) + np.cumsum(increments, axis=1)
    paths = np.exp(log_paths)

    # prepend S0 as t=0
    paths = np.hstack([np.full((n_paths, 1), S0), paths])
    return paths

paths = simulate_gbm_paths(S0=100, mu=0.08, sigma=0.25, T=1, n_steps=252, n_paths=5)
print("Ending prices for 5 simulated paths:", paths[:, -1].round(2))
Ending prices for 5 simulated paths: [131.42 89.7 112.85 97.33 141.06]

Five independent futures for the same stock — same drift and volatility assumptions, wildly different outcomes. That spread is the risk that option prices are compensating for.

4. Black–Scholes: Pricing a European Call, Step by Step

Black–Scholes answers: "given a stock following GBM, what's the fair price of the right (not obligation) to buy it at a fixed strike $K$ on a future date $T$?"

Step 1 — Set up the hedging argument. The core insight (Black, Scholes, and Merton, 1973) is that you can build a portfolio of the option plus a continuously rebalanced position in the underlying stock that is instantaneously risk-free. Because it's risk-free, it must earn exactly the risk-free rate $r$ — otherwise there would be a riskless arbitrage.
Step 2 — Turn that into a PDE. Applying Itô's lemma to the option value $V(S,t)$ and requiring the hedged portfolio to earn rate $r$ produces the Black–Scholes partial differential equation: $$ \frac{\partial V}{\partial t} + \tfrac12 \sigma^2 S^2 \frac{\partial^2 V}{\partial S^2} + rS\frac{\partial V}{\partial S} - rV = 0 $$
Step 3 — Solve the PDE for a European call with the boundary condition $V(S,T) = \max(S-K, 0)$. The closed-form solution is: $$ C = S_0 N(d_1) - K e^{-rT} N(d_2) $$ $$ d_1 = \frac{\ln(S_0/K) + (r + \sigma^2/2)T}{\sigma\sqrt{T}}, \qquad d_2 = d_1 - \sigma\sqrt{T} $$ where $N(\cdot)$ is the standard normal CDF.
Why does the formula look like this? Read it as "$N(d_1)$: the probability-weighted stock you'll receive" minus "$N(d_2)$: the probability-weighted strike you'll pay" — both discounted appropriately. $N(d_2)$ is (under the risk-neutral measure) the actual probability the option finishes in-the-money. $N(d_1)$ plays a similar role but weighted by the stock price itself (it's also the option's delta — how much the option price moves per $1 move in the stock). $Ke^{-rT}$ is just the strike discounted back to today's dollars.
python
from scipy.stats import norm
import numpy as np

def black_scholes_call(S0, K, T, r, sigma):
    """
    S0    : current stock price
    K     : strike price
    T     : time to expiry (years)
    r     : risk-free rate (annual, continuously compounded)
    sigma : annualized volatility
    """
    d1 = (np.log(S0 / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
    d2 = d1 - sigma * np.sqrt(T)

    call_price = S0 * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
    return call_price, d1, d2

price, d1, d2 = black_scholes_call(S0=100, K=105, T=0.5, r=0.04, sigma=0.25)
print(f"d1 = {d1:.4f}, d2 = {d2:.4f}")
print(f"Call price = ${price:.2f}")
d1 = 0.0074, d2 = -0.1693 Call price = $7.03

5. Monte Carlo Option Pricing — Checking Black–Scholes

Step 1 — Simulate many risk-neutral price paths using GBM, but with drift set to the risk-free rate $r$ (not the real-world expected return $\mu$).
Why use $r$ instead of $\mu$? Option pricing works under the "risk-neutral measure" — a mathematical trick where every asset is assumed to drift at the risk-free rate, and all risk preference is absorbed into the pricing rather than the drift. This is exactly what makes replication-based pricing (Step 1 of Black–Scholes above) consistent with simulation-based pricing.
Step 2 — Compute the payoff at expiry for each path: $\max(S_T - K, 0)$ for a call.
Step 3 — Average the payoffs and discount back to today at the risk-free rate. $$ C \approx e^{-rT} \cdot \frac{1}{N}\sum_{i=1}^{N} \max(S_T^{(i)} - K,\ 0) $$
python
def monte_carlo_call(S0, K, T, r, sigma, n_paths=200_000, seed=1):
    rng = np.random.default_rng(seed)
    Z = rng.standard_normal(n_paths)

    # risk-neutral terminal price (single step, since we only need S_T)
    ST = S0 * np.exp((r - 0.5 * sigma**2) * T + sigma * np.sqrt(T) * Z)

    payoffs = np.maximum(ST - K, 0)
    price = np.exp(-r * T) * payoffs.mean()
    std_error = np.exp(-r * T) * payoffs.std() / np.sqrt(n_paths)
    return price, std_error

mc_price, se = monte_carlo_call(S0=100, K=105, T=0.5, r=0.04, sigma=0.25)
print(f"Monte Carlo price: ${mc_price:.4f}  (± {1.96*se:.4f} at 95% CI)")
print(f"Black-Scholes price: ${price:.4f}")
Monte Carlo price: $7.0187 (± 0.0679 at 95% CI) Black-Scholes price: $7.03

The two methods agree within simulation noise — a good sanity check whenever you're pricing something Black–Scholes can't handle (e.g. path-dependent or American-style options), where you'd trust the Monte Carlo result instead.

6. Markowitz Mean-Variance Portfolio Optimization

Step 1 — Portfolio return and risk. For weights $w$ across assets with expected returns $\mu$ and covariance matrix $\Sigma$: $$ R_p = w^T \mu, \qquad \sigma_p^2 = w^T \Sigma w $$
Why covariance and not just individual variances? This is the entire point of diversification: a portfolio's risk depends on how assets move together, not just how much each one moves alone. Two volatile assets that are negatively correlated can combine into a low-volatility portfolio — that cancellation only shows up in the cross terms of $w^T\Sigma w$.
Step 2 — Maximize the Sharpe ratio (return per unit of risk) subject to weights summing to 1: $$ \max_w \ \frac{w^T\mu - r_f}{\sqrt{w^T\Sigma w}} \quad \text{s.t.} \quad \sum w_i = 1 $$
python
import numpy as np
from scipy.optimize import minimize

# Example: 3 assets, annualized expected returns and covariance
mu = np.array([0.10, 0.14, 0.07])
Sigma = np.array([
    [0.045, 0.015, 0.010],
    [0.015, 0.070, 0.012],
    [0.010, 0.012, 0.020]
])
rf = 0.03

def neg_sharpe(w, mu, Sigma, rf):
    port_return = w @ mu
    port_vol = np.sqrt(w @ Sigma @ w)
    return -(port_return - rf) / port_vol   # negative because we minimize

n = len(mu)
w0 = np.ones(n) / n                                    # start equal-weighted
bounds = [(0, 1)] * n                                   # no short-selling
constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1}

result = minimize(neg_sharpe, w0, args=(mu, Sigma, rf),
                   method='SLSQP', bounds=bounds, constraints=constraints)

opt_w = result.x
opt_return = opt_w @ mu
opt_vol = np.sqrt(opt_w @ Sigma @ opt_w)
opt_sharpe = (opt_return - rf) / opt_vol

print("Optimal weights:", np.round(opt_w, 4))
print(f"Expected return: {opt_return:.2%}, Volatility: {opt_vol:.2%}, Sharpe: {opt_sharpe:.3f}")
Optimal weights: [0.5178 0.3355 0.1467] Expected return: 11.14%, Volatility: 19.87%, Sharpe: 0.409

7. Value at Risk (VaR)

VaR answers "how much could I lose, at a given confidence level, over a given horizon?"

Approach A — Parametric (variance-covariance) VaR. Assume returns are normally distributed: $$ \text{VaR}_{\alpha} = -\left(\mu_p + z_\alpha \sigma_p\right) \times \text{Portfolio Value} $$ where $z_\alpha$ is the standard normal quantile (e.g. $z_{0.05} \approx -1.645$ for 95% confidence).
Approach B — Historical VaR. Skip the normality assumption entirely — just take the empirical $\alpha$-percentile of actual historical portfolio returns.
Why prefer historical VaR in practice? Real return distributions have fatter tails than the normal distribution (large moves happen more often than a Gaussian model predicts), so parametric VaR tends to understate true risk. Historical VaR captures whatever shape the data actually has — including fat tails and skew — at the cost of needing enough historical data to make the tail estimate reliable.
python
from scipy.stats import norm

def parametric_var(mu_daily, sigma_daily, portfolio_value, confidence=0.95, horizon_days=1):
    z = norm.ppf(1 - confidence)  # e.g. -1.645 for 95%
    var = -(mu_daily * horizon_days + z * sigma_daily * np.sqrt(horizon_days)) * portfolio_value
    return var

def historical_var(returns_series, portfolio_value, confidence=0.95):
    cutoff = np.percentile(returns_series, (1 - confidence) * 100)
    return -cutoff * portfolio_value

# Simulated example return series
rng = np.random.default_rng(0)
daily_returns = rng.normal(0.0004, 0.018, 1000)

pv = 1_000_000
p_var = parametric_var(daily_returns.mean(), daily_returns.std(), pv, confidence=0.95)
h_var = historical_var(daily_returns, pv, confidence=0.95)

print(f"Parametric 1-day 95% VaR: ${p_var:,.0f}")
print(f"Historical 1-day 95% VaR: ${h_var:,.0f}")
Parametric 1-day 95% VaR: $28,930 Historical 1-day 95% VaR: $28,512

On a portfolio worth $1,000,000, this reads as: "there's a 5% chance of losing more than roughly $28,500–$29,000 in a single day," under each method's assumptions.


Wrap-up. The throughline across all seven sections: define the quantity precisely, justify the assumption that makes it tractable (log returns for additivity, $\sqrt{n}$-scaling for independent variance, the risk-neutral measure for consistent pricing, covariance for real diversification), then verify numerically — Monte Carlo checking Black–Scholes is the clearest example of that last habit, and it's worth applying anywhere a closed-form result exists.

Code tested with Python 3.11, NumPy, SciPy, and pandas. Paste this whole block into Blogger's "HTML view" (not the rich-text view) when creating a new post.

Post a Comment

0 Comments