Source code for financialkit.pde_methods.utils.operators

r"""Spatial operators, tridiagonal solves and grid interpolation for
financialkit.pde_methods.

On a grid :math:`S_0 < \dots < S_M` with local spacings
:math:`h_- = S_i - S_{i-1}` and :math:`h_+ = S_{i+1} - S_i`, the
Black-Scholes operator

.. math:: \mathcal{L}V = \tfrac12 \sigma^2 S^2 V_{SS} + (r - q) S V_S - r V

is discretised with the three-point central differences

.. math:: V_S \approx \frac{-h_+^2 V_{i-1} + (h_+^2 - h_-^2) V_i + h_-^2 V_{i+1}}{h_- h_+ (h_- + h_+)}, \qquad
          V_{SS} \approx \frac{2 (h_+ V_{i-1} - (h_- + h_+) V_i + h_- V_{i+1})}{h_- h_+ (h_- + h_+)},

second-order accurate on smoothly varying grids and the familiar
formulas when :math:`h_- = h_+`. See D. Tavella and C. Randall, *Pricing
Financial Instruments: The Finite Difference Method* (Wiley, 2000),
Ch. 5, and P. Wilmott, *Paul Wilmott on Quantitative Finance*, 2nd ed.
(Wiley, 2006), Ch. 77-78.
"""

from __future__ import annotations

import numpy as np
import scipy.sparse as sp
from numpy.typing import ArrayLike
from scipy.interpolate import CubicSpline
from scipy.linalg import solve_banded

from financialkit.options.utils.common import option_sign
from financialkit.pde_methods.core.kernels import thomas_kernel

__all__ = [
    "black_scholes_operator",
    "tridiagonal_matrix",
    "solve_tridiagonal",
    "discounted_intrinsic",
    "interpolate_at",
]


[docs] def black_scholes_operator(S: ArrayLike, sigma: float, r: float, q: float = 0.0) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Tridiagonal coefficients of the Black-Scholes operator at the interior nodes. Parameters ---------- S : array_like, shape (M + 1,) Strictly increasing spot grid, boundaries included. sigma, r, q : float Volatility, risk-free rate and dividend yield. Returns ------- lower, diag, upper : ndarray, shape (M - 1,) ``(LV)_i = lower[i] V_{i} + diag[i] V_{i+1} + upper[i] V_{i+2}`` for interior node ``i + 1``; ``lower[0]`` and ``upper[-1]`` multiply the boundary values. Examples -------- On a uniform grid the coefficients are Wilmott's :math:`\\tfrac12(\\sigma^2 i^2 \\mp (r - q) i)` and :math:`-(\\sigma^2 i^2 + r)`: >>> lo, di, up = black_scholes_operator([0.0, 1.0, 2.0], 0.2, 0.05) >>> [round(float(c[0]), 6) for c in (lo, di, up)] [-0.005, -0.09, 0.045] """ S = np.asarray(S, dtype=np.float64) if S.ndim != 1 or S.size < 3 or np.any(np.diff(S) <= 0): raise ValueError("S must be a strictly increasing grid of at least three nodes") hm, hp = np.diff(S)[:-1], np.diff(S)[1:] s = S[1:-1] diffusion, drift = 0.5 * sigma**2 * s**2, (r - q) * s denom = hm * hp * (hm + hp) lower = (2.0 * diffusion * hp - drift * hp**2) / denom upper = (2.0 * diffusion * hm + drift * hm**2) / denom diag = (-2.0 * diffusion * (hm + hp) + drift * (hp**2 - hm**2)) / denom - r return lower, diag, upper
[docs] def tridiagonal_matrix(lower: ArrayLike, diag: ArrayLike, upper: ArrayLike) -> sp.csr_matrix: """Assemble the sparse matrix of a tridiagonal system in the kernels' full-length convention. Parameters ---------- lower, diag, upper : array_like, shape (m,) ``lower[0]`` and ``upper[-1]`` are ignored. Returns ------- scipy.sparse.csr_matrix, shape (m, m) """ lower, diag, upper = (np.asarray(a, dtype=np.float64) for a in (lower, diag, upper)) return sp.diags([lower[1:], diag, upper[:-1]], [-1, 0, 1], format="csr")
[docs] def solve_tridiagonal(lower: ArrayLike, diag: ArrayLike, upper: ArrayLike, rhs: ArrayLike, solver: str = "banded") -> np.ndarray: """Solve a tridiagonal system with :func:`scipy.linalg.solve_banded` or the Thomas kernel. Parameters ---------- lower, diag, upper : array_like, shape (m,) ``lower[0]`` and ``upper[-1]`` are ignored. rhs : array_like, shape (m,) or (m, k) Right-hand side(s); several columns need ``solver="banded"``. solver : {"banded", "thomas"} Returns ------- ndarray, shape like `rhs` """ lower, diag, upper, rhs = (np.asarray(a, dtype=np.float64) for a in (lower, diag, upper, rhs)) if solver == "thomas": if rhs.ndim != 1: raise ValueError('solver="thomas" takes a single right-hand side') return thomas_kernel(lower, diag, upper, rhs) if solver != "banded": raise ValueError(f'solver must be "banded" or "thomas", got {solver!r}') ab = np.zeros((3, diag.size)) ab[0, 1:], ab[1], ab[2, :-1] = upper[:-1], diag, lower[1:] return solve_banded((1, 1), ab, rhs, check_finite=False)
[docs] def discounted_intrinsic(S: ArrayLike, K: float, tau: float, r: float, q: float = 0.0, option_type: str = "call") -> np.ndarray: r"""Value of a vanilla option far from the strike: :math:`\max(\pm(S e^{-q\tau} - K e^{-r\tau}), 0)`. This is the Black-Scholes price in the limits :math:`S \to 0` and :math:`S \to \infty`, and serves as the Dirichlet boundary condition. Parameters ---------- S : array_like K, tau, r, q : float Strike, time to maturity, rate and dividend yield. option_type : {"call", "put"} Returns ------- ndarray """ sign = option_sign(option_type) S = np.asarray(S, dtype=np.float64) return np.maximum(sign * (S * np.exp(-q * tau) - K * np.exp(-r * tau)), 0.0)
[docs] def interpolate_at(S_grid: ArrayLike, V: ArrayLike, S: float) -> tuple[float, float, float]: """Value, delta and gamma at `S` from a cubic spline through the grid values. Parameters ---------- S_grid, V : array_like, shape (M + 1,) S : float Spot inside the grid. Returns ------- price, delta, gamma : float """ S_grid, V = np.asarray(S_grid, dtype=np.float64), np.asarray(V, dtype=np.float64) if not S_grid[0] <= S <= S_grid[-1]: raise ValueError(f"spot {S} outside the grid [{S_grid[0]}, {S_grid[-1]}]") spline = CubicSpline(S_grid, V) return float(spline(S)), float(spline(S, 1)), float(spline(S, 2))