Skip to content

Levy Estimation Variance Gamma/Normal Inverse Gaussian (cantaro86)

Background

Levy Process Parameter Estimation: VG and NIG -- Educational Version

Based on cells 36-62 of the notebook "1.5 SDE - Levy processes" from "Financial Models Numerical Methods" by cantaro86.

Original repository: https://github.com/cantaro86/Financial-Models-Numerical-Methods

References: [1] Cont, R. and Tankov, P. (2003) "Financial Modelling with Jump Processes", Chapman and Hall/CRC. [2] Madan, D. and Seneta, E. (1990) "The Variance Gamma model for share market returns", The Journal of Business, 63(4), 511-524. [3] Barndorff-Nielsen, O. E. (1998) "Processes of Normal Inverse Gaussian type", Finance and Stochastics, 2, 41-68.

This file implements a two-stage parameter estimation pipeline for both the Variance Gamma (VG) and Normal Inverse Gaussian (NIG) Levy processes:

Stage 1 -- Approximated Method of Moments (MoM): A first-order Taylor expansion in theta decouples the moment equations, yielding closed-form estimators that serve as warm starts for the optimizer.

Stage 2 -- Maximum Likelihood Estimation (MLE): L-BFGS-B optimization of the log-likelihood, seeded with the MoM estimates and constrained to enforce the correct sign of theta (matching the observed skewness).

Also included is a simulation of the Inverse Gaussian first-passage-time distribution, which provides physical intuition for WHY the IG distribution arises as the NIG subordinator.

This file is self-contained and requires only standard scientific Python libraries: numpy, scipy, matplotlib.


Code

```python

!/usr/bin/env python3

-- coding: utf-8 --

""" Levy Process Parameter Estimation: VG and NIG -- Educational Version =====================================================================

Based on cells 36-62 of the notebook "1.5 SDE - Levy processes" from "Financial Models Numerical Methods" by cantaro86.

Original repository: https://github.com/cantaro86/Financial-Models-Numerical-Methods

References: [1] Cont, R. and Tankov, P. (2003) "Financial Modelling with Jump Processes", Chapman and Hall/CRC. [2] Madan, D. and Seneta, E. (1990) "The Variance Gamma model for share market returns", The Journal of Business, 63(4), 511-524. [3] Barndorff-Nielsen, O. E. (1998) "Processes of Normal Inverse Gaussian type", Finance and Stochastics, 2, 41-68.

This file implements a two-stage parameter estimation pipeline for both the Variance Gamma (VG) and Normal Inverse Gaussian (NIG) Levy processes:

Stage 1 -- Approximated Method of Moments (MoM):
    A first-order Taylor expansion in theta decouples the moment
    equations, yielding closed-form estimators that serve as warm
    starts for the optimizer.

Stage 2 -- Maximum Likelihood Estimation (MLE):
    L-BFGS-B optimization of the log-likelihood, seeded with the
    MoM estimates and constrained to enforce the correct sign of
    theta (matching the observed skewness).

Also included is a simulation of the Inverse Gaussian first-passage-time distribution, which provides physical intuition for WHY the IG distribution arises as the NIG subordinator.

This file is self-contained and requires only standard scientific Python libraries: numpy, scipy, matplotlib. """

import numpy as np import scipy.stats as ss import scipy.special as scps from scipy.optimize import minimize import matplotlib.pyplot as plt

============================================================================

VG and NIG Density Functions

============================================================================

def VG_density(x, T, c, theta, sigma, kappa): """ Variance Gamma probability density function.

The VG density is expressed in terms of the modified Bessel function of
the second kind K_v.  With location parameter c (deterministic drift),
the VG random variable at time T is:

    Y_T = c * T + X_T^{VG}

Parameters
----------
x : array_like
    Points at which to evaluate the density.
T : float
    Time horizon.
c : float
    Deterministic drift (location parameter).
theta : float
    Drift of the subordinated Brownian motion.
sigma : float
    Volatility of the subordinated Brownian motion.
kappa : float
    Variance rate of the Gamma subordinator.

Returns
-------
array_like
    VG density values.

Notes
-----
The density formula is (see [1], Section 4.3):

    f(x) = 2 * exp(theta*(x-c) / sigma^2)
           / (kappa^(T/kappa) * sqrt(2*pi) * sigma * Gamma(T/kappa))
           * ((x-c)^2 / (2*sigma^2/kappa + theta^2))^(T/(2*kappa) - 1/4)
           * K_{T/kappa - 1/2}(
                 sigma^{-2} * sqrt((x-c)^2 * (2*sigma^2/kappa + theta^2))
             )

where K_v is the modified Bessel function of the second kind (order v).
"""
return (
    2
    * np.exp(theta * (x - c) / sigma**2)
    / (kappa ** (T / kappa) * np.sqrt(2 * np.pi) * sigma
       * scps.gamma(T / kappa))
    * ((x - c) ** 2
       / (2 * sigma**2 / kappa + theta**2)) ** (T / (2 * kappa) - 0.25)
    * scps.kv(
        T / kappa - 0.5,
        sigma ** (-2)
        * np.sqrt((x - c) ** 2 * (2 * sigma**2 / kappa + theta**2)),
    )
)

def NIG_density(x, T, c, theta, sigma, kappa): """ Normal Inverse Gaussian probability density function.

With location parameter c (deterministic drift), the NIG random
variable at time T is:

    Y_T = c * T + X_T^{NIG}

Parameters
----------
x : array_like
    Points at which to evaluate the density.
T : float
    Time horizon.
c : float
    Deterministic drift (location parameter).
theta : float
    Drift of the subordinated Brownian motion.
sigma : float
    Volatility of the subordinated Brownian motion.
kappa : float
    Variance of the Inverse Gaussian subordinator.

Returns
-------
array_like
    NIG density values.

Notes
-----
Defining:
    A = theta / sigma^2
    B = sqrt(theta^2 + sigma^2/kappa) / sigma^2
    C = (T/pi) * exp(T/kappa) * sqrt(theta^2/(kappa*sigma^2) + 1/kappa^2)

the NIG density is:

    f(x) = C * exp(A*(x - c*T))
           * K_1(B * sqrt((x - c*T)^2 + T^2 * sigma^2/kappa))
           / sqrt((x - c*T)^2 + T^2 * sigma^2/kappa)

Note: unlike the VG density (which uses K_v with variable order
T/kappa - 1/2), the NIG density always uses K_1, the Bessel function
of order 1.  This simplification comes from the Inverse Gaussian
subordinator being a special case of the generalized inverse Gaussian.
"""
A = theta / (sigma**2)
B = np.sqrt(theta**2 + sigma**2 / kappa) / sigma**2
C = (T / np.pi * np.exp(T / kappa)
     * np.sqrt(theta**2 / (kappa * sigma**2) + 1.0 / kappa**2))

arg = np.sqrt((x - c * T) ** 2 + T**2 * sigma**2 / kappa)

return C * np.exp(A * (x - c * T)) * scps.kv(1, B * arg) / arg

============================================================================

Approximated Method of Moments (shared by VG and NIG)

============================================================================

def approximate_method_of_moments(data, T): """ First-order (in theta) approximated method of moments.

This method exploits the fact that theta is typically small in financial
applications.  By dropping terms of order theta^2 and higher from the
VG/NIG moment formulas, the system of four equations (mean, variance,
skewness, kurtosis) decouples into four closed-form estimators.

KEY INSIGHT: at first order in theta, the VG and NIG moment formulas
are IDENTICAL:

    E[X_T]    = T * (c + theta)
    Var[X_T]  = T * sigma^2              (no theta^2 * kappa term)
    Skew[X_T] = (1/sqrt(T)) * 3*theta*kappa / sigma
    Kurt[X_T] = (1/T) * 3*kappa          (excess kurtosis)

This means the SAME MoM formulas serve as initial guesses for BOTH
the VG and NIG maximum likelihood estimations.

Inverting gives:
    sigma = std(X) / sqrt(T)
    kappa = T * excess_kurtosis(X) / 3
    theta = sqrt(T) * skew(X) * sigma / (3 * kappa)
    c     = mean(X) / T - theta

Interpretation:
    - kappa controls the KURTOSIS (fat-tailedness).
    - theta controls the SKEWNESS (asymmetry).
      Sign of theta = sign of observed skewness.
    - sigma sets the overall VOLATILITY scale.
    - c absorbs whatever mean is not explained by theta.

Parameters
----------
data : array_like
    Observed sample of the process at time T (i.e., X_T values).
T : float
    Time horizon at which data was observed.

Returns
-------
c_mm : float
    Estimated deterministic drift.
theta_mm : float
    Estimated Brownian drift.
sigma_mm : float
    Estimated Brownian volatility.
kappa_mm : float
    Estimated subordinator variance.
"""
sigma_mm = np.std(data) / np.sqrt(T)
kappa_mm = T * ss.kurtosis(data) / 3        # excess kurtosis
theta_mm = np.sqrt(T) * ss.skew(data) * sigma_mm / (3 * kappa_mm)
c_mm = np.mean(data) / T - theta_mm

return c_mm, theta_mm, sigma_mm, kappa_mm

============================================================================

Maximum Likelihood Estimation -- VG

============================================================================

def vg_mle(data, T, verbose=True): """ Two-stage VG parameter estimation: MoM warm start + MLE.

Stage 1: Compute approximate method-of-moments estimates.
Stage 2: Maximize log-likelihood using L-BFGS-B with:
    - MoM estimates as initial guess (warm start)
    - Bounds that enforce the correct sign of theta
      (matching observed skewness)
    - Positivity constraints on sigma and kappa

The log-likelihood is:
    LL = sum_{i=1}^{N} log( VG_density(x_i; T, c, theta, sigma, kappa) )

We minimize -LL via scipy.optimize.minimize.

Parameters
----------
data : array_like
    Observed sample X_T.
T : float
    Time horizon.
verbose : bool
    If True, print intermediate results.

Returns
-------
c_mle : float
    MLE estimate of c.
theta_mle : float
    MLE estimate of theta.
sigma_mle : float
    MLE estimate of sigma.
kappa_mle : float
    MLE estimate of kappa.
result : scipy.optimize.OptimizeResult
    Full optimization result object.
"""
# --- Stage 1: Method of Moments ---
c_mm, theta_mm, sigma_mm, kappa_mm = approximate_method_of_moments(data, T)

if verbose:
    print("  VG Method-of-Moments estimates:")
    print(f"    c     = {c_mm:.6f}")
    print(f"    theta = {theta_mm:.6f}")
    print(f"    sigma = {sigma_mm:.6f}")
    print(f"    kappa = {kappa_mm:.6f}")
    print(f"    c + theta = {c_mm + theta_mm:.6f}")
    print()

# --- Stage 2: MLE ---
def neg_log_likelihood(params, data, T):
    c, theta, sigma, kappa = params
    pdf_vals = VG_density(data, T, c, theta, sigma, kappa)
    # Guard against log(0) or log(negative)
    pdf_vals = np.maximum(pdf_vals, 1e-300)
    return -np.sum(np.log(pdf_vals))

# Determine bounds based on observed skewness:
# theta must have the same sign as the observed skewness
observed_skew = ss.skew(data)
if observed_skew < 0:
    theta_bounds = (-1.0, -1e-15)
else:
    theta_bounds = (1e-15, 1.0)

bounds = [
    (-1.0, 1.0),           # c: location drift
    theta_bounds,          # theta: sign matches skewness
    (1e-15, 2.0),         # sigma: positive
    (1e-15, None),        # kappa: positive
]

result = minimize(
    neg_log_likelihood,
    x0=[c_mm, theta_mm, sigma_mm, kappa_mm],
    method="L-BFGS-B",
    args=(data, T),
    tol=1e-8,
    bounds=bounds,
)

c_mle, theta_mle, sigma_mle, kappa_mle = result.x

if verbose:
    print(f"  VG MLE optimization: {result.message}")
    print(f"    Iterations: {result.nit}")
    print(f"  VG MLE estimates:")
    print(f"    c     = {c_mle:.6f}")
    print(f"    theta = {theta_mle:.6f}")
    print(f"    sigma = {sigma_mle:.6f}")
    print(f"    kappa = {kappa_mle:.6f}")

return c_mle, theta_mle, sigma_mle, kappa_mle, result

============================================================================

Maximum Likelihood Estimation -- NIG

============================================================================

def nig_mle(data, T, verbose=True): """ Two-stage NIG parameter estimation: MoM warm start + MLE.

This is the ONLY NIG parameter estimation implementation in the
repository.  The key insight that makes the two-stage approach work
is that at first order in theta, the VG and NIG moment formulas are
IDENTICAL.  Therefore, the SAME approximate MoM formulas provide
good initial guesses for the NIG MLE.

The NIG log-likelihood is:
    LL = sum_{i=1}^{N} log( NIG_density(x_i; T, c, theta, sigma, kappa) )

Parameters
----------
data : array_like
    Observed sample X_T.
T : float
    Time horizon.
verbose : bool
    If True, print intermediate results.

Returns
-------
c_mle : float
    MLE estimate of c.
theta_mle : float
    MLE estimate of theta.
sigma_mle : float
    MLE estimate of sigma.
kappa_mle : float
    MLE estimate of kappa.
result : scipy.optimize.OptimizeResult
    Full optimization result object.
"""
# --- Stage 1: Method of Moments ---
# Same formulas as VG, because first-order moments in theta are identical
c_mm, theta_mm, sigma_mm, kappa_mm = approximate_method_of_moments(data, T)

if verbose:
    print("  NIG Method-of-Moments estimates (same formulas as VG):")
    print(f"    c     = {c_mm:.6f}")
    print(f"    theta = {theta_mm:.6f}")
    print(f"    sigma = {sigma_mm:.6f}")
    print(f"    kappa = {kappa_mm:.6f}")
    print(f"    c + theta = {c_mm + theta_mm:.6f}")
    print()

# --- Stage 2: MLE ---
def neg_log_likelihood(params, data, T):
    c, theta, sigma, kappa = params
    pdf_vals = NIG_density(data, T, c, theta, sigma, kappa)
    pdf_vals = np.maximum(pdf_vals, 1e-300)
    return -np.sum(np.log(pdf_vals))

observed_skew = ss.skew(data)
if observed_skew < 0:
    theta_bounds = (-1.0, -1e-15)
else:
    theta_bounds = (1e-15, 1.0)

bounds = [
    (-1.0, 1.0),
    theta_bounds,
    (1e-15, 2.0),
    (1e-15, None),
]

result = minimize(
    neg_log_likelihood,
    x0=[c_mm, theta_mm, sigma_mm, kappa_mm],
    method="L-BFGS-B",
    args=(data, T),
    tol=1e-8,
    bounds=bounds,
)

c_mle, theta_mle, sigma_mle, kappa_mle = result.x

if verbose:
    print(f"  NIG MLE optimization: {result.message}")
    print(f"    Iterations: {result.nit}")
    print(f"  NIG MLE estimates:")
    print(f"    c     = {c_mle:.6f}")
    print(f"    theta = {theta_mle:.6f}")
    print(f"    sigma = {sigma_mle:.6f}")
    print(f"    kappa = {kappa_mle:.6f}")

return c_mle, theta_mle, sigma_mle, kappa_mle, result

============================================================================

Inverse Gaussian First-Passage-Time Simulation

============================================================================

def simulate_ig_first_passage(delta, gamma, T_max=20, paths=40000, steps=10000, seed=42): """ Simulate the Inverse Gaussian distribution as a first-passage-time.

This provides PHYSICAL INTUITION for why the IG distribution appears as
the NIG subordinator.  The IG(mu, lambda) distribution is the distribution
of the first time a drifted Brownian motion:

    dZ_s = gamma * ds + dW_s

crosses a linearly-growing barrier delta * t.  The IG process T_t at
time t equals:

    T_t = inf{ s > 0 : Z_s = delta * t }

which is distributed as IG(delta*t / gamma, delta^2 * t^2).

This connects the abstract IG subordinator to a concrete physical picture:
the random "operational time" in the NIG model is the first time a noisy
signal (Brownian motion with drift) reaches a linearly-growing threshold.

Parameters
----------
delta : float
    Barrier slope (the barrier at time t is delta * t).
gamma : float
    Drift of the Brownian motion.
T_max : float
    Maximum simulation time.
paths : int
    Number of Monte Carlo paths.
steps : int
    Number of time steps.
seed : int
    Random seed.

Returns
-------
first_passage_times : ndarray
    Array of first passage times (one per path).
theoretical_mean : float
    Theoretical mean = delta / gamma.
theoretical_var : float
    Theoretical variance = delta / gamma^3.
lam : float
    IG shape parameter = delta^2.
mu_scaled : float
    Scaled mean for scipy.stats.invgauss = (delta/gamma) / delta^2.
"""
np.random.seed(seed)

T_vec, dt = np.linspace(0, T_max, steps, retstep=True)
X0 = np.zeros((paths, 1))
increments = ss.norm.rvs(
    loc=gamma * dt, scale=np.sqrt(dt), size=(paths, steps - 1)
)

Z = np.concatenate((X0, increments), axis=1).cumsum(axis=1)

# First passage time: first index where Z > delta (the barrier)
first_passage_times = np.argmax(Z > delta, axis=1) * dt

theoretical_mean = delta / gamma
theoretical_var = delta / gamma**3
lam = delta**2
mu_scaled = (delta / gamma) / lam  # scipy's mu parameter

return first_passage_times, theoretical_mean, theoretical_var, lam, mu_scaled

============================================================================

Simulation helpers

============================================================================

def simulate_vg(T, N, theta, sigma, kappa, c=0.0, seed=42): """ Simulate VG process via Gamma subordination.

    Y_T = c * T + theta * G + sigma * sqrt(G) * Z

where G ~ Gamma(T/kappa, kappa), Z ~ N(0,1).

Parameters
----------
T : float
    Time horizon.
N : int
    Number of samples.
theta, sigma, kappa : float
    VG model parameters.
c : float
    Deterministic drift.
seed : int
    Random seed.

Returns
-------
ndarray
    Array of N samples from the VG distribution at time T.
"""
np.random.seed(seed)
G = ss.gamma(T / kappa, scale=kappa).rvs(N)
Z = ss.norm.rvs(0, 1, N)
return c * T + theta * G + sigma * np.sqrt(G) * Z

def simulate_nig(T, N, theta, sigma, kappa, c=0.0, seed=42): """ Simulate NIG process via Inverse Gaussian subordination.

    Y_T = c * T + theta * IG + sigma * sqrt(IG) * Z

where IG ~ InverseGaussian(mu=T, lambda=T^2/kappa), Z ~ N(0,1).

Parameters
----------
T : float
    Time horizon.
N : int
    Number of samples.
theta, sigma, kappa : float
    NIG model parameters.
c : float
    Deterministic drift.
seed : int
    Random seed.

Returns
-------
ndarray
    Array of N samples from the NIG distribution at time T.
"""
np.random.seed(seed)
lam = T**2 / kappa
mu_s = T / lam  # scipy's mu parameter
IG = ss.invgauss.rvs(mu=mu_s, scale=lam, size=N)
Z = ss.norm.rvs(0, 1, N)
return c * T + theta * IG + sigma * np.sqrt(IG) * Z

============================================================================

Main demonstration

============================================================================

if name == "main":

# ================================================================
# True parameters for both VG and NIG
# ================================================================
T_true = 2.0              # time horizon
N_samples = 1_000_000     # number of simulated samples
theta_true = -0.1         # BM drift (negative -> left skew)
sigma_true = 0.2          # BM volatility
kappa_true = 0.5          # subordinator variance
c_true = 0.0              # deterministic drift

print("=" * 70)
print("  Levy Process Parameter Estimation")
print("  cantaro86: '1.5 SDE - Levy processes', cells 36-62")
print("=" * 70)
print()
print(f"  True parameters: c={c_true}, theta={theta_true}, "
      f"sigma={sigma_true}, kappa={kappa_true}")
print(f"  Time horizon T={T_true}, samples N={N_samples:,}")
print()

# ================================================================
# PART 1: VG Estimation
# ================================================================
print("-" * 70)
print("  PART 1: Variance Gamma (VG) Parameter Estimation")
print("-" * 70)
print()

X_vg = simulate_vg(T_true, N_samples, theta_true, sigma_true,
                    kappa_true, c=c_true, seed=42)

print(f"  Sample statistics of VG data:")
print(f"    Mean:              {np.mean(X_vg):.6f}  "
      f"(theoretical: {(c_true + theta_true) * T_true:.6f})")
print(f"    Std deviation:     {np.std(X_vg):.6f}")
print(f"    Skewness:          {ss.skew(X_vg):.6f}")
print(f"    Excess kurtosis:   {ss.kurtosis(X_vg):.6f}")
print()

c_vg, theta_vg, sigma_vg, kappa_vg, res_vg = vg_mle(X_vg, T_true)
print()

print("  VG estimation summary:")
print(f"    {'Param':<8} {'True':>10} {'MoM':>10} {'MLE':>10}")
print(f"    {'-----':<8} {'----':>10} {'---':>10} {'---':>10}")
c_mm, theta_mm, sigma_mm, kappa_mm = approximate_method_of_moments(
    X_vg, T_true
)
print(f"    {'c':<8} {c_true:>10.4f} {c_mm:>10.4f} {c_vg:>10.4f}")
print(f"    {'theta':<8} {theta_true:>10.4f} {theta_mm:>10.4f} "
      f"{theta_vg:>10.4f}")
print(f"    {'sigma':<8} {sigma_true:>10.4f} {sigma_mm:>10.4f} "
      f"{sigma_vg:>10.4f}")
print(f"    {'kappa':<8} {kappa_true:>10.4f} {kappa_mm:>10.4f} "
      f"{kappa_vg:>10.4f}")
print()

# ================================================================
# PART 2: NIG Estimation
# ================================================================
print("-" * 70)
print("  PART 2: Normal Inverse Gaussian (NIG) Parameter Estimation")
print("-" * 70)
print()

X_nig = simulate_nig(T_true, N_samples, theta_true, sigma_true,
                      kappa_true, c=c_true, seed=42)

print(f"  Sample statistics of NIG data:")
print(f"    Mean:              {np.mean(X_nig):.6f}  "
      f"(theoretical: {(c_true + theta_true) * T_true:.6f})")
print(f"    Std deviation:     {np.std(X_nig):.6f}")
print(f"    Skewness:          {ss.skew(X_nig):.6f}")
print(f"    Excess kurtosis:   {ss.kurtosis(X_nig):.6f}")
print()

c_nig, theta_nig, sigma_nig, kappa_nig, res_nig = nig_mle(X_nig, T_true)
print()

print("  NIG estimation summary:")
print(f"    {'Param':<8} {'True':>10} {'MoM':>10} {'MLE':>10}")
print(f"    {'-----':<8} {'----':>10} {'---':>10} {'---':>10}")
c_mm2, theta_mm2, sigma_mm2, kappa_mm2 = approximate_method_of_moments(
    X_nig, T_true
)
print(f"    {'c':<8} {c_true:>10.4f} {c_mm2:>10.4f} {c_nig:>10.4f}")
print(f"    {'theta':<8} {theta_true:>10.4f} {theta_mm2:>10.4f} "
      f"{theta_nig:>10.4f}")
print(f"    {'sigma':<8} {sigma_true:>10.4f} {sigma_mm2:>10.4f} "
      f"{sigma_nig:>10.4f}")
print(f"    {'kappa':<8} {kappa_true:>10.4f} {kappa_mm2:>10.4f} "
      f"{kappa_nig:>10.4f}")
print()

# ================================================================
# PART 3: Inverse Gaussian First-Passage-Time Simulation
# ================================================================
print("-" * 70)
print("  PART 3: Inverse Gaussian as First-Passage-Time Distribution")
print("-" * 70)
print()
print("  Physical picture: the IG subordinator in the NIG model")
print("  represents the first time a drifted Brownian motion")
print("  crosses a linearly-growing barrier.")
print()

t_fpt = 2          # time parameter for the barrier
delta_fpt = 3 * t_fpt   # barrier = delta * t
gamma_fpt = 2       # drift of the Brownian motion

fpt, th_mean, th_var, lam_ig, mu_ig = simulate_ig_first_passage(
    delta=delta_fpt, gamma=gamma_fpt, T_max=20,
    paths=40000, steps=10000, seed=42
)

print(f"  Barrier: delta = {delta_fpt}, BM drift: gamma = {gamma_fpt}")
print(f"  Theoretical mean: {th_mean:.4f}")
print(f"  Theoretical variance: {th_var:.4f}")
print(f"  Simulated mean: {fpt.mean():.4f}")
print(f"  Simulated variance: {fpt.var():.4f}")
print()

# ================================================================
# PART 4: Plots
# ================================================================
print("-" * 70)
print("  PART 4: Generating plots...")
print("-" * 70)
print()

fig, axes = plt.subplots(2, 2, figsize=(16, 12))

# --- Plot (a): VG histogram vs estimated density ---
ax = axes[0, 0]
x_grid = np.linspace(X_vg.min(), X_vg.max(), 500)
ax.hist(X_vg, density=True, bins=200, facecolor="LightBlue", alpha=0.7,
        label="Simulated VG data")
ax.plot(x_grid,
        VG_density(x_grid, T_true, c_true, theta_true, sigma_true,
                   kappa_true),
        "r-", linewidth=2, label="True VG density")
ax.plot(x_grid,
        VG_density(x_grid, T_true, c_vg, theta_vg, sigma_vg, kappa_vg),
        "g--", linewidth=2, label="MLE-estimated VG density")
ax.set_xlabel("x", fontsize=11)
ax.set_ylabel("Density", fontsize=11)
ax.set_title("VG: Histogram vs True and Estimated Densities", fontsize=12)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)

# --- Plot (b): NIG histogram vs estimated density ---
ax = axes[0, 1]
x_grid2 = np.linspace(X_nig.min(), X_nig.max(), 500)
ax.hist(X_nig, density=True, bins=200, facecolor="LightBlue", alpha=0.7,
        label="Simulated NIG data")
ax.plot(x_grid2,
        NIG_density(x_grid2, T_true, c_true, theta_true, sigma_true,
                    kappa_true),
        "r-", linewidth=2, label="True NIG density")
ax.plot(x_grid2,
        NIG_density(x_grid2, T_true, c_nig, theta_nig, sigma_nig,
                    kappa_nig),
        "g--", linewidth=2, label="MLE-estimated NIG density")
ax.set_xlabel("x", fontsize=11)
ax.set_ylabel("Density", fontsize=11)
ax.set_title("NIG: Histogram vs True and Estimated Densities", fontsize=12)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)

# --- Plot (c): VG vs NIG comparison (log scale) ---
ax = axes[1, 0]
# Use a common grid for comparison
x_common = np.linspace(
    max(X_vg.min(), X_nig.min()),
    min(X_vg.max(), X_nig.max()),
    500,
)
vg_dens = VG_density(x_common, T_true, c_true, theta_true, sigma_true,
                     kappa_true)
nig_dens = NIG_density(x_common, T_true, c_true, theta_true, sigma_true,
                       kappa_true)
# Normal density for comparison
normal_dens = ss.norm.pdf(
    x_common,
    loc=(c_true + theta_true) * T_true,
    scale=np.sqrt((sigma_true**2 + theta_true**2 * kappa_true) * T_true),
)

ax.semilogy(x_common, vg_dens, "b-", linewidth=2, label="VG (true)")
ax.semilogy(x_common, nig_dens, "r-", linewidth=2, label="NIG (true)")
ax.semilogy(x_common, normal_dens, "k--", linewidth=1.5,
            label="Normal (matched moments)")
ax.set_xlabel("x", fontsize=11)
ax.set_ylabel("Density (log scale)", fontsize=11)
ax.set_title(
    "VG vs NIG vs Normal (log scale)\n"
    r"Same $\theta, \sigma, \kappa$ -- different tail behavior",
    fontsize=12,
)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
ax.set_ylim(bottom=1e-6)

# --- Plot (d): Inverse Gaussian first-passage-time ---
ax = axes[1, 1]
x_ig = np.linspace(0.01, 10, 10000)
ax.hist(fpt[fpt > 0], density=True, bins=100, facecolor="LightBlue",
        alpha=0.7, label="Simulated first-passage times")
ax.plot(x_ig,
        ss.invgauss.pdf(x_ig, mu=mu_ig, scale=lam_ig),
        "r-", linewidth=2, label="Theoretical IG density")
ax.set_xlabel("First passage time", fontsize=11)
ax.set_ylabel("Density", fontsize=11)
ax.set_title(
    "Inverse Gaussian as First-Passage-Time\n"
    rf"$\delta$={delta_fpt}, $\gamma$={gamma_fpt}: "
    rf"IG($\mu$={th_mean:.1f}, $\lambda$={lam_ig:.0f})",
    fontsize=12,
)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
ax.set_xlim(0, 10)

plt.tight_layout()
plt.savefig("levy_estimation_results.png", dpi=150)
plt.show()
print("  Plot saved to levy_estimation_results.png")
print()
print("=" * 70)
print("  Done.")
print("=" * 70)

```

Exercises

Exercise 1. Describe the two-stage estimation pipeline for Levy process parameters. Why is Method of Moments (MoM) used as a warm start for MLE?

Solution to Exercise 1

Stage 1 (MoM): Match theoretical moments (mean, variance, skewness, kurtosis) to sample moments. For VG/NIG processes with parameters \((\sigma, \theta, \kappa)\), first-order Taylor approximation decouples the equations, yielding closed-form estimators.

Stage 2 (MLE): Maximize the log-likelihood \(\ell(\theta) = \sum \ln f(x_i; \theta)\) starting from the MoM estimates.

MoM provides a fast, robust initial guess close to the MLE. Without warm starting, the MLE optimizer might converge to a local maximum or diverge.


Exercise 2. Write the first four moments of the Variance Gamma process in terms of \(\sigma\), \(\theta\), \(\kappa\).

Solution to Exercise 2

For a VG process over period \(\Delta t\):

\[ E[X] = \theta\Delta t, \quad \mathrm{Var}(X) = (\sigma^2 + \theta^2\kappa)\Delta t \]

The skewness is proportional to \(\theta\kappa\) and the excess kurtosis is proportional to \(\kappa\). The VG process can produce arbitrary skewness (via \(\theta\)) and excess kurtosis (via \(\kappa\)), unlike Brownian motion which has zero skewness and zero excess kurtosis.


Exercise 3. Compare the VG and NIG processes. What are their subordinators and tail behavior?

Solution to Exercise 3
Feature Variance Gamma Normal Inverse Gaussian
Subordinator Gamma process Inverse Gaussian process
Construction \(X_t = \theta G(t) + \sigma W(G(t))\) \(X_t = \theta IG(t) + \sigma W(IG(t))\)
Tail behavior Semi-heavy (exponential decay) Semi-heavy (slightly heavier than VG)
Path activity Finite variation, infinite activity Infinite variation, infinite activity

NIG generally provides better fits to financial data due to slightly heavier tails and infinite variation.


Exercise 4. Why is MLE preferred over MoM for final parameter estimates? When can MoM be sufficient?

Solution to Exercise 4

MLE is preferred because it is asymptotically efficient, uses all information in the data, and provides standard errors via the Fisher information matrix.

MoM is sufficient when: (1) sample size is small, (2) the likelihood is unavailable or expensive, (3) a quick approximate estimate is needed, or (4) the model has few parameters well-determined by moments.