roughprix 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- roughprix/__init__.py +10 -0
- roughprix/fourier.py +539 -0
- roughprix/heston/__init__.py +42 -0
- roughprix/heston/base_solution.py +157 -0
- roughprix/heston/integration.py +1094 -0
- roughprix/heston/pade.py +610 -0
- roughprix/heston/rough_heston.py +1771 -0
- roughprix-0.1.0.dist-info/METADATA +401 -0
- roughprix-0.1.0.dist-info/RECORD +13 -0
- roughprix-0.1.0.dist-info/WHEEL +5 -0
- roughprix-0.1.0.dist-info/licenses/LICENSE +202 -0
- roughprix-0.1.0.dist-info/licenses/NOTICE +2 -0
- roughprix-0.1.0.dist-info/top_level.txt +1 -0
roughprix/__init__.py
ADDED
roughprix/fourier.py
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""Generic Fourier quadrature pricing utilities.
|
|
2
|
+
|
|
3
|
+
Works with plain numpy/JAX arrays — no model objects.
|
|
4
|
+
|
|
5
|
+
General methodology
|
|
6
|
+
-------------------
|
|
7
|
+
Pricing is split into a model-independent quadrature stage and a
|
|
8
|
+
model-dependent evaluation stage. The characteristic function is
|
|
9
|
+
factored as
|
|
10
|
+
|
|
11
|
+
phi(u, T) = phi_0(u, T) * hN(u, T)
|
|
12
|
+
|
|
13
|
+
where phi_0 is the *base* characteristic function (the order-0 Riccati
|
|
14
|
+
solution, cheap to reuse across strikes and maturities) and hN is the
|
|
15
|
+
*expansion correction* (phi_N / phi_0, capturing the Taylor correction in H).
|
|
16
|
+
At expansion order 0 one has hN = 1 identically.
|
|
17
|
+
|
|
18
|
+
The separation is useful because the Chebyshev interpolation weights W
|
|
19
|
+
depend only on phi_0 and the payoff, while hN is evaluated on the coarse
|
|
20
|
+
node grid and multiplied in cheaply. For a rough-Heston model the
|
|
21
|
+
caller produces phi_0 and hN via ``model.expansion_state`` together with
|
|
22
|
+
``model.log_charfun_from_field``; the high-level entry point
|
|
23
|
+
``RoughHeston.eur_call_put_measure_quad`` handles this automatically.
|
|
24
|
+
|
|
25
|
+
Two contour paths are provided:
|
|
26
|
+
|
|
27
|
+
Damped-measure path (any real R):
|
|
28
|
+
Contour: u + iR, dampening factor exp(-R * log K) absorbed into the
|
|
29
|
+
payoff transform. Needs E[S_T^R] to be finite. The integral is the
|
|
30
|
+
price for R > 1 (vanilla calls), R > 0 (digital calls) or R < 0
|
|
31
|
+
(puts); on other contours step 4 adds the payoff poles in between.
|
|
32
|
+
RoughHeston prices digitals on R = 0.5 by default, which needs only
|
|
33
|
+
E[S_T^0.5].
|
|
34
|
+
|
|
35
|
+
1. build_panel_quadrature — build Chebyshev–GL panel nodes once
|
|
36
|
+
2. measure_weights_from_phi — W[k,j] = ∫ payoff_hat(K_k, u+iR) S^-i(u+iR) phi_0(u+iR) ℓ_j(u) du
|
|
37
|
+
3. eur_call_put_from_weights — price[k] = (disc/π) Re(W[k,:] @ hN)
|
|
38
|
+
4. damped_contour_residues — + the residues of the poles at R = 0 and R = 1
|
|
39
|
+
|
|
40
|
+
Lewis contour path (R = 0.5, calls only):
|
|
41
|
+
Contour: u - i/2. No dampening factor; numerically recommended.
|
|
42
|
+
|
|
43
|
+
1. build_panel_quadrature — same quadrature setup
|
|
44
|
+
2. lewis_weights_from_phi — W[k,j] = ∫ exp(iu log(S/K_k))/(u²+¼) phi_0(u) ℓ_j(u) du
|
|
45
|
+
3. eur_call_from_lewis_weights — C_k = S - (disc/π) sqrt(S·K_k) Re(W[k,:] @ hN)
|
|
46
|
+
"""
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"PanelQuadrature",
|
|
51
|
+
"build_panel_quadrature",
|
|
52
|
+
"fourier_payoff_call_put",
|
|
53
|
+
"measure_weights_from_phi",
|
|
54
|
+
"eur_call_put_from_weights",
|
|
55
|
+
"damped_contour_residues",
|
|
56
|
+
"lewis_weights_from_phi",
|
|
57
|
+
"eur_call_from_lewis_weights",
|
|
58
|
+
"otm_prices_to_ivs",
|
|
59
|
+
"adaptive_check_values",
|
|
60
|
+
"adaptive_converged",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
from dataclasses import dataclass, field
|
|
64
|
+
from functools import lru_cache
|
|
65
|
+
|
|
66
|
+
import numpy as np
|
|
67
|
+
import jax.numpy as jnp
|
|
68
|
+
from scipy.stats import norm
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Black-Scholes / implied-vol helpers
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def _bs_price(S_0, K, sigma, T, r=0., call=True):
|
|
76
|
+
d1 = (np.log(S_0 / K) + (r + sigma ** 2 / 2) * T) / (sigma * np.sqrt(T))
|
|
77
|
+
d2 = d1 - sigma * np.sqrt(T)
|
|
78
|
+
d1 = np.clip(d1, -30, 30)
|
|
79
|
+
d2 = np.clip(d2, -30, 30)
|
|
80
|
+
if call:
|
|
81
|
+
return S_0 * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
|
|
82
|
+
return -S_0 * norm.cdf(-d1) + K * np.exp(-r * T) * norm.cdf(-d2)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _iv_bisect(price_fun, price, tol=1e-10, sl=1e-10, sr=10., max_iter=100):
|
|
86
|
+
"""Vectorised bisection for the implied vol on the bracket ``[sl, sr]``.
|
|
87
|
+
|
|
88
|
+
``price_fun(sigma)`` must be increasing in ``sigma``. The result is NaN
|
|
89
|
+
where ``price`` is not finite, where ``price_fun`` is not finite, and where
|
|
90
|
+
the implied vol is not strictly inside the bracket. The last case covers
|
|
91
|
+
prices outside the no-arbitrage bounds: at or below intrinsic value the
|
|
92
|
+
bisection collapses onto ``sl``, and at or above ``price_fun(sr)`` it
|
|
93
|
+
collapses onto ``sr``.
|
|
94
|
+
|
|
95
|
+
The loop runs at most ``max_iter`` times, so it always terminates. The
|
|
96
|
+
default bracket reaches ``tol`` after 37 halvings.
|
|
97
|
+
"""
|
|
98
|
+
price = np.asarray(price, dtype=float)
|
|
99
|
+
finite = np.isfinite(price)
|
|
100
|
+
# A NaN price compares false against everything, which would freeze its
|
|
101
|
+
# bracket. Bisect a placeholder for every non-finite price and mask it at
|
|
102
|
+
# the end.
|
|
103
|
+
price = np.where(finite, price, 0.0)
|
|
104
|
+
lo, hi = sl, sr
|
|
105
|
+
sm = (sl + sr) / 2
|
|
106
|
+
for _ in range(max_iter):
|
|
107
|
+
if np.amax(sr - sl) <= tol:
|
|
108
|
+
break
|
|
109
|
+
em = price_fun(sm) - price
|
|
110
|
+
sl = np.where(em < 0, sm, sl)
|
|
111
|
+
sr = np.where(em >= 0, sm, sr)
|
|
112
|
+
sm = (sl + sr) / 2
|
|
113
|
+
# A NaN from price_fun also freezes the bracket, so it fails the width test.
|
|
114
|
+
ok = finite & (sr - sl <= tol) & (sm >= lo + tol) & (sm <= hi - tol)
|
|
115
|
+
return np.where(ok, sm, np.nan)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _iv_eur(S_0, K, T, price, payoff, r=0.):
|
|
119
|
+
if isinstance(T, np.ndarray) and T.ndim == 1:
|
|
120
|
+
T = T[..., None]
|
|
121
|
+
call = payoff == "call"
|
|
122
|
+
def price_fun(s):
|
|
123
|
+
return _bs_price(S_0=S_0, K=K, sigma=s, T=T, r=r, call=call)
|
|
124
|
+
return _iv_bisect(price_fun, price)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Fourier payoff transform (JAX, returns complex128 array (N_K, N_u))
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def fourier_payoff_call_put(K, u, call=True, digital=False, logarithmic=True):
|
|
132
|
+
"""Fourier transform of call/put payoff evaluated at complex argument u.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
K: strikes, real array (N_K,)
|
|
136
|
+
u: complex frequency array (N_u,); includes the dampening shift
|
|
137
|
+
call / digital / logarithmic: payoff variant flags
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
complex JAX array (N_K, N_u)
|
|
141
|
+
"""
|
|
142
|
+
K = jnp.asarray(K, dtype=jnp.float64).reshape(-1)
|
|
143
|
+
u = jnp.asarray(u, dtype=jnp.complex128)
|
|
144
|
+
iu = 1j * u # (N_u,)
|
|
145
|
+
log_K = jnp.log(K)[:, None] # (N_K, 1)
|
|
146
|
+
iu2d = iu[None, :] # (1, N_u)
|
|
147
|
+
|
|
148
|
+
if digital:
|
|
149
|
+
sign = -1.0 if call else 1.0
|
|
150
|
+
if logarithmic:
|
|
151
|
+
return sign * jnp.exp(log_K * iu2d) / iu2d
|
|
152
|
+
return sign * jnp.exp(K[:, None] * iu2d) / iu2d
|
|
153
|
+
if logarithmic:
|
|
154
|
+
return jnp.exp(log_K * (1 + iu2d)) / (iu2d * (1 + iu2d))
|
|
155
|
+
return jnp.exp(K[:, None] * iu2d) / iu2d ** 2
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# Chebyshev / barycentric quadrature helpers (numpy, computed once)
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def _cheb_lobatto_nodes(a: float, b: float, m: int) -> np.ndarray:
|
|
163
|
+
if m < 2:
|
|
164
|
+
raise ValueError("m must be >= 2")
|
|
165
|
+
j = np.arange(m)
|
|
166
|
+
x = np.cos(np.pi * j / (m - 1))
|
|
167
|
+
return np.sort(0.5 * (a + b) + 0.5 * (b - a) * x)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _barycentric_weights(x: np.ndarray) -> np.ndarray:
|
|
171
|
+
x = np.asarray(x, dtype=float)
|
|
172
|
+
diff = x[:, None] - x[None, :] # (m, m), zero on diagonal
|
|
173
|
+
np.fill_diagonal(diff, 1.0)
|
|
174
|
+
return 1.0 / np.prod(diff, axis=1)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _barycentric_interp_matrix(
|
|
178
|
+
x_eval: np.ndarray,
|
|
179
|
+
x_nodes: np.ndarray,
|
|
180
|
+
tol: float = 1e-14,
|
|
181
|
+
) -> np.ndarray:
|
|
182
|
+
x_eval = np.asarray(x_eval, dtype=float).reshape(-1)
|
|
183
|
+
x_nodes = np.asarray(x_nodes, dtype=float).reshape(-1)
|
|
184
|
+
w = _barycentric_weights(x_nodes)
|
|
185
|
+
diff = x_eval[:, None] - x_nodes[None, :] # (N_eval, m)
|
|
186
|
+
near = np.abs(diff) < tol * np.maximum(1.0, np.abs(x_eval[:, None]))
|
|
187
|
+
safe_diff = np.where(near, 1.0, diff)
|
|
188
|
+
tmp = np.where(near, 0.0, w[None, :] / safe_diff)
|
|
189
|
+
row_sum = tmp.sum(axis=1, keepdims=True)
|
|
190
|
+
M = tmp / np.where(row_sum == 0.0, 1.0, row_sum)
|
|
191
|
+
near_rows = np.where(near.any(axis=1))[0]
|
|
192
|
+
for i in near_rows:
|
|
193
|
+
M[i, :] = 0.0
|
|
194
|
+
M[i, np.argmax(near[i])] = 1.0
|
|
195
|
+
return M
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _as_strict_panels(panels) -> np.ndarray:
|
|
199
|
+
panels = np.asarray(panels, dtype=float)
|
|
200
|
+
if panels[0] != 0.0:
|
|
201
|
+
raise ValueError("panels must start at 0.")
|
|
202
|
+
if not np.all(np.diff(panels) > 0):
|
|
203
|
+
raise ValueError("panels must be strictly increasing.")
|
|
204
|
+
return panels
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Panel quadrature data (built once, numpy arrays)
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class PanelQuadrature:
|
|
213
|
+
"""Pre-built panel quadrature for the Fourier u-axis integration.
|
|
214
|
+
|
|
215
|
+
u_nodes — Chebyshev interpolation nodes across all panels; shape (N_u,).
|
|
216
|
+
hN (the correction multiplier) is evaluated here.
|
|
217
|
+
u_quad — Gauss-Legendre quadrature points across all panels; shape (N_q,).
|
|
218
|
+
phi0 (the base char function) is evaluated here.
|
|
219
|
+
w_quad — GL weights (panel-scaled); shape (N_q,).
|
|
220
|
+
Lmat — block-diagonal barycentric interpolation matrix (N_q, N_u).
|
|
221
|
+
"""
|
|
222
|
+
u_nodes: np.ndarray
|
|
223
|
+
u_quad: np.ndarray
|
|
224
|
+
w_quad: np.ndarray
|
|
225
|
+
Lmat: np.ndarray
|
|
226
|
+
panels: np.ndarray
|
|
227
|
+
panel_data: list = field(default_factory=list)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@lru_cache(maxsize=None)
|
|
231
|
+
def _gauss_legendre(q_weight: int) -> tuple[np.ndarray, np.ndarray]:
|
|
232
|
+
"""Gauss-Legendre nodes and weights of order q_weight on [-1, 1], as read-only arrays.
|
|
233
|
+
|
|
234
|
+
numpy's leggauss finds the nodes as the eigenvalues of the symmetric companion matrix of
|
|
235
|
+
the Legendre polynomial P_q; this eigenvalue problem is solved once per order.
|
|
236
|
+
"""
|
|
237
|
+
xg, wg = np.polynomial.legendre.leggauss(q_weight)
|
|
238
|
+
xg.flags.writeable = False
|
|
239
|
+
wg.flags.writeable = False
|
|
240
|
+
return xg, wg
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def build_panel_quadrature(
|
|
244
|
+
panels: np.ndarray,
|
|
245
|
+
m_cheb: int,
|
|
246
|
+
q_weight: int,
|
|
247
|
+
) -> PanelQuadrature:
|
|
248
|
+
"""Build Chebyshev–Gauss-Legendre panel quadrature for the Fourier integral."""
|
|
249
|
+
panels = _as_strict_panels(np.asarray(panels, dtype=float))
|
|
250
|
+
xg, wg = _gauss_legendre(q_weight)
|
|
251
|
+
|
|
252
|
+
all_u_nodes, all_u_quad, all_w_quad, Lmat_blocks, panel_data = [], [], [], [], []
|
|
253
|
+
|
|
254
|
+
for p in range(len(panels) - 1):
|
|
255
|
+
a, b = float(panels[p]), float(panels[p + 1])
|
|
256
|
+
u_nodes = _cheb_lobatto_nodes(a, b, m_cheb)
|
|
257
|
+
u_q = 0.5 * (a + b) + 0.5 * (b - a) * xg
|
|
258
|
+
w_q = 0.5 * (b - a) * wg
|
|
259
|
+
all_u_nodes.append(u_nodes)
|
|
260
|
+
all_u_quad.append(u_q)
|
|
261
|
+
all_w_quad.append(w_q)
|
|
262
|
+
Lmat_blocks.append(_barycentric_interp_matrix(u_q, u_nodes))
|
|
263
|
+
panel_data.append({"a": a, "b": b, "u_nodes": u_nodes, "u_quad": u_q, "w_quad": w_q})
|
|
264
|
+
|
|
265
|
+
u_nodes_all = np.concatenate(all_u_nodes)
|
|
266
|
+
u_quad_all = np.concatenate(all_u_quad)
|
|
267
|
+
w_quad_all = np.concatenate(all_w_quad)
|
|
268
|
+
|
|
269
|
+
N_q = len(u_quad_all)
|
|
270
|
+
N_u = len(u_nodes_all)
|
|
271
|
+
Lmat = np.zeros((N_q, N_u))
|
|
272
|
+
q_off = u_off = 0
|
|
273
|
+
for Lb in Lmat_blocks:
|
|
274
|
+
nq, nu = Lb.shape
|
|
275
|
+
Lmat[q_off:q_off + nq, u_off:u_off + nu] = Lb
|
|
276
|
+
q_off += nq
|
|
277
|
+
u_off += nu
|
|
278
|
+
|
|
279
|
+
return PanelQuadrature(
|
|
280
|
+
u_nodes=u_nodes_all,
|
|
281
|
+
u_quad=u_quad_all,
|
|
282
|
+
w_quad=w_quad_all,
|
|
283
|
+
Lmat=Lmat,
|
|
284
|
+
panels=panels,
|
|
285
|
+
panel_data=panel_data,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# Phase 2: integrate phi0 * payoff_hat onto Chebyshev nodes (JAX)
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
def measure_weights_from_phi(
|
|
294
|
+
phi0: jnp.ndarray,
|
|
295
|
+
quad: PanelQuadrature,
|
|
296
|
+
K: np.ndarray,
|
|
297
|
+
R: float,
|
|
298
|
+
call: bool = True,
|
|
299
|
+
digital: bool = False,
|
|
300
|
+
S_0: float = 1.0,
|
|
301
|
+
) -> jnp.ndarray:
|
|
302
|
+
"""Compute interpolation weights W[k, j] = ∫ payoff_hat(K_k, u) S_0^(-iu) phi0(u) ell_j(u) du.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
phi0: complex JAX array (N_q,) — base char function of log(S_T/S_0) at quad.u_quad
|
|
306
|
+
quad: PanelQuadrature from build_panel_quadrature
|
|
307
|
+
K: strike array (N_K,)
|
|
308
|
+
R: dampening shift (real)
|
|
309
|
+
call / digital: payoff variant
|
|
310
|
+
S_0: spot price
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
complex JAX array (N_K, N_u)
|
|
314
|
+
"""
|
|
315
|
+
phi0 = jnp.asarray(phi0, dtype=jnp.complex128)
|
|
316
|
+
w_quad = jnp.asarray(quad.w_quad, dtype=jnp.float64)
|
|
317
|
+
Lmat = jnp.asarray(quad.Lmat, dtype=jnp.float64)
|
|
318
|
+
u_c = jnp.asarray(quad.u_quad + 1j * R, dtype=jnp.complex128)
|
|
319
|
+
|
|
320
|
+
payoff_hat = fourier_payoff_call_put(K, u_c, call=call, digital=digital) # (N_K, N_q)
|
|
321
|
+
phi_S = jnp.exp(-1j * np.log(S_0) * u_c) * phi0 # CF of log S_T
|
|
322
|
+
B = payoff_hat * phi_S[None, :] # (N_K, N_q)
|
|
323
|
+
return (B * w_quad[None, :]) @ Lmat # (N_K, N_u)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
# Phase 3: apply correction multiplier and price (JAX)
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
def eur_call_put_from_weights(
|
|
331
|
+
weights: jnp.ndarray,
|
|
332
|
+
hN: jnp.ndarray,
|
|
333
|
+
discount: float,
|
|
334
|
+
) -> jnp.ndarray:
|
|
335
|
+
"""Price European options from interpolation weights and correction multiplier.
|
|
336
|
+
|
|
337
|
+
price[k] = discount / pi * Re( weights[k, :] @ hN )
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
weights: complex JAX array (N_K, N_u)
|
|
341
|
+
hN: complex JAX array (N_u,) — correction multiplier at u_nodes
|
|
342
|
+
discount: exp(-r * T)
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
real JAX array (N_K,)
|
|
346
|
+
"""
|
|
347
|
+
return (discount / np.pi) * jnp.real(weights @ hN)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def damped_contour_residues(
|
|
351
|
+
K: np.ndarray,
|
|
352
|
+
R: float,
|
|
353
|
+
S_0: float,
|
|
354
|
+
discount: float,
|
|
355
|
+
call: bool = True,
|
|
356
|
+
digital: bool = False,
|
|
357
|
+
) -> np.ndarray:
|
|
358
|
+
"""Pole terms that turn eur_call_put_from_weights into the price on any contour.
|
|
359
|
+
|
|
360
|
+
The integral is the price itself when R lies in the payoff's strip: R > 1 for
|
|
361
|
+
vanilla calls, R > 0 for digital calls, R < 0 for puts. Each pole of the payoff
|
|
362
|
+
transform between R and that strip adds its residue, half of it when the contour
|
|
363
|
+
runs through the pole: S_0 at R = 1 (vanillas), and K * discount (vanillas) or
|
|
364
|
+
discount (digitals) at R = 0. For calls on R = 0.5 this is the Lewis formula,
|
|
365
|
+
C = S_0 + integral.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
K: strike array (N_K,)
|
|
369
|
+
R: contour height, as passed to measure_weights_from_phi
|
|
370
|
+
S_0: spot price
|
|
371
|
+
discount: exp(-r * T)
|
|
372
|
+
call / digital: payoff variant
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
real numpy array (N_K,)
|
|
376
|
+
"""
|
|
377
|
+
K = np.asarray(K, dtype=float).reshape(-1)
|
|
378
|
+
# np.heaviside(x, 0.5): 1 above 0, 1/2 on the pole, 0 below
|
|
379
|
+
if digital:
|
|
380
|
+
return np.full(K.shape, discount * np.heaviside(-R if call else R, 0.5))
|
|
381
|
+
if call:
|
|
382
|
+
return S_0 * np.heaviside(1 - R, 0.5) - K * discount * np.heaviside(-R, 0.5)
|
|
383
|
+
return K * discount * np.heaviside(R, 0.5) - S_0 * np.heaviside(R - 1, 0.5)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# Lewis-contour pricing (R=0.5 path)
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
def lewis_weights_from_phi(
|
|
391
|
+
phi0: jnp.ndarray,
|
|
392
|
+
quad: PanelQuadrature,
|
|
393
|
+
K: np.ndarray,
|
|
394
|
+
S_0: float,
|
|
395
|
+
) -> jnp.ndarray:
|
|
396
|
+
"""Compute Lewis weights W[k, j] for the contour u - i/2.
|
|
397
|
+
|
|
398
|
+
W[k, j] = int_0^L exp(iu*log(S_0/K_k)) / (u^2+1/4) * phi0(u) * ell_j(u) du
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
phi0: complex JAX array (N_q,) — char function at quad.u_quad (real nodes)
|
|
402
|
+
quad: PanelQuadrature from build_panel_quadrature
|
|
403
|
+
K: strike array (N_K,)
|
|
404
|
+
S_0: spot price
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
complex JAX array (N_K, N_u)
|
|
408
|
+
"""
|
|
409
|
+
phi0 = jnp.asarray(phi0, dtype=jnp.complex128)
|
|
410
|
+
w_quad = jnp.asarray(quad.w_quad, dtype=jnp.float64)
|
|
411
|
+
Lmat = jnp.asarray(quad.Lmat, dtype=jnp.float64)
|
|
412
|
+
u_q = jnp.asarray(quad.u_quad, dtype=jnp.float64)
|
|
413
|
+
K_arr = jnp.asarray(np.asarray(K, dtype=float).reshape(-1), dtype=jnp.float64)
|
|
414
|
+
|
|
415
|
+
log_ratio = jnp.log(float(S_0) / K_arr)[:, None] # (N_K, 1)
|
|
416
|
+
phase = jnp.exp(1j * u_q[None, :] * log_ratio) # (N_K, N_q)
|
|
417
|
+
denom = u_q[None, :] ** 2 + 0.25 # (1, N_q)
|
|
418
|
+
B = (phase / denom) * phi0[None, :] # (N_K, N_q)
|
|
419
|
+
return (B * w_quad[None, :]) @ Lmat # (N_K, N_u)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def eur_call_from_lewis_weights(
|
|
423
|
+
weights: jnp.ndarray,
|
|
424
|
+
hN: jnp.ndarray,
|
|
425
|
+
S_0: float,
|
|
426
|
+
K: np.ndarray,
|
|
427
|
+
discount: float,
|
|
428
|
+
) -> jnp.ndarray:
|
|
429
|
+
"""Call price from Lewis weights and correction multiplier.
|
|
430
|
+
|
|
431
|
+
C_k = S_0 - discount * sqrt(S_0 * K_k) / pi * Re( weights[k, :] @ hN )
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
weights: complex JAX array (N_K, N_u)
|
|
435
|
+
hN: complex JAX array (N_u,) — correction multiplier at u_nodes
|
|
436
|
+
S_0: spot price
|
|
437
|
+
K: strike array (N_K,)
|
|
438
|
+
discount: exp(-r * T)
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
real JAX array (N_K,)
|
|
442
|
+
"""
|
|
443
|
+
K_arr = jnp.asarray(np.asarray(K, dtype=float).reshape(-1), dtype=jnp.float64)
|
|
444
|
+
sqrt_S0K = jnp.sqrt(float(S_0) * K_arr)
|
|
445
|
+
integral = jnp.real(weights @ jnp.asarray(hN, dtype=jnp.complex128))
|
|
446
|
+
return float(S_0) - (float(discount) / np.pi) * sqrt_S0K * integral
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
# Adaptive panel convergence utilities (model-agnostic)
|
|
451
|
+
# ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
def otm_prices_to_ivs(
|
|
454
|
+
raw_otm_prices: np.ndarray,
|
|
455
|
+
K: np.ndarray,
|
|
456
|
+
maturity: float,
|
|
457
|
+
S_0: float,
|
|
458
|
+
call_mask: np.ndarray,
|
|
459
|
+
put_mask: np.ndarray,
|
|
460
|
+
r: float = 0.0,
|
|
461
|
+
) -> np.ndarray:
|
|
462
|
+
"""Invert a vector of OTM option prices to Black-Scholes implied vols.
|
|
463
|
+
|
|
464
|
+
Strikes in ``call_mask`` are treated as calls, those in ``put_mask`` as puts.
|
|
465
|
+
The two masks must partition K (no overlap, no gap).
|
|
466
|
+
"""
|
|
467
|
+
ivs = np.empty_like(raw_otm_prices, dtype=float)
|
|
468
|
+
if np.any(call_mask):
|
|
469
|
+
ivs[call_mask] = _iv_eur(
|
|
470
|
+
S_0=S_0, K=K[call_mask], T=maturity,
|
|
471
|
+
price=raw_otm_prices[call_mask], payoff="call", r=r,
|
|
472
|
+
)
|
|
473
|
+
if np.any(put_mask):
|
|
474
|
+
ivs[put_mask] = _iv_eur(
|
|
475
|
+
S_0=S_0, K=K[put_mask], T=maturity,
|
|
476
|
+
price=raw_otm_prices[put_mask], payoff="put", r=r,
|
|
477
|
+
)
|
|
478
|
+
return ivs
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def adaptive_check_values(
|
|
482
|
+
result: np.ndarray,
|
|
483
|
+
raw_otm_prices: np.ndarray,
|
|
484
|
+
call_mask: np.ndarray,
|
|
485
|
+
put_mask: np.ndarray,
|
|
486
|
+
implied_vol: bool,
|
|
487
|
+
adaptive_metric: str,
|
|
488
|
+
K: np.ndarray,
|
|
489
|
+
maturity: float,
|
|
490
|
+
S_0: float,
|
|
491
|
+
r: float = 0.0,
|
|
492
|
+
scalar_H: bool = True,
|
|
493
|
+
) -> np.ndarray:
|
|
494
|
+
"""Extract a flat array of convergence-check values from a fixed-panel result.
|
|
495
|
+
|
|
496
|
+
Used to compare successive adaptive-panel iterations. Returns raw OTM prices
|
|
497
|
+
when ``adaptive_metric='price'``, and implied vols when ``adaptive_metric='iv'``
|
|
498
|
+
(inverting prices if the result is not already in IV space).
|
|
499
|
+
|
|
500
|
+
``scalar_H=False`` signals that ``raw_otm_prices`` has shape ``(N_K, N_H)``
|
|
501
|
+
and each H-slice is inverted independently.
|
|
502
|
+
"""
|
|
503
|
+
if adaptive_metric == "price":
|
|
504
|
+
return np.asarray(raw_otm_prices).ravel()
|
|
505
|
+
# metric == "iv"
|
|
506
|
+
if implied_vol:
|
|
507
|
+
return np.asarray(result).ravel()
|
|
508
|
+
if scalar_H:
|
|
509
|
+
return otm_prices_to_ivs(
|
|
510
|
+
raw_otm_prices, K, maturity, S_0, call_mask, put_mask, r
|
|
511
|
+
).ravel()
|
|
512
|
+
n_K, n_H = raw_otm_prices.shape
|
|
513
|
+
ivs = np.empty_like(raw_otm_prices)
|
|
514
|
+
for j in range(n_H):
|
|
515
|
+
ivs[:, j] = otm_prices_to_ivs(
|
|
516
|
+
raw_otm_prices[:, j], K, maturity, S_0, call_mask, put_mask, r
|
|
517
|
+
)
|
|
518
|
+
return ivs.ravel()
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def adaptive_converged(
|
|
522
|
+
prev_check: np.ndarray,
|
|
523
|
+
check: np.ndarray,
|
|
524
|
+
metric: str,
|
|
525
|
+
tol: float,
|
|
526
|
+
abs_tol: float,
|
|
527
|
+
rel_tol: float,
|
|
528
|
+
) -> bool:
|
|
529
|
+
"""Return True when successive adaptive-panel check values are within tolerance.
|
|
530
|
+
|
|
531
|
+
For ``metric='iv'`` a single absolute tolerance ``tol`` is applied.
|
|
532
|
+
For ``metric='price'`` a combined absolute + relative criterion is used.
|
|
533
|
+
"""
|
|
534
|
+
err = float(np.max(np.abs(check - prev_check)))
|
|
535
|
+
if metric == "iv":
|
|
536
|
+
return err <= tol
|
|
537
|
+
scale = max(float(np.max(np.abs(check))), 1.0)
|
|
538
|
+
return err <= abs_tol + rel_tol * scale
|
|
539
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from .rough_heston import (
|
|
2
|
+
RoughHeston,
|
|
3
|
+
RoughHestonParam,
|
|
4
|
+
HestonExpansionState,
|
|
5
|
+
)
|
|
6
|
+
from .pade import (
|
|
7
|
+
h_pade22,
|
|
8
|
+
h_pade33,
|
|
9
|
+
h_pade44,
|
|
10
|
+
rough_heston_pade_f0,
|
|
11
|
+
rough_heston_root_pade_f0,
|
|
12
|
+
)
|
|
13
|
+
from .integration import (
|
|
14
|
+
TimeGrid,
|
|
15
|
+
Field,
|
|
16
|
+
Integrate,
|
|
17
|
+
LinearVolterraSolver,
|
|
18
|
+
AdamsFractionalRiccatiSolver,
|
|
19
|
+
ProductIntegrationRiccatiSolver,
|
|
20
|
+
suggested_time_grid_power,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
# Model
|
|
25
|
+
"RoughHeston",
|
|
26
|
+
"RoughHestonParam",
|
|
27
|
+
"HestonExpansionState",
|
|
28
|
+
# Padé approximations
|
|
29
|
+
"h_pade22",
|
|
30
|
+
"h_pade33",
|
|
31
|
+
"h_pade44",
|
|
32
|
+
"rough_heston_pade_f0",
|
|
33
|
+
"rough_heston_root_pade_f0",
|
|
34
|
+
# Integration
|
|
35
|
+
"TimeGrid",
|
|
36
|
+
"Field",
|
|
37
|
+
"Integrate",
|
|
38
|
+
"LinearVolterraSolver",
|
|
39
|
+
"AdamsFractionalRiccatiSolver",
|
|
40
|
+
"ProductIntegrationRiccatiSolver",
|
|
41
|
+
"suggested_time_grid_power",
|
|
42
|
+
]
|