Skip to content
All library documents

Debugging Monte Carlo Greeks for a European Call

Article Quant Q&A · Author: Hasek

Summary

The document investigates why a Monte Carlo European call valuation can appear close to its Black–Scholes price while its finite-difference delta and vega disagree sharply. The responses identify implementation issues, including a volatility argument that is not actually used in the path simulation, accidental dependence on a global variable, and seed handling that does not reliably produce comparable simulations. They also emphasize discounting simulated payoffs and accounting for dividends when calculating analytical Greeks.

A revised example simulates the terminal asset value directly under geometric Brownian motion and uses matched random seeds for bumped valuations, reducing noise in finite differences. Reported comparisons show the revised Monte Carlo price and Greeks close to analytic values in that example. These are illustrative results, not a general convergence guarantee; finite-difference estimates remain sensitive to bump size and simulation noise. The document’s sample Greek snippets also warrant careful review before reuse, since bump conventions must be applied consistently.

Key ideas

  • The simulation must use the passed volatility parameter consistently rather than a global value.
  • Discount simulated option payoffs before comparing them with Black–Scholes prices.
  • Dividend yield affects the analytical call delta and should be included in comparisons.
  • Using the same random draws for bumped prices can reduce Monte Carlo noise in finite-difference Greeks.
  • Finite-difference estimates depend on correct bump conventions and remain subject to simulation error.

Tags

Full text
# Monte Carlo Greeks aren't matching Black-Scholes ones


# Monte Carlo Greeks aren't matching Black-Scholes ones












I'm implementing a Monte Carlo simulation of Geometric Brownian Motion for having a hands on model for pricing and hedging exotic barrier and autocallable payoffs. The idea is to test my implementation against Black-Scholes valuation of vanilla European call to make sure the price and greeks are more or less in line with analytical ones.

```
Monte-Carlo price 0.0034602543023745684 %
Monte-Carlo probability of expiration ITM 74.77669999999999 %
Monte-Carlo cashflow 34.60254302374568 USD

Black-Scholes price 0.0032218232666278717 %
Black-Scholes probability of expiration ITM 74.75673753241554 %
Black-Scholes cashflow 32.218232666278716 USD

Monte-Carlo delta 0.8232844460563724
Black-Scholes delta 0.7687961324513397

Monte-Carlo vega -4.402817163679625
Black-Scholes vega 63.063541724176005
```

Despite the price being close enough to analytical the greeks seems to be completely off on one million simulations with negative vega making no sense for a long European call. How did numerical vega ended up being negative here? Is there anything wrong with the delta as well not converging closer to the analytical one?

Please see my code below. Any help is appreciated.

```
import numpy as np
from scipy.stats import norm
import math
import datetime

notional = 1000000
coupon = 0.10 * notional
current_spot = 294. 
initial_spot = 250
barrier = 1.10 * initial_spot
rate = 0.12
div = 0.05
sigma = 0.19
valuationDate = datetime.date(2025, 2, 17)
maturityDate = datetime.date(2025, 9, 22)
maturity = (maturityDate - valuationDate).days / 365
n_of_steps = (maturityDate - valuationDate).days

def MonteCarlo(S, r, d, vol, T, n_of_steps, n_of_sims, seed=np.random.seed(2025)):
    seed
    dt = float(T) / n_of_steps
    paths = np.zeros((n_of_steps + 1, n_of_sims), np.float64)
    paths[0] = S
    for t in range(1, n_of_steps + 1):
        rand = np.random.standard_normal(n_of_sims)
        paths[t] = paths[t - 1] * np.exp((r - d - 0.5 * sigma ** 2) * dt + sigma * np.sqrt(dt) * rand)
    return paths

def callMonteCarlo(paths, strike):
    price = np.mean(np.clip(paths[-1] - strike, a_min=0, a_max=None))      
    return price

def callBlackScholes(spot, strike, time, vol, rate=0., div=0.):
    d1 = (math.log(spot / strike) + (rate - div + vol**2 / 2) * time) / (vol * math.sqrt(time))
    d2 = d1 - vol * math.sqrt(time)
    price = math.exp(- div * time) * norm.cdf(d1) * spot - norm.cdf(d2) * strike * math.exp(- rate * time)
    return price

def callProbabilityInTheMoney(paths, strike):
    n = len(paths[-1])
    m = sum(s >= strike for s in paths[-1])
    return float(m / n)

def N_d2(spot, strike, time, vol, rate=0., div=0.):
    d1 = (math.log(spot / strike) + (rate - div + vol**2 / 2) * time) / (vol * math.sqrt(time))
    d2 = d1 - vol * math.sqrt(time)
    return norm.cdf(d2)

def analyticalCallDelta(spot, strike, time, vol, rate=0., div=0.):
    d1 = (math.log(spot / strike) + (rate - div + vol**2 / 2) * time) / (vol * math.sqrt(time))
    delta = math.exp(- div * time) * norm.cdf(d1)
    return delta

def analyticalVega(spot, strike, time, vol, rate=0., div=0.):
    d1 = (math.log(spot / strike) + (rate - div + vol**2 / 2) * time) / (vol * math.sqrt(time))
    vega = spot * math.exp(- div * time) * norm.pdf(d1) * math.sqrt(time)
    return vega

def numericalCallDelta(K, S, r, d, vol, T, n_of_steps, n_of_sims, seed=np.random.seed(2025)):
    S_up = S + S / 100.
    S_down = S - S / 100.
    seed
    paths_up = MonteCarlo(S_up, r, d, vol, T, n_of_steps, n_of_sims)
    paths_down = MonteCarlo(S_down, r, d, vol, T, n_of_steps, n_of_sims)
    price_up = callMonteCarlo(paths_up, K)
    price_down = callMonteCarlo(paths_down, K)
    delta = (price_up - price_down) / (S_up - S_down)
    return delta

def numericalVega(K, S, r, d, vol, T, n_of_steps, n_of_sims, seed=np.random.seed(2025)):
    vol_up = vol + 0.01
    vol_down = vol - 0.01
    seed
    paths_up = MonteCarlo(S, r, d, vol_up, T, n_of_steps, n_of_sims)
    paths_down = MonteCarlo(S, r, d, vol_down, T, n_of_steps, n_of_sims)
    price_up = callMonteCarlo(paths_up, K)
    price_down = callMonteCarlo(paths_down, K)
    vega = (price_up - price_down) / (vol_up - vol_down)
    return vega

seed = np.random.seed(2025)
paths = MonteCarlo(S=current_spot, r=rate, d=div, vol=sigma, T=maturity, n_of_steps=n_of_steps, n_of_sims=1000000, seed=seed)

priceMC = math.exp(- rate * maturity) * callMonteCarlo(paths=paths, strike=barrier) / notional
priceBS = math.exp(- rate * maturity) * callBlackScholes(spot=current_spot, strike=barrier, time=maturity, vol=sigma, rate=rate, div=div) / notional

print("Monte-Carlo price {0} %".format(100 * priceMC))
print("Monte-Carlo probability of expiration ITM {0} %".format(100 * monteCarloProbability))
print("Monte-Carlo cashflow {0} USD".format(priceMC * notional))
print()
print("Black-Scholes price {0} %".format(100 * priceBS))
print("Black-Scholes probability of expiration ITM {0} %".format(100 * blackScholesProbability))
print("Black-Scholes cashflow {0} USD".format(priceBS * notional))
print()

deltaMC = numericalCallDelta(K=barrier, S=current_spot, r=rate, d=div, vol=sigma, T=maturity, n_of_steps=n_of_steps, n_of_sims=1000000)
deltaBS = analyticalCallDelta(spot=current_spot, strike=barrier, time=maturity, vol=sigma, rate=rate, div=div)

print("Monte-Carlo delta {0}".format(deltaMC))
print("Black-Scholes delta {0}".format(deltaBS))
print()

vegaMC = numericalVega(K=barrier, S=current_spot, r=rate, d=div, vol=sigma, T=maturity, n_of_steps=n_of_steps, n_of_sims=1000000)
vegaBS = analyticalVega(spot=current_spot, strike=barrier, time=maturity, vol=sigma, rate=rate, div=div)

print("Monte-Carlo vega {0}".format(vegaMC))
print("Black-Scholes vega {0}".format(vegaBS))
```

## Answer by Andrea (score 1, accepted)

https://quant.stackexchange.com/a/81953

- Don't use global variables, not even in small scripts, they hide issues. `vol` is not used in `MonteCarlo()`.

- `seed=np.random.seed(2025)` as a default argument is only evaluated once. Make sure your `delta price` is 0 for a 0 bump, or you get extra MC noise.

## Answer by Pedro (score 5)

https://quant.stackexchange.com/a/81955

I think you had a few issues in your code, including discounting. This code works (notice you can sample the final spot in one step, you do not have to create unnecessary numerical noise)

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

def black_scholes_mc_call(
    spot0: float,
    rate: float,
    dividend: float,
    sigma: float,
    strike: float,
    maturity: float,
    num_paths: int,
    seeds: tuple[int, ...],
) -> float:

    partials = np.zeros(len(seeds))
    for i, seed in enumerate(seeds):
        normals = np.random.RandomState(seed).standard_normal(num_paths)
        s_maturity = (
            spot0
            * np.exp((rate - dividend) * maturity)
            * np.exp(-maturity * sigma**2 / 2)
            * np.exp(sigma * np.sqrt(maturity) * normals)
        )
        val = np.exp(-maturity * rate) * np.mean(np.maximum(s_maturity - strike, 0))
        partials[i] = val
    return np.mean(partials)

def black_scholes_call(
    spot: float,
    strike: float,
    time: float,
    vol: float,
    rate: float,
    div: float,
) -> float:
    fwd = spot * np.exp((rate - div) * time)
    z = math.log(fwd / strike)
    term_vol = vol * math.sqrt(time)
    d1 = z / term_vol + term_vol / 2
    d2 = z / term_vol - term_vol / 2
    df = np.exp(-time * rate)
    price = df * (fwd * norm.cdf(d1) - norm.cdf(d2) * strike)
    return price

current_spot = 5000.0
initial_spot = 5000.0
rate = 0.05
barrier = (1 + rate) * initial_spot
dividend = 0.08
sigma = 0.15
valuationDate = datetime.date(2025, 1, 1)
maturityDate = datetime.date(2026, 1, 1)
maturity = (maturityDate - valuationDate).days / 365

priceMC = black_scholes_mc_call(
    spot0=current_spot,
    rate=rate,
    dividend=dividend,
    sigma=sigma,
    maturity=maturity,
    num_paths=int(1e7),
    strike=barrier,
    seeds=(1,2,3,4,5),
)

priceBS = black_scholes_call(
    spot=current_spot,
    strike=barrier,
    time=maturity,
    vol=sigma,
    rate=rate,
    div=dividend,
)

print(f"Monte-Carlo price {priceMC}")
print(f"Analytic price {priceBS}")
```

I get

```
Monte-Carlo price 136.68000878498842
Analytic price 136.71161616372243
```

For the Greeks, you have to be careful about the dividends, among other things:

```
def delta(
    spot: float,
    strike: float,
    maturity: float,
    volatility: float,
    rate: float,
    dividend: float,
):
    fwd = spot * np.exp((rate - dividend) * maturity)
    z = math.log(fwd / strike)
    d1 = z / volatility * math.sqrt(maturity) + volatility * math.sqrt(maturity) / 2
    return np.exp(-maturity * dividend) * norm.cdf(d1)

def delta_mc(
    spot0: float,
    strike: float,
    bump: float,
    rate: float,
    div: float,
    vol: float,
    maturity: float,
    num_paths: int,
    seeds: tuple[int, ...],
):
    price_up = black_scholes_mc_call(
        spot0=spot0 + bump,
        rate=rate,
        dividend=div,
        sigma=vol,
        strike=strike,
        maturity=maturity,
        num_paths=num_paths,
        seeds=seeds,
    )
    price_down = black_scholes_mc_call(
        spot0=spot0,
        rate=rate,
        dividend=div,
        sigma=vol,
        strike=strike,
        maturity=maturity,
        num_paths=num_paths,
        seeds=seeds,
    )
    delta = (price_up - price_down) / bump
    return delta
```

Running

```
delta = delta(
    spot=initial_spot,
    strike=barrier,
    maturity=maturity,
    volatility=sigma,
    rate=rate,
    dividend=dividend,
)
deltamc = delta_mc(
    spot0=initial_spot,
    strike=barrier,
    bump=1,
    maturity=maturity,
    vol=sigma,
    rate=rate,
    div=dividend,
    num_paths=int(1e7),
    seeds=(1, 2, 3, 4,5),
)
delta, deltamc
```

gives me

```
(0.3011747308109762, 0.30133159022952327)
```

which is acceptable.

Finally for vegas

```
def vega_mc(
    spot0: float,
    strike: float,
    rate: float,
    dividend: float,
    vol: float,
    vol_bump: float,
    maturity: float,
    num_paths: int,
    seeds: tuple[int, ...],
):
    price_up = black_scholes_mc_call(
        spot0,
        rate,
        dividend,
        vol + vol_bump,
        strike,
        maturity,
        num_paths,
        seeds,
    )
    price_down = black_scholes_mc_call(
        spot0,
        rate,
        dividend,
        vol,
        strike,
        maturity,
        num_paths,
        seeds,
    )

    return (price_up - price_down) / vol_bump
```

and running

```
vegaa = vega(initial_spot, barrier, maturity, sigma, rate, dividend)

vegamc = vega_mc(
    spot0=initial_spot,
    strike=barrier,
    rate=rate,
    dividend=dividend,
    vol=sigma,
    vol_bump=sigma*0.005,
    maturity=maturity,
    num_paths=1000000,
    seeds=(1, 2, 3, 4),
)

vegaa, vegamc
```

I get

```
(1663.8411096873679, 1661.890544055268)
```

which again is in the ballpark. Here are some plots, with 200000 paths per strike:

Shown in full with attribution under the source's licence. Licence: CC BY-SA 4.0 (Stack Exchange)

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.