Source code for tensorquantlib.finance.local_vol

"""Local volatility model (Dupire).

The Dupire (1994) local volatility formula extracts local vol from
an implied volatility surface, enabling exact calibration to European
option prices.
"""

from __future__ import annotations

import numpy as np
from scipy.interpolate import RectBivariateSpline
from scipy.stats import norm


[docs] def dupire_local_vol( strikes: np.ndarray, expiries: np.ndarray, iv_surface: np.ndarray, S: float, r: float = 0.0, q: float = 0.0, ) -> np.ndarray: """Compute Dupire local volatility from an implied vol surface. Uses the Dupire (1994) formula: sigma_loc^2(K,T) = (dC/dT + (r-q)*K*dC/dK + q*C) / (0.5 * K^2 * d2C/dK^2) where C(K,T) is the Black-Scholes price at implied vol sigma(K,T). Parameters ---------- strikes : array, shape (nK,) Strike grid. expiries : array, shape (nT,) Expiry grid (must be > 0). iv_surface : array, shape (nK, nT) Implied volatility surface, ``iv_surface[i,j] = sigma(K_i, T_j)``. S : float Spot price. r : float Risk-free rate. q : float Dividend yield. Returns ------- local_vol : array, shape (nK, nT) Local volatility surface. """ nK, nT = iv_surface.shape # Build BS prices on the (K, T) grid C = np.zeros_like(iv_surface) for i in range(nK): for j in range(nT): K, T, sig = strikes[i], expiries[j], iv_surface[i, j] d1 = (np.log(S / K) + (r - q + 0.5 * sig**2) * T) / (sig * np.sqrt(T)) d2 = d1 - sig * np.sqrt(T) C[i, j] = S * np.exp(-q * T) * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2) # Use spline interpolation for smooth derivatives spline = RectBivariateSpline(strikes, expiries, C, kx=3, ky=3) local_vol2 = np.zeros_like(iv_surface) for i in range(nK): for j in range(nT): K, T = strikes[i], expiries[j] dC_dT = spline(K, T, dx=0, dy=1).item() dC_dK = spline(K, T, dx=1, dy=0).item() d2C_dK2 = spline(K, T, dx=2, dy=0).item() numerator = dC_dT + (r - q) * K * dC_dK + q * C[i, j] denominator = 0.5 * K**2 * d2C_dK2 if denominator > 1e-15: local_vol2[i, j] = numerator / denominator else: local_vol2[i, j] = iv_surface[i, j] ** 2 # Clamp to avoid negative values (numerical noise) local_vol2 = np.maximum(local_vol2, 1e-8) return np.sqrt(local_vol2)
[docs] def local_vol_mc( S: float, K: float, T: float, r: float, strikes: np.ndarray, expiries: np.ndarray, local_vol_surface: np.ndarray, option_type: str = "call", q: float = 0.0, n_paths: int = 100_000, n_steps: int = 252, seed: int | None = None, ) -> float: """Price an option using local vol MC simulation. Simulates paths under dS = (r-q)*S*dt + sigma_loc(S,t)*S*dW. Parameters ---------- S : float Spot price. K : float Strike. T : float Expiry. r : float Risk-free rate. strikes : array Strike grid for local vol interpolation. expiries : array Time grid for local vol interpolation. local_vol_surface : array, shape (nK, nT) Local volatility surface. option_type : str ``'call'`` or ``'put'``. q : float Dividend yield. n_paths : int MC paths. n_steps : int Time steps. seed : int, optional Random seed. Returns ------- float MC price. """ rng = np.random.default_rng(seed) dt = T / n_steps spline = RectBivariateSpline(strikes, expiries, local_vol_surface, kx=3, ky=3) S_paths = np.full(n_paths, float(S)) for step in range(n_steps): t = step * dt # Evaluate local vol for each path's current S # Clamp S to grid bounds for interpolation S_clamped = np.clip(S_paths, strikes[0], strikes[-1]) t_clamped = min(max(t, expiries[0]), expiries[-1]) sigma_loc = np.array([spline(s, t_clamped).item() for s in S_clamped]) sigma_loc = np.maximum(sigma_loc, 1e-6) z = rng.standard_normal(n_paths) S_paths = S_paths * np.exp((r - q - 0.5 * sigma_loc**2) * dt + sigma_loc * np.sqrt(dt) * z) if option_type == "call": payoff = np.maximum(S_paths - K, 0.0) else: payoff = np.maximum(K - S_paths, 0.0) return float(np.exp(-r * T) * np.mean(payoff))