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.
Files changed (92) hide show
  1. optpricing-2.0.0.dist-info/METADATA +105 -0
  2. optpricing-2.0.0.dist-info/RECORD +92 -0
  3. optpricing-2.0.0.dist-info/WHEEL +5 -0
  4. optpricing-2.0.0.dist-info/entry_points.txt +3 -0
  5. optpricing-2.0.0.dist-info/licenses/LICENSE +9 -0
  6. optpricing-2.0.0.dist-info/top_level.txt +1 -0
  7. quantfin/__init__.py +101 -0
  8. quantfin/atoms/__init__.py +21 -0
  9. quantfin/atoms/bond.py +28 -0
  10. quantfin/atoms/option.py +70 -0
  11. quantfin/atoms/rate.py +46 -0
  12. quantfin/atoms/stock.py +39 -0
  13. quantfin/calibration/__init__.py +25 -0
  14. quantfin/calibration/calibrator.py +155 -0
  15. quantfin/calibration/fit_jump_parameters.py +66 -0
  16. quantfin/calibration/fit_market_params.py +120 -0
  17. quantfin/calibration/iv_surface.py +155 -0
  18. quantfin/calibration/technique_selector.py +47 -0
  19. quantfin/calibration/vectorized_bsm_iv.py +93 -0
  20. quantfin/calibration/vectorized_integration_iv.py +143 -0
  21. quantfin/cli.py +372 -0
  22. quantfin/config.py +57 -0
  23. quantfin/dashboard/__init__.py +14 -0
  24. quantfin/dashboard/app.py +26 -0
  25. quantfin/dashboard/pages/1_Calibration.py +57 -0
  26. quantfin/dashboard/pages/2_Pricer_and_Greeks.py +150 -0
  27. quantfin/dashboard/pages/3_Financial_Tools.py +69 -0
  28. quantfin/dashboard/pages/__init__.py +0 -0
  29. quantfin/dashboard/plots.py +157 -0
  30. quantfin/dashboard/service.py +176 -0
  31. quantfin/dashboard/widgets.py +87 -0
  32. quantfin/data/__init__.py +26 -0
  33. quantfin/data/historical_manager.py +86 -0
  34. quantfin/data/market_data_manager.py +236 -0
  35. quantfin/models/__init__.py +54 -0
  36. quantfin/models/base/__init__.py +16 -0
  37. quantfin/models/base/base_model.py +198 -0
  38. quantfin/models/base/validators.py +95 -0
  39. quantfin/models/bates.py +192 -0
  40. quantfin/models/blacks_approx.py +134 -0
  41. quantfin/models/bsm.py +262 -0
  42. quantfin/models/cev.py +109 -0
  43. quantfin/models/cgmy.py +189 -0
  44. quantfin/models/cir.py +90 -0
  45. quantfin/models/dupire_local.py +67 -0
  46. quantfin/models/heston.py +204 -0
  47. quantfin/models/hyperbolic.py +170 -0
  48. quantfin/models/kou.py +148 -0
  49. quantfin/models/merton_jump.py +225 -0
  50. quantfin/models/nig.py +180 -0
  51. quantfin/models/perpetual_put.py +115 -0
  52. quantfin/models/sabr.py +64 -0
  53. quantfin/models/sabr_jump.py +68 -0
  54. quantfin/models/vasicek.py +88 -0
  55. quantfin/models/vg.py +178 -0
  56. quantfin/parity/__init__.py +13 -0
  57. quantfin/parity/implied_rate.py +110 -0
  58. quantfin/parity/parity_model.py +178 -0
  59. quantfin/techniques/__init__.py +31 -0
  60. quantfin/techniques/base/__init__.py +21 -0
  61. quantfin/techniques/base/base_technique.py +54 -0
  62. quantfin/techniques/base/greek_mixin.py +265 -0
  63. quantfin/techniques/base/iv_mixin.py +116 -0
  64. quantfin/techniques/base/lattice_technique.py +129 -0
  65. quantfin/techniques/base/pricing_result.py +27 -0
  66. quantfin/techniques/base/random_utils.py +31 -0
  67. quantfin/techniques/closed_form.py +296 -0
  68. quantfin/techniques/crr.py +59 -0
  69. quantfin/techniques/fft.py +178 -0
  70. quantfin/techniques/integration.py +202 -0
  71. quantfin/techniques/kernels/__init__.py +37 -0
  72. quantfin/techniques/kernels/lattice_kernels.py +286 -0
  73. quantfin/techniques/kernels/mc_kernels.py +337 -0
  74. quantfin/techniques/leisen_reimer.py +59 -0
  75. quantfin/techniques/monte_carlo.py +259 -0
  76. quantfin/techniques/pde.py +155 -0
  77. quantfin/techniques/topm.py +59 -0
  78. quantfin/workflows/__init__.py +14 -0
  79. quantfin/workflows/backtest_workflow.py +133 -0
  80. quantfin/workflows/configs/__init__.py +51 -0
  81. quantfin/workflows/configs/bates_config.py +25 -0
  82. quantfin/workflows/configs/bsm_config.py +9 -0
  83. quantfin/workflows/configs/cev_config.py +16 -0
  84. quantfin/workflows/configs/cgmy_config.py +18 -0
  85. quantfin/workflows/configs/heston_config.py +21 -0
  86. quantfin/workflows/configs/hyperbolic_config.py +18 -0
  87. quantfin/workflows/configs/kou_config.py +16 -0
  88. quantfin/workflows/configs/merton_config.py +18 -0
  89. quantfin/workflows/configs/nig_config.py +13 -0
  90. quantfin/workflows/configs/sabr_config.py +19 -0
  91. quantfin/workflows/configs/vg_config.py +9 -0
  92. quantfin/workflows/daily_workflow.py +150 -0
@@ -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