# Pricing one call option two ways

Vraj Vyas · 22 September 2026

Two methods, one number. How far apart can they land before you should worry?

## Introduction

People ask what quants do, and the answers they get are about maths, or speed, or pay. I would rather show you a small slice of the work. Below is one call option, priced twice by different methods, followed by the question that matters: do the two prices agree, and if they don't, which one is wrong?

I think that question is the most underrated habit in the job. You build a number, then you spend real effort trying to break it.

## The option

A European call pays you the amount the stock finishes above the strike, or nothing:

$$\text{payoff} = \max(S_T - K,\ 0)$$

The numbers I'll use:

| Symbol | Meaning | Value |
|--------|---------|-------|
| S₀ | Spot price today | 100 |
| K | Strike | 105 |
| r | Risk-free rate | 4% |
| σ | Volatility | 20% |
| T | Time to expiry | 1 year |

## Route one: the formula

Black-Scholes gives the price in closed form:

$$C = S_0\,N(d_1) - K e^{-rT} N(d_2)$$

$$d_1 = \frac{\ln(S_0/K) + (r + \tfrac{1}{2}\sigma^2)\,T}{\sigma\sqrt{T}}, \qquad d_2 = d_1 - \sigma\sqrt{T}$$

N is the standard normal CDF. N(d₂) has a meaning worth holding onto: it is the risk-neutral probability that the stock finishes above the strike. Here that is 44.3%.

```python
import numpy as np
from scipy.stats import norm

S0, K, r, sigma, T = 100.0, 105.0, 0.04, 0.20, 1.0

def bs_call(S0, K, r, sigma, T):
    d1 = (np.log(S0 / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
    d2 = d1 - sigma * np.sqrt(T)
    return S0 * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
```

It returns 7.567.

## Route two: simulate it

Under the risk-neutral measure, the stock at expiry is

$$S_T = S_0 \exp\!\Big[(r - \tfrac{1}{2}\sigma^2)\,T + \sigma\sqrt{T}\,Z\Big], \qquad Z \sim N(0,1)$$

and the option price is the discounted expected payoff:

$$C = e^{-rT}\,\mathbb{E}\big[\max(S_T - K,\ 0)\big]$$

So draw a lot of Z values, turn each into a payoff, average them and discount. That average is a sample mean, and a sample mean comes with a standard error: the standard deviation of the payoffs divided by the square root of the number of paths.

```python
def mc_call(S0, K, r, sigma, T, n, seed=0):
    rng = np.random.default_rng(seed)
    z = rng.standard_normal(n)
    ST = S0 * np.exp((r - 0.5 * sigma**2) * T + sigma * np.sqrt(T) * z)
    payoff = np.exp(-r * T) * np.maximum(ST - K, 0.0)
    return payoff.mean(), payoff.std(ddof=1) / np.sqrt(n)
```

Running it at four sizes, all with seed 0:

| Paths | MC price | Std error | Gap to formula |
|-------|----------|-----------|----------------|
| 1,000 | 6.836 | 0.379 | -0.731 |
| 10,000 | 7.639 | 0.127 | +0.072 |
| 100,000 | 7.558 | 0.041 | -0.009 |
| 1,000,000 | 7.584 | 0.013 | +0.017 |

## Reading the table

The 1,000-path row is my favourite. It misses by 0.73, which looks bad until you divide by its standard error of 0.38 and get 1.9. A miss that size turns up in about one run in twenty. Nothing is broken. That is what 1,000 paths look like.

Look at the standard error column too. Each tenfold jump in paths cuts it by about three, the square root of ten. One more decimal place of accuracy costs you a hundred times the paths, and that trade shapes a lot of the engineering you end up doing.

Judge the gap in standard errors, never in pennies. A gap of 0.017 is fine when the standard error is 0.013. It is a red flag when the standard error is 0.001.

## Why bother doing both

Nobody simulates a vanilla call in practice, since the formula is instant. Simulation earns its place when the payoff depends on the path. An Asian call pays on the average price over the year, and with an arithmetic average there is no closed form. With 12 monthly fixings and a million paths I get 3.683 with a standard error of 0.007.

Nothing exists to check that number against, so the trust has to come from somewhere else. It comes from running the same simulator logic on the vanilla case first and seeing it land on 7.567 within its error bar.

## Where it breaks

Both routes assume volatility is a constant 20%. Their agreement shows the code is right. It says nothing about whether the model is.

Volatility moves around over time. Big days tend to follow big days, and quiet stretches follow quiet stretches, a pattern called volatility clustering. The simulator draws every return from one fixed distribution, so it cannot produce a cluster. Price the same option after a calm month and again after an earnings shock, and the model still uses 20%. Models such as GARCH and stochastic volatility let σ change through time, and they exist because of this.

Real markets price different strikes at different implied volatilities, a pattern called the smile, and a single σ cannot reproduce it. Implied volatility is the σ you must feed into the formula to recover the market price, so options get quoted in it. I use Black-Scholes as a unit conversion between price and volatility. I do not believe stocks move this way.

## What to try next

If you are thinking of applying, this makes a good weekend:

1. Reproduce the table, then change the seed and watch the gaps move around while staying within a couple of standard errors.
2. Check put-call parity, C − P = S₀ − Ke^(−rT), with the formula first and then with simulated prices. The formula version holds to floating point. The simulated version holds to within its error bars.
3. Price the Asian call yourself, then raise the fixings from 12 to 250 and see what happens to the price.


Part of [Quant Foundations](/blog/quant-foundations-2627) · Next: [From Market Data to Order Book](/blog/quant-foundations-2627/how-market-data-becomes-an-orderbook)
