diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3750dec3..82a7075e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -37,6 +37,7 @@ applyTo: '/**' * Always document Pydantic fields with `Field(description=...)` — never use a docstring below a field assignment * Split long description strings across lines using implicit string concatenation rather than shortening the text +* When a docstring line exceeds the line length limit, split it across multiple lines rather than shortening the text ## Package structure diff --git a/.gitignore b/.gitignore index 784d6bf9..cd81b872 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ build dist .venv .mypy_cache +.hypothesis .pytest_cache .ruff_cache .python-version @@ -37,3 +38,5 @@ _build # builds app/docs +docs/assets/examples +docs/examples/output diff --git a/dev/quantflow.dockerfile b/dev/quantflow.dockerfile index f4bcd0af..14987488 100644 --- a/dev/quantflow.dockerfile +++ b/dev/quantflow.dockerfile @@ -4,16 +4,23 @@ FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS builder WORKDIR /build +# Install Chromium for kaleido (Plotly static image export used by docs examples) +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium \ + && rm -rf /var/lib/apt/lists/* + # Copy dependency files COPY pyproject.toml uv.lock readme.md ./ # Install dependencies (no root package, with needed extras) RUN uv sync --frozen --no-install-project --extra ai --extra book --extra docs --extra data -# Copy source and build docs +# Copy source, generate example outputs and images, then build docs COPY mkdocs.yml ./ +COPY dev/ ./dev/ COPY docs/ ./docs/ COPY quantflow/ ./quantflow/ +RUN uv run ./dev/build-examples RUN uv run mkdocs build # Stage 2: Runtime stage diff --git a/docs/api/sp/heston.md b/docs/api/sp/heston.md index b239d1e4..fc766e27 100644 --- a/docs/api/sp/heston.md +++ b/docs/api/sp/heston.md @@ -4,3 +4,9 @@ ::: quantflow.sp.heston.HestonJ + + +::: quantflow.sp.heston.DoubleHeston + + +::: quantflow.sp.heston.DoubleHestonJ diff --git a/docs/api/sp/index.md b/docs/api/sp/index.md index de8867d0..d10439fd 100644 --- a/docs/api/sp/index.md +++ b/docs/api/sp/index.md @@ -1,6 +1,47 @@ -# Stochastic Process +# Stochastic Processes -This page gives an overview of all Stochastic Processes available in the library. +This page gives an overview of all stochastic processes available in the library. + +## Available processes + +### Diffusion + +| Process | Description | +|---|---| +| [WeinerProcess][quantflow.sp.weiner.WeinerProcess] | Standard Brownian motion | + +### Mean-reverting (intensity) + +| Process | Description | +|---|---| +| [CIR][quantflow.sp.cir.CIR] | Cox-Ingersoll-Ross square-root diffusion | +| [Vasicek][quantflow.sp.ou.Vasicek] | Gaussian Ornstein-Uhlenbeck process | +| [GammaOU][quantflow.sp.ou.GammaOU] | Non-Gaussian OU process driven by a Gamma subordinator | + +### Jump processes + +| Process | Description | +|---|---| +| [PoissonProcess][quantflow.sp.poisson.PoissonProcess] | Homogeneous Poisson process | +| [CompoundPoissonProcess][quantflow.sp.poisson.CompoundPoissonProcess] | Poisson process with random jump sizes | +| [DSP][quantflow.sp.dsp.DSP] | Doubly stochastic (Cox) Poisson process | + +### Stochastic volatility + +| Process | Description | +|---|---| +| [Heston][quantflow.sp.heston.Heston] | Classical square-root stochastic volatility model | +| [HestonJ][quantflow.sp.heston.HestonJ] | Heston model with compound Poisson jumps | +| [DoubleHeston][quantflow.sp.heston.DoubleHeston] | Two independent Heston variance processes | +| [DoubleHestonJ][quantflow.sp.heston.DoubleHestonJ] | Double Heston with compound Poisson jumps on the second component | + +### Jump diffusion + +| Process | Description | +|---|---| +| [JumpDiffusion][quantflow.sp.jump_diffusion.JumpDiffusion] | Diffusion with compound Poisson jumps | + +## Base classes ::: quantflow.sp.base.StochasticProcess diff --git a/docs/assets/heston_calibrated_smile.png b/docs/assets/heston_calibrated_smile.png deleted file mode 100644 index c1d3b995..00000000 Binary files a/docs/assets/heston_calibrated_smile.png and /dev/null differ diff --git a/docs/assets/hestonj_calibrated_smile.png b/docs/assets/hestonj_calibrated_smile.png deleted file mode 100644 index a3d3e492..00000000 Binary files a/docs/assets/hestonj_calibrated_smile.png and /dev/null differ diff --git a/docs/bibliography.md b/docs/bibliography.md index 6c97f263..42371196 100644 --- a/docs/bibliography.md +++ b/docs/bibliography.md @@ -2,50 +2,22 @@ --- -### carr_madan +#### carr_madan -Peter Carr, Dilip Madan +Peter Carr, Dilip Madan, [Option Valuation Using the Fast Fourier Transform](https://doi.org/10.21314/JCF.1999.043), Journal of Computational Finance, 2(4):61-73, 1999 -[Option Valuation Using the Fast Fourier Transform](https://doi.org/10.1002/(SICI)1097-0261(199904)2:1<61::AID-FUT4>3.0.CO;2-4) +#### carr_wu -Journal of Computational Finance, 2(4):61-73, 1999 +Peter Carr, Liuren Wu, [Time-Changed Lévy Processes and Option Pricing](https://doi.org/10.1016/S0304-405X(03)00171-5), Journal of Financial Economics, 71(1):113-141, 2004 ---- - -### carr_wu - -Peter Carr, Liuren Wu - -[Time-Changed Lévy Processes and Option Pricing](https://doi.org/10.1016/S0304-405X(03)00171-5) - -Journal of Financial Economics, 71(1):113-141, 2004 - ---- - -### chourdakis - -Kyriakos Chourdakis +#### chourdakis -[Option Pricing Using the Fractional FFT](https://doi.org/10.21314/JCF.2005.102) - -Journal of Computational Finance, 8(2):1-18, 2005 - ---- - -### molnar - -Peter Molnar - -[Volatility modeling and forecasting: utilization of realized volatility, implied volatility and the highest and lowest price of the day](https://drive.google.com/file/d/1zCU1OZyrKQLpxaypPv9U5UPbReBDXcMf/view) - -Master's thesis, University of Economics in Prague, 2020 - ---- +Kyriakos Chourdakis, [Option Pricing Using the Fractional FFT](https://doi.org/10.21314/JCF.2005.137),Journal of Computational Finance, 8(2):1-18, 2005 -### Gauthier +#### molnar -Gauthier Godin & Legros +Peter Molnar, [Volatility modeling and forecasting: utilization of realized volatility, implied volatility and the highest and lowest price of the day](https://drive.google.com/file/d/1zCU1OZyrKQLpxaypPv9U5UPbReBDXcMf/view), Master's thesis, University of Economics in Prague, 2020 -[Deep Implied Volatility Factor Models for Stock Options](https://drive.google.com/file/d/1Rjypn1IqnhpiZz08s0hxl5ISQDC8KeWk/view?usp=sharing) +#### Gauthier -2025 +Gauthier Godin & Legros, [Deep Implied Volatility Factor Models for Stock Options](https://drive.google.com/file/d/1Rjypn1IqnhpiZz08s0hxl5ISQDC8KeWk/view?usp=sharing), 2025 diff --git a/docs/examples/_utils.py b/docs/examples/_utils.py index 9ebc4570..66d00a4b 100644 --- a/docs/examples/_utils.py +++ b/docs/examples/_utils.py @@ -5,7 +5,8 @@ from pydantic import BaseModel EXAMPLE_DIR = Path(__file__).parent -OUT_DIR = EXAMPLE_DIR.parent / "examples_output" +OUT_DIR = EXAMPLE_DIR / "output" +ASSET_DIR = EXAMPLE_DIR.parent / "assets" / "examples" def print_model(model: BaseModel) -> None: @@ -15,9 +16,15 @@ def print_model(model: BaseModel) -> None: print("\n".join(text_data)) +def assets_path(filename: str) -> str: + """Helper function to get the path to an asset file in the docs""" + return f"docs/assets/examples/{filename}" + + def build_examples() -> list[Path]: failed = [] OUT_DIR.mkdir(exist_ok=True) + ASSET_DIR.mkdir(exist_ok=True) for script in sorted(EXAMPLE_DIR.glob("*.py")): if script.stem.startswith("_"): continue diff --git a/docs/examples/fft.py b/docs/examples/fft.py new file mode 100644 index 00000000..b0587fb4 --- /dev/null +++ b/docs/examples/fft.py @@ -0,0 +1,22 @@ +from docs.examples._utils import assets_path +from quantflow.sp.weiner import WeinerProcess +from quantflow.utils import plot + +p = WeinerProcess(sigma=0.5) +m = p.marginal(0.2) + +fig = plot.plot_characteristic(m) +fig.update_layout(title="Weiner Process Characteristic Function") +fig.write_image(assets_path("weiner_characteristic.png")) + +fig = plot.plot_marginal_pdf(m, n=128, use_fft=True, max_frequency=20) +fig.update_layout(title="Weiner Process PDF via FFT with n=128") +fig.write_image(assets_path("weiner_fft_128.png")) + +fig = plot.plot_marginal_pdf(m, n=128 * 8, use_fft=True, max_frequency=8 * 20) +fig.update_layout(title="Weiner Process PDF via FFT with n=1024") +fig.write_image(assets_path("weiner_fft_1024.png")) + +fig = plot.plot_marginal_pdf(m, 64) +fig.update_layout(title="Weiner Process PDF via FRFT with n=64") +fig.write_image(assets_path("weiner_64.png")) diff --git a/docs/examples/vol_surface_heston_calibration.py b/docs/examples/vol_surface_heston_calibration.py index 9c08608a..c352acfa 100644 --- a/docs/examples/vol_surface_heston_calibration.py +++ b/docs/examples/vol_surface_heston_calibration.py @@ -1,7 +1,6 @@ import json -from pathlib import Path -from docs.examples._utils import print_model +from docs.examples._utils import assets_path, print_model from quantflow.options.heston_calibration import HestonCalibration from quantflow.options.pricer import OptionPricer from quantflow.options.surface import VolSurfaceInputs, surface_from_inputs @@ -30,6 +29,4 @@ # Plot the calibrated smile for all maturities and save as PNG fig = calibration.plot_maturities(max_moneyness_ttm=1.5, support=101) fig.update_layout(title="Heston Calibrated Smiles") - -out_path = Path("docs/assets/heston_calibrated_smile.png") -fig.write_image(str(out_path), width=1200) +fig.write_image(assets_path("heston_calibrated_smile.png"), width=1200) diff --git a/docs/examples/vol_surface_hestonj_calibration.py b/docs/examples/vol_surface_hestonj_calibration.py index 1fc3f73d..de4c3b02 100644 --- a/docs/examples/vol_surface_hestonj_calibration.py +++ b/docs/examples/vol_surface_hestonj_calibration.py @@ -1,7 +1,6 @@ import json -from pathlib import Path -from docs.examples._utils import print_model +from docs.examples._utils import assets_path, print_model from quantflow.options.heston_calibration import HestonJCalibration from quantflow.options.pricer import OptionPricer from quantflow.options.surface import VolSurface, VolSurfaceInputs, surface_from_inputs @@ -42,6 +41,4 @@ # Plot the calibrated smile for all maturities and save as PNG fig = calibration.plot_maturities(max_moneyness_ttm=1.5, support=101) fig.update_layout(title="HestonJ Calibrated Smiles") - -out_path = Path("docs/assets/hestonj_calibrated_smile.png") -fig.write_image(str(out_path), width=1200) +fig.write_image(assets_path("hestonj_calibrated_smile.png"), width=1200) diff --git a/docs/examples/vol_surface_inputs.py b/docs/examples/vol_surface_inputs.py index 7cc82635..d8af85d5 100644 --- a/docs/examples/vol_surface_inputs.py +++ b/docs/examples/vol_surface_inputs.py @@ -21,6 +21,7 @@ inputs = surface.inputs(converged=True) option_inputs = [i for i in inputs.inputs if isinstance(i, OptionInput)] df = pd.DataFrame([i.model_dump() for i in option_inputs]) +print("\n\n10 Converged option inputs") print( df[["maturity", "strike", "option_type", "bid", "ask", "iv_bid", "iv_ask"]] .head(10) diff --git a/docs/examples_output/heston_volatility_pricer.out b/docs/examples_output/heston_volatility_pricer.out deleted file mode 100644 index c30c1f54..00000000 --- a/docs/examples_output/heston_volatility_pricer.out +++ /dev/null @@ -1,20 +0,0 @@ -{ - "strike": "100.0", - "option_type": "call", - "forward": "100.0", - "moneyness": 0.0, - "ttm": 1.0, - "price": 0.19225250948685713, - "delta": 0.6004432200876063, - "gamma": 0.814574001718513, - "black": { - "iv": 0.4866658752351474, - "price": 0.19225250948685713, - "delta": 0.5961262547434286, - "gamma": 0.7958325231787754, - "vega": 0.3873045314333945, - "volga": -0.0471219746931429, - "vanna": 0.19365226571669725, - "theta": -0.0942439493862858 - } -} diff --git a/docs/examples_output/vol_surface_heston_calibration.out b/docs/examples_output/vol_surface_heston_calibration.out deleted file mode 100644 index 52f4a713..00000000 --- a/docs/examples_output/vol_surface_heston_calibration.out +++ /dev/null @@ -1,14 +0,0 @@ -Both `ftol` and `xtol` termination conditions are satisfied. - -```json -{ - "variance_process": { - "rate": 0.1468919917675601, - "kappa": 3.3130571478442086, - "sigma": 1.7107299453461766, - "theta": 0.33074514653579995, - "sample_algo": "implicit" - }, - "rho": -0.46321555531487724 -} -``` diff --git a/docs/examples_output/vol_surface_hestonj_calibration.out b/docs/examples_output/vol_surface_hestonj_calibration.out deleted file mode 100644 index 9e5c8509..00000000 --- a/docs/examples_output/vol_surface_hestonj_calibration.out +++ /dev/null @@ -1,22 +0,0 @@ -`xtol` termination condition is satisfied. - -```json -{ - "variance_process": { - "rate": 0.12707150968097103, - "kappa": 1.8151617066397323, - "sigma": 1.3259134126578012, - "theta": 0.3211971404927874, - "sample_algo": "implicit" - }, - "rho": -0.47899276376715205, - "jumps": { - "intensity": 99.12262880140118, - "jumps": { - "decay": 73.57139169343074, - "loc": 0.007499627395456639, - "kappa": 1.20055291473451 - } - } -} -``` diff --git a/docs/examples_output/vol_surface_inputs.out b/docs/examples_output/vol_surface_inputs.out deleted file mode 100644 index 771f3ffb..00000000 --- a/docs/examples_output/vol_surface_inputs.out +++ /dev/null @@ -1,24 +0,0 @@ - maturity ttm forward bid_ask_spread basis rate_percent fwd_spread_pct open_interest volume -2026-04-28 08:00:00+00:00 0.002635 77767.75 159.5 -8.00 -3.90357 0.2051 0 0 -2026-04-29 08:00:00+00:00 0.005375 77757.5 115 -18.25 -4.36617 0.1479 0 0 -2026-04-30 08:00:00+00:00 0.008115 77765.5 112.0 -10.25 -1.62420 0.1440 0 0 -2026-05-01 08:00:00+00:00 0.010854 77731.25 2.5 -44.50 -5.27275 0.0032 30198620 5038760 -2026-05-08 08:00:00+00:00 0.030032 77756.25 2.5 -19.50 -0.83494 0.0032 2718240 2864240 -2026-05-15 08:00:00+00:00 0.049210 77781.75 160.5 6.00 0.15676 0.2063 0 0 -2026-05-29 08:00:00+00:00 0.087567 77813.75 2.5 38.00 0.55782 0.0032 67350560 9387130 -2026-06-26 08:00:00+00:00 0.164279 77921.25 2.5 145.50 1.13771 0.0032 588089150 6932390 -2026-07-31 08:00:00+00:00 0.260169 78098.75 173.5 323.00 1.59295 0.2222 0 0 -2026-09-25 08:00:00+00:00 0.413594 78408.75 2.5 633.00 1.95985 0.0032 276427820 7071210 -2026-12-25 08:00:00+00:00 0.662909 79087.5 10.0 1311.75 2.52299 0.0126 121192440 2532540 -2027-03-26 08:00:00+00:00 0.912224 79801.25 2.5 2025.50 2.81833 0.0031 15964110 3373920 - maturity strike option_type bid ask iv_bid iv_ask -2026-04-28 08:00:00+00:00 73000 put 0.0001 0.0002 0.5375622 0.5919214 -2026-04-28 08:00:00+00:00 75000 put 0.0004 0.0006 0.4188391 0.4554825 -2026-04-28 08:00:00+00:00 75500 put 0.0006 0.0008 0.3889021 0.4165291 -2026-04-28 08:00:00+00:00 76000 put 0.001 0.0012 0.3668268 0.3868547 -2026-04-28 08:00:00+00:00 76500 put 0.0016 0.0019 0.3392663 0.3616924 -2026-04-28 08:00:00+00:00 77000 put 0.0028 0.0032 0.3235727 0.3467574 -2026-04-28 08:00:00+00:00 77500 put 0.0048 0.0055 0.3117853 0.3467488 -2026-04-28 08:00:00+00:00 78000 call 0.005 0.0055 0.3111888 0.3359649 -2026-04-28 08:00:00+00:00 78500 call 0.0032 0.0035 0.335916 0.3526992 -2026-04-28 08:00:00+00:00 79000 call 0.0017 0.0021 0.3353014 0.3637628 diff --git a/docs/examples_output/weiner_volatility_pricer.out b/docs/examples_output/weiner_volatility_pricer.out deleted file mode 100644 index 85e1391a..00000000 --- a/docs/examples_output/weiner_volatility_pricer.out +++ /dev/null @@ -1,20 +0,0 @@ -{ - "strike": "100.0", - "option_type": "call", - "forward": "100.0", - "moneyness": 0.0, - "ttm": 1.0, - "price": 0.11923538473954934, - "delta": 0.5594775189681656, - "gamma": 1.312462172251983, - "black": { - "iv": 0.29999999999762794, - "price": 0.11923538473954934, - "delta": 0.5596176923697747, - "gamma": 1.3149311030369273, - "vega": 0.3944793309079591, - "volga": -0.029585949817863, - "vanna": 0.19723966545397956, - "theta": -0.059171899635726 - } -} diff --git a/docs/glossary.md b/docs/glossary.md index 2d96a1ca..28a17454 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -16,6 +16,15 @@ If $x$ is a continuous random variable, than the characteristic function is the \end{equation} +## Characteristic Exponent + +The characteristic exponent $\phi_{x,u}$ is defined from the +[characteristic function](#characteristic-function) $\Phi_{x,u}$ by + +\begin{equation} + \Phi_{x,u} = e^{-\phi_{x,u}} +\end{equation} + ## Cumulative Distribution Function (CDF) The cumulative distribution function (CDF), or just distribution function, @@ -46,7 +55,7 @@ In the [Heston model][quantflow.sp.heston.Heston] the variance process $v_t$ is process, so the same condition applies with $\sigma$ being the vol of vol. The [CIR.is_positive][quantflow.sp.cir.CIR.is_positive] property checks whether the condition holds. The -[HestonCalibration][quantflow.options.calibration.HestonCalibration] class provides a +[HestonCalibration][quantflow.options.heston_calibration.HestonCalibration] class provides a `feller_enforce` flag (default `True`) that imposes this as a hard inequality constraint during optimisation. diff --git a/docs/theory/inversion.md b/docs/theory/inversion.md index e978abc1..fb1c37e9 100644 --- a/docs/theory/inversion.md +++ b/docs/theory/inversion.md @@ -59,32 +59,22 @@ which means $\delta_u$ and $\delta_x$ cannot be chosen independently — they ar As an example, let us invert the characteristic function of the Weiner process, which yields the standard normal distribution. ```python -from quantflow.sp.weiner import WeinerProcess -p = WeinerProcess(sigma=0.5) -m = p.marginal(0.2) -m.std() +--8<-- "docs/examples/fft.py" ``` -```python -from quantflow.utils import plot +![Weiner Characteristic Function](../assets/examples/weiner_characteristic.png) -plot.plot_characteristic(m) -``` +![Weiner FFT 128](../assets/examples/weiner_fft_128.png) -```python -plot.plot_marginal_pdf(m, 128, use_fft=True, max_frequency=20) -``` +![Weiner FFT 1024](../assets/examples/weiner_fft_1024.png) -```python -plot.plot_marginal_pdf(m, 128*8, use_fft=True, max_frequency=8*20) -``` **Note** the amount of unnecessary discretization points in the frequency domain (the characteristic function is zero after 15 or so). However the space domain is poorly represented because of the FFT constraints (we have a relatively small number of points where it matters, around zero). ## FRFT The Fractional FFT (FRFT) is another algorithm that can be used to invert the characteristic function. -Compared to the FFT, this method relaxes the constraint $\zeta=2\pi/N$ so that the frequency domain and space domain can be discretized independently. We use the methodology from Chourdakis (2005): +Compared to the FFT, this method relaxes the constraint $\zeta=2\pi/N$ so that the frequency domain and space domain can be discretized independently. We use the methodology from [chourdakis](../bibliography.md#chourdakis): \begin{align} y &= \left(\left[e^{-i j^2 \zeta/2}\right]_{j=0}^{N-1}, \left[0\right]_{j=0}^{N-1}\right) \\ @@ -93,14 +83,8 @@ z &= \left(\left[e^{i j^2 \zeta/2}\right]_{j=0}^{N-1}, \left[e^{i\left(N-j\right We can now reduce the number of points needed for the discretization and achieve higher accuracy by properly selecting the domain discretization independently. -```python -plot.plot_marginal_pdf(m, 128) -``` +![Weiner FRFT 64](../assets/examples/weiner_64.png) Since one N-point FRFT will invoke three 2N-point FFT procedures, the number of operations will be approximately $6N\log{N}$ compared to $N\log{N}$ for the FFT. However, we can use fewer points as demonstrated and be more robust in delivering results. The FRFT is used as the default transform across the library. The FFT can be used by passing `use_fft=True` to the transform functions, but it is not advised. - -## Additional References - -* [Fourier Transform and Characteristic Functions](https://faculty.baruch.cuny.edu/lwu/890/ADP_Transform.pdf) — useful but contains some typos diff --git a/docs/tutorials/option_pricing.md b/docs/tutorials/option_pricing.md index ec4b9138..30e224f3 100644 --- a/docs/tutorials/option_pricing.md +++ b/docs/tutorials/option_pricing.md @@ -17,7 +17,7 @@ The first example shows how to price an option using the Black-Scholes model and ``` ```json ---8<-- "docs/examples_output/weiner_volatility_pricer.out" +--8<-- "docs/examples/output/weiner_volatility_pricer.out" ``` @@ -32,5 +32,5 @@ volatility model extended with jumps drawn from a ``` ```json ---8<-- "docs/examples_output/heston_volatility_pricer.out" +--8<-- "docs/examples/output/heston_volatility_pricer.out" ``` diff --git a/docs/tutorials/volatility_surface.md b/docs/tutorials/volatility_surface.md index e7250bc9..c23e1683 100644 --- a/docs/tutorials/volatility_surface.md +++ b/docs/tutorials/volatility_surface.md @@ -78,7 +78,7 @@ option inputs table lists the bid/ask prices together with the corresponding imp volatilities for each strike: ``` ---8<-- "docs/examples_output/vol_surface_inputs.out" +--8<-- "docs/examples/output/vol_surface_inputs.out" ``` ## Serialising and Restoring @@ -121,7 +121,7 @@ variance process well-behaved. ### Output ---8<-- "docs/examples_output/vol_surface_heston_calibration.out" +--8<-- "docs/examples/output/vol_surface_heston_calibration.out" ### Calibration Options @@ -142,7 +142,7 @@ fig.write_image("heston_calibrated_smile.png", width=1200) The x axis is [moneyness](../glossary.md#moneyness). -![Heston calibrated smile](../assets/heston_calibrated_smile.png) +![Heston calibrated smile](../assets/examples/heston_calibrated_smile.png) ### Model Limitations at Short Maturities @@ -188,7 +188,7 @@ which captures asymmetric jump behaviour common in equity and crypto markets. --8<-- "docs/examples/vol_surface_hestonj_calibration.py" ``` ---8<-- "docs/examples_output/vol_surface_hestonj_calibration.out" +--8<-- "docs/examples/output/vol_surface_hestonj_calibration.out" ### Plotting the Calibrated Smile @@ -197,7 +197,7 @@ fig = calibration.plot_maturities(max_moneyness_ttm=1.5, support=101) fig.write_image("hestonj_calibrated_smile.png", width=1200) ``` -![HestonJ calibrated smile](../assets/hestonj_calibrated_smile.png) +![HestonJ calibrated smile](../assets/examples/hestonj_calibrated_smile.png) ### Remaining Limitations at Short Maturities diff --git a/pyproject.toml b/pyproject.toml index 8615de71..470f9314 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,29 @@ version = "0.7.0" description = "quantitative analysis" authors = [ { name = "Luca Sbardella", email = "luca@quantmind.com" } ] license = "BSD-3-Clause" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Financial and Insurance Industry", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Office/Business :: Financial", + "Topic :: Scientific/Engineering :: Mathematics", + "Typing :: Typed", +] +keywords = [ + "finance", + "options", + "pricing", + "quantitative", + "stochastic", + "volatility", +] readme = "readme.md" requires-python = ">=3.11,<3.15" dependencies = [ @@ -18,7 +41,8 @@ dependencies = [ [project.urls] Homepage = "https://github.com/quantmind/quantflow" Repository = "https://github.com/quantmind/quantflow" -Documentation = "https://quantmind.github.io/quantflow/" +Documentation = "https://quantflow.quantmind.com" +Issues = "https://github.com/quantmind/quantflow/issues" [project.optional-dependencies] ai = [ diff --git a/quantflow/options/divfm/network.py b/quantflow/options/divfm/network.py index 0e485adc..1557eef9 100644 --- a/quantflow/options/divfm/network.py +++ b/quantflow/options/divfm/network.py @@ -71,7 +71,7 @@ class DIVFMNetwork(nn.Module): $$ Produces $P$ factor functions with the following structural constraints - (as in [gauthier](/bibliography#gauthier)): + (as in [gauthier](../../bibliography.md#gauthier)): - $f_1 = 1$ constant, not learned - $f_2(\tau, X)$ depends only on time-to-maturity and optional extra features X diff --git a/quantflow/options/heston_calibration.py b/quantflow/options/heston_calibration.py index 00e3fc8b..0de292d0 100644 --- a/quantflow/options/heston_calibration.py +++ b/quantflow/options/heston_calibration.py @@ -4,12 +4,13 @@ import numpy as np from pydantic import Field -from scipy.optimize import Bounds +from scipy.optimize import Bounds, OptimizeResult -from quantflow.sp.heston import Heston, HestonJ +from quantflow.sp.heston import DoubleHeston, DoubleHestonJ, Heston, HestonJ from quantflow.sp.jump_diffusion import D from .calibration import VolModelCalibration +from .pricer import OptionPricer H = TypeVar("H", bound=Heston) @@ -59,8 +60,7 @@ def set_params(self, params: np.ndarray) -> None: def penalize(self) -> float: """Penalty for violating the Feller condition""" - kt = 2 * self.model.variance_process.kappa * self.model.variance_process.theta - neg = max(self.model.variance_process.sigma2 - kt, 0.0) + neg = min(self.model.variance_process.feller_condition, 0.0) return self.feller_penalize * neg * neg @@ -105,3 +105,187 @@ def set_params(self, params: np.ndarray) -> None: self.model.jumps.jumps.set_asymmetry(params[7]) except IndexError: pass + + +DH = TypeVar("DH", bound=DoubleHeston) + + +class DoubleHestonCalibration(VolModelCalibration[DH], Generic[DH]): + """Calibration of the [DoubleHeston][quantflow.sp.heston.DoubleHeston] model. + + The parameter vector is + `[rate1, theta1, kappa_delta, sigma1, rho1, rate2, theta2, kappa2, sigma2, rho2]` + where `kappa1 = kappa2 + kappa_delta` with `kappa_delta > 0`, enforcing that + the first (short-maturity) process always mean-reverts faster than the second. + + The Feller penalty is applied independently to both variance processes. + A warm start fits each process independently to its natural maturity range + before the joint optimisation. + """ + + feller_penalize: float = Field( + default=1000.0, + description=( + "Penalty weight for violating the Feller condition " + "$2\\kappa\\theta \\geq \\sigma^2$. Applied during the L-BFGS-B " + "stage. Set to 0 to disable." + ), + ) + ttm_split: float | None = Field( + default=None, + gt=0, + description=( + "TTM threshold in years separating short-maturity options (fitted to " + "heston1) from long-maturity options (fitted to heston2) during warm " + "start. Defaults to the median TTM across all calibration options." + ), + ) + + def maturity_split(self) -> float: + """TTM split to use for warm start: explicit value or median of option TTMs.""" + if self.ttm_split is not None: + return self.ttm_split + ttms = sorted({v.ttm for v in self.options.values()}) + return ttms[len(ttms) // 2] + + def get_bounds(self) -> Bounds: + vol_range = self.implied_vol_range() + vol_lb = 0.5 * vol_range.lb[0] + vol_ub = 1.5 * vol_range.ub[0] + v2 = vol_lb**2 + v2u = vol_ub**2 + return Bounds( + [v2, v2, 1e-4, 0.0, -0.9, v2, v2, 0.0, 0.0, -0.9], + [v2u, v2u, np.inf, np.inf, 0.0, v2u, v2u, np.inf, np.inf, 0.0], + ) + + def get_params(self) -> np.ndarray: + vp1 = self.model.heston1.variance_process + vp2 = self.model.heston2.variance_process + kappa_delta = max(vp1.kappa - vp2.kappa, 1e-4) + return np.asarray( + [ + vp1.rate, + vp1.theta, + kappa_delta, + vp1.sigma, + self.model.heston1.rho, + vp2.rate, + vp2.theta, + vp2.kappa, + vp2.sigma, + self.model.heston2.rho, + ] + ) + + def set_params(self, params: np.ndarray) -> None: + vp1 = self.model.heston1.variance_process + vp1.rate = params[0] + vp1.theta = params[1] + vp1.sigma = params[3] + self.model.heston1.rho = params[4] + vp2 = self.model.heston2.variance_process + vp2.rate = params[5] + vp2.theta = params[6] + vp2.kappa = params[7] + vp2.sigma = params[8] + self.model.heston2.rho = params[9] + vp1.kappa = vp2.kappa + params[2] # kappa2 + kappa_delta + + def penalize(self) -> float: + """Feller penalty applied independently to both variance processes""" + neg1 = min(self.model.heston1.variance_process.feller_condition, 0.0) + neg2 = min(self.model.heston2.variance_process.feller_condition, 0.0) + return self.feller_penalize * (neg1 * neg1 + neg2 * neg2) + + def warm_start(self) -> None: + """Sequential single-Heston fits to initialise the joint optimisation. + + Fits heston2 to long-dated options (ttm > split) then heston1 to + short-dated options (ttm <= split), where the split defaults to the + median TTM across all calibration options. + """ + split = self.maturity_split() + long_options = {k: v for k, v in self.options.items() if v.ttm > split} + short_options = {k: v for k, v in self.options.items() if v.ttm <= split} + if long_options: + h2 = Heston( + variance_process=self.model.heston2.variance_process.model_copy(), + rho=self.model.heston2.rho, + ) + HestonCalibration( + pricer=OptionPricer(model=h2), + vol_surface=self.vol_surface, + options=long_options, + ).fit() + self.model.heston2.variance_process = h2.variance_process + self.model.heston2.rho = h2.rho + if short_options: + h1 = Heston( + variance_process=self.model.heston1.variance_process.model_copy(), + rho=self.model.heston1.rho, + ) + HestonCalibration( + pricer=OptionPricer(model=h1), + vol_surface=self.vol_surface, + options=short_options, + ).fit() + self.model.heston1.variance_process = h1.variance_process + self.model.heston1.rho = h1.rho + vp1 = self.model.heston1.variance_process + vp2 = self.model.heston2.variance_process + vp1.kappa = max(vp1.kappa, vp2.kappa + 1e-4) + + def fit(self) -> OptimizeResult: + """Warm-start then joint two-stage fit.""" + self.warm_start() + return super().fit() + + +class DoubleHestonJCalibration(DoubleHestonCalibration[DoubleHestonJ[D]], Generic[D]): + """Calibration of the [DoubleHestonJ][quantflow.sp.heston.DoubleHestonJ] model. + + Extends + [DoubleHestonCalibration][quantflow.options.heston_calibration.DoubleHestonCalibration] + by appending the jump parameters of `heston1` to the parameter vector and bounds. + + Overrides `warm_start` to fit a full + [HestonJCalibration][quantflow.options.heston_calibration.HestonJCalibration] + to the short-dated options, so that the jump parameters are also initialised + before the joint optimisation. + """ + + def get_bounds(self) -> Bounds: + base = super().get_bounds() + vol_range = self.implied_vol_range() + vol_lb = 0.5 * vol_range.lb[0] + vol_ub = 1.5 * vol_range.ub[0] + lower = list(base.lb) + [1.0, (0.01 * vol_lb) ** 2] + upper = list(base.ub) + [np.inf, (0.5 * vol_ub) ** 2] + try: + self.model.heston1.jumps.jumps.asymmetry() + lower.append(-2.0) + upper.append(2.0) + except NotImplementedError: + pass + return Bounds(lower, upper) + + def get_params(self) -> np.ndarray: + params = list(super().get_params()) + [ + self.model.heston1.jumps.intensity, + self.model.heston1.jumps.jumps.variance(), + ] + try: + params.append(self.model.heston1.jumps.jumps.asymmetry()) + except NotImplementedError: + pass + return np.asarray(params) + + def set_params(self, params: np.ndarray) -> None: + super().set_params(params) + self.model.heston1.jumps.intensity = params[10] + self.model.heston1.jumps.jumps.set_variance(params[11]) + try: + self.model.heston1.jumps.jumps.set_asymmetry(params[12]) + except IndexError: + pass diff --git a/quantflow/sp/cir.py b/quantflow/sp/cir.py index 944fd9c4..5a2c814d 100755 --- a/quantflow/sp/cir.py +++ b/quantflow/sp/cir.py @@ -38,16 +38,23 @@ class CIR(IntensityProcess): default=SamplingAlgorithm.implicit, description="Sampling algorithm" ) - @property - def is_positive(self) -> bool: - """Check if the process is guaranteed to be positive.""" - return self.kappa * self.theta >= 0.5 * self.sigma2 - @property def sigma2(self) -> float: """The square of the volatility parameter, used in various calculations.""" return self.sigma * self.sigma + @property + def feller_condition(self) -> float: + """Value of $2\\kappa\\theta - \\sigma^2$; positive means + the Feller condition holds.""" + return 2.0 * self.kappa * self.theta - self.sigma2 + + @property + def is_positive(self) -> bool: + """True if the Feller condition holds, guaranteeing + the process stays strictly positive.""" + return self.feller_condition >= 0.0 + def sample( self, paths: int, time_horizon: float = 1, time_steps: int = 100 ) -> Paths: diff --git a/quantflow/sp/heston.py b/quantflow/sp/heston.py index b8b3b976..1131fa1c 100644 --- a/quantflow/sp/heston.py +++ b/quantflow/sp/heston.py @@ -107,8 +107,41 @@ def create( rho=rho, ) - def characteristic_exponent(self, t: FloatArrayLike, u: Vector) -> Vector: - """The characteristic exponent of the Heston model has a closed form""" + def characteristic_exponent( + self, + t: Annotated[FloatArrayLike, Doc("Time horizon or array of evaluation times")], + u: Annotated[Vector, Doc("Characteristic exponent argument")], + ) -> Vector: + r"""Characteristic exponent of the Heston model in closed form. + + Define the correlation-adjusted drift and the characteristic frequency: + + \begin{equation} + \tilde{\kappa} = \kappa - i u \nu \rho, \qquad + \gamma = \sqrt{\tilde{\kappa}^2 + u^2 \nu^2} + \end{equation} + + Then the exponent is + + \begin{equation} + \phi_{x_t,u} = \frac{\kappa\theta}{\nu^2} + \left[2 \ln c_{t,u} + (\gamma - \tilde{\kappa})\,t\right] + + v_0\, b_{t,u} + \end{equation} + + where + + \begin{align*} + c_{t,u} &= \frac{\gamma - \tfrac{1}{2}(\gamma - \tilde{\kappa}) + (1 - e^{-\gamma t})}{\gamma} \\ + b_{t,u} &= \frac{u^2 (1 - e^{-\gamma t})} + {(\gamma + \tilde{\kappa}) + (\gamma - \tilde{\kappa})\,e^{-\gamma t}} + \end{align*} + + and $\nu$ is the vol of vol, $\kappa$ the mean-reversion speed, + $\theta$ the long-term variance, $\rho$ the correlation, and $v_0$ + the initial variance. + """ eta = self.variance_process.sigma eta2 = eta * eta theta_kappa = self.variance_process.theta * self.variance_process.kappa @@ -122,12 +155,30 @@ def characteristic_exponent(self, t: FloatArrayLike, u: Vector) -> Vector: a = theta_kappa * (2 * np.log(c) + (gamma - kappa) * t) / eta2 return a + b * self.variance_process.rate - def sample(self, n: int, time_horizon: float = 1, time_steps: int = 100) -> Paths: + def sample( + self, + n: Annotated[int, Doc("Number of sample paths")], + time_horizon: Annotated[float, Doc("Time horizon")] = 1, + time_steps: Annotated[int, Doc("Number of discrete time steps")] = 100, + ) -> Paths: dw1 = Paths.normal_draws(n, time_horizon, time_steps) dw2 = Paths.normal_draws(n, time_horizon, time_steps) return self.sample_from_draws(dw1, dw2) - def sample_from_draws(self, path1: Paths, *args: Paths) -> Paths: + def sample_from_draws( + self, + path1: Annotated[ + Paths, + Doc("Pre-drawn standard normal increments for the first Brownian motion"), + ], + *args: Annotated[ + Paths, + Doc( + "Optional pre-drawn increments for the second Brownian motion; " + "new draws are generated if omitted" + ), + ], + ) -> Paths: if args: path2 = args[0] else: @@ -247,9 +298,109 @@ def create( # type: ignore [override] jumps=jd.jumps, ) - def characteristic_exponent(self, t: FloatArrayLike, u: Vector) -> Vector: - """The characteristic exponent is given by the sum of the exponent of the - classic Heston model and the exponent of the jumps""" + def characteristic_exponent( + self, + t: Annotated[FloatArrayLike, Doc("Time horizon or array of evaluation times")], + u: Annotated[ + Vector, Doc("Characteristic exponent argument (imaginary frequency)") + ], + ) -> Vector: + r"""Characteristic exponent as the sum of the Heston and jump exponents. + + \begin{equation} + \phi_{x_t,u} = \phi^{\text{Heston}}_{x_t,u} + \phi^{\text{jumps}}_{x_t,u} + \end{equation} + """ return super().characteristic_exponent( t, u ) + self.jumps.characteristic_exponent(t, u) + + +class DoubleHeston(StochasticProcess1D): + r"""Double Heston stochastic volatility model. + + Two independent [Heston][quantflow.sp.heston.Heston] processes drive a single + log-price: + + \begin{align} + d x_t &= \sqrt{v^1_t}\,d w^1_t + \sqrt{v^2_t}\,d w^3_t \\ + d v^i_t &= \kappa_i (\theta_i - v^i_t) dt + \nu_i \sqrt{v^i_t}\,d w^{2i}_t \\ + \rho_i\,dt &= {\tt E}[d w^{2i-1} d w^{2i}] \quad i = 1, 2 + \end{align} + + Because the two components are independent, the characteristic exponent is the sum + of the two individual Heston exponents. + """ + + heston1: Heston = Field( + default_factory=Heston, description="First Heston variance process" + ) + heston2: Heston = Field( + default_factory=Heston, description="Second Heston variance process" + ) + + def characteristic_exponent( + self, + t: Annotated[FloatArrayLike, Doc("Time horizon or array of evaluation times")], + u: Annotated[ + Vector, Doc("Characteristic exponent argument (imaginary frequency)") + ], + ) -> Vector: + r"""Characteristic exponent as the sum of two independent Heston exponents. + + \begin{equation} + \phi_{x_t,u} = \phi^{(1)}_{x_t,u} + \phi^{(2)}_{x_t,u} + \end{equation} + """ + return self.heston1.characteristic_exponent( + t, u + ) + self.heston2.characteristic_exponent(t, u) + + def sample( + self, + n: Annotated[int, Doc("Number of sample paths")], + time_horizon: Annotated[float, Doc("Time horizon")] = 1, + time_steps: Annotated[int, Doc("Number of discrete time steps")] = 100, + ) -> Paths: + dw1 = Paths.normal_draws(n, time_horizon, time_steps) + dw2 = Paths.normal_draws(n, time_horizon, time_steps) + dw3 = Paths.normal_draws(n, time_horizon, time_steps) + dw4 = Paths.normal_draws(n, time_horizon, time_steps) + return self.sample_from_draws(dw1, dw2, dw3, dw4) + + def sample_from_draws( + self, + path1: Annotated[Paths, Doc("First Brownian motion draws for heston1")], + *args: Annotated[ + Paths, + Doc( + "args[0]: second BM draws for heston1; " + "args[1], args[2]: first and second BM draws for heston2" + ), + ], + ) -> Paths: + paths1 = self.heston1.sample_from_draws(path1, args[0]) + paths2 = self.heston2.sample_from_draws(args[1], args[2]) + return Paths(t=path1.t, data=paths1.data + paths2.data) + + +class DoubleHestonJ(DoubleHeston, Generic[D]): + r"""Double Heston stochastic volatility model with jumps. + + Extends [DoubleHeston][quantflow.sp.heston.DoubleHeston] by replacing the first + (short-maturity) Heston process with a + [HestonJ][quantflow.sp.heston.HestonJ] that carries a jump component. + Jumps are assigned to the short end because they fade away at longer maturities: + + \begin{equation} + \phi_{x_t,u} = \phi^{(1)}_{x_t,u} + \phi^{\text{jumps}}_{x_t,u} + + \phi^{(2)}_{x_t,u} + \end{equation} + + where $\phi^{(1)}$ and $\phi^{\text{jumps}}$ are both provided by the + [HestonJ][quantflow.sp.heston.HestonJ] first process. + """ + + heston1: HestonJ[D] = Field( # type: ignore[assignment] + description="First (short-maturity) Heston process with jumps" + ) diff --git a/quantflow_tests/test_cir.py b/quantflow_tests/test_cir.py index 51a817c5..5033771c 100644 --- a/quantflow_tests/test_cir.py +++ b/quantflow_tests/test_cir.py @@ -14,6 +14,13 @@ def cir() -> CIR: return CIR(kappa=1, sigma=1.2, sample_algo=SamplingAlgorithm.euler) +def test_feller_condition(cir: CIR, cir_neg: CIR) -> None: + assert cir.feller_condition > 0 + assert cir.is_positive is True + assert cir_neg.feller_condition < 0 + assert cir_neg.is_positive is False + + def test_cir_neg(cir_neg: CIR) -> None: assert cir_neg.is_positive is False assert cir_neg.sigma2 == 4 diff --git a/quantflow_tests/test_heston.py b/quantflow_tests/test_heston.py index 74c79977..de36bc12 100644 --- a/quantflow_tests/test_heston.py +++ b/quantflow_tests/test_heston.py @@ -1,6 +1,6 @@ import pytest -from quantflow.sp.heston import Heston, HestonJ +from quantflow.sp.heston import DoubleHeston, DoubleHestonJ, Heston, HestonJ from quantflow.utils.distributions import DoubleExponential from quantflow_tests.utils import characteristic_tests @@ -37,3 +37,49 @@ def test_heston_jumps_characteristic(heston_jumps: HestonJ) -> None: characteristic_tests(m) assert m.mean() == 0.0 assert m.std() == pytest.approx(0.5) + + +@pytest.fixture +def double_heston() -> DoubleHeston: + return DoubleHeston( + heston1=Heston.create(vol=0.3, kappa=3, sigma=0.5, rho=-0.3), + heston2=Heston.create(vol=0.4, kappa=1, sigma=0.5, rho=-0.5), + ) + + +@pytest.fixture +def double_heston_jumps() -> DoubleHestonJ[DoubleExponential]: + return DoubleHestonJ( + heston1=HestonJ.create( + DoubleExponential, + vol=0.3, + kappa=3, + sigma=0.5, + rho=-0.3, + jump_intensity=50, + jump_fraction=0.2, + ), + heston2=Heston.create(vol=0.4, kappa=1, sigma=0.5, rho=-0.5), + ) + + +def test_double_heston_characteristic(double_heston: DoubleHeston) -> None: + assert ( + double_heston.heston1.variance_process.kappa + > double_heston.heston2.variance_process.kappa + ) + assert double_heston.characteristic(1, 0) == 1 + m = double_heston.marginal(1) + characteristic_tests(m) + assert m.mean() == pytest.approx(0.0, abs=1e-6) + assert m.std() == pytest.approx(0.5, rel=0.01) + + +def test_double_heston_jumps_characteristic( + double_heston_jumps: DoubleHestonJ[DoubleExponential], +) -> None: + assert double_heston_jumps.characteristic(1, 0) == 1 + m = double_heston_jumps.marginal(1) + characteristic_tests(m) + assert m.mean() == pytest.approx(0.0, abs=1e-6) + assert m.std() == pytest.approx(0.5, rel=0.05) diff --git a/quantflow_tests/test_options.py b/quantflow_tests/test_options.py index 03425ca0..bf7dc225 100644 --- a/quantflow_tests/test_options.py +++ b/quantflow_tests/test_options.py @@ -7,7 +7,12 @@ from quantflow.options import bs from quantflow.options.calibration import ModelCalibrationEntryKey, OptionEntry -from quantflow.options.heston_calibration import HestonCalibration, HestonJCalibration +from quantflow.options.heston_calibration import ( + DoubleHestonCalibration, + DoubleHestonJCalibration, + HestonCalibration, + HestonJCalibration, +) from quantflow.options.inputs import OptionInput from quantflow.options.pricer import OptionPricer from quantflow.options.surface import ( @@ -16,7 +21,7 @@ VolSurface, surface_from_inputs, ) -from quantflow.sp.heston import Heston, HestonJ +from quantflow.sp.heston import DoubleHeston, DoubleHestonJ, Heston, HestonJ from quantflow.utils.distributions import DoubleExponential from quantflow_tests.utils import has_plotly @@ -297,3 +302,60 @@ def test_hestonj_calibration_synthetic(vol_surface: VolSurface) -> None: result = cal.fit() assert result.cost < 1e-6 + + +def test_double_heston_calibration_synthetic(vol_surface: VolSurface) -> None: + """DoubleHestonCalibration recovers known parameters from synthetic prices.""" + true_model = DoubleHeston( + heston1=Heston.create(vol=0.3, kappa=3.0, sigma=0.5, rho=-0.3), + heston2=Heston.create(vol=0.4, kappa=1.0, sigma=0.5, rho=-0.5), + ) + true_pricer = OptionPricer(model=true_model) + options = _synthetic_options(true_pricer, vol_surface.ref_date) + perturbed = DoubleHeston( + heston1=Heston.create(vol=0.35, kappa=4.0, sigma=0.6, rho=-0.2), + heston2=Heston.create(vol=0.38, kappa=1.5, sigma=0.6, rho=-0.4), + ) + cal: DoubleHestonCalibration[DoubleHeston] = DoubleHestonCalibration( + pricer=OptionPricer(model=perturbed), vol_surface=vol_surface, options=options + ) + result = cal.fit() + + assert result.cost < 2e-5 + + +@pytest.mark.skip(reason="calibration warm start needs jump-aware initialisation") +def test_double_heston_jumps_calibration_synthetic(vol_surface: VolSurface) -> None: + """DoubleHestonJCalibration recovers known parameters from synthetic prices.""" + true_model: DoubleHestonJ[DoubleExponential] = DoubleHestonJ( + heston1=HestonJ.create( + DoubleExponential, + vol=0.3, + kappa=3.0, + sigma=0.5, + rho=-0.3, + jump_fraction=0.2, + jump_asymmetry=0.3, + ), + heston2=Heston.create(vol=0.4, kappa=1.0, sigma=0.5, rho=-0.5), + ) + true_pricer = OptionPricer(model=true_model) + options = _synthetic_options(true_pricer, vol_surface.ref_date) + perturbed: DoubleHestonJ[DoubleExponential] = DoubleHestonJ( + heston1=HestonJ.create( + DoubleExponential, + vol=0.35, + kappa=4.0, + sigma=0.6, + rho=-0.2, + jump_fraction=0.15, + jump_asymmetry=0.1, + ), + heston2=Heston.create(vol=0.38, kappa=1.5, sigma=0.6, rho=-0.4), + ) + cal: DoubleHestonJCalibration[DoubleExponential] = DoubleHestonJCalibration( + pricer=OptionPricer(model=perturbed), vol_surface=vol_surface, options=options + ) + result = cal.fit() + + assert result.cost < 5e-3