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.
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ """Release version; statically read by setuptools without loading the native library."""
2
+ __version__ = "0.3.0"
@@ -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}")