optPricing 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- optpricing-2.0.0.dist-info/METADATA +105 -0
- optpricing-2.0.0.dist-info/RECORD +92 -0
- optpricing-2.0.0.dist-info/WHEEL +5 -0
- optpricing-2.0.0.dist-info/entry_points.txt +3 -0
- optpricing-2.0.0.dist-info/licenses/LICENSE +9 -0
- optpricing-2.0.0.dist-info/top_level.txt +1 -0
- quantfin/__init__.py +101 -0
- quantfin/atoms/__init__.py +21 -0
- quantfin/atoms/bond.py +28 -0
- quantfin/atoms/option.py +70 -0
- quantfin/atoms/rate.py +46 -0
- quantfin/atoms/stock.py +39 -0
- quantfin/calibration/__init__.py +25 -0
- quantfin/calibration/calibrator.py +155 -0
- quantfin/calibration/fit_jump_parameters.py +66 -0
- quantfin/calibration/fit_market_params.py +120 -0
- quantfin/calibration/iv_surface.py +155 -0
- quantfin/calibration/technique_selector.py +47 -0
- quantfin/calibration/vectorized_bsm_iv.py +93 -0
- quantfin/calibration/vectorized_integration_iv.py +143 -0
- quantfin/cli.py +372 -0
- quantfin/config.py +57 -0
- quantfin/dashboard/__init__.py +14 -0
- quantfin/dashboard/app.py +26 -0
- quantfin/dashboard/pages/1_Calibration.py +57 -0
- quantfin/dashboard/pages/2_Pricer_and_Greeks.py +150 -0
- quantfin/dashboard/pages/3_Financial_Tools.py +69 -0
- quantfin/dashboard/pages/__init__.py +0 -0
- quantfin/dashboard/plots.py +157 -0
- quantfin/dashboard/service.py +176 -0
- quantfin/dashboard/widgets.py +87 -0
- quantfin/data/__init__.py +26 -0
- quantfin/data/historical_manager.py +86 -0
- quantfin/data/market_data_manager.py +236 -0
- quantfin/models/__init__.py +54 -0
- quantfin/models/base/__init__.py +16 -0
- quantfin/models/base/base_model.py +198 -0
- quantfin/models/base/validators.py +95 -0
- quantfin/models/bates.py +192 -0
- quantfin/models/blacks_approx.py +134 -0
- quantfin/models/bsm.py +262 -0
- quantfin/models/cev.py +109 -0
- quantfin/models/cgmy.py +189 -0
- quantfin/models/cir.py +90 -0
- quantfin/models/dupire_local.py +67 -0
- quantfin/models/heston.py +204 -0
- quantfin/models/hyperbolic.py +170 -0
- quantfin/models/kou.py +148 -0
- quantfin/models/merton_jump.py +225 -0
- quantfin/models/nig.py +180 -0
- quantfin/models/perpetual_put.py +115 -0
- quantfin/models/sabr.py +64 -0
- quantfin/models/sabr_jump.py +68 -0
- quantfin/models/vasicek.py +88 -0
- quantfin/models/vg.py +178 -0
- quantfin/parity/__init__.py +13 -0
- quantfin/parity/implied_rate.py +110 -0
- quantfin/parity/parity_model.py +178 -0
- quantfin/techniques/__init__.py +31 -0
- quantfin/techniques/base/__init__.py +21 -0
- quantfin/techniques/base/base_technique.py +54 -0
- quantfin/techniques/base/greek_mixin.py +265 -0
- quantfin/techniques/base/iv_mixin.py +116 -0
- quantfin/techniques/base/lattice_technique.py +129 -0
- quantfin/techniques/base/pricing_result.py +27 -0
- quantfin/techniques/base/random_utils.py +31 -0
- quantfin/techniques/closed_form.py +296 -0
- quantfin/techniques/crr.py +59 -0
- quantfin/techniques/fft.py +178 -0
- quantfin/techniques/integration.py +202 -0
- quantfin/techniques/kernels/__init__.py +37 -0
- quantfin/techniques/kernels/lattice_kernels.py +286 -0
- quantfin/techniques/kernels/mc_kernels.py +337 -0
- quantfin/techniques/leisen_reimer.py +59 -0
- quantfin/techniques/monte_carlo.py +259 -0
- quantfin/techniques/pde.py +155 -0
- quantfin/techniques/topm.py +59 -0
- quantfin/workflows/__init__.py +14 -0
- quantfin/workflows/backtest_workflow.py +133 -0
- quantfin/workflows/configs/__init__.py +51 -0
- quantfin/workflows/configs/bates_config.py +25 -0
- quantfin/workflows/configs/bsm_config.py +9 -0
- quantfin/workflows/configs/cev_config.py +16 -0
- quantfin/workflows/configs/cgmy_config.py +18 -0
- quantfin/workflows/configs/heston_config.py +21 -0
- quantfin/workflows/configs/hyperbolic_config.py +18 -0
- quantfin/workflows/configs/kou_config.py +16 -0
- quantfin/workflows/configs/merton_config.py +18 -0
- quantfin/workflows/configs/nig_config.py +13 -0
- quantfin/workflows/configs/sabr_config.py +19 -0
- quantfin/workflows/configs/vg_config.py +9 -0
- quantfin/workflows/daily_workflow.py +150 -0
quantfin/models/bates.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from quantfin.models.base import CF, BaseModel, ParamValidator
|
|
9
|
+
from quantfin.models.heston import HestonModel
|
|
10
|
+
from quantfin.models.merton_jump import MertonJumpModel
|
|
11
|
+
|
|
12
|
+
__doc__ = """
|
|
13
|
+
Defines the Bates stochastic volatility with jumps model.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BatesModel(BaseModel):
|
|
18
|
+
"""
|
|
19
|
+
Bates stochastic volatility jump-diffusion model.
|
|
20
|
+
|
|
21
|
+
This model combines the Heston stochastic volatility process with Merton's
|
|
22
|
+
log-normal jump-diffusion process, providing a rich framework for capturing
|
|
23
|
+
both volatility smiles and sudden market shocks.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name: str = "Bates"
|
|
27
|
+
supports_cf: bool = True
|
|
28
|
+
supports_sde: bool = True
|
|
29
|
+
has_jumps: bool = True
|
|
30
|
+
has_variance_process: bool = True
|
|
31
|
+
cf_kwargs = ("v0",)
|
|
32
|
+
|
|
33
|
+
default_params = {
|
|
34
|
+
"v0": 0.04,
|
|
35
|
+
"kappa": 2.0,
|
|
36
|
+
"theta": 0.04,
|
|
37
|
+
"rho": -0.7,
|
|
38
|
+
"vol_of_vol": 0.5,
|
|
39
|
+
"lambda": 0.5,
|
|
40
|
+
"mu_j": -0.1,
|
|
41
|
+
"sigma_j": 0.15,
|
|
42
|
+
}
|
|
43
|
+
param_defs = {**HestonModel.param_defs, **MertonJumpModel.param_defs}
|
|
44
|
+
|
|
45
|
+
def __init__(self, params: dict[str, float] | None = None):
|
|
46
|
+
"""
|
|
47
|
+
Initializes the Bates model.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
params : dict[str, float] | None, optional
|
|
52
|
+
A dictionary of model parameters. If None, `default_params` are used.
|
|
53
|
+
"""
|
|
54
|
+
super().__init__(params or self.default_params)
|
|
55
|
+
|
|
56
|
+
def _validate_params(self) -> None:
|
|
57
|
+
"""Validates all Heston and Merton parameters."""
|
|
58
|
+
p = self.params
|
|
59
|
+
req = ["kappa", "theta", "rho", "vol_of_vol", "lambda", "mu_j", "sigma_j"]
|
|
60
|
+
ParamValidator.require(p, req, model=self.name)
|
|
61
|
+
ParamValidator.positive(
|
|
62
|
+
p, ["kappa", "theta", "vol_of_vol", "lambda", "sigma_j"], model=self.name
|
|
63
|
+
)
|
|
64
|
+
ParamValidator.bounded(p, "rho", -1.0, 1.0, model=self.name)
|
|
65
|
+
|
|
66
|
+
def __eq__(self, other: object) -> bool:
|
|
67
|
+
if not isinstance(other, BatesModel):
|
|
68
|
+
return NotImplemented
|
|
69
|
+
return self.params == other.params
|
|
70
|
+
|
|
71
|
+
def __hash__(self) -> int:
|
|
72
|
+
return hash((self.__class__, tuple(sorted(self.params.items()))))
|
|
73
|
+
|
|
74
|
+
def _cf_impl(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
t: float,
|
|
78
|
+
spot: float,
|
|
79
|
+
r: float,
|
|
80
|
+
q: float,
|
|
81
|
+
v0: float,
|
|
82
|
+
**_: Any,
|
|
83
|
+
) -> CF:
|
|
84
|
+
"""
|
|
85
|
+
Bates characteristic function, a product of Heston and Merton CFs.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
t : float
|
|
90
|
+
The time to maturity of the option, in years.
|
|
91
|
+
spot : float
|
|
92
|
+
The current price of the underlying asset.
|
|
93
|
+
r : float
|
|
94
|
+
The continuously compounded risk-free rate.
|
|
95
|
+
q : float
|
|
96
|
+
The continuously compounded dividend yield.
|
|
97
|
+
v0 : float
|
|
98
|
+
The initial variance of the asset's returns.
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
CF
|
|
103
|
+
The characteristic function.
|
|
104
|
+
"""
|
|
105
|
+
p = self.params
|
|
106
|
+
kappa, theta, rho, vol_of_vol = (
|
|
107
|
+
p["kappa"],
|
|
108
|
+
p["theta"],
|
|
109
|
+
p["rho"],
|
|
110
|
+
p["vol_of_vol"],
|
|
111
|
+
)
|
|
112
|
+
lambda_, mu_j, sigma_j = p["lambda"], p["mu_j"], p["sigma_j"]
|
|
113
|
+
|
|
114
|
+
# Risk-neutral compensator for the jump part
|
|
115
|
+
compensator = lambda_ * (np.exp(mu_j + 0.5 * sigma_j**2) - 1)
|
|
116
|
+
|
|
117
|
+
def phi(u: np.ndarray | complex) -> np.ndarray | complex:
|
|
118
|
+
# Heston part (with drift adjusted for jump compensator)
|
|
119
|
+
d = np.sqrt(
|
|
120
|
+
(rho * vol_of_vol * u * 1j - kappa) ** 2
|
|
121
|
+
- (vol_of_vol**2) * (-u * 1j - u**2)
|
|
122
|
+
)
|
|
123
|
+
g = (kappa - rho * vol_of_vol * u * 1j - d) / (
|
|
124
|
+
kappa - rho * vol_of_vol * u * 1j + d
|
|
125
|
+
)
|
|
126
|
+
C = (r - q - compensator) * u * 1j * t + (kappa * theta / vol_of_vol**2) * (
|
|
127
|
+
(kappa - rho * vol_of_vol * u * 1j - d) * t
|
|
128
|
+
- 2 * np.log((1 - g * np.exp(-d * t)) / (1 - g))
|
|
129
|
+
)
|
|
130
|
+
D = ((kappa - rho * vol_of_vol * u * 1j - d) / vol_of_vol**2) * (
|
|
131
|
+
(1 - np.exp(-d * t)) / (1 - g * np.exp(-d * t))
|
|
132
|
+
)
|
|
133
|
+
heston_part = np.exp(C + D * v0 + 1j * u * np.log(spot))
|
|
134
|
+
|
|
135
|
+
# Merton jump part
|
|
136
|
+
jump_part = np.exp(
|
|
137
|
+
lambda_ * t * (np.exp(1j * u * mu_j - 0.5 * u**2 * sigma_j**2) - 1)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return heston_part * jump_part
|
|
141
|
+
|
|
142
|
+
return phi
|
|
143
|
+
|
|
144
|
+
def get_sde_stepper(self) -> Callable:
|
|
145
|
+
"""Returns the SDE stepper function for the Bates model."""
|
|
146
|
+
p = self.params
|
|
147
|
+
kappa, theta, rho, vol_of_vol = (
|
|
148
|
+
p["kappa"],
|
|
149
|
+
p["theta"],
|
|
150
|
+
p["rho"],
|
|
151
|
+
p["vol_of_vol"],
|
|
152
|
+
)
|
|
153
|
+
lambda_, mu_j, sigma_j = p["lambda"], p["mu_j"], p["sigma_j"]
|
|
154
|
+
compensator = lambda_ * (np.exp(mu_j + 0.5 * sigma_j**2) - 1)
|
|
155
|
+
|
|
156
|
+
def stepper(
|
|
157
|
+
log_s_t: np.ndarray,
|
|
158
|
+
v_t: np.ndarray,
|
|
159
|
+
r: float,
|
|
160
|
+
q: float,
|
|
161
|
+
dt: float,
|
|
162
|
+
dw_s: np.ndarray,
|
|
163
|
+
dw_v: np.ndarray,
|
|
164
|
+
jump_counts: np.ndarray,
|
|
165
|
+
rng: np.random.Generator,
|
|
166
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
167
|
+
v_t_pos = np.maximum(v_t, 0)
|
|
168
|
+
v_sqrt = np.sqrt(v_t_pos)
|
|
169
|
+
# Evolve variance
|
|
170
|
+
v_t_next = v_t + kappa * (theta - v_t_pos) * dt + vol_of_vol * v_sqrt * dw_v
|
|
171
|
+
# Evolve log-spot (Heston part + jump compensator)
|
|
172
|
+
s_drift = (r - q - 0.5 * v_t_pos - compensator) * dt
|
|
173
|
+
next_log_s = log_s_t + s_drift + v_sqrt * dw_s
|
|
174
|
+
# Add jumps
|
|
175
|
+
paths_with_jumps = np.where(jump_counts > 0)[0]
|
|
176
|
+
for path_idx in paths_with_jumps:
|
|
177
|
+
num_jumps = jump_counts[path_idx]
|
|
178
|
+
jump_size = np.sum(rng.normal(loc=mu_j, scale=sigma_j, size=num_jumps))
|
|
179
|
+
next_log_s[path_idx] += jump_size
|
|
180
|
+
return next_log_s, np.maximum(v_t_next, 0)
|
|
181
|
+
|
|
182
|
+
return stepper
|
|
183
|
+
|
|
184
|
+
# Abstract Method Implementations
|
|
185
|
+
def _sde_impl(self, **kwargs: Any) -> Callable:
|
|
186
|
+
return self.get_sde_stepper()
|
|
187
|
+
|
|
188
|
+
def _pde_impl(self, **kwargs: Any) -> Any:
|
|
189
|
+
raise NotImplementedError
|
|
190
|
+
|
|
191
|
+
def _closed_form_impl(self, **kwargs: Any) -> Any:
|
|
192
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from quantfin.models.base import BaseModel, ParamValidator
|
|
9
|
+
from quantfin.models.bsm import BSMModel
|
|
10
|
+
|
|
11
|
+
__doc__ = """
|
|
12
|
+
Defines Black's (1975) approximation for pricing an American call option
|
|
13
|
+
on a stock paying discrete dividends.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BlacksApproxModel(BaseModel):
|
|
18
|
+
"""
|
|
19
|
+
Black's approximation for an American call on a stock with discrete dividends.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
name: str = "Black's Approximation"
|
|
23
|
+
has_closed_form: bool = True
|
|
24
|
+
cf_kwargs = BaseModel.cf_kwargs + ("discrete_dividends", "ex_div_times")
|
|
25
|
+
|
|
26
|
+
default_params = {"sigma": 0.30}
|
|
27
|
+
|
|
28
|
+
def __init__(self, params: dict[str, float]):
|
|
29
|
+
"""
|
|
30
|
+
Initializes the Black's Approximation model.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
params : dict[str, float] | None, optional
|
|
35
|
+
A dictionary of model parameters. If None, `default_params` are used.
|
|
36
|
+
Must contain 'sigma'.
|
|
37
|
+
"""
|
|
38
|
+
super().__init__(params)
|
|
39
|
+
self.bsm_solver = BSMModel(params={"sigma": self.params["sigma"]})
|
|
40
|
+
|
|
41
|
+
def _validate_params(self) -> None:
|
|
42
|
+
"""Validate the 'sigma' parameter."""
|
|
43
|
+
ParamValidator.require(self.params, ["sigma"], model=self.name)
|
|
44
|
+
ParamValidator.positive(self.params, ["sigma"], model=self.name)
|
|
45
|
+
|
|
46
|
+
def _closed_form_impl(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
spot: float,
|
|
50
|
+
strike: float,
|
|
51
|
+
r: float,
|
|
52
|
+
t: float,
|
|
53
|
+
call: bool = True,
|
|
54
|
+
discrete_dividends: np.ndarray,
|
|
55
|
+
ex_div_times: np.ndarray,
|
|
56
|
+
q: float | None = None,
|
|
57
|
+
) -> float:
|
|
58
|
+
"""
|
|
59
|
+
Calculates the price by comparing holding vs. exercising before each dividend.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
spot : float
|
|
64
|
+
The current price of the underlying asset.
|
|
65
|
+
strike : float
|
|
66
|
+
The strike price of the option.
|
|
67
|
+
r : float
|
|
68
|
+
The continuously compounded risk-free rate.
|
|
69
|
+
t : float
|
|
70
|
+
The time to maturity of the option, in years.
|
|
71
|
+
call : bool, optional
|
|
72
|
+
Must be True, as the model is for calls only. Defaults to True.
|
|
73
|
+
discrete_dividends : np.ndarray
|
|
74
|
+
An array of discrete dividend amounts.
|
|
75
|
+
ex_div_times : np.ndarray
|
|
76
|
+
An array of ex-dividend dates, in years.
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
float
|
|
81
|
+
The approximated price of the American call option.
|
|
82
|
+
|
|
83
|
+
Raises
|
|
84
|
+
------
|
|
85
|
+
NotImplementedError
|
|
86
|
+
If the option is a put.
|
|
87
|
+
ValueError
|
|
88
|
+
If `discrete_dividends` is empty.
|
|
89
|
+
"""
|
|
90
|
+
if not call:
|
|
91
|
+
raise NotImplementedError(
|
|
92
|
+
"Black's Approximation is for American calls only."
|
|
93
|
+
)
|
|
94
|
+
if not hasattr(discrete_dividends, "__len__") or len(discrete_dividends) == 0:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
"BlacksApproxModel requires non-empty 'discrete_dividends'."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Value of holding until maturity T
|
|
100
|
+
pv_all_divs = sum(
|
|
101
|
+
D * math.exp(-r * tD)
|
|
102
|
+
for D, tD in zip(discrete_dividends, ex_div_times)
|
|
103
|
+
if tD < t
|
|
104
|
+
)
|
|
105
|
+
S_adj_T = spot - pv_all_divs
|
|
106
|
+
price_hold_to_maturity = self.bsm_solver.price_closed_form(
|
|
107
|
+
spot=S_adj_T, strike=strike, r=r, q=0, t=t, call=True
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Value of exercising just before each ex-dividend date
|
|
111
|
+
prices_early_exercise = []
|
|
112
|
+
for i, t_i in enumerate(ex_div_times):
|
|
113
|
+
if t_i >= t:
|
|
114
|
+
continue
|
|
115
|
+
pv_divs_before_i = sum(
|
|
116
|
+
discrete_dividends[j] * math.exp(-r * ex_div_times[j]) for j in range(i)
|
|
117
|
+
)
|
|
118
|
+
S_adj_i = spot - pv_divs_before_i
|
|
119
|
+
price_at_t_i = self.bsm_solver.price_closed_form(
|
|
120
|
+
spot=S_adj_i, strike=strike, r=r, q=0, t=t_i, call=True
|
|
121
|
+
)
|
|
122
|
+
prices_early_exercise.append(price_at_t_i)
|
|
123
|
+
|
|
124
|
+
max_early_price = max(prices_early_exercise) if prices_early_exercise else 0.0
|
|
125
|
+
return max(price_hold_to_maturity, max_early_price)
|
|
126
|
+
|
|
127
|
+
def _cf_impl(self, *args: Any, **kwargs: Any) -> Any:
|
|
128
|
+
raise NotImplementedError
|
|
129
|
+
|
|
130
|
+
def _sde_impl(self) -> Any:
|
|
131
|
+
raise NotImplementedError
|
|
132
|
+
|
|
133
|
+
def _pde_impl(self) -> Any:
|
|
134
|
+
raise NotImplementedError
|
quantfin/models/bsm.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.stats import norm
|
|
7
|
+
|
|
8
|
+
from quantfin.models.base import CF, BaseModel, ParamValidator, PDECoeffs
|
|
9
|
+
|
|
10
|
+
__doc__ = """
|
|
11
|
+
Defines the Black-Scholes-Merton (BSM) model for pricing European options.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BSMModel(BaseModel):
|
|
16
|
+
"""
|
|
17
|
+
Black-Scholes-Merton (BSM) model for pricing European options.
|
|
18
|
+
|
|
19
|
+
This model assumes the underlying asset follows a geometric Brownian motion
|
|
20
|
+
with constant volatility and risk-free rate.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: str = "Black-Scholes-Merton"
|
|
24
|
+
supports_cf: bool = True
|
|
25
|
+
supports_sde: bool = True
|
|
26
|
+
supports_pde: bool = True
|
|
27
|
+
has_closed_form: bool = True
|
|
28
|
+
|
|
29
|
+
default_params = {"sigma": 0.2}
|
|
30
|
+
param_defs = {
|
|
31
|
+
"sigma": {
|
|
32
|
+
"label": "Volatility",
|
|
33
|
+
"default": 0.2,
|
|
34
|
+
"min": 0.01,
|
|
35
|
+
"max": 2.0,
|
|
36
|
+
"step": 0.01,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def __init__(self, params: dict[str, float] | None = None):
|
|
41
|
+
"""
|
|
42
|
+
Initializes the BSM model.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
params : dict[str, float] | None, optional
|
|
47
|
+
A dictionary of model parameters. If None, `default_params` are used.
|
|
48
|
+
Must contain 'sigma'.
|
|
49
|
+
"""
|
|
50
|
+
super().__init__(params or self.default_params)
|
|
51
|
+
|
|
52
|
+
def _validate_params(self) -> None:
|
|
53
|
+
"""Validate the 'sigma' parameter."""
|
|
54
|
+
ParamValidator.require(self.params, ["sigma"], model=self.name)
|
|
55
|
+
ParamValidator.positive(self.params, ["sigma"], model=self.name)
|
|
56
|
+
|
|
57
|
+
def __eq__(self, other: object) -> bool:
|
|
58
|
+
if not isinstance(other, BSMModel):
|
|
59
|
+
return NotImplemented
|
|
60
|
+
return self.params == other.params
|
|
61
|
+
|
|
62
|
+
def __hash__(self) -> int:
|
|
63
|
+
return hash((self.__class__, tuple(sorted(self.params.items()))))
|
|
64
|
+
|
|
65
|
+
def _closed_form_impl(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
spot: float,
|
|
69
|
+
strike: float,
|
|
70
|
+
r: float,
|
|
71
|
+
q: float,
|
|
72
|
+
t: float,
|
|
73
|
+
call: bool = True,
|
|
74
|
+
) -> float:
|
|
75
|
+
"""
|
|
76
|
+
Computes the Black-Scholes-Merton price in closed form.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
spot : float
|
|
81
|
+
The current price of the underlying asset.
|
|
82
|
+
strike : float
|
|
83
|
+
The strike price of the option.
|
|
84
|
+
r : float
|
|
85
|
+
The continuously compounded risk-free rate.
|
|
86
|
+
q : float
|
|
87
|
+
The continuously compounded dividend yield.
|
|
88
|
+
t : float
|
|
89
|
+
The time to maturity of the option, in years.
|
|
90
|
+
call : bool, optional
|
|
91
|
+
True for a call option, False for a put. Defaults to True.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
float
|
|
96
|
+
The price of the European option.
|
|
97
|
+
"""
|
|
98
|
+
sigma = self.params["sigma"]
|
|
99
|
+
sqrt_t = np.sqrt(t)
|
|
100
|
+
d1 = (np.log(spot / strike) + (r - q + 0.5 * sigma**2) * t) / (sigma * sqrt_t)
|
|
101
|
+
d2 = d1 - sigma * sqrt_t
|
|
102
|
+
|
|
103
|
+
df_div = np.exp(-q * t)
|
|
104
|
+
df_rate = np.exp(-r * t)
|
|
105
|
+
|
|
106
|
+
if call:
|
|
107
|
+
price = spot * df_div * norm.cdf(d1) - strike * df_rate * norm.cdf(d2)
|
|
108
|
+
else:
|
|
109
|
+
price = strike * df_rate * norm.cdf(-d2) - spot * df_div * norm.cdf(-d1)
|
|
110
|
+
return price
|
|
111
|
+
|
|
112
|
+
def delta_analytic(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
spot: float,
|
|
116
|
+
strike: float,
|
|
117
|
+
r: float,
|
|
118
|
+
q: float,
|
|
119
|
+
t: float,
|
|
120
|
+
call: bool = True,
|
|
121
|
+
) -> float:
|
|
122
|
+
"""Analytic delta for the BSM model."""
|
|
123
|
+
sigma = self.params["sigma"]
|
|
124
|
+
d1 = (np.log(spot / strike) + (r - q + 0.5 * sigma**2) * t) / (
|
|
125
|
+
sigma * np.sqrt(t)
|
|
126
|
+
)
|
|
127
|
+
df_div = np.exp(-q * t)
|
|
128
|
+
return df_div * norm.cdf(d1) if call else -df_div * norm.cdf(-d1)
|
|
129
|
+
|
|
130
|
+
def gamma_analytic(
|
|
131
|
+
self,
|
|
132
|
+
*,
|
|
133
|
+
spot: float,
|
|
134
|
+
strike: float,
|
|
135
|
+
r: float,
|
|
136
|
+
q: float,
|
|
137
|
+
t: float,
|
|
138
|
+
) -> float:
|
|
139
|
+
"""Analytic gamma for the BSM model."""
|
|
140
|
+
sigma = self.params["sigma"]
|
|
141
|
+
sqrt_t = np.sqrt(t)
|
|
142
|
+
d1 = (np.log(spot / strike) + (r - q + 0.5 * sigma**2) * t) / (sigma * sqrt_t)
|
|
143
|
+
df_div = np.exp(-q * t)
|
|
144
|
+
return df_div * norm.pdf(d1) / (spot * sigma * sqrt_t)
|
|
145
|
+
|
|
146
|
+
def vega_analytic(
|
|
147
|
+
self,
|
|
148
|
+
*,
|
|
149
|
+
spot: float,
|
|
150
|
+
strike: float,
|
|
151
|
+
r: float,
|
|
152
|
+
q: float,
|
|
153
|
+
t: float,
|
|
154
|
+
) -> float:
|
|
155
|
+
"""Analytic vega for the BSM model."""
|
|
156
|
+
sigma = self.params["sigma"]
|
|
157
|
+
sqrt_t = np.sqrt(t)
|
|
158
|
+
d1 = (np.log(spot / strike) + (r - q + 0.5 * sigma**2) * t) / (sigma * sqrt_t)
|
|
159
|
+
return spot * np.exp(-q * t) * norm.pdf(d1) * sqrt_t
|
|
160
|
+
|
|
161
|
+
def theta_analytic(
|
|
162
|
+
self,
|
|
163
|
+
*,
|
|
164
|
+
spot: float,
|
|
165
|
+
strike: float,
|
|
166
|
+
r: float,
|
|
167
|
+
q: float,
|
|
168
|
+
t: float,
|
|
169
|
+
call: bool = True,
|
|
170
|
+
) -> float:
|
|
171
|
+
"""Analytic theta for the BSM model."""
|
|
172
|
+
sigma = self.params["sigma"]
|
|
173
|
+
sqrt_t = np.sqrt(t)
|
|
174
|
+
d1 = (np.log(spot / strike) + (r - q + 0.5 * sigma**2) * t) / (sigma * sqrt_t)
|
|
175
|
+
d2 = d1 - sigma * sqrt_t
|
|
176
|
+
term1 = -spot * np.exp(-q * t) * norm.pdf(d1) * sigma / (2 * sqrt_t)
|
|
177
|
+
if call:
|
|
178
|
+
term2 = q * spot * np.exp(-q * t) * norm.cdf(d1)
|
|
179
|
+
term3 = -r * strike * np.exp(-r * t) * norm.cdf(d2)
|
|
180
|
+
else:
|
|
181
|
+
term2 = -q * spot * np.exp(-q * t) * norm.cdf(-d1)
|
|
182
|
+
term3 = r * strike * np.exp(-r * t) * norm.cdf(-d2)
|
|
183
|
+
return term1 + term2 + term3
|
|
184
|
+
|
|
185
|
+
def rho_analytic(
|
|
186
|
+
self,
|
|
187
|
+
*,
|
|
188
|
+
spot: float,
|
|
189
|
+
strike: float,
|
|
190
|
+
r: float,
|
|
191
|
+
q: float,
|
|
192
|
+
t: float,
|
|
193
|
+
call: bool = True,
|
|
194
|
+
) -> float:
|
|
195
|
+
"""Analytic rho for the BSM model."""
|
|
196
|
+
sigma = self.params["sigma"]
|
|
197
|
+
sqrt_t = np.sqrt(t)
|
|
198
|
+
d2 = (np.log(spot / strike) + (r - q - 0.5 * sigma**2) * t) / (sigma * sqrt_t)
|
|
199
|
+
df_rate = np.exp(-r * t)
|
|
200
|
+
return strike * t * df_rate * (norm.cdf(d2) if call else -norm.cdf(-d2))
|
|
201
|
+
|
|
202
|
+
def _cf_impl(
|
|
203
|
+
self,
|
|
204
|
+
*,
|
|
205
|
+
t: float,
|
|
206
|
+
spot: float,
|
|
207
|
+
r: float,
|
|
208
|
+
q: float,
|
|
209
|
+
**_,
|
|
210
|
+
) -> CF:
|
|
211
|
+
"""
|
|
212
|
+
Returns the characteristic function phi(u) for the log-spot price log(S_t).
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
t : float
|
|
217
|
+
The time to maturity of the option, in years.
|
|
218
|
+
spot : float
|
|
219
|
+
The current price of the underlying asset.
|
|
220
|
+
r : float
|
|
221
|
+
The continuously compounded risk-free rate.
|
|
222
|
+
q : float
|
|
223
|
+
The continuously compounded dividend yield.
|
|
224
|
+
|
|
225
|
+
Returns
|
|
226
|
+
-------
|
|
227
|
+
CF
|
|
228
|
+
The characteristic function.
|
|
229
|
+
"""
|
|
230
|
+
sigma = self.params["sigma"]
|
|
231
|
+
drift = r - q - 0.5 * sigma**2
|
|
232
|
+
|
|
233
|
+
def phi(u: np.ndarray | complex) -> np.ndarray | complex:
|
|
234
|
+
mean_component = 1j * u * (np.log(spot) + drift * t)
|
|
235
|
+
variance_component = -0.5 * (u**2) * (sigma**2) * t
|
|
236
|
+
return np.exp(mean_component + variance_component)
|
|
237
|
+
|
|
238
|
+
return phi
|
|
239
|
+
|
|
240
|
+
def _sde_impl(self) -> Callable:
|
|
241
|
+
"""Returns the Euler-Maruyama stepper for the BSM log-price process."""
|
|
242
|
+
sigma = self.params["sigma"]
|
|
243
|
+
|
|
244
|
+
def stepper(
|
|
245
|
+
log_s_t: np.ndarray, r: float, q: float, dt: float, dw_t: np.ndarray
|
|
246
|
+
) -> np.ndarray:
|
|
247
|
+
drift = (r - q - 0.5 * sigma**2) * dt
|
|
248
|
+
diffusion = sigma * dw_t
|
|
249
|
+
return log_s_t + drift + diffusion
|
|
250
|
+
|
|
251
|
+
return stepper
|
|
252
|
+
|
|
253
|
+
def _pde_impl(self) -> PDECoeffs:
|
|
254
|
+
"""Returns the Black-Scholes PDE coefficients."""
|
|
255
|
+
sigma = self.params["sigma"]
|
|
256
|
+
|
|
257
|
+
def coeffs(
|
|
258
|
+
S: np.ndarray, r: float, q: float
|
|
259
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
260
|
+
return sigma**2 * S**2, (r - q) * S, -r * np.ones_like(S)
|
|
261
|
+
|
|
262
|
+
return coeffs
|
quantfin/models/cev.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from quantfin.models.base import BaseModel, ParamValidator
|
|
8
|
+
|
|
9
|
+
__doc__ = """
|
|
10
|
+
Defines the Constant Elasticity of Variance (CEV) model.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CEVModel(BaseModel):
|
|
15
|
+
"""Constant Elasticity of Variance (CEV) model."""
|
|
16
|
+
|
|
17
|
+
name: str = "CEV"
|
|
18
|
+
supports_sde: bool = True
|
|
19
|
+
has_exact_sampler: bool = True
|
|
20
|
+
default_params = {"sigma": 0.8, "gamma": 0.7}
|
|
21
|
+
|
|
22
|
+
def __init__(self, params: dict[str, float] | None = None):
|
|
23
|
+
"""
|
|
24
|
+
Initializes the CEV model.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
params : dict[str, float] | None, optional
|
|
29
|
+
A dictionary of model parameters. If None, `default_params` are used.
|
|
30
|
+
Must contain 'sigma' and 'gamma'. Defaults to None.
|
|
31
|
+
"""
|
|
32
|
+
super().__init__(params or self.default_params)
|
|
33
|
+
|
|
34
|
+
def _validate_params(self) -> None:
|
|
35
|
+
"""Validates that 'sigma' and 'gamma' are present and positive."""
|
|
36
|
+
p = self.params
|
|
37
|
+
req = ["sigma", "gamma"]
|
|
38
|
+
ParamValidator.require(p, req, model=self.name)
|
|
39
|
+
ParamValidator.positive(p, ["sigma"], model=self.name)
|
|
40
|
+
|
|
41
|
+
def __eq__(self, other: object) -> bool:
|
|
42
|
+
if not isinstance(other, CEVModel):
|
|
43
|
+
return NotImplemented
|
|
44
|
+
return self.params == other.params
|
|
45
|
+
|
|
46
|
+
def __hash__(self) -> int:
|
|
47
|
+
return hash((self.__class__, tuple(sorted(self.params.items()))))
|
|
48
|
+
|
|
49
|
+
def sample_terminal_spot(
|
|
50
|
+
self,
|
|
51
|
+
S0: float,
|
|
52
|
+
r: float,
|
|
53
|
+
T: float,
|
|
54
|
+
size: int,
|
|
55
|
+
) -> np.ndarray:
|
|
56
|
+
"""
|
|
57
|
+
Exact simulation of the terminal spot price via Non-Central Chi-Squared.
|
|
58
|
+
|
|
59
|
+
This method provides a way to sample directly from the terminal
|
|
60
|
+
distribution of the CEV process, which is more efficient than
|
|
61
|
+
path-based simulation for pricing European options.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
S0 : float
|
|
66
|
+
The initial spot price of the asset.
|
|
67
|
+
r : float
|
|
68
|
+
The continuously compounded risk-free rate.
|
|
69
|
+
T : float
|
|
70
|
+
The time to maturity, in years.
|
|
71
|
+
size : int
|
|
72
|
+
The number of samples to generate.
|
|
73
|
+
|
|
74
|
+
Returns
|
|
75
|
+
-------
|
|
76
|
+
np.ndarray
|
|
77
|
+
An array of simulated terminal spot prices.
|
|
78
|
+
"""
|
|
79
|
+
from scipy.stats import ncx2
|
|
80
|
+
|
|
81
|
+
p = self.params
|
|
82
|
+
sigma, gamma = p["sigma"], p["gamma"]
|
|
83
|
+
|
|
84
|
+
# Handle the boundary case where gamma -> 1 (which is BSM)
|
|
85
|
+
if abs(1.0 - gamma) < 1e-6:
|
|
86
|
+
drift = (r - 0.5 * sigma**2) * T
|
|
87
|
+
diffusion = sigma * np.sqrt(T) * np.random.standard_normal(size) # noqa: NPY002
|
|
88
|
+
return S0 * np.exp(drift + diffusion)
|
|
89
|
+
|
|
90
|
+
df = 2 + 2 / (1 - gamma)
|
|
91
|
+
k = 2 * r / (sigma**2 * (1 - gamma) * (np.exp(r * (1 - gamma) * T) - 1))
|
|
92
|
+
nc = k * S0 ** (2 * (1 - gamma)) * np.exp(-r * (1 - gamma) * T)
|
|
93
|
+
|
|
94
|
+
chi2_draws = ncx2.rvs(df=df, nc=nc, size=size)
|
|
95
|
+
ST = (chi2_draws / k) ** (1 / (2 * (1 - gamma)))
|
|
96
|
+
return ST
|
|
97
|
+
|
|
98
|
+
# Abstract Method Implementations
|
|
99
|
+
def _sde_impl(self, **kwargs: Any) -> Any:
|
|
100
|
+
raise NotImplementedError("CEV uses a specialized exact sampler.")
|
|
101
|
+
|
|
102
|
+
def _cf_impl(self, **kwargs: Any) -> Any:
|
|
103
|
+
raise NotImplementedError
|
|
104
|
+
|
|
105
|
+
def _pde_impl(self, **kwargs: Any) -> Any:
|
|
106
|
+
raise NotImplementedError
|
|
107
|
+
|
|
108
|
+
def _closed_form_impl(self, **kwargs: Any) -> Any:
|
|
109
|
+
raise NotImplementedError
|