Source code for tensorquantlib.finance.exotics

"""
Exotic options: Asian, digital (binary), and barrier options.

Provides both analytic closed-forms (where available) and Monte Carlo pricing
for the following option types built on log-normal GBM dynamics:

    Asian (arithmetic average):
        asian_price_mc       -- Monte Carlo arithmetic Asian call/put
        asian_geometric_price -- Analytic geometric-average Asian (closed-form)

    Digital (binary):
        digital_price        -- Analytic cash-or-nothing and asset-or-nothing
        digital_price_mc     -- Monte Carlo digital price (validation)

    Barrier options (single barrier, European):
        barrier_price        -- Analytic single-barrier option (Reiner-Rubinstein)
        barrier_price_mc     -- Monte Carlo barrier option price

All analytic formulas assume GBM with constant parameters.

References:
    Kemna & Vorst (1990). A Pricing Method for Options Based on Average Asset Values.
    Journal of Banking and Finance, 14(1), 113-129.

    Rubinstein, M. & Reiner, E. (1991). Breaking Down the Barriers. Risk 4(8), 28-35.

    Reiner, E. (1992). Quanto Mechanics. Risk 5(3), 59-63.
"""

from __future__ import annotations

from typing import Union

import numpy as np
from scipy.stats import norm

# ------------------------------------------------------------------ #
# Helper
# ------------------------------------------------------------------ #


def _gbm_paths(
    S: float,
    T: float,
    r: float,
    sigma: float,
    q: float,
    n_paths: int,
    n_steps: int,
    rng: np.random.Generator,
) -> np.ndarray:
    """Simulate GBM paths. Returns shape (n_steps+1, n_paths)."""
    dt = T / n_steps
    z = rng.standard_normal((n_steps, n_paths))
    log_increments = (r - q - 0.5 * sigma**2) * dt + sigma * np.sqrt(dt) * z
    log_S = np.vstack(
        [
            np.full(n_paths, np.log(S)),
            np.log(S) + np.cumsum(log_increments, axis=0),
        ]
    )
    return np.exp(log_S)


# ================================================================== #
# ASIAN OPTIONS
# ================================================================== #


[docs] def asian_price_mc( S: float, K: float, T: float, r: float, sigma: float, q: float = 0.0, option_type: str = "call", average_type: str = "arithmetic", *, n_paths: int = 100_000, n_steps: int = 252, seed: int | None = None, return_stderr: bool = False, ) -> Union[float, tuple[float, float]]: """Price an Asian average-rate option by Monte Carlo. The payoff is based on the average of the asset price over [0, T]: Call: max(avg(S) - K, 0) * exp(-rT) Put: max(K - avg(S), 0) * exp(-rT) Args: S: Spot price. K: Strike. T: Time to expiry (years). r: Risk-free rate. sigma: Volatility. q: Dividend yield. option_type: 'call' or 'put'. average_type: 'arithmetic' or 'geometric'. n_paths: Monte Carlo paths. n_steps: Averaging time steps. seed: Random seed. return_stderr: If True, return (price, stderr). Returns: Asian option price, or (price, stderr). Example: >>> price = asian_price_mc(100, 100, 1.0, 0.05, 0.2, seed=0) >>> 5.0 < price < 12.0 True """ rng = np.random.default_rng(seed) paths = _gbm_paths(S, T, r, sigma, q, n_paths, n_steps, rng) # Exclude time-0 from average (average over [dt, T]) obs = paths[1:] # shape: (n_steps, n_paths) if average_type == "arithmetic": avg = obs.mean(axis=0) elif average_type == "geometric": avg = np.exp(np.log(obs).mean(axis=0)) else: raise ValueError(f"average_type must be 'arithmetic' or 'geometric', got {average_type!r}") if option_type == "call": payoffs = np.maximum(avg - K, 0.0) else: payoffs = np.maximum(K - avg, 0.0) discount = np.exp(-r * T) price = discount * float(np.mean(payoffs)) stderr = discount * float(np.std(payoffs) / np.sqrt(n_paths)) return (price, stderr) if return_stderr else price
[docs] def asian_geometric_price( S: float, K: float, T: float, r: float, sigma: float, q: float = 0.0, option_type: str = "call", ) -> float: """Closed-form price for continuous geometric-average Asian option (Kemna & Vorst 1990). Applicable to continuous monitoring (limit of n_steps → ∞). Args: S, K, T, r, sigma, q: Standard Black-Scholes inputs. option_type: 'call' or 'put'. Returns: Geometric Asian option price. Example: >>> p = asian_geometric_price(100, 100, 1.0, 0.05, 0.2) >>> 5.0 < p < 12.0 True """ # Adjusted parameters for geometric average sigma_geo = sigma / np.sqrt(3.0) b = 0.5 * (r - q - sigma**2 / 6.0) d1 = (np.log(S / K) + (b + 0.5 * sigma_geo**2) * T) / (sigma_geo * np.sqrt(T)) d2 = d1 - sigma_geo * np.sqrt(T) if option_type == "call": price = S * np.exp((b - r) * T) * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2) else: price = K * np.exp(-r * T) * norm.cdf(-d2) - S * np.exp((b - r) * T) * norm.cdf(-d1) return float(price)
# ================================================================== # # DIGITAL (BINARY) OPTIONS # ================================================================== #
[docs] def digital_price( S: float, K: float, T: float, r: float, sigma: float, q: float = 0.0, option_type: str = "call", payoff_type: str = "cash", payoff_amount: float = 1.0, ) -> float: """Analytic Black-Scholes price for a digital (binary) option. Args: S: Spot price. K: Strike. T: Time to expiry (years). r: Risk-free rate. sigma: Volatility. q: Dividend yield. option_type: 'call' (pays if S_T > K) or 'put' (pays if S_T < K). payoff_type: 'cash' (fixed cash) or 'asset' (deliver asset if triggered). payoff_amount: Size of the cash payment if payoff_type='cash' (default 1.0). Returns: Digital option price. Example: >>> p = digital_price(100, 100, 1.0, 0.05, 0.2, payoff_type='cash') >>> 0.0 < p < 1.0 True """ d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) d2 = d1 - sigma * np.sqrt(T) if payoff_type == "cash": # Cash-or-nothing: pays payoff_amount if in the money at expiry if option_type == "call": price = payoff_amount * np.exp(-r * T) * norm.cdf(d2) else: price = payoff_amount * np.exp(-r * T) * norm.cdf(-d2) elif payoff_type == "asset": # Asset-or-nothing: delivers the asset if in the money if option_type == "call": price = S * np.exp(-q * T) * norm.cdf(d1) else: price = S * np.exp(-q * T) * norm.cdf(-d1) else: raise ValueError(f"payoff_type must be 'cash' or 'asset', got {payoff_type!r}") return float(price)
[docs] def digital_greeks( S: float, K: float, T: float, r: float, sigma: float, q: float = 0.0, option_type: str = "call", payoff_type: str = "cash", payoff_amount: float = 1.0, ) -> dict[str, float]: """Analytic Black-Scholes Greeks for digital options. Returns delta, gamma, vega, theta, rho. Example: >>> g = digital_greeks(100, 100, 1.0, 0.05, 0.2) >>> 'delta' in g and 'gamma' in g True """ d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) d2 = d1 - sigma * np.sqrt(T) phi_d2 = float(norm.pdf(d2)) phi_d1 = float(norm.pdf(d1)) if payoff_type == "cash": sign = 1.0 if option_type == "call" else -1.0 factor = payoff_amount * np.exp(-r * T) delta = sign * factor * phi_d2 / (S * sigma * np.sqrt(T)) gamma = -sign * factor * phi_d2 * d1 / (S**2 * sigma**2 * T) vega = -sign * factor * phi_d2 * d1 / sigma theta = sign * factor * r * float(norm.cdf(sign * d2)) + sign * factor * phi_d2 * ( d1 / (2 * T) - r / (sigma * np.sqrt(T)) ) rho = -T * float( digital_price(S, K, T, r, sigma, q, option_type, payoff_type, payoff_amount) ) else: # Asset-or-nothing greeks sign = 1.0 if option_type == "call" else -1.0 factor = S * np.exp(-q * T) ncdf_d1 = float(norm.cdf(sign * d1)) delta = np.exp(-q * T) * (ncdf_d1 + sign * phi_d1 / (sigma * np.sqrt(T))) gamma = ( sign * np.exp(-q * T) * phi_d1 * (1.0 / (S * sigma * np.sqrt(T))) * (1.0 - d2 / (sigma * np.sqrt(T))) ) vega = sign * factor * phi_d1 * (np.sqrt(T) - d1 / sigma) theta = float("nan") # complex expression omitted here; use FD rho = float("nan") return { "delta": float(delta), "gamma": float(gamma), "vega": float(vega), "theta": float(theta), "rho": float(rho), }
[docs] def digital_price_mc( S: float, K: float, T: float, r: float, sigma: float, q: float = 0.0, option_type: str = "call", payoff_type: str = "cash", payoff_amount: float = 1.0, *, n_paths: int = 100_000, seed: int | None = None, return_stderr: bool = False, ) -> Union[float, tuple[float, float]]: """Monte Carlo price for a digital option (validation). Example: >>> p = digital_price_mc(100, 100, 1.0, 0.05, 0.2, seed=0) >>> 0.0 < p < 1.0 True """ rng = np.random.default_rng(seed) z = rng.standard_normal(n_paths) S_T = S * np.exp((r - q - 0.5 * sigma**2) * T + sigma * np.sqrt(T) * z) if option_type == "call": triggered = S_T > K else: triggered = S_T < K if payoff_type == "cash": payoffs = np.where(triggered, payoff_amount, 0.0) else: payoffs = np.where(triggered, S_T, 0.0) discount = np.exp(-r * T) price = discount * float(np.mean(payoffs)) stderr = discount * float(np.std(payoffs) / np.sqrt(n_paths)) return (price, stderr) if return_stderr else price
# ================================================================== # # BARRIER OPTIONS # ================================================================== # def _bs_call(S: float, K: float, T: float, r: float, b: float, sigma: float) -> float: """Generalised Black-Scholes call price with cost-of-carry b = r - q.""" if T <= 0 or K <= 0 or S <= 0: return max(S - K, 0.0) sqrt_T = np.sqrt(T) d1 = (np.log(S / K) + (b + 0.5 * sigma**2) * T) / (sigma * sqrt_T) d2 = d1 - sigma * sqrt_T return float(S * np.exp((b - r) * T) * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)) def _bs_put(S: float, K: float, T: float, r: float, b: float, sigma: float) -> float: """Generalised Black-Scholes put price with cost-of-carry b = r - q.""" if T <= 0 or K <= 0 or S <= 0: return max(K - S, 0.0) sqrt_T = np.sqrt(T) d1 = (np.log(S / K) + (b + 0.5 * sigma**2) * T) / (sigma * sqrt_T) d2 = d1 - sigma * sqrt_T return float(-S * np.exp((b - r) * T) * norm.cdf(-d1) + K * np.exp(-r * T) * norm.cdf(-d2))
[docs] def barrier_price( S: float, K: float, T: float, r: float, sigma: float, barrier: float, barrier_type: str, q: float = 0.0, option_type: str = "call", rebate: float = 0.0, ) -> float: """Analytic price for European single-barrier options (Rubinstein-Reiner 1991). Supports all 8 standard barrier option types: 'down-and-in' call/put -- activated if S crosses H from above 'down-and-out' call/put -- extinguished if S crosses H from above 'up-and-in' call/put -- activated if S crosses H from below 'up-and-out' call/put -- extinguished if S crosses H from below Args: S: Spot price. K: Strike price. T: Time to expiry. r: Risk-free rate. sigma: Volatility. barrier: Barrier level H. barrier_type: One of {'down-and-in', 'down-and-out', 'up-and-in', 'up-and-out'}. q: Dividend yield. option_type: 'call' or 'put'. rebate: Cash rebate paid if barrier is not hit (for out options). Returns: Barrier option price. Raises: ValueError: If barrier_type or option_type are invalid. Example: >>> p = barrier_price(100, 100, 1.0, 0.05, 0.2, barrier=90, barrier_type='down-and-out') >>> p > 0 True """ valid_barrier_types = {"down-and-in", "down-and-out", "up-and-in", "up-and-out"} if barrier_type not in valid_barrier_types: raise ValueError(f"barrier_type must be one of {valid_barrier_types}, got {barrier_type!r}") if option_type not in ("call", "put"): raise ValueError(f"option_type must be 'call' or 'put', got {option_type!r}") H = float(barrier) b = r - q # cost-of-carry sqrt_T = np.sqrt(T) v = sigma v2 = v * v # Vanilla prices c = _bs_call(S, K, T, r, b, v) p = _bs_put(S, K, T, r, b, v) # Haug/Rubinstein-Reiner notation (following FinancePy fx_barrier_option.py exactly) # ll = (b + sigma^2/2) / sigma^2 # y = log(H^2/(S*K))/(sigma*sqrt(T)) + ll*sigma*sqrt(T) # x1 = log(S/H)/(sigma*sqrt(T)) + ll*sigma*sqrt(T) # y1 = log(H/S)/(sigma*sqrt(T)) + ll*sigma*sqrt(T) sigma_rt = v * sqrt_T ll = (b + 0.5 * v2) / v2 y = np.log(H * H / (S * K)) / sigma_rt + ll * sigma_rt x1 = np.log(S / H) / sigma_rt + ll * sigma_rt y1 = np.log(H / S) / sigma_rt + ll * sigma_rt dq = np.exp((b - r) * T) # S discount factor (= exp(-q*T)) df = np.exp(-r * T) # K discount factor h_over_s = H / S pow_ll = h_over_s ** (2.0 * ll) pow_ll2 = h_over_s ** (2.0 * ll - 2.0) N = norm.cdf def c_di() -> float: """Down-and-in call, H <= K.""" return S * dq * pow_ll * N(y) - K * df * pow_ll2 * N(y - sigma_rt) def c_di_H_gt_K() -> float: """Down-and-in call, H > K.""" return ( S * dq * N(x1) - K * df * N(x1 - sigma_rt) - S * dq * pow_ll * (N(-y) - N(-y1)) + K * df * pow_ll2 * (N(-y + sigma_rt) - N(-y1 + sigma_rt)) ) def c_uo() -> float: """Up-and-out call, H > K (the only meaningful case for up-out call).""" return ( S * dq * N(x1) - K * df * N(x1 - sigma_rt) - S * dq * pow_ll * N(y1) + K * df * pow_ll2 * N(y1 - sigma_rt) ) def c_ui() -> float: """Up-and-in call, H >= K.""" return ( S * dq * N(x1) - K * df * N(x1 - sigma_rt) - S * dq * pow_ll * (N(-y) - N(-y1)) + K * df * pow_ll2 * (N(-y + sigma_rt) - N(-y1 + sigma_rt)) ) def p_ui() -> float: """Up-and-in put, H >= K.""" return -S * dq * pow_ll * N(-y) + K * df * pow_ll2 * N(-y + sigma_rt) def p_ui_H_lt_K() -> float: """Up-and-in put, H < K.""" return ( -S * dq * N(-x1) + K * df * N(-x1 + sigma_rt) + S * dq * pow_ll * (N(y) - N(y1)) - K * df * pow_ll2 * (N(y - sigma_rt) - N(y1 - sigma_rt)) ) def p_di() -> float: """Down-and-in put, H < K.""" return ( -S * dq * N(-x1) + K * df * N(-x1 + sigma_rt) + S * dq * pow_ll * (N(y) - N(y1)) - K * df * pow_ll2 * (N(y - sigma_rt) - N(y1 - sigma_rt)) ) # Main dispatch if option_type == "call": if barrier_type == "down-and-in": price = c_di() if H <= K else c_di_H_gt_K() elif barrier_type == "down-and-out": price = c - (c_di() if H <= K else c_di_H_gt_K()) elif barrier_type == "up-and-in": if H >= K: price = c_ui() else: price = c # barrier is below strike: always knocked in already else: # up-and-out if H >= K: price = c - c_ui() else: price = 0.0 # barrier <= strike: call knocked out before it can pay else: # put if barrier_type == "up-and-in": price = p_ui() if H >= K else p_ui_H_lt_K() elif barrier_type == "up-and-out": price = p - (p_ui() if H >= K else p_ui_H_lt_K()) elif barrier_type == "down-and-in": if H < K: price = p_di() else: # H >= K: barrier is at or above strike, always knocked in price = p else: # down-and-out if H < K: price = p - p_di() else: price = 0.0 return float(max(price, 0.0))
def _d1(S: float, K: float, T: float, r: float, sigma: float, q: float) -> float: return (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) def _d2(S: float, K: float, T: float, r: float, sigma: float, q: float) -> float: return _d1(S, K, T, r, sigma, q) - sigma * np.sqrt(T) def _indicator_hit( S: float, H: float, barrier_type: str, T: float, r: float, sigma: float, q: float ) -> float: """Probability of hitting the barrier (approximation via reflection principle).""" mu = (r - q - 0.5 * sigma**2) / (sigma**2) x = np.log(H / S) / (sigma * np.sqrt(T)) if "down" in barrier_type: return float( norm.cdf(-x + mu * sigma * np.sqrt(T)) + (H / S) ** (2 * mu) * norm.cdf(-x - mu * sigma * np.sqrt(T)) ) else: return float( norm.cdf(x - mu * sigma * np.sqrt(T)) + (H / S) ** (2 * mu) * norm.cdf(x + mu * sigma * np.sqrt(T)) )
[docs] def barrier_price_mc( S: float, K: float, T: float, r: float, sigma: float, barrier: float, barrier_type: str, q: float = 0.0, option_type: str = "call", rebate: float = 0.0, *, n_paths: int = 200_000, n_steps: int = 252, seed: int | None = None, return_stderr: bool = False, ) -> Union[float, tuple[float, float]]: """Monte Carlo price for a European single-barrier option. Args: S, K, T, r, sigma, q: Standard parameters. barrier: Barrier level. barrier_type: 'down-and-in', 'down-and-out', 'up-and-in', 'up-and-out'. option_type: 'call' or 'put'. rebate: Rebate paid when knocked out/never knocked in. n_paths, n_steps, seed: Simulation parameters. return_stderr: Return (price, stderr) if True. Returns: Price, or (price, stderr). Example: >>> p = barrier_price_mc(100, 100, 1.0, 0.05, 0.2, barrier=90, barrier_type='down-and-out', seed=0) >>> p > 0 True """ rng = np.random.default_rng(seed) paths = _gbm_paths(S, T, r, sigma, q, n_paths, n_steps, rng) H = barrier # Track barrier crossing if "down" in barrier_type: crossed = np.any(paths <= H, axis=0) # shape: (n_paths,) else: crossed = np.any(paths >= H, axis=0) S_T = paths[-1] if option_type == "call": payoff_vanilla = np.maximum(S_T - K, 0.0) else: payoff_vanilla = np.maximum(K - S_T, 0.0) if "out" in barrier_type: # Knocked out when barrier is crossed payoffs = np.where(crossed, rebate, payoff_vanilla) else: # Knocked in: only alive when barrier was crossed payoffs = np.where(crossed, payoff_vanilla, rebate) discount = np.exp(-r * T) price = discount * float(np.mean(payoffs)) stderr = discount * float(np.std(payoffs) / np.sqrt(n_paths)) return (price, stderr) if return_stderr else price
# --------------------------------------------------------------------------- # Lookback options # ---------------------------------------------------------------------------
[docs] def lookback_fixed_analytic( S: float, K: float, T: float, r: float, sigma: float, q: float = 0.0, option_type: str = "call", ) -> float: """Analytic fixed-strike lookback option price (Goldman-Sosin-Gatto 1979). For a fixed-strike lookback call, the payoff is max(S_max - K, 0). For a fixed-strike lookback put, the payoff is max(K - S_min, 0). Parameters ---------- S, K, T, r, sigma, q, option_type : standard option parameters. Returns ------- float Option price. """ from scipy.stats import norm b = r - q # cost of carry s2 = sigma**2 if option_type == "call": d1 = (np.log(S / K) + (b + 0.5 * s2) * T) / (sigma * np.sqrt(T)) d2 = d1 - sigma * np.sqrt(T) price = ( S * np.exp((b - r) * T) * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2) + S * np.exp(-r * T) * (s2 / (2.0 * b)) * ( -((S / K) ** (-2.0 * b / s2)) * norm.cdf(d1 - 2.0 * b * np.sqrt(T) / sigma) + np.exp(b * T) * norm.cdf(d1) ) ) else: d1 = (np.log(S / K) + (b + 0.5 * s2) * T) / (sigma * np.sqrt(T)) d2 = d1 - sigma * np.sqrt(T) price = ( K * np.exp(-r * T) * norm.cdf(-d2) - S * np.exp((b - r) * T) * norm.cdf(-d1) + S * np.exp(-r * T) * (s2 / (2.0 * b)) * ( (S / K) ** (-2.0 * b / s2) * norm.cdf(-d1 + 2.0 * b * np.sqrt(T) / sigma) - np.exp(b * T) * norm.cdf(-d1) ) ) return float(price)
[docs] def lookback_floating_analytic( S: float, T: float, r: float, sigma: float, q: float = 0.0, option_type: str = "call", ) -> float: """Analytic floating-strike lookback option price. At inception S_min = S_max = S. Returns ------- float Option price. """ from scipy.stats import norm b = r - q s2 = sigma**2 sqT = sigma * np.sqrt(T) a1 = (b + 0.5 * s2) * T / sqT a2 = a1 - sqT if option_type == "call": if abs(b) < 1e-12: price = S * sqT * (2.0 * norm.pdf(a1) + a1 * (2.0 * norm.cdf(a1) - 1.0)) else: price = ( S * np.exp((b - r) * T) * norm.cdf(a1) - S * np.exp(-r * T) * norm.cdf(a2) + S * np.exp(-r * T) * (s2 / (2.0 * b)) * (np.exp(b * T) * norm.cdf(a1) - norm.cdf(a2)) ) else: if abs(b) < 1e-12: price = S * sqT * (2.0 * norm.pdf(a1) - a1 * (2.0 * norm.cdf(a1) - 1.0)) else: price = ( -S * np.exp((b - r) * T) * norm.cdf(-a1) + S * np.exp(-r * T) * norm.cdf(-a2) + S * np.exp(-r * T) * (s2 / (2.0 * b)) * (-np.exp(b * T) * norm.cdf(-a1) + norm.cdf(-a2)) ) return float(max(price, 0.0))
[docs] def lookback_price_mc( S: float, K: float | None, T: float, r: float, sigma: float, q: float = 0.0, option_type: str = "call", strike_type: str = "fixed", n_paths: int = 100_000, n_steps: int = 252, seed: int | None = None, ) -> tuple[float, float]: """Monte Carlo lookback option price. Returns ------- tuple (price, standard_error) """ rng = np.random.default_rng(seed) dt = T / n_steps drift = (r - q - 0.5 * sigma**2) * dt vol = sigma * np.sqrt(dt) Z = rng.standard_normal((n_paths, n_steps)) log_S = np.log(S) + np.cumsum(drift + vol * Z, axis=1) paths = np.exp(log_S) paths = np.concatenate([np.full((n_paths, 1), S), paths], axis=1) S_T = paths[:, -1] S_max = np.max(paths, axis=1) S_min = np.min(paths, axis=1) if strike_type == "fixed": if K is None: raise ValueError("K required for fixed-strike lookback") if option_type == "call": payoffs = np.maximum(S_max - K, 0.0) else: payoffs = np.maximum(K - S_min, 0.0) else: if option_type == "call": payoffs = S_T - S_min else: payoffs = S_max - S_T discount = np.exp(-r * T) price = discount * float(np.mean(payoffs)) stderr = discount * float(np.std(payoffs) / np.sqrt(n_paths)) return price, stderr
# --------------------------------------------------------------------------- # Cliquet (ratchet) options # ---------------------------------------------------------------------------
[docs] def cliquet_price_mc( S: float, T: float, r: float, sigma: float, q: float = 0.0, n_periods: int = 4, cap: float | None = None, floor: float | None = None, global_cap: float | None = None, global_floor: float | None = None, n_paths: int = 100_000, n_steps_per_period: int = 63, seed: int | None = None, ) -> tuple[float, float]: """Monte Carlo cliquet (ratchet) option pricer. Returns ------- tuple (price, standard_error) """ rng = np.random.default_rng(seed) dt = T / (n_periods * n_steps_per_period) drift = (r - q - 0.5 * sigma**2) * dt vol = sigma * np.sqrt(dt) total_returns = np.zeros(n_paths) S_start = np.full(n_paths, S) for _ in range(n_periods): log_S = np.log(S_start) for _step in range(n_steps_per_period): Z = rng.standard_normal(n_paths) log_S = log_S + drift + vol * Z S_end = np.exp(log_S) period_return = (S_end - S_start) / S_start if cap is not None: period_return = np.minimum(period_return, cap) if floor is not None: period_return = np.maximum(period_return, floor) total_returns += period_return S_start = S_end if global_cap is not None: total_returns = np.minimum(total_returns, global_cap) if global_floor is not None: total_returns = np.maximum(total_returns, global_floor) payoffs = S * np.maximum(total_returns, 0.0) discount = np.exp(-r * T) price = discount * float(np.mean(payoffs)) stderr = discount * float(np.std(payoffs) / np.sqrt(n_paths)) return price, stderr
# --------------------------------------------------------------------------- # Rainbow options (best-of / worst-of) # ---------------------------------------------------------------------------
[docs] def rainbow_price_mc( spots: np.ndarray, K: float, T: float, r: float, sigmas: np.ndarray, corr: np.ndarray, q: np.ndarray | None = None, option_type: str = "call", rainbow_type: str = "best-of", n_paths: int = 100_000, n_steps: int = 252, seed: int | None = None, ) -> tuple[float, float]: """Monte Carlo rainbow option pricer (best-of / worst-of). Returns ------- tuple (price, standard_error) """ spots = np.asarray(spots, dtype=float) sigmas = np.asarray(sigmas, dtype=float) corr = np.asarray(corr, dtype=float) n_assets = len(spots) q_arr = np.zeros(n_assets) if q is None else np.asarray(q, dtype=float) rng = np.random.default_rng(seed) dt = T / n_steps L = np.linalg.cholesky(corr) log_S = np.tile(np.log(spots), (n_paths, 1)) for _step in range(n_steps): Z = rng.standard_normal((n_paths, n_assets)) Z_corr = Z @ L.T drift = (r - q_arr - 0.5 * sigmas**2) * dt vol = sigmas * np.sqrt(dt) * Z_corr log_S += drift + vol S_T = np.exp(log_S) if rainbow_type == "best-of": selected = np.max(S_T, axis=1) else: selected = np.min(S_T, axis=1) if option_type == "call": payoffs = np.maximum(selected - K, 0.0) else: payoffs = np.maximum(K - selected, 0.0) discount = np.exp(-r * T) price = discount * float(np.mean(payoffs)) stderr = discount * float(np.std(payoffs) / np.sqrt(n_paths)) return price, stderr