SDE

Geometric Brownian Motion (Monte Carlo)

SDE option-pricing example with ensemble statistics across Euler-Maruyama, Milstein, and SRA1.

  • intermediate

The geometric Brownian motion model dS = μS dt + σS dW is the backbone of Black-Scholes pricing. This example runs a Monte Carlo ensemble of paths (numra::sde::EnsembleRunner) using three different SDE solvers and compares the empirical mean and variance against the known analytical moments.

S(T) = S(0) * exp((μ - σ²/2)T + σ W(T))

Run it:

cargo run --release --example gbm_monte_carlo

Source

numra/examples/gbm_monte_carlo.rs on GitHub · Related book chapter

use numra::sde::{
    EnsembleRunner, EnsembleStats, EulerMaruyama, Milstein, SdeOptions, SdeSystem, Sra1,
};

/// Geometric Brownian Motion model for stock prices.
#[allow(clippy::upper_case_acronyms)]
struct GBM {
    /// Drift rate (expected return)
    mu: f64,
    /// Volatility
    sigma: f64,
}

impl SdeSystem<f64> for GBM {
    fn dim(&self) -> usize {
        1
    }

    fn drift(&self, _t: f64, x: &[f64], f: &mut [f64]) {
        f[0] = self.mu * x[0];
    }

    fn diffusion(&self, _t: f64, x: &[f64], g: &mut [f64]) {
        g[0] = self.sigma * x[0];
    }

    fn diffusion_derivative(&self, _t: f64, x: &[f64], gdg: &mut [f64]) {
        // g = σx, dg/dx = σ, so g * dg/dx = σ²x
        gdg[0] = self.sigma * self.sigma * x[0];
    }
}

fn main() {
    println!("=== Geometric Brownian Motion Monte Carlo ===\n");

    // Model parameters
    let s0 = 100.0; // Initial stock price
    let mu = 0.05; // 5% expected annual return
    let sigma = 0.2; // 20% annual volatility
    let t_final = 1.0; // 1 year

    // Number of Monte Carlo paths
    let n_paths = 10_000;

    let gbm = GBM { mu, sigma };

    println!("Parameters:");
    println!("  Initial price: ${:.2}", s0);
    println!("  Drift (μ): {:.1}%", mu * 100.0);
    println!("  Volatility (σ): {:.1}%", sigma * 100.0);
    println!("  Time horizon: {:.1} year", t_final);
    println!("  Monte Carlo paths: {}", n_paths);
    println!();

    // Analytical solution for comparison
    // E[S(T)] = S(0) * exp(μT)
    // Var[S(T)] = S(0)² * exp(2μT) * (exp(σ²T) - 1)
    let expected_mean = s0 * (mu * t_final).exp();
    let expected_var =
        s0 * s0 * (2.0 * mu * t_final).exp() * ((sigma * sigma * t_final).exp() - 1.0);
    let expected_std = expected_var.sqrt();

    println!("Analytical (exact) results:");
    println!("  E[S(T)] = ${:.2}", expected_mean);
    println!("  Std[S(T)] = ${:.2}", expected_std);
    println!();

    // Compare different solvers
    let options = SdeOptions::default()
        .dt(0.001) // Small step for accuracy
        .seed(42)
        .save_trajectory(false); // Only need final values

    println!("Running Monte Carlo simulations...\n");

    // Euler-Maruyama
    let em_result =
        EnsembleRunner::run::<_, _, EulerMaruyama>(&gbm, 0.0, t_final, &[s0], &options, n_paths);
    let em_finals = em_result.successful_final_values(0);
    let em_stats = EnsembleStats::from_samples(&em_finals).unwrap();

    println!("Euler-Maruyama (strong order 0.5):");
    println!(
        "  Mean: ${:.2} (error: {:.2}%)",
        em_stats.mean,
        (em_stats.mean - expected_mean).abs() / expected_mean * 100.0
    );
    println!(
        "  Std:  ${:.2} (error: {:.2}%)",
        em_stats.std,
        (em_stats.std - expected_std).abs() / expected_std * 100.0
    );
    println!(
        "  95% CI: [${:.2}, ${:.2}]",
        em_stats.percentiles.p5, em_stats.percentiles.p95
    );
    println!();

    // Milstein
    let mil_result =
        EnsembleRunner::run::<_, _, Milstein>(&gbm, 0.0, t_final, &[s0], &options, n_paths);
    let mil_finals = mil_result.successful_final_values(0);
    let mil_stats = EnsembleStats::from_samples(&mil_finals).unwrap();

    println!("Milstein (strong order 1.0):");
    println!(
        "  Mean: ${:.2} (error: {:.2}%)",
        mil_stats.mean,
        (mil_stats.mean - expected_mean).abs() / expected_mean * 100.0
    );
    println!(
        "  Std:  ${:.2} (error: {:.2}%)",
        mil_stats.std,
        (mil_stats.std - expected_std).abs() / expected_std * 100.0
    );
    println!(
        "  95% CI: [${:.2}, ${:.2}]",
        mil_stats.percentiles.p5, mil_stats.percentiles.p95
    );
    println!();

    // SRA1 (adaptive)
    let sra_options = SdeOptions::default()
        .dt(0.01) // Larger initial step (adaptive will adjust)
        .rtol(1e-3)
        .atol(1e-6)
        .seed(42)
        .save_trajectory(false);

    let sra_result =
        EnsembleRunner::run::<_, _, Sra1>(&gbm, 0.0, t_final, &[s0], &sra_options, n_paths);
    let sra_finals = sra_result.successful_final_values(0);
    let sra_stats = EnsembleStats::from_samples(&sra_finals).unwrap();

    println!("SRA1 (adaptive, strong order 1.5):");
    println!(
        "  Mean: ${:.2} (error: {:.2}%)",
        sra_stats.mean,
        (sra_stats.mean - expected_mean).abs() / expected_mean * 100.0
    );
    println!(
        "  Std:  ${:.2} (error: {:.2}%)",
        sra_stats.std,
        (sra_stats.std - expected_std).abs() / expected_std * 100.0
    );
    println!(
        "  95% CI: [${:.2}, ${:.2}]",
        sra_stats.percentiles.p5, sra_stats.percentiles.p95
    );
    println!();

    // Option pricing example: European call option
    println!("=== European Call Option Pricing ===\n");

    let strike = 105.0;
    let r = 0.03; // Risk-free rate

    // For risk-neutral pricing, use μ = r (risk-neutral drift)
    let gbm_rn = GBM { mu: r, sigma };

    let rn_result =
        EnsembleRunner::run::<_, _, Milstein>(&gbm_rn, 0.0, t_final, &[s0], &options, n_paths);
    let rn_finals = rn_result.successful_final_values(0);

    // Payoff: max(S(T) - K, 0)
    let payoffs: Vec<f64> = rn_finals.iter().map(|&s| (s - strike).max(0.0)).collect();
    let payoff_stats = EnsembleStats::from_samples(&payoffs).unwrap();

    // Discounted expected payoff = option price
    let discount = (-r * t_final).exp();
    let mc_price = discount * payoff_stats.mean;
    let mc_std_err = discount * payoff_stats.standard_error();

    println!("Call option parameters:");
    println!("  Strike: ${:.2}", strike);
    println!("  Risk-free rate: {:.1}%", r * 100.0);
    println!();
    println!(
        "Monte Carlo price: ${:.4} ± ${:.4} (95% CI)",
        mc_price,
        1.96 * mc_std_err
    );

    // Black-Scholes analytical price for comparison
    let bs_price = black_scholes_call(s0, strike, r, sigma, t_final);
    println!("Black-Scholes price: ${:.4}", bs_price);
    println!(
        "Pricing error: ${:.4} ({:.2}%)",
        (mc_price - bs_price).abs(),
        (mc_price - bs_price).abs() / bs_price * 100.0
    );
}

/// Black-Scholes formula for European call option.
fn black_scholes_call(s: f64, k: f64, r: f64, sigma: f64, t: f64) -> f64 {
    let d1 = ((s / k).ln() + (r + 0.5 * sigma * sigma) * t) / (sigma * t.sqrt());
    let d2 = d1 - sigma * t.sqrt();
    s * norm_cdf(d1) - k * (-r * t).exp() * norm_cdf(d2)
}

/// Standard normal CDF approximation (Abramowitz & Stegun).
fn norm_cdf(x: f64) -> f64 {
    // Approximation using the error function via polynomial
    let a1 = 0.254829592;
    let a2 = -0.284496736;
    let a3 = 1.421413741;
    let a4 = -1.453152027;
    let a5 = 1.061405429;
    let p = 0.3275911;

    let sign = if x < 0.0 { -1.0 } else { 1.0 };
    let x = x.abs() / std::f64::consts::SQRT_2;

    let t = 1.0 / (1.0 + p * x);
    let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();

    0.5 * (1.0 + sign * y)
}