C++Monte CarloEuler-MaruyamaMilstein SchemeOOP Design Patterns

Monte Carlo Pricing Framework in C++

Module 5 of 832 min readLevel: Hard

Setup

The Pricing Problem

Before writing any code, fix the mathematical framework precisely.

We work on a filtered probability space (Ω,F,(Ft)t0,Q)(\Omega, \mathcal{F}, (\mathcal{F}_t)_{t \geq 0}, \mathbb{Q}) where Q\mathbb{Q} is the risk-neutral (pricing) measure. Under Q\mathbb{Q}, the underlying asset price StS_t follows the SDE:

dSt=μ(t,St)dt+σ(t,St)dWtQdS_t = \mu(t, S_t)\,dt + \sigma(t, S_t)\,dW_t^{\mathbb{Q}}

The model is entirely determined by the pair of functions {(t,s)μ(t,s),  (t,s)σ(t,s)}\{(t,s) \mapsto \mu(t,s),\; (t,s) \mapsto \sigma(t,s)\}. For Black-Scholes: μ(t,s)=rs\mu(t,s) = rs and σ(t,s)=σBSs\sigma(t,s) = \sigma_{\text{BS}} s. For Dupire local volatility: μ(t,s)=rs\mu(t,s) = rs and σ(t,s)=σD(t,s)s\sigma(t,s) = \sigma_D(t,s) s where σD\sigma_D is read from a calibrated surface.

The general pricing formula for a derivative with payoff functional ψ\psi over the path (St)t[0,T](S_t)_{t \in [0,T]} is:

PV(0)=EQ ⁣[D(0,T)ψ ⁣((St)t[0,T])]\text{PV}(0) = \mathbb{E}^{\mathbb{Q}}\!\left[D(0,T)\cdot\psi\!\left((S_t)_{t\in[0,T]}\right)\right]

where D(0,T)=e0Tr(s)dsD(0,T) = e^{-\int_0^T r(s)\,ds} is the stochastic discount factor. For constant rr: D(0,T)=erTD(0,T) = e^{-rT}.

The Monte Carlo approximation over NN independent paths:

PV(0)1Nj=1ND(0,T)ψ ⁣(St0(j),,Stm(j))\text{PV}(0) \approx \frac{1}{N}\sum_{j=1}^{N} D(0,T)\cdot\psi\!\left(S^{(j)}_{t_0},\ldots,S^{(j)}_{t_m}\right)

Three independent components are required:

  1. N — the number of simulation paths. Determines statistical precision: standard error 1/N\propto 1/\sqrt{N}.
  2. Payoff functor ψ\psi — a callable that maps a simulated path (St0,,Stm)(S_{t_0},\ldots,S_{t_m}) to a real number. Separates the product specification from the simulation.
  3. PathSimulator — generates the path (St0(j),,Stm(j))(S_{t_0}^{(j)},\ldots,S_{t_m}^{(j)}) for each jj. Encapsulates the discretisation scheme and the model.

Design Hierarchy

The object-oriented structure isolates each concern:

Model (abstract)
  ├── BlackScholesModel        μ = r·s,  σ = σ_BS·s
  └── DupireLocalVolModel      μ = r·s,  σ = σ_D(t,s)·s

PathSimulator (abstract)       owns a Model* via clone()
  ├── EulerPathSimulator       Euler-Maruyama discretisation
  └── MilsteinPathSimulator    Milstein correction

Payoff (abstract)              callable on std::vector<double>
  ├── CallPayoff               max(S_T - K, 0)
  ├── PutPayoff                max(K - S_T, 0)
  └── AsianCallPayoff          max(mean(S) - K, 0)

MonteCarlo                     engine; owns PathSimulator* via clone()

This hierarchy ensures that any combination of model, scheme, and payoff can be assembled without changing existing code — the Open/Closed Principle applied to a pricing context.

Conventions used throughout this module:

  • Rate rr: continuously compounded, annualised.
  • Volatility σ\sigma: annualised, decimal (0.20 = 20%).
  • Time TT: in years.
  • All prices in the same currency unit.

Theory

1. Model Abstract Class

The Model abstraction separates the SDE specification from the numerical scheme. Any class implementing drift_term and diffusion_term can be plugged into any PathSimulator without modification.

Two pure virtual methods are required:

  • drift_term(double t, double s) — returns μ(t,s)\mu(t, s), the drift coefficient.
  • diffusion_term(double t, double s) — returns σ(t,s)\sigma(t, s), the diffusion coefficient.

A clone() method returns a heap-allocated copy. This is the prototype pattern: the PathSimulator constructor takes a const Model& and immediately calls clone() to store its own owned copy. Without clone(), the simulator would hold a reference to an object that might go out of scope or be modified by the caller after construction.

Virtual destructor is mandatory whenever a class is intended to be used polymorphically through a base pointer. Omitting it causes undefined behaviour when a derived object is deleted through a Model*.

Black-Scholes model: μ(t,s)=rs,σ(t,s)=σBSs\mu(t, s) = r \cdot s, \qquad \sigma(t, s) = \sigma_{\text{BS}} \cdot s

This is GBM (Geometric Brownian Motion). The drift is linear in ss; the diffusion is also linear in ss. For constant coefficients, the SDE has the exact solution ST=S0exp ⁣[(r12σ2)T+σWT]S_T = S_0 \exp\!\left[(r - \tfrac{1}{2}\sigma^2)T + \sigma W_T\right], which can be simulated in a single step. We retain the general scheme for generality.

Dupire local volatility model: μ(t,s)=rs,σ(t,s)=σD(t,s)s\mu(t, s) = r \cdot s, \qquad \sigma(t, s) = \sigma_D(t, s) \cdot s

where σD(t,s)\sigma_D(t,s) is the local volatility function extracted from the implied vol surface (see Module 6). The model matches all market call prices by construction; the function σD\sigma_D is not constant, so the exact solution above is not available and discretisation is mandatory.


2. Euler-Maruyama Scheme

Discretise [0,T][0,T] uniformly: t0=0<t1<<tm=Tt_0 = 0 < t_1 < \cdots < t_m = T, with step Δt=T/m\Delta t = T/m.

The Euler-Maruyama update at step ii:

Sti+1=Sti+μ(ti,Sti)Δt+σ(ti,Sti)ΔWiS_{t_{i+1}} = S_{t_i} + \mu(t_i, S_{t_i})\,\Delta t + \sigma(t_i, S_{t_i})\,\Delta W_i

where ΔWi=Wti+1WtiN(0,Δt)\Delta W_i = W_{t_{i+1}} - W_{t_i} \sim \mathcal{N}(0, \Delta t). In practice: ΔWi=ΔtZi\Delta W_i = \sqrt{\Delta t}\, Z_i with ZiiidN(0,1)Z_i \overset{\text{iid}}{\sim} \mathcal{N}(0,1).

Convergence properties:

ModeOrderWhat it measures
StrongO(Δt)\mathcal{O}(\sqrt{\Delta t})Pathwise accuracy: E[STexactSTEM]\mathbb{E}[\|S_T^{\text{exact}} - S_T^{\text{EM}}\|]
WeakO(Δt)\mathcal{O}(\Delta t)Functional accuracy: $

For option pricing, weak convergence governs the bias in the price estimate. For smooth payoffs (vanilla calls, puts), the pricing error shrinks as O(Δt)\mathcal{O}(\Delta t). For discontinuous payoffs (digital options, barrier options), the effective weak rate degrades.

Assumptions for convergence: μ\mu and σ\sigma satisfy a global Lipschitz condition in ss and at most linear growth. GBM satisfies both (μ=rs\mu = rs, σ=σs\sigma = \sigma s are globally Lipschitz). CEV with β>1\beta > 1 violates linear growth and can cause Euler to produce negative prices — see Limitations.


3. Milstein Scheme

The Milstein scheme adds one term to the Euler update, derived by applying Itô's lemma to the diffusion coefficient σ(t,St)\sigma(t,S_t) itself:

Sti+1=Sti+μ(ti,Sti)Δt+σ(ti,Sti)ΔWi+12σ(ti,Sti)σs(ti,Sti)[(ΔWi)2Δt]S_{t_{i+1}} = S_{t_i} + \mu(t_i, S_{t_i})\,\Delta t + \sigma(t_i, S_{t_i})\,\Delta W_i + \frac{1}{2}\sigma(t_i, S_{t_i})\,\frac{\partial \sigma}{\partial s}(t_i, S_{t_i})\left[(\Delta W_i)^2 - \Delta t\right]

The additional term 12σsσ[(ΔW)2Δt]\tfrac{1}{2}\sigma \cdot \partial_s\sigma \cdot [(\Delta W)^2 - \Delta t] captures the curvature of the diffusion coefficient with respect to the state — the contribution of quadratic variation to the path that Euler ignores.

For GBM: σ(t,s)=σs\sigma(t,s) = \sigma s, so σ/s=σ\partial\sigma/\partial s = \sigma. The correction term becomes:

12σ2Sti[(ΔWi)2Δt]\frac{1}{2}\sigma^2 S_{t_i}\left[(\Delta W_i)^2 - \Delta t\right]

Convergence:

ModeOrderImprovement over Euler
StrongO(Δt)\mathcal{O}(\Delta t)One full order gained
WeakO(Δt)\mathcal{O}(\Delta t)Same as Euler

Milstein's strong order O(Δt)\mathcal{O}(\Delta t) means the pathwise error is squared relative to Euler at the same step size. This matters for path-dependent options where the quality of each individual path, not just the average, affects the result.

Requirement: σ\sigma must be differentiable in ss. For models where sσ\partial_s\sigma does not exist (e.g., Heston where the variance process has a square root diffusion), Milstein requires a transformation or an alternative formulation.


4. Random Number Generator

Wrap std::mt19937_64 (Mersenne Twister, 64-bit state) seeded at construction. Produce standard normal samples via std::normal_distribution<double>.

The generator is a value member of PathSimulator — not a pointer, not a global. This ensures:

  • No heap allocation per path for random number generation.
  • Each PathSimulator instance has independent state (important if multiple simulators are constructed for parallel execution).
  • Reproducibility: seeding with a fixed value yields identical paths across runs, which is mandatory for unit tests.

Production note: A fixed seed makes tests deterministic but all instances seeded identically will produce identical sequences. For parallel Monte Carlo, use a seed sequence (std::seed_seq) derived from an independent source per thread.


5. PathSimulator Architecture

PathSimulator holds:

  • A time grid std::vector<double> _timePoints storing (t0,t1,,tm)(t_0, t_1, \ldots, t_m).
  • A const Model* _model — a heap-allocated clone of the model passed at construction.
  • A std::mt19937_64 _mt — the random number generator.
  • A std::normal_distribution<double> _normalDist.

The constructor signature is PathSimulator(const Model& model, const std::vector<double>& timePoints). It calls model.clone() to store _model. The destructor deletes _model.

The pure virtual method simulate_path(double s0) returns std::vector<double> — the simulated path values at each time point. Derived classes implement the specific scheme.


6. Monte Carlo Engine

The MonteCarlo class stores an initial spot _s0, a discount factor _discount, and a PathSimulator* _simulator (owned via clone). Its pricing method:

price(N, payoff):
    sum = 0
    for j = 1..N:
        path = _simulator->simulate_path(_s0)
        sum += payoff(path)
    return _discount * sum / N

Standard error of the estimate: SE^=σ^ψ/N\hat{\text{SE}} = \hat{\sigma}_{\psi} / \sqrt{N}, where σ^ψ2=1N1j=1N[ψjψˉ]2\hat{\sigma}_{\psi}^2 = \frac{1}{N-1}\sum_{j=1}^N [\psi_j - \bar{\psi}]^2 is the sample variance of the discounted payoffs. A 95% confidence interval: ψˉ±1.96SE^\bar{\psi} \pm 1.96\,\hat{\text{SE}}.


Implementation

All files target C++17 with -Wall -Wextra -Wpedantic -Werror.

Model.h

// Model.h
// Abstract base class for SDE drift and diffusion coefficients.
// Compile: part of a multi-file project; see CMakeLists.txt or main.cpp build comment.
#pragma once

class Model {
public:
    // Pure virtual: drift coefficient μ(t, s) in dS = μ dt + σ dW
    virtual double drift_term(double t, double s) const = 0;

    // Pure virtual: diffusion coefficient σ(t, s)
    virtual double diffusion_term(double t, double s) const = 0;

    // Prototype pattern: returns a heap-allocated copy of the concrete model.
    // PathSimulator calls this so it owns an independent copy of the model.
    virtual Model* clone() const = 0;

    // Virtual destructor: required for correct cleanup through a Model* pointer.
    virtual ~Model() = default;
};

// ---------------------------------------------------------------------------
// Black-Scholes model: dS = r·S dt + σ·S dW
// ---------------------------------------------------------------------------
class BlackScholesModel : public Model {
public:
    // rate: continuously compounded, annualised risk-free rate
    // sigma: annualised volatility (decimal: 0.20 = 20%)
    BlackScholesModel(double rate, double sigma);

    double drift_term(double t, double s) const override;
    double diffusion_term(double t, double s) const override;
    BlackScholesModel* clone() const override;

private:
    double _rate;
    double _sigma;
};

Model.cpp

// Model.cpp
#include "Model.h"

BlackScholesModel::BlackScholesModel(double rate, double sigma)
    : _rate(rate), _sigma(sigma) {}

double BlackScholesModel::drift_term(double /*t*/, double s) const {
    // μ(t, s) = r·s  — linear drift; risk-neutral measure removes risk premium
    return _rate * s;
}

double BlackScholesModel::diffusion_term(double /*t*/, double s) const {
    // σ(t, s) = σ_BS·s  — proportional diffusion gives lognormal paths
    return _sigma * s;
}

BlackScholesModel* BlackScholesModel::clone() const {
    return new BlackScholesModel(_rate, _sigma);
}

PathSimulator.h

// PathSimulator.h
// Abstract base for path discretisation schemes.
// Concrete classes implement Euler-Maruyama and Milstein.
#pragma once

#include "Model.h"
#include <vector>
#include <random>

class PathSimulator {
public:
    // model: the SDE model — a clone is stored internally.
    // timePoints: the time grid {t_0=0, t_1, ..., t_m=T}.
    // seed: RNG seed for reproducibility; default 42 for unit tests.
    PathSimulator(const Model& model,
                  const std::vector<double>& timePoints,
                  unsigned long long seed = 42ULL);

    // Simulate one path from s0 and return the path at each time point.
    // Returns std::vector<double> of size timePoints.size().
    virtual std::vector<double> simulate_path(double s0) const = 0;

    virtual PathSimulator* clone() const = 0;
    virtual ~PathSimulator();

protected:
    const Model*         _model;       // heap-allocated clone; owned
    std::vector<double>  _timePoints;  // t_0, t_1, ..., t_m

    // Mutable: simulate_path is logically const (same distribution) but
    // advances the RNG state. Declared mutable to allow const simulate_path.
    mutable std::mt19937_64                    _mt;
    mutable std::normal_distribution<double>   _normalDist;
};

The full lesson requires Premium

The complete derivation, the C++ / Python implementation, the validation tables, the quiz, and the interview-angle notes are part of Premium. Start a Premium plan to unlock every module in this track.