Source code for tensorquantlib.finance.basket

"""
Basket option pricing via Monte Carlo simulation.

Provides:
    - simulate_basket: Multi-asset GBM simulation for basket option pricing
    - build_pricing_grid: Construct a multi-dimensional pricing tensor
                          for Tensor-Train compression
"""

from __future__ import annotations

import numpy as np


[docs] def simulate_basket( S0: np.ndarray, K: float, T: float, r: float, sigma: np.ndarray, corr: np.ndarray, weights: np.ndarray, n_paths: int = 100_000, q: np.ndarray | None = None, option_type: str = "call", seed: int | None = None, ) -> tuple[float, float]: """Price a basket option via Monte Carlo simulation. Uses correlated geometric Brownian motion (GBM) under the risk-neutral measure to simulate terminal asset prices. Payoff_call = max(sum(w_i * S_i(T)) - K, 0) Price = exp(-rT) * E[Payoff] Args: S0: Initial spot prices, shape (d,). K: Strike price. T: Time to expiry (years). r: Risk-free rate. sigma: Volatilities, shape (d,). corr: Correlation matrix, shape (d, d). weights: Basket weights, shape (d,). n_paths: Number of Monte Carlo paths. q: Dividend yields, shape (d,). Defaults to zero. option_type: 'call' or 'put'. seed: Random seed for reproducibility. Returns: (price, stderr): Discounted expected payoff and its standard error. """ S0 = np.asarray(S0, dtype=float) sigma = np.asarray(sigma, dtype=float) corr = np.asarray(corr, dtype=float) weights = np.asarray(weights, dtype=float) d = len(S0) if option_type not in ("call", "put"): raise ValueError(f"option_type must be 'call' or 'put', got {option_type!r}") if np.any(S0 <= 0): raise ValueError("All initial spot prices S0 must be positive") if K <= 0: raise ValueError("Strike K must be positive") if T <= 0: raise ValueError("Time to expiry T must be positive") if np.any(sigma <= 0): raise ValueError("All volatilities must be positive") if sigma.shape != (d,): raise ValueError(f"sigma shape {sigma.shape} doesn't match S0 length {d}") if corr.shape != (d, d): raise ValueError(f"corr shape {corr.shape} doesn't match (d,d)=({d},{d})") if weights.shape != (d,): raise ValueError(f"weights shape {weights.shape} doesn't match S0 length {d}") if n_paths < 1: raise ValueError("n_paths must be >= 1") if q is None: q = np.zeros(d) rng = np.random.default_rng(seed) # Cholesky decomposition of correlation matrix # Regularize if needed for numerical stability try: L = np.linalg.cholesky(corr) except np.linalg.LinAlgError: corr_reg = corr + 1e-8 * np.eye(d) L = np.linalg.cholesky(corr_reg) # Generate correlated standard normals: (n_paths, d) Z = rng.standard_normal((n_paths, d)) Z_corr = Z @ L.T # Simulate terminal prices under risk-neutral measure # S_i(T) = S_i(0) * exp((r - q_i - sigma_i^2/2)*T + sigma_i*sqrt(T)*Z_i) drift = (r - q - 0.5 * sigma**2) * T # shape (d,) diffusion = sigma * np.sqrt(T) * Z_corr # shape (n_paths, d) S_T = S0 * np.exp(drift + diffusion) # shape (n_paths, d) # Basket value at maturity basket = S_T @ weights # shape (n_paths,) # Payoff if option_type == "call": payoff = np.maximum(basket - K, 0.0) else: payoff = np.maximum(K - basket, 0.0) # Discounted price and standard error discount = np.exp(-r * T) price = discount * np.mean(payoff) stderr = discount * np.std(payoff) / np.sqrt(n_paths) return float(price), float(stderr)
def _price_at_spots( spots: np.ndarray, K: float, T: float, r: float, sigma: np.ndarray, corr: np.ndarray, weights: np.ndarray, n_mc_paths: int, q: np.ndarray | None, option_type: str, seed: int, ) -> float: """Price a single basket option for given spot values.""" price, _ = simulate_basket( S0=spots, K=K, T=T, r=r, sigma=sigma, corr=corr, weights=weights, n_paths=n_mc_paths, q=q, option_type=option_type, seed=seed, ) return price
[docs] def build_pricing_grid( S0_ranges: list[tuple[float, float]], K: float, T: float, r: float, sigma: np.ndarray, corr: np.ndarray, weights: np.ndarray, n_points: int = 30, n_mc_paths: int = 1_000, q: np.ndarray | None = None, option_type: str = "call", seed: int = 42, ) -> tuple[np.ndarray, list[np.ndarray]]: """Build a multi-dimensional pricing tensor for TT compression. For each combination of spot prices on the grid, runs a Monte Carlo simulation to compute the basket option price. Returns a d-dimensional tensor of shape (n_points, n_points, ..., n_points). Args: S0_ranges: List of (min, max) tuples for each asset's spot range. K: Strike price. T: Time to expiry. r: Risk-free rate. sigma: Volatilities, shape (d,). corr: Correlation matrix, shape (d, d). weights: Basket weights, shape (d,). n_points: Number of grid points per asset. n_mc_paths: MC paths per grid point (use fewer for speed). q: Dividend yields. option_type: 'call' or 'put'. seed: Base random seed. Returns: (grid_tensor, axes): The pricing tensor and list of 1D grid axes. """ d = len(S0_ranges) if q is None: q = np.zeros(d) # Create grid axes for each asset axes = [np.linspace(lo, hi, n_points) for lo, hi in S0_ranges] # Create meshgrid indices grid_shape = tuple([n_points] * d) grid = np.zeros(grid_shape) # Iterate over all grid points # Use np.ndindex for clean iteration over d-dimensional grid n_points**d for flat_idx, multi_idx in enumerate(np.ndindex(*grid_shape)): # Construct spot vector for this grid point spots = np.array([axes[i][multi_idx[i]] for i in range(d)]) # Use a deterministic seed per grid point for reproducibility point_seed = seed + flat_idx grid[multi_idx] = _price_at_spots( spots, K, T, r, sigma, corr, weights, n_mc_paths, q, option_type, point_seed, ) return grid, axes
[docs] def build_pricing_grid_analytic( S0_ranges: list[tuple[float, float]], K: float, T: float, r: float, sigma: np.ndarray, weights: np.ndarray, n_points: int = 30, ) -> tuple[np.ndarray, list[np.ndarray]]: """Build a pricing grid using log-normal moment-matching (Gentle 1993). Approximates the basket option price by matching the first two moments of the basket value to a single log-normal distribution, then applying the Black-Scholes formula to that equivalent asset. This is the standard "moment-matching" approximation used in practice for European basket options. The approximation is accurate to within ~1-3% for typical parameters and produces a smooth, low-rank tensor ideal for TT compression. For deep ITM/OTM or highly correlated assets (rho > 0.9), consider using ``build_pricing_grid`` (Monte Carlo) for higher accuracy. Args: S0_ranges: List of (min, max) tuples for each asset's spot range. K: Strike price. T: Time to expiry. r: Risk-free rate. sigma: Volatilities per asset, shape (d,). weights: Basket weights, shape (d,). n_points: Grid points per axis. Returns: (grid_tensor, axes): Pricing tensor and grid axes. """ from scipy.stats import norm as _norm d = len(S0_ranges) sigma = np.asarray(sigma, dtype=float) weights = np.asarray(weights, dtype=float) axes = [np.linspace(lo, hi, n_points) for lo, hi in S0_ranges] grids = np.meshgrid(*axes, indexing="ij") # d arrays each shape (n,...,n) # Forward prices for each asset at each grid point # F_i = S_i * exp(r * T) forwards = [grids[i] * np.exp(r * T) for i in range(d)] # First moment of basket: E[B] = sum_i w_i * F_i E_B = sum(weights[i] * forwards[i] for i in range(d)) # Second moment via independence assumption (worst case: zero correlation) # E[B^2] = sum_i sum_j w_i * w_j * F_i * F_j * exp(sigma_i^2 * T if i==j else 0) # For the diagonal (i==j): E[S_i^2] = F_i^2 * exp(sigma_i^2 * T) E_B2 = np.zeros_like(E_B) for i in range(d): for j in range(d): if i == j: E_B2 += ( weights[i] * weights[j] * forwards[i] * forwards[j] * np.exp(sigma[i] ** 2 * T) ) else: E_B2 += weights[i] * weights[j] * forwards[i] * forwards[j] # Equivalent lognormal: match E[B] and E[B^2] # If X ~ LN(mu_X, sigma_X^2) then E[X] = exp(mu_X + 0.5*sigma_X^2) # and E[X^2] = exp(2*mu_X + 2*sigma_X^2) # => sigma_X^2 = log(E[B^2]) - 2*log(E[B]) # => mu_X = log(E[B]) - 0.5 * sigma_X^2 sigma_X2 = np.log(np.maximum(E_B2, 1e-15)) - 2.0 * np.log(np.maximum(E_B, 1e-15)) sigma_X = np.sqrt(np.maximum(sigma_X2, 1e-12)) F_X = E_B # forward price of the equivalent asset equals E[B] # Black-76 call price: C = exp(-rT) * (F * N(d1) - K * N(d2)) # d1 = (log(F/K) + 0.5*sigma_X^2*T) / (sigma_X * sqrt(T)) # d2 = d1 - sigma_X * sqrt(T) sqrtT = np.sqrt(T) log_FK = np.log(np.maximum(F_X, 1e-15) / K) d1 = (log_FK + 0.5 * sigma_X2 * T) / np.maximum(sigma_X * sqrtT, 1e-12) d2 = d1 - sigma_X * sqrtT N_d1 = _norm.cdf(d1) N_d2 = _norm.cdf(d2) discount = np.exp(-r * T) grid = discount * (F_X * N_d1 - K * N_d2) # Where intrinsic value exceeds BS value (shouldn't happen but clip for safety) intrinsic = discount * np.maximum(E_B - K, 0.0) grid = np.maximum(grid, intrinsic) return grid, axes