quantlibtorch 0.3.0__cp313-cp313-win_amd64.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.
- quantlibtorch/__init__.py +28 -0
- quantlibtorch/_native/build_info.json +9 -0
- quantlibtorch/_native/quantlibtorch_native.dll +0 -0
- quantlibtorch/_version.py +2 -0
- quantlibtorch/analytic.py +96 -0
- quantlibtorch/api.py +407 -0
- quantlibtorch/batch.py +218 -0
- quantlibtorch/calibration.py +212 -0
- quantlibtorch/conventions.py +70 -0
- quantlibtorch/fd.py +220 -0
- quantlibtorch/fd_basket.py +156 -0
- quantlibtorch/financial.py +25 -0
- quantlibtorch/heston.py +222 -0
- quantlibtorch/kernels.py +70 -0
- quantlibtorch/latent.py +190 -0
- quantlibtorch/multi_asset.py +150 -0
- quantlibtorch/pricing.py +165 -0
- quantlibtorch/shout.py +117 -0
- quantlibtorch/solver.py +92 -0
- quantlibtorch-0.3.0.dist-info/Abseil.txt +203 -0
- quantlibtorch-0.3.0.dist-info/Boost.txt +23 -0
- quantlibtorch-0.3.0.dist-info/JSON.txt +21 -0
- quantlibtorch-0.3.0.dist-info/LICENSE +29 -0
- quantlibtorch-0.3.0.dist-info/METADATA +147 -0
- quantlibtorch-0.3.0.dist-info/NOTICE.txt +60 -0
- quantlibtorch-0.3.0.dist-info/PyTorch.txt +9105 -0
- quantlibtorch-0.3.0.dist-info/QuantLib.txt +215 -0
- quantlibtorch-0.3.0.dist-info/RECORD +30 -0
- quantlibtorch-0.3.0.dist-info/WHEEL +5 -0
- quantlibtorch-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""QuantLib/LibTorch CPU pricing and batched CPU/CUDA tensor primitives."""
|
|
2
|
+
from ._version import __version__
|
|
3
|
+
from .solver import tridiagonal_solve
|
|
4
|
+
from .fd import fd_vanilla
|
|
5
|
+
from .analytic import analytic_european
|
|
6
|
+
from .heston import HestonMesh, HestonModel, heston_mesh, fd_heston
|
|
7
|
+
from .shout import ShoutMesh, shout_mesh, fd_shout
|
|
8
|
+
from .conventions import adjust_date, advance_date, year_fraction
|
|
9
|
+
from .api import (Exercise, VanillaOption, EquityMarket, PricingEngine, PricingLayer,
|
|
10
|
+
AnalyticEuropeanEngine, FdVanillaEngine, FdLatentEngine, FdHestonEngine,
|
|
11
|
+
FdShoutEngine)
|
|
12
|
+
from .fd_basket import fd_basket
|
|
13
|
+
from .latent import diffusion_rollback, fd_latent, state_grid
|
|
14
|
+
from .batch import LatentBatch, diffusion_rollback_batch
|
|
15
|
+
from .multi_asset import MultiAssetBatch
|
|
16
|
+
from .calibration import OptionQuote, LatentCalibration
|
|
17
|
+
from .pricing import european_call, log_moneyness_grid
|
|
18
|
+
from .kernels import tridiagonal_apply, band_axpyb, tridiagonal_solve_pcr
|
|
19
|
+
|
|
20
|
+
__all__ = ["tridiagonal_solve", "european_call", "log_moneyness_grid", "fd_vanilla", "fd_basket",
|
|
21
|
+
"tridiagonal_apply", "band_axpyb", "tridiagonal_solve_pcr",
|
|
22
|
+
"diffusion_rollback", "fd_latent", "state_grid", "OptionQuote", "LatentCalibration",
|
|
23
|
+
"LatentBatch", "diffusion_rollback_batch", "MultiAssetBatch", "analytic_european",
|
|
24
|
+
"Exercise", "VanillaOption", "EquityMarket", "PricingEngine", "PricingLayer",
|
|
25
|
+
"AnalyticEuropeanEngine", "FdVanillaEngine", "FdLatentEngine", "FdHestonEngine",
|
|
26
|
+
"HestonMesh", "HestonModel", "heston_mesh", "fd_heston",
|
|
27
|
+
"ShoutMesh", "shout_mesh", "fd_shout", "FdShoutEngine",
|
|
28
|
+
"adjust_date", "advance_date", "year_fraction"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"torch_version": "2.10.0+cu126",
|
|
3
|
+
"python_tag": "cpython-313",
|
|
4
|
+
"platform": "win-amd64",
|
|
5
|
+
"cxx11_abi": true,
|
|
6
|
+
"quantlib_commit": "06edddbdb85f006c43a449b5fc846a00c67477f3",
|
|
7
|
+
"native_source_sha256": "da42aa58f2a2f0a7f64e0e967aaf2dbfa1ce81a964651cd919cd2124370b45ae",
|
|
8
|
+
"library_sha256": "c45a898bc856771d34ea30ca37d6fddd5a8ff72c06a74463a8c0593816f240f7"
|
|
9
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Original QuantLib analytic European engines with graph-connected market data."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import date
|
|
5
|
+
import torch
|
|
6
|
+
|
|
7
|
+
from .fd import _serial
|
|
8
|
+
from .pricing import _finite_number
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _scalar(name, value):
|
|
12
|
+
if not isinstance(value, torch.Tensor):
|
|
13
|
+
return torch.tensor(_finite_number(name, value), dtype=torch.float64, device="cpu")
|
|
14
|
+
if (value.device.type != "cpu" or value.dtype != torch.float64
|
|
15
|
+
or value.layout != torch.strided or value.ndim != 0):
|
|
16
|
+
raise ValueError(f"{name} must be a dense CPU float64 scalar tensor")
|
|
17
|
+
if not bool(torch.isfinite(value)):
|
|
18
|
+
raise ValueError(f"{name} must be finite")
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _discounts(name, points, reference, maturity):
|
|
23
|
+
if points is None:
|
|
24
|
+
return [], torch.empty(0, dtype=torch.float64, device="cpu")
|
|
25
|
+
points = list(points)
|
|
26
|
+
if len(points) < 2:
|
|
27
|
+
raise ValueError(f"{name} requires at least two dated discount factors")
|
|
28
|
+
dates = [_serial(name, d) for d, _ in points]
|
|
29
|
+
values = [_scalar(name, v) for _, v in points]
|
|
30
|
+
if dates[0] != reference or dates[-1] < maturity:
|
|
31
|
+
raise ValueError(f"{name} must start at valuation_date and cover maturity_date")
|
|
32
|
+
if any(b <= a for a, b in zip(dates, dates[1:])):
|
|
33
|
+
raise ValueError(f"{name} dates must be strictly increasing")
|
|
34
|
+
if values[0].requires_grad or values[0].item() != 1.0:
|
|
35
|
+
raise ValueError(f"{name} first discount must be a fixed one")
|
|
36
|
+
if any(v.item() <= 0 for v in values):
|
|
37
|
+
raise ValueError(f"{name} discounts must be positive")
|
|
38
|
+
return dates, torch.stack(values)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def analytic_european(
|
|
42
|
+
volatility: torch.Tensor, *, spot: float | torch.Tensor = 100.0,
|
|
43
|
+
strike: float | torch.Tensor = 100.0,
|
|
44
|
+
rate: float | torch.Tensor = 0.03, dividend_yield: float | torch.Tensor = 0.01,
|
|
45
|
+
valuation_date: date = date(2025, 1, 1), maturity_date: date = date(2026, 1, 1),
|
|
46
|
+
discount_curve=None, dividend_curve=None, cash_dividends=(),
|
|
47
|
+
option_type: str = "call", mode: str = "full",
|
|
48
|
+
) -> torch.Tensor:
|
|
49
|
+
"""Price with QuantLib AnalyticEuropeanEngine or AnalyticDividendEuropeanEngine.
|
|
50
|
+
|
|
51
|
+
Volatility is a positive CPU float64 scalar tensor. Spot, strike, continuous
|
|
52
|
+
flat rates, noninitial dated discount factors and cash dividend amounts may
|
|
53
|
+
also be scalar tensors and retain their graphs. Full reverse AD and higher
|
|
54
|
+
derivatives are supported. Dates and the first curve discount (one) are
|
|
55
|
+
fixed. Dated curves use QuantLib log-linear interpolation and Actual/365
|
|
56
|
+
Fixed; supplying a curve replaces its flat rate. Active shadowed flat rates
|
|
57
|
+
raise instead of silently losing a requested sensitivity.
|
|
58
|
+
|
|
59
|
+
Cash schedules select QuantLib's analytic escrowed-dividend approximation,
|
|
60
|
+
which subtracts discounted dividends from spot. It is not the FD cash-jump
|
|
61
|
+
model. Dates must increase, follow valuation and not exceed maturity;
|
|
62
|
+
dividend amounts are nonnegative and adjusted spot must remain positive.
|
|
63
|
+
This engine supports European exercise, CPU scalar execution and full AD
|
|
64
|
+
only. There is no equation adjoint or automatic CUDA fallback.
|
|
65
|
+
"""
|
|
66
|
+
if not isinstance(volatility, torch.Tensor):
|
|
67
|
+
raise TypeError("volatility must be a torch.Tensor")
|
|
68
|
+
if mode != "full":
|
|
69
|
+
raise ValueError("analytic_european supports mode='full' only")
|
|
70
|
+
if option_type not in ("call", "put"):
|
|
71
|
+
raise ValueError("option_type must be call or put")
|
|
72
|
+
reference = _serial("valuation_date", valuation_date)
|
|
73
|
+
maturity = _serial("maturity_date", maturity_date)
|
|
74
|
+
if maturity <= reference:
|
|
75
|
+
raise ValueError("maturity_date must follow valuation_date")
|
|
76
|
+
names = ("spot", "strike", "volatility", "rate", "dividend_yield")
|
|
77
|
+
values = [_scalar(n, v) for n, v in zip(names, (spot, strike, volatility, rate, dividend_yield))]
|
|
78
|
+
if any(v.item() <= 0 for v in values[:3]):
|
|
79
|
+
raise ValueError("spot, strike and volatility must be positive")
|
|
80
|
+
risk_dates, risk_dfs = _discounts("discount_curve", discount_curve, reference, maturity)
|
|
81
|
+
yield_dates, yield_dfs = _discounts("dividend_curve", dividend_curve, reference, maturity)
|
|
82
|
+
for n, v, ds in (("rate", values[3], risk_dates), ("dividend_yield", values[4], yield_dates)):
|
|
83
|
+
if ds and v.requires_grad:
|
|
84
|
+
raise ValueError(f"differentiable {n} is overridden by its dated curve")
|
|
85
|
+
cash_pairs = list(cash_dividends)
|
|
86
|
+
cash_dates = [_serial("cash_dividends", d) for d, _ in cash_pairs]
|
|
87
|
+
cash_values = [_scalar("cash_dividends", v) for _, v in cash_pairs]
|
|
88
|
+
if (any(d <= reference or d > maturity for d in cash_dates)
|
|
89
|
+
or any(b <= a for a, b in zip(cash_dates, cash_dates[1:]))):
|
|
90
|
+
raise ValueError("cash dividend dates must increase, follow valuation and not exceed maturity")
|
|
91
|
+
if any(v.item() < 0 for v in cash_values):
|
|
92
|
+
raise ValueError("cash dividends must be nonnegative")
|
|
93
|
+
cash = torch.stack(cash_values) if cash_values else torch.empty(0, dtype=torch.float64, device="cpu")
|
|
94
|
+
return torch.ops.quantlibtorch.analytic_european(
|
|
95
|
+
torch.stack(values), reference, maturity, 1 if option_type == "call" else -1,
|
|
96
|
+
risk_dates, risk_dfs, yield_dates, yield_dfs, cash_dates, cash)
|
quantlibtorch/api.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Reusable financial configuration and PyTorch pricing modules.
|
|
2
|
+
|
|
3
|
+
Configuration objects contain fixed Python data. Supply differentiable tensors
|
|
4
|
+
on each call, either directly or from a registered ``torch.nn.Module``. Engines
|
|
5
|
+
delegate to the same public pricing functions; they do not introduce a second
|
|
6
|
+
numerical implementation or change the functions' derivative contracts.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import date
|
|
12
|
+
from typing import Any, Literal, Protocol, Sequence
|
|
13
|
+
|
|
14
|
+
import torch
|
|
15
|
+
from torch import nn
|
|
16
|
+
|
|
17
|
+
from .fd import _axis, _curve, _integer, _serial, fd_vanilla
|
|
18
|
+
from .latent import _axes, fd_latent
|
|
19
|
+
from .heston import HestonMesh, fd_heston
|
|
20
|
+
from .shout import ShoutMesh, fd_shout
|
|
21
|
+
from .conventions import _choice, _DAY_COUNTERS
|
|
22
|
+
from .pricing import _finite_number
|
|
23
|
+
|
|
24
|
+
DateValues = Sequence[tuple[date, float]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _fixed_axis(name: str, values: Sequence[float] | None) -> tuple[float, ...] | None:
|
|
28
|
+
if values is None:
|
|
29
|
+
return None
|
|
30
|
+
if isinstance(values, torch.Tensor):
|
|
31
|
+
raise TypeError(f"{name} must be a fixed sequence, not a tensor")
|
|
32
|
+
values = tuple(values)
|
|
33
|
+
if not values:
|
|
34
|
+
raise ValueError(f"{name} must not be empty")
|
|
35
|
+
return tuple(_axis(name, values, len(values), 0.0))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Exercise:
|
|
40
|
+
"""An exercise rule; Bermudan dates must include the instrument's maturity.
|
|
41
|
+
|
|
42
|
+
American exercise starts at the market valuation date. Dates are discrete
|
|
43
|
+
configuration and are never differentiable.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
style: Literal["european", "american", "bermudan"] = "european"
|
|
47
|
+
dates: tuple[date, ...] = ()
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
if self.style not in ("european", "american", "bermudan"):
|
|
51
|
+
raise ValueError("exercise style must be european, american, or bermudan")
|
|
52
|
+
dates = tuple(self.dates)
|
|
53
|
+
serials = [_serial("exercise dates", d) for d in dates]
|
|
54
|
+
if self.style == "bermudan":
|
|
55
|
+
if not serials or any(b <= a for a, b in zip(serials, serials[1:])):
|
|
56
|
+
raise ValueError("Bermudan exercise dates must be nonempty and strictly increasing")
|
|
57
|
+
elif dates:
|
|
58
|
+
raise ValueError("exercise dates apply only to Bermudan exercise")
|
|
59
|
+
object.__setattr__(self, "dates", dates)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def european(cls) -> Exercise:
|
|
63
|
+
return cls("european")
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def american(cls) -> Exercise:
|
|
67
|
+
return cls("american")
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def bermudan(cls, dates: Sequence[date]) -> Exercise:
|
|
71
|
+
return cls("bermudan", tuple(dates))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class VanillaOption:
|
|
76
|
+
"""Fixed strike, maturity, call/put type, and exercise convention.
|
|
77
|
+
|
|
78
|
+
For engines supporting strike derivatives, override ``strike`` with a
|
|
79
|
+
tensor when calling the pricing layer.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
strike: float
|
|
83
|
+
maturity_date: date
|
|
84
|
+
option_type: Literal["call", "put"] = "call"
|
|
85
|
+
exercise: Exercise = field(default_factory=Exercise)
|
|
86
|
+
|
|
87
|
+
def __post_init__(self) -> None:
|
|
88
|
+
strike = _finite_number("strike", self.strike)
|
|
89
|
+
if strike <= 0:
|
|
90
|
+
raise ValueError("strike must be positive")
|
|
91
|
+
_serial("maturity_date", self.maturity_date)
|
|
92
|
+
if self.option_type not in ("call", "put"):
|
|
93
|
+
raise ValueError("option_type must be call or put")
|
|
94
|
+
if not isinstance(self.exercise, Exercise):
|
|
95
|
+
raise TypeError("exercise must be an Exercise object")
|
|
96
|
+
if self.exercise.style == "bermudan" and self.exercise.dates[-1] != self.maturity_date:
|
|
97
|
+
raise ValueError("Bermudan exercise dates must end at maturity_date")
|
|
98
|
+
object.__setattr__(self, "strike", strike)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class EquityMarket:
|
|
103
|
+
"""Reusable passive equity market using Actual/365 Fixed conventions.
|
|
104
|
+
|
|
105
|
+
Curves contain dated discount factors with log-linear interpolation. Cash
|
|
106
|
+
dividends contain dated amounts. Sequences are copied to immutable tuples.
|
|
107
|
+
Tensor inputs belong in ``PricingLayer.forward`` overrides, so market
|
|
108
|
+
configuration never retains a parameter graph between optimizer steps.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
spot: float = 100.0
|
|
112
|
+
valuation_date: date = date(2025, 1, 1)
|
|
113
|
+
rate: float = 0.03
|
|
114
|
+
dividend_yield: float = 0.01
|
|
115
|
+
discount_curve: DateValues | None = None
|
|
116
|
+
dividend_curve: DateValues | None = None
|
|
117
|
+
cash_dividends: DateValues = ()
|
|
118
|
+
|
|
119
|
+
def __post_init__(self) -> None:
|
|
120
|
+
reference = _serial("valuation_date", self.valuation_date)
|
|
121
|
+
for name in ("spot", "rate", "dividend_yield"):
|
|
122
|
+
object.__setattr__(self, name, _finite_number(name, getattr(self, name)))
|
|
123
|
+
if self.spot <= 0:
|
|
124
|
+
raise ValueError("spot must be positive")
|
|
125
|
+
for name in ("discount_curve", "dividend_curve"):
|
|
126
|
+
values = getattr(self, name)
|
|
127
|
+
if values is not None:
|
|
128
|
+
values = tuple((d, _finite_number(name, v)) for d, v in values)
|
|
129
|
+
_curve(name, values, reference, reference + 1)
|
|
130
|
+
object.__setattr__(self, name, values)
|
|
131
|
+
cash = tuple((d, _finite_number("cash_dividends", v)) for d, v in self.cash_dividends)
|
|
132
|
+
serials = [_serial("cash_dividends", d) for d, _ in cash]
|
|
133
|
+
if any(d <= reference for d in serials) or any(b <= a for a, b in zip(serials, serials[1:])):
|
|
134
|
+
raise ValueError("cash dividend dates must increase and follow valuation_date")
|
|
135
|
+
if any(v < 0 for _, v in cash):
|
|
136
|
+
raise ValueError("cash dividends must be nonnegative")
|
|
137
|
+
object.__setattr__(self, "cash_dividends", cash)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _arguments(instrument: VanillaOption, market: EquityMarket,
|
|
141
|
+
overrides: dict[str, Any], allowed: set[str]) -> dict[str, Any]:
|
|
142
|
+
if not isinstance(instrument, VanillaOption) or not isinstance(market, EquityMarket):
|
|
143
|
+
raise TypeError("instrument and market must be VanillaOption and EquityMarket objects")
|
|
144
|
+
unknown = overrides.keys() - allowed
|
|
145
|
+
if unknown:
|
|
146
|
+
raise TypeError(f"unsupported pricing overrides: {', '.join(sorted(unknown))}")
|
|
147
|
+
if instrument.maturity_date <= market.valuation_date:
|
|
148
|
+
raise ValueError("maturity_date must follow valuation_date")
|
|
149
|
+
if instrument.exercise.dates and instrument.exercise.dates[0] <= market.valuation_date:
|
|
150
|
+
raise ValueError("exercise dates must follow valuation_date")
|
|
151
|
+
result = dict(
|
|
152
|
+
spot=market.spot, strike=instrument.strike,
|
|
153
|
+
valuation_date=market.valuation_date, maturity_date=instrument.maturity_date,
|
|
154
|
+
rate=market.rate, dividend_yield=market.dividend_yield,
|
|
155
|
+
discount_curve=market.discount_curve, dividend_curve=market.dividend_curve,
|
|
156
|
+
cash_dividends=market.cash_dividends, option_type=instrument.option_type,
|
|
157
|
+
exercise=instrument.exercise.style, exercise_dates=instrument.exercise.dates,
|
|
158
|
+
)
|
|
159
|
+
result.update(overrides)
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_MARKET_OVERRIDES = {"spot", "strike", "rate", "dividend_yield", "discount_curve",
|
|
164
|
+
"dividend_curve", "cash_dividends"}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class PricingEngine(Protocol):
|
|
168
|
+
"""Structural interface for engines accepted by :class:`PricingLayer`."""
|
|
169
|
+
|
|
170
|
+
def price(self, instrument: VanillaOption, market: EquityMarket,
|
|
171
|
+
*inputs: torch.Tensor, **overrides: Any) -> torch.Tensor: ...
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class AnalyticEuropeanEngine:
|
|
176
|
+
"""QuantLib analytic European pricing with CPU float64 full AD.
|
|
177
|
+
|
|
178
|
+
Supply a scalar volatility tensor. Tensor market overrides support the
|
|
179
|
+
differentiable inputs of ``analytic_european``. A nonempty cash schedule
|
|
180
|
+
selects QuantLib's analytic dividend engine and its escrowed approximation.
|
|
181
|
+
Early exercise is rejected. There is no equation-adjoint mode.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def price(self, instrument: VanillaOption, market: EquityMarket,
|
|
185
|
+
volatility: torch.Tensor, **overrides: Any) -> torch.Tensor:
|
|
186
|
+
from .analytic import analytic_european
|
|
187
|
+
|
|
188
|
+
kwargs = _arguments(instrument, market, overrides, _MARKET_OVERRIDES)
|
|
189
|
+
if kwargs.pop("exercise") != "european":
|
|
190
|
+
raise ValueError("AnalyticEuropeanEngine supports European exercise only")
|
|
191
|
+
kwargs.pop("exercise_dates")
|
|
192
|
+
return analytic_european(volatility, **kwargs)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True)
|
|
196
|
+
class FdVanillaEngine:
|
|
197
|
+
"""Configuration for QuantLib's 1D Black-Scholes finite-difference engine.
|
|
198
|
+
|
|
199
|
+
``price`` takes a scalar or sampled local-volatility tensor. Financial
|
|
200
|
+
overrides remain fixed values, as required by ``fd_vanilla``. Full AD
|
|
201
|
+
supports higher derivatives; equation adjoints support first derivatives.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
time_steps: int = 50
|
|
205
|
+
grid_points: int = 100
|
|
206
|
+
damping_steps: int = 2
|
|
207
|
+
scheme: str = "douglas"
|
|
208
|
+
mesh_volatility: float = 0.2
|
|
209
|
+
cash_dividend_model: str = "spot"
|
|
210
|
+
times: Sequence[float] | None = None
|
|
211
|
+
log_spots: Sequence[float] | None = None
|
|
212
|
+
mode: Literal["full", "adjoint"] = "full"
|
|
213
|
+
kernels: str = "auto"
|
|
214
|
+
|
|
215
|
+
def __post_init__(self) -> None:
|
|
216
|
+
for name, minimum in (("time_steps", 1), ("grid_points", 5), ("damping_steps", 0)):
|
|
217
|
+
object.__setattr__(self, name, _integer(name, getattr(self, name), minimum))
|
|
218
|
+
if self.scheme not in ("douglas", "crank_nicolson", "implicit_euler", "craig_sneyd",
|
|
219
|
+
"modified_craig_sneyd", "hundsdorfer"):
|
|
220
|
+
raise ValueError("unsupported finite-difference scheme")
|
|
221
|
+
if self.cash_dividend_model not in ("spot", "escrowed"):
|
|
222
|
+
raise ValueError("cash_dividend_model must be spot or escrowed")
|
|
223
|
+
volatility = _finite_number("mesh_volatility", self.mesh_volatility)
|
|
224
|
+
if volatility <= 0:
|
|
225
|
+
raise ValueError("mesh_volatility must be positive")
|
|
226
|
+
object.__setattr__(self, "mesh_volatility", volatility)
|
|
227
|
+
_validate_mode(self.mode, self.kernels, ("auto", "scalar", "apply", "vectorized", "pcr"))
|
|
228
|
+
if self.kernels == "pcr" and self.mode != "full":
|
|
229
|
+
raise ValueError("PCR pricing supports mode='full' only")
|
|
230
|
+
for name in ("times", "log_spots"):
|
|
231
|
+
object.__setattr__(self, name, _fixed_axis(name, getattr(self, name)))
|
|
232
|
+
if self.times is not None and self.times[0] < 0:
|
|
233
|
+
raise ValueError("times must be nonnegative")
|
|
234
|
+
|
|
235
|
+
def price(self, instrument: VanillaOption, market: EquityMarket,
|
|
236
|
+
local_vol: torch.Tensor, **overrides: Any) -> torch.Tensor:
|
|
237
|
+
kwargs = _arguments(instrument, market, overrides, _MARKET_OVERRIDES)
|
|
238
|
+
return fd_vanilla(
|
|
239
|
+
local_vol, **kwargs, times=self.times, log_spots=self.log_spots,
|
|
240
|
+
time_steps=self.time_steps, grid_points=self.grid_points,
|
|
241
|
+
damping_steps=self.damping_steps, scheme=self.scheme,
|
|
242
|
+
mesh_volatility=self.mesh_volatility, cash_dividend_model=self.cash_dividend_model,
|
|
243
|
+
mode=self.mode, kernels=self.kernels,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _validate_mode(mode: str, kernels: str, choices: tuple[str, ...]) -> None:
|
|
248
|
+
if mode not in ("full", "adjoint"):
|
|
249
|
+
raise ValueError("mode must be full or adjoint")
|
|
250
|
+
if kernels not in choices:
|
|
251
|
+
raise ValueError(f"kernels must be one of {choices}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(frozen=True)
|
|
255
|
+
class FdLatentEngine:
|
|
256
|
+
"""QuantLib 2D/3D latent pricing with fixed axes and coefficient times.
|
|
257
|
+
|
|
258
|
+
Inputs are latent drift, diffusion loadings and initial log-spot/latent
|
|
259
|
+
state, matching ``fd_latent``. The market's passive spot is unused because
|
|
260
|
+
initial_state explicitly supplies log-spot. Rate/yield and dated discount
|
|
261
|
+
factor overrides may be graph-connected tensors. No cash-dividend path is
|
|
262
|
+
exposed by this scalar adapter.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
axes: Sequence[Sequence[float]]
|
|
266
|
+
times: Sequence[float] | None = None
|
|
267
|
+
time_steps: int = 30
|
|
268
|
+
scheme: str = "hundsdorfer"
|
|
269
|
+
mode: Literal["full", "adjoint"] = "full"
|
|
270
|
+
kernels: str = "auto"
|
|
271
|
+
|
|
272
|
+
def __post_init__(self) -> None:
|
|
273
|
+
axes = tuple(tuple(a) for a in _axes(self.axes, minimum=4))
|
|
274
|
+
if len(axes) not in (2, 3):
|
|
275
|
+
raise ValueError("FdLatentEngine supports one or two latent states")
|
|
276
|
+
object.__setattr__(self, "axes", axes)
|
|
277
|
+
object.__setattr__(self, "times", _fixed_axis("times", self.times))
|
|
278
|
+
object.__setattr__(self, "time_steps", _integer("time_steps", self.time_steps, 1))
|
|
279
|
+
if self.scheme not in ("douglas", "craig_sneyd", "modified_craig_sneyd", "hundsdorfer"):
|
|
280
|
+
raise ValueError("unsupported latent finite-difference scheme")
|
|
281
|
+
_validate_mode(self.mode, self.kernels, ("auto", "scalar"))
|
|
282
|
+
|
|
283
|
+
def price(self, instrument: VanillaOption, market: EquityMarket,
|
|
284
|
+
latent_drift: torch.Tensor, diffusion: torch.Tensor,
|
|
285
|
+
initial_state: torch.Tensor, **overrides: Any) -> torch.Tensor:
|
|
286
|
+
kwargs = _arguments(instrument, market, overrides, _MARKET_OVERRIDES - {"spot"})
|
|
287
|
+
if kwargs.pop("cash_dividends"):
|
|
288
|
+
raise ValueError("FdLatentEngine does not support cash dividends")
|
|
289
|
+
kwargs.pop("spot")
|
|
290
|
+
return fd_latent(
|
|
291
|
+
latent_drift, diffusion, initial_state, **kwargs, axes=self.axes, times=self.times,
|
|
292
|
+
time_steps=self.time_steps, scheme=self.scheme, mode=self.mode, kernels=self.kernels,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass(frozen=True)
|
|
297
|
+
class FdHestonEngine:
|
|
298
|
+
"""Original QuantLib Heston engine with explicitly frozen geometry.
|
|
299
|
+
|
|
300
|
+
Supply five scalar inputs (v0, kappa, theta, sigma, rho), or register a
|
|
301
|
+
``HestonModel`` on the pricing layer. Spot, rate/yield and dated curve
|
|
302
|
+
overrides may be tensors. Strike and cash dividends remain fixed.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
mesh: HestonMesh
|
|
306
|
+
time_steps: int = 30
|
|
307
|
+
damping_steps: int = 0
|
|
308
|
+
scheme: str = "hundsdorfer"
|
|
309
|
+
mode: Literal["full", "adjoint"] = "full"
|
|
310
|
+
kernels: str = "auto"
|
|
311
|
+
day_counter: str = "actual365fixed"
|
|
312
|
+
|
|
313
|
+
def __post_init__(self) -> None:
|
|
314
|
+
if not isinstance(self.mesh, HestonMesh):
|
|
315
|
+
raise TypeError("mesh must be a HestonMesh; prepare it with heston_mesh")
|
|
316
|
+
object.__setattr__(self, "time_steps", _integer("time_steps", self.time_steps, 1))
|
|
317
|
+
object.__setattr__(self, "damping_steps", _integer("damping_steps", self.damping_steps, 0))
|
|
318
|
+
if self.scheme not in ("douglas", "craig_sneyd", "modified_craig_sneyd", "hundsdorfer"):
|
|
319
|
+
raise ValueError("unsupported Heston finite-difference scheme")
|
|
320
|
+
_validate_mode(self.mode, self.kernels, ("auto", "scalar"))
|
|
321
|
+
_choice("day_counter", self.day_counter, _DAY_COUNTERS)
|
|
322
|
+
|
|
323
|
+
def price(self, instrument: VanillaOption, market: EquityMarket,
|
|
324
|
+
v0: torch.Tensor, kappa: torch.Tensor, theta: torch.Tensor,
|
|
325
|
+
sigma: torch.Tensor, rho: torch.Tensor, **overrides: Any) -> torch.Tensor:
|
|
326
|
+
kwargs = _arguments(instrument, market, overrides, _MARKET_OVERRIDES)
|
|
327
|
+
return fd_heston(v0, kappa, theta, sigma, rho, **kwargs, mesh=self.mesh,
|
|
328
|
+
time_steps=self.time_steps, damping_steps=self.damping_steps,
|
|
329
|
+
scheme=self.scheme, mode=self.mode, kernels=self.kernels,
|
|
330
|
+
day_counter=self.day_counter)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass(frozen=True)
|
|
334
|
+
class FdShoutEngine:
|
|
335
|
+
"""Original one-shout engine with constant Black volatility.
|
|
336
|
+
|
|
337
|
+
The instrument exercise rule specifies when shouting is allowed. Cash
|
|
338
|
+
dividends use the original escrowed model. Financial tensor overrides
|
|
339
|
+
support spot, rate/yield and dated discount factors on the explicit mesh.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
mesh: ShoutMesh
|
|
343
|
+
time_steps: int = 50
|
|
344
|
+
damping_steps: int = 0
|
|
345
|
+
scheme: str = "douglas"
|
|
346
|
+
mode: Literal["full", "adjoint"] = "full"
|
|
347
|
+
kernels: str = "auto"
|
|
348
|
+
day_counter: str = "actual365fixed"
|
|
349
|
+
|
|
350
|
+
def __post_init__(self) -> None:
|
|
351
|
+
if not isinstance(self.mesh, ShoutMesh):
|
|
352
|
+
raise TypeError("mesh must be a ShoutMesh; prepare it with shout_mesh")
|
|
353
|
+
object.__setattr__(self, "time_steps", _integer("time_steps", self.time_steps, 1))
|
|
354
|
+
object.__setattr__(self, "damping_steps", _integer("damping_steps", self.damping_steps, 0))
|
|
355
|
+
if self.scheme not in ("douglas", "crank_nicolson", "implicit_euler", "craig_sneyd",
|
|
356
|
+
"modified_craig_sneyd", "hundsdorfer"):
|
|
357
|
+
raise ValueError("unsupported shout finite-difference scheme")
|
|
358
|
+
_validate_mode(self.mode, self.kernels, ("auto", "scalar"))
|
|
359
|
+
_choice("day_counter", self.day_counter, _DAY_COUNTERS)
|
|
360
|
+
|
|
361
|
+
def price(self, instrument: VanillaOption, market: EquityMarket,
|
|
362
|
+
volatility: torch.Tensor, **overrides: Any) -> torch.Tensor:
|
|
363
|
+
kwargs = _arguments(instrument, market, overrides, _MARKET_OVERRIDES)
|
|
364
|
+
return fd_shout(volatility, **kwargs, mesh=self.mesh, time_steps=self.time_steps,
|
|
365
|
+
damping_steps=self.damping_steps, scheme=self.scheme,
|
|
366
|
+
mode=self.mode, kernels=self.kernels, day_counter=self.day_counter)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class PricingLayer(nn.Module):
|
|
370
|
+
"""Compose an instrument, market and engine with ordinary PyTorch models.
|
|
371
|
+
|
|
372
|
+
Without ``model``, positional inputs pass directly to ``engine.price``.
|
|
373
|
+
With a registered ``nn.Module``, they go to that model first; its tensor or
|
|
374
|
+
tuple of tensors supplies the engine inputs. Keyword arguments are market
|
|
375
|
+
overrides and go directly to the engine. Every call constructs a fresh
|
|
376
|
+
graph. Model parameters participate in ``parameters()`` and ``state_dict``;
|
|
377
|
+
financial configuration is supplied separately when reconstructing a layer.
|
|
378
|
+
|
|
379
|
+
No implicit dtype/device conversion occurs. The engine's supported devices,
|
|
380
|
+
tensor shapes, gradient order and nonsmooth derivative rules still apply.
|
|
381
|
+
A custom engine can implement the small :class:`PricingEngine` protocol.
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
def __init__(self, instrument: VanillaOption, market: EquityMarket,
|
|
385
|
+
engine: PricingEngine, *, model: nn.Module | None = None) -> None:
|
|
386
|
+
super().__init__()
|
|
387
|
+
_arguments(instrument, market, {}, set())
|
|
388
|
+
if not callable(getattr(engine, "price", None)):
|
|
389
|
+
raise TypeError("engine must provide a callable price method")
|
|
390
|
+
if model is not None and not isinstance(model, nn.Module):
|
|
391
|
+
raise TypeError("model must be a torch.nn.Module")
|
|
392
|
+
self.instrument = instrument
|
|
393
|
+
self.market = market
|
|
394
|
+
self.engine = engine
|
|
395
|
+
self.model = model
|
|
396
|
+
|
|
397
|
+
def forward(self, *inputs: torch.Tensor, **overrides: Any) -> torch.Tensor:
|
|
398
|
+
if self.model is not None:
|
|
399
|
+
result = self.model(*inputs)
|
|
400
|
+
inputs = result if isinstance(result, tuple) else (result,)
|
|
401
|
+
if not inputs or any(not isinstance(value, torch.Tensor) for value in inputs):
|
|
402
|
+
raise TypeError("engine inputs must be tensors; model must return a tensor or tuple of tensors")
|
|
403
|
+
return self.engine.price(self.instrument, self.market, *inputs, **overrides)
|
|
404
|
+
|
|
405
|
+
def extra_repr(self) -> str:
|
|
406
|
+
return (f"engine={type(self.engine).__name__}, option_type={self.instrument.option_type}, "
|
|
407
|
+
f"exercise={self.instrument.exercise.style}, strike={self.instrument.strike}")
|