Source code for financialkit.pde_methods.systems.adi

r"""Peaceman-Rachford alternating-direction implicit scheme for two assets.

A two-asset option solves, in :math:`x = \ln S_1`, :math:`y = \ln S_2` and
time to maturity :math:`\tau`,

.. math:: V_\tau = \underbrace{\tfrac12\sigma_1^2 V_{xx} + \nu_1 V_x - \tfrac r2 V}_{A_1 V}
          + \underbrace{\tfrac12\sigma_2^2 V_{yy} + \nu_2 V_y - \tfrac r2 V}_{A_2 V}
          + \underbrace{\rho\sigma_1\sigma_2 V_{xy}}_{A_0 V},
          \qquad \nu_i = r - q_i - \tfrac12\sigma_i^2.

A fully implicit two-dimensional step is a large banded system.
Peaceman and Rachford (1955) split each step into two half steps, each
implicit in one direction only, so that every half step is a set of
independent tridiagonal solves:

.. math:: (I - \tfrac{\Delta\tau}{2} A_1) V^{*} = (I + \tfrac{\Delta\tau}{2} (A_2 + A_0)) V^n, \qquad
          (I - \tfrac{\Delta\tau}{2} A_2) V^{n+1} = (I + \tfrac{\Delta\tau}{2} (A_1 + A_0)) V^{*}.

Without correlation this is the original, unconditionally stable and
second-order scheme; the mixed derivative :math:`A_0` is taken explicitly,
which is the simplest way to keep each half step tridiagonal. Along each
grid line the matrix is the same, so one :func:`scipy.linalg.solve_banded`
call handles all lines at once. Boundary values are the discounted payoff
of the forwards, :math:`e^{-r\tau} f(S_1 e^{(r - q_1)\tau}, S_2 e^{(r - q_2)\tau})`.

References: D. W. Peaceman and H. H. Rachford, *J. SIAM* 3 (1955) 28-41;
K. J. in 't Hout and S. Foulon, *Int. J. Numer. Anal. Model.* 7 (2010)
303-320; Tavella and Randall, *Pricing Financial Instruments* (Wiley,
2000), Ch. 7.
"""

from __future__ import annotations

from collections.abc import Callable

import numpy as np

from financialkit.pde_methods.core.base import ADIResult
from financialkit.pde_methods.utils.operators import solve_tridiagonal

__all__ = ["PeacemanRachfordADI"]


[docs] class PeacemanRachfordADI: """Two-asset European option by the Peaceman-Rachford ADI scheme. Parameters ---------- n1, n2 : int Number of log-spot intervals for each asset (rounded up to even, so the spots are nodes). n_time : int Number of full time steps. width : float Half-width of each grid in standard deviations :math:`\\sigma_i\\sqrt{T}`. Examples -------- Margrabe's exchange option, worth Black-Scholes with spot :math:`S_1`, strike :math:`S_2`, rate :math:`q_2` and volatility :math:`\\sqrt{\\sigma_1^2 + \\sigma_2^2 - 2\\rho\\sigma_1\\sigma_2} = 0.2646`: >>> adi = PeacemanRachfordADI(100, 100, 50) >>> res = adi.price(100.0, 100.0, 1.0, 0.05, 0.2, 0.3, 0.5, lambda s1, s2: np.maximum(s1 - s2, 0.0)) >>> round(res.price, 1) # Margrabe: 10.5 10.5 """ def __init__(self, n1: int = 100, n2: int = 100, n_time: int = 50, width: float = 5.0): for name, n in (("n1", n1), ("n2", n2), ("n_time", n_time)): if int(n) != n or n < 2: raise ValueError(f"{name} must be an integer >= 2, got {n}") self.n1, self.n2 = int(n1) + int(n1) % 2, int(n2) + int(n2) % 2 self.n_time, self.width = int(n_time), float(width)
[docs] def price( self, S1: float, S2: float, T: float, r: float, sigma1: float, sigma2: float, rho: float, payoff: Callable[[np.ndarray, np.ndarray], np.ndarray], q1: float = 0.0, q2: float = 0.0, ) -> ADIResult: """Value a European payoff ``payoff(S1, S2)`` at maturity `T`. Parameters ---------- S1, S2 : float Spots. T, r : float Maturity and risk-free rate. sigma1, sigma2 : float Volatilities. rho : float Correlation of the two Brownian motions. payoff : callable Vectorised ``payoff(S1, S2)`` on meshgrid arrays. q1, q2 : float Dividend yields. Returns ------- ADIResult """ if not (S1 > 0 and S2 > 0 and T > 0 and sigma1 > 0 and sigma2 > 0): raise ValueError("S1, S2, T, sigma1 and sigma2 must all be positive") if not -1.0 <= rho <= 1.0: raise ValueError("rho must lie in [-1, 1]") h1, h2 = self.width * sigma1 * np.sqrt(T), self.width * sigma2 * np.sqrt(T) x = np.log(S1) + np.linspace(-h1, h1, self.n1 + 1) y = np.log(S2) + np.linspace(-h2, h2, self.n2 + 1) dx, dy = x[1] - x[0], y[1] - y[0] X, Y = np.meshgrid(np.exp(x), np.exp(y), indexing="ij") nu1, nu2 = r - q1 - 0.5 * sigma1**2, r - q2 - 0.5 * sigma2**2 a1, b1, c1 = 0.5 * sigma1**2 / dx**2 - 0.5 * nu1 / dx, -(sigma1**2) / dx**2 - 0.5 * r, 0.5 * sigma1**2 / dx**2 + 0.5 * nu1 / dx a2, b2, c2 = 0.5 * sigma2**2 / dy**2 - 0.5 * nu2 / dy, -(sigma2**2) / dy**2 - 0.5 * r, 0.5 * sigma2**2 / dy**2 + 0.5 * nu2 / dy cross = rho * sigma1 * sigma2 / (4.0 * dx * dy) dt = T / self.n_time h = 0.5 * dt def A1(V): return a1 * V[:-2, 1:-1] + b1 * V[1:-1, 1:-1] + c1 * V[2:, 1:-1] def A2(V): return a2 * V[1:-1, :-2] + b2 * V[1:-1, 1:-1] + c2 * V[1:-1, 2:] def A0(V): return cross * (V[2:, 2:] - V[2:, :-2] - V[:-2, 2:] + V[:-2, :-2]) def edges(tau: float) -> np.ndarray: return np.exp(-r * tau) * payoff(X * np.exp((r - q1) * tau), Y * np.exp((r - q2) * tau)) def implicit(a, b, c, m): return np.full(m, -h * a), np.full(m, 1.0 - h * b), np.full(m, -h * c) lo1, di1, up1 = implicit(a1, b1, c1, self.n1 - 1) lo2, di2, up2 = implicit(a2, b2, c2, self.n2 - 1) V = np.asarray(payoff(X, Y), dtype=np.float64) for n in range(self.n_time): star = edges((n + 0.5) * dt) rhs = V[1:-1, 1:-1] + h * (A2(V) + A0(V)) rhs[0] += h * a1 * star[0, 1:-1] rhs[-1] += h * c1 * star[-1, 1:-1] star[1:-1, 1:-1] = solve_tridiagonal(lo1, di1, up1, rhs) new = edges((n + 1) * dt) rhs = star[1:-1, 1:-1] + h * (A1(star) + A0(star)) rhs[:, 0] += h * a2 * new[1:-1, 0] rhs[:, -1] += h * c2 * new[1:-1, -1] new[1:-1, 1:-1] = solve_tridiagonal(lo2, di2, up2, rhs.T).T V = new return ADIResult(price=float(V[self.n1 // 2, self.n2 // 2]), S1=np.exp(x), S2=np.exp(y), V=V, n_time=self.n_time)