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
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from scipy.optimize import minimize, minimize_scalar
|
|
6
|
+
|
|
7
|
+
from quantfin.atoms import Option, OptionType, Rate, Stock
|
|
8
|
+
from quantfin.models import BaseModel
|
|
9
|
+
|
|
10
|
+
from .technique_selector import select_fastest_technique
|
|
11
|
+
|
|
12
|
+
__doc__ = """
|
|
13
|
+
Defines the main Calibrator class used to fit financial models to market data.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Calibrator:
|
|
18
|
+
"""
|
|
19
|
+
A generic class for calibrating financial models to market data.
|
|
20
|
+
|
|
21
|
+
This class orchestrates the process of finding the model parameters that
|
|
22
|
+
minimize the difference between model prices and observed market prices.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
model: BaseModel,
|
|
28
|
+
market_data: pd.DataFrame,
|
|
29
|
+
stock: Stock,
|
|
30
|
+
rate: Rate,
|
|
31
|
+
):
|
|
32
|
+
"""
|
|
33
|
+
Initializes the Calibrator.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
model : BaseModel
|
|
38
|
+
The financial model to be calibrated (e.g., HestonModel).
|
|
39
|
+
market_data : pd.DataFrame
|
|
40
|
+
A DataFrame containing market prices of options. Must include
|
|
41
|
+
'strike', 'maturity', 'optionType', and 'marketPrice' columns.
|
|
42
|
+
stock : Stock
|
|
43
|
+
The underlying asset's properties.
|
|
44
|
+
rate : Rate
|
|
45
|
+
The risk-free rate structure.
|
|
46
|
+
"""
|
|
47
|
+
self.model = model
|
|
48
|
+
self.market_data = market_data
|
|
49
|
+
self.stock = stock
|
|
50
|
+
self.rate = rate
|
|
51
|
+
self.technique = select_fastest_technique(model)
|
|
52
|
+
_class_name = self.technique.__class__.__name__
|
|
53
|
+
print(f"Calibrator using '{_class_name}' for model '{model.name}'.")
|
|
54
|
+
|
|
55
|
+
def _objective_function(
|
|
56
|
+
self,
|
|
57
|
+
params_to_fit_values: np.ndarray,
|
|
58
|
+
params_to_fit_names: list[str],
|
|
59
|
+
frozen_params: dict[str, float],
|
|
60
|
+
) -> float:
|
|
61
|
+
"""The objective function to be minimized, calculating total squared error."""
|
|
62
|
+
current_params = {
|
|
63
|
+
**frozen_params,
|
|
64
|
+
**dict(zip(params_to_fit_names, params_to_fit_values)),
|
|
65
|
+
}
|
|
66
|
+
print(
|
|
67
|
+
f" Trying params: { {k: f'{v:.4f}' for k, v in current_params.items()} }",
|
|
68
|
+
end="",
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
temp_model = self.model.with_params(**current_params)
|
|
72
|
+
except ValueError as e:
|
|
73
|
+
print(f" -> Invalid params ({e}), returning large error.")
|
|
74
|
+
return 1e12
|
|
75
|
+
|
|
76
|
+
# Vectorized pricing for speed
|
|
77
|
+
# assumes the technique supports vectorized pricing.
|
|
78
|
+
# For now, fall back to iterating, but this is where future optimization lies.
|
|
79
|
+
total_error = 0.0
|
|
80
|
+
for _, row in self.market_data.iterrows():
|
|
81
|
+
option = Option(
|
|
82
|
+
strike=row["strike"],
|
|
83
|
+
maturity=row["maturity"],
|
|
84
|
+
option_type=OptionType.CALL
|
|
85
|
+
if row["optionType"] == "call"
|
|
86
|
+
else OptionType.PUT,
|
|
87
|
+
)
|
|
88
|
+
model_price = self.technique.price(
|
|
89
|
+
option, self.stock, temp_model, self.rate, **current_params
|
|
90
|
+
).price
|
|
91
|
+
total_error += (model_price - row["marketPrice"]) ** 2
|
|
92
|
+
|
|
93
|
+
print(f" --> Total Error: {total_error:.4f}")
|
|
94
|
+
return total_error
|
|
95
|
+
|
|
96
|
+
def fit(
|
|
97
|
+
self,
|
|
98
|
+
initial_guess: dict[str, float],
|
|
99
|
+
bounds: dict[str, tuple],
|
|
100
|
+
frozen_params: dict[str, float] = None,
|
|
101
|
+
) -> dict[str, float]:
|
|
102
|
+
"""
|
|
103
|
+
Performs the calibration using an optimization algorithm.
|
|
104
|
+
|
|
105
|
+
This method uses `scipy.optimize.minimize` (or `minimize_scalar` for
|
|
106
|
+
a single parameter) to find the optimal set of parameters that
|
|
107
|
+
minimizes the objective function.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
initial_guess : dict[str, float]
|
|
112
|
+
A dictionary of initial guesses for the parameters to be fitted.
|
|
113
|
+
bounds : dict[str, tuple]
|
|
114
|
+
A dictionary mapping parameter names to their (min, max) bounds.
|
|
115
|
+
frozen_params : dict[str, float] | None, optional
|
|
116
|
+
A dictionary of parameters to hold constant during the optimization.
|
|
117
|
+
Defaults to None.
|
|
118
|
+
|
|
119
|
+
Returns
|
|
120
|
+
-------
|
|
121
|
+
dict[str, float]
|
|
122
|
+
A dictionary containing the full set of calibrated and frozen parameters.
|
|
123
|
+
"""
|
|
124
|
+
frozen_params = frozen_params or {}
|
|
125
|
+
params_to_fit_names = [p for p in initial_guess if p not in frozen_params]
|
|
126
|
+
print(f"Fitting parameters: {params_to_fit_names}")
|
|
127
|
+
if not params_to_fit_names:
|
|
128
|
+
return frozen_params
|
|
129
|
+
|
|
130
|
+
fit_bounds = [bounds.get(p) for p in params_to_fit_names]
|
|
131
|
+
initial_values = [initial_guess[p] for p in params_to_fit_names]
|
|
132
|
+
|
|
133
|
+
if len(params_to_fit_names) == 1:
|
|
134
|
+
# scalar minimizer for one parameter
|
|
135
|
+
res = minimize_scalar(
|
|
136
|
+
lambda x: self._objective_function(
|
|
137
|
+
np.array([x]), params_to_fit_names, frozen_params
|
|
138
|
+
),
|
|
139
|
+
bounds=fit_bounds[0],
|
|
140
|
+
method="bounded",
|
|
141
|
+
)
|
|
142
|
+
final_params = {**frozen_params, params_to_fit_names[0]: res.x}
|
|
143
|
+
print(f"Scalar optimization finished. Final loss: {res.fun:.6f}")
|
|
144
|
+
else:
|
|
145
|
+
# gradient-based optimizer for multiple parameters
|
|
146
|
+
res = minimize(
|
|
147
|
+
fun=self._objective_function,
|
|
148
|
+
x0=initial_values,
|
|
149
|
+
args=(params_to_fit_names, frozen_params),
|
|
150
|
+
method="L-BFGS-B",
|
|
151
|
+
bounds=fit_bounds,
|
|
152
|
+
)
|
|
153
|
+
final_params = {**frozen_params, **dict(zip(params_to_fit_names, res.x))}
|
|
154
|
+
print(f"Multivariate optimization finished. Final loss: {res.fun:.6f}")
|
|
155
|
+
return final_params
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
__doc__ = """
|
|
7
|
+
Provides a utility function to estimate jump parameters from historical data.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def fit_jump_params_from_history(
|
|
12
|
+
log_returns: pd.Series,
|
|
13
|
+
threshold_stds: float = 3.0,
|
|
14
|
+
) -> dict:
|
|
15
|
+
"""
|
|
16
|
+
Estimates jump parameters and diffusion volatility from historical returns.
|
|
17
|
+
|
|
18
|
+
This function separates historical log returns into a "diffusion" component
|
|
19
|
+
(normal daily movements) and a "jump" component (extreme movements) based
|
|
20
|
+
on a standard deviation threshold. It then calculates the annualized
|
|
21
|
+
parameters for a jump-diffusion model like Merton's.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
log_returns : pd.Series
|
|
26
|
+
A pandas Series of daily log returns.
|
|
27
|
+
threshold_stds : float, optional
|
|
28
|
+
The number of standard deviations to use as a threshold for identifying
|
|
29
|
+
jumps, by default 3.0.
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
dict
|
|
34
|
+
A dictionary containing the estimated parameters: 'sigma', 'lambda',
|
|
35
|
+
'mu_j', and 'sigma_j'.
|
|
36
|
+
"""
|
|
37
|
+
print("Fitting jump parameters from historical returns...")
|
|
38
|
+
|
|
39
|
+
std_dev = log_returns.std()
|
|
40
|
+
jump_threshold = threshold_stds * std_dev
|
|
41
|
+
|
|
42
|
+
diffusion_returns = log_returns[abs(log_returns) < jump_threshold]
|
|
43
|
+
jump_returns = log_returns[abs(log_returns) >= jump_threshold]
|
|
44
|
+
|
|
45
|
+
# Annualize daily std dev by multiplying by sqrt(252 trading days)
|
|
46
|
+
sigma_est = diffusion_returns.std() * np.sqrt(252)
|
|
47
|
+
|
|
48
|
+
if len(jump_returns) > 2:
|
|
49
|
+
lambda_est = len(jump_returns) / len(log_returns) * 252
|
|
50
|
+
mu_j_est = jump_returns.mean()
|
|
51
|
+
sigma_j_est = jump_returns.std()
|
|
52
|
+
else:
|
|
53
|
+
lambda_est, mu_j_est, sigma_j_est = 0.1, 0.0, 0.0
|
|
54
|
+
print(" -> Warning: Not enough jumps detected. Using default jump parameters.")
|
|
55
|
+
|
|
56
|
+
# Use the key 'sigma' to match the parameter name in the Merton/Kou models.
|
|
57
|
+
fitted_params = {
|
|
58
|
+
"sigma": sigma_est,
|
|
59
|
+
"lambda": lambda_est,
|
|
60
|
+
"mu_j": mu_j_est,
|
|
61
|
+
"sigma_j": sigma_j_est,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
formatted = ", ".join(f"{k}: {v:.4f}" for k, v in fitted_params.items())
|
|
65
|
+
print(f" -> Estimated Historical Params: {{{formatted}}}")
|
|
66
|
+
return fitted_params
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from scipy.optimize import minimize
|
|
6
|
+
|
|
7
|
+
__doc__ = """
|
|
8
|
+
Provides utility functions to fit market-implied parameters like the
|
|
9
|
+
risk-free rate and dividend yield from option prices using put-call parity.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def find_atm_options(
|
|
14
|
+
calls: pd.DataFrame,
|
|
15
|
+
puts: pd.DataFrame,
|
|
16
|
+
spot: float,
|
|
17
|
+
) -> pd.DataFrame:
|
|
18
|
+
"""
|
|
19
|
+
Finds the closest at-the-money (ATM) call-put pair for each expiry.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
calls : pd.DataFrame
|
|
24
|
+
A DataFrame of call options.
|
|
25
|
+
puts : pd.DataFrame
|
|
26
|
+
A DataFrame of put options.
|
|
27
|
+
spot : float
|
|
28
|
+
The current spot price of the underlying.
|
|
29
|
+
|
|
30
|
+
Returns
|
|
31
|
+
-------
|
|
32
|
+
pd.DataFrame
|
|
33
|
+
A DataFrame containing the merged ATM call-put pairs.
|
|
34
|
+
"""
|
|
35
|
+
merged = pd.merge(
|
|
36
|
+
calls, puts, on=["strike", "maturity"], suffixes=("_call", "_put"), how="inner"
|
|
37
|
+
)
|
|
38
|
+
if merged.empty:
|
|
39
|
+
return pd.DataFrame()
|
|
40
|
+
merged["moneyness_dist"] = abs(merged["strike"] - spot)
|
|
41
|
+
atm_indices = merged.groupby("maturity")["moneyness_dist"].idxmin()
|
|
42
|
+
return merged.loc[atm_indices]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def fit_rate_and_dividend(
|
|
46
|
+
calls: pd.DataFrame,
|
|
47
|
+
puts: pd.DataFrame,
|
|
48
|
+
spot: float,
|
|
49
|
+
r_fixed: float | None = None,
|
|
50
|
+
q_fixed: float | None = None,
|
|
51
|
+
) -> tuple[float, float]:
|
|
52
|
+
"""
|
|
53
|
+
Fits the risk-free rate (r) and dividend yield (q) from put-call parity.
|
|
54
|
+
|
|
55
|
+
This function uses the prices of at-the-money (ATM) call-put pairs to
|
|
56
|
+
solve for the `r` and `q` that minimize the parity pricing error.
|
|
57
|
+
Parameters can be held fixed or fitted.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
calls : pd.DataFrame
|
|
62
|
+
A DataFrame of call options with 'strike', 'maturity', 'marketPrice'.
|
|
63
|
+
puts : pd.DataFrame
|
|
64
|
+
A DataFrame of put options with 'strike', 'maturity', 'marketPrice'.
|
|
65
|
+
spot : float
|
|
66
|
+
The current spot price of the underlying.
|
|
67
|
+
r_fixed : float | None, optional
|
|
68
|
+
If provided, the risk-free rate is held fixed at this value.
|
|
69
|
+
Defaults to None.
|
|
70
|
+
q_fixed : float | None, optional
|
|
71
|
+
If provided, the dividend yield is held fixed at this value.
|
|
72
|
+
Defaults to None.
|
|
73
|
+
|
|
74
|
+
Returns
|
|
75
|
+
-------
|
|
76
|
+
tuple[float, float]
|
|
77
|
+
A tuple containing the estimated (or fixed) risk-free rate and dividend yield.
|
|
78
|
+
"""
|
|
79
|
+
atm_pairs = find_atm_options(calls, puts, spot)
|
|
80
|
+
if atm_pairs.empty:
|
|
81
|
+
print("Warning: No ATM pairs found. Using default r=0.05, q=0.0.")
|
|
82
|
+
return 0.05, 0.0
|
|
83
|
+
|
|
84
|
+
free_param_indices, initial_guess, bounds = [], [], []
|
|
85
|
+
if r_fixed is None:
|
|
86
|
+
free_param_indices.append(0)
|
|
87
|
+
initial_guess.append(0.05)
|
|
88
|
+
bounds.append((0.0, 0.15))
|
|
89
|
+
if q_fixed is None:
|
|
90
|
+
free_param_indices.append(1)
|
|
91
|
+
initial_guess.append(0.01)
|
|
92
|
+
bounds.append((0.0, 0.10))
|
|
93
|
+
|
|
94
|
+
if not free_param_indices:
|
|
95
|
+
return r_fixed, q_fixed
|
|
96
|
+
|
|
97
|
+
def parity_error(x: np.ndarray) -> float:
|
|
98
|
+
r = r_fixed if 0 not in free_param_indices else x[free_param_indices.index(0)]
|
|
99
|
+
q = q_fixed if 1 not in free_param_indices else x[free_param_indices.index(1)]
|
|
100
|
+
parity_rhs = spot * np.exp(-q * atm_pairs["maturity"]) - atm_pairs[
|
|
101
|
+
"strike"
|
|
102
|
+
] * np.exp(-r * atm_pairs["maturity"])
|
|
103
|
+
parity_lhs = atm_pairs["marketPrice_call"] - atm_pairs["marketPrice_put"]
|
|
104
|
+
return np.sum((parity_lhs - parity_rhs) ** 2)
|
|
105
|
+
|
|
106
|
+
solution = minimize(
|
|
107
|
+
fun=parity_error, x0=initial_guess, bounds=bounds, method="L-BFGS-B"
|
|
108
|
+
)
|
|
109
|
+
r_est = (
|
|
110
|
+
r_fixed
|
|
111
|
+
if 0 not in free_param_indices
|
|
112
|
+
else solution.x[free_param_indices.index(0)]
|
|
113
|
+
)
|
|
114
|
+
q_est = (
|
|
115
|
+
q_fixed
|
|
116
|
+
if 1 not in free_param_indices
|
|
117
|
+
else solution.x[free_param_indices.index(1)]
|
|
118
|
+
)
|
|
119
|
+
print(f"Fitted market params -> r: {r_est:.4f}, q: {q_est:.4f}")
|
|
120
|
+
return float(r_est), float(q_est)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
from quantfin.atoms import Option, OptionType, Rate, Stock
|
|
7
|
+
from quantfin.models import BaseModel
|
|
8
|
+
from quantfin.techniques.base import BaseTechnique
|
|
9
|
+
|
|
10
|
+
from .vectorized_bsm_iv import BSMIVSolver
|
|
11
|
+
|
|
12
|
+
__doc__ = """
|
|
13
|
+
Defines the VolatilitySurface class, used to compute and store implied
|
|
14
|
+
volatility surfaces from both market and model prices.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class VolatilitySurface:
|
|
19
|
+
"""
|
|
20
|
+
A class to compute and hold market and model-implied volatility surfaces.
|
|
21
|
+
|
|
22
|
+
This class takes a DataFrame of option market data and provides methods
|
|
23
|
+
to calculate the Black-Scholes implied volatility (IV) for each option,
|
|
24
|
+
either from its market price or from a price generated by a financial model.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, option_data: pd.DataFrame):
|
|
28
|
+
"""
|
|
29
|
+
Initializes the VolatilitySurface.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
option_data : pd.DataFrame
|
|
34
|
+
A DataFrame containing market prices of options. Must include
|
|
35
|
+
'strike', 'maturity', 'marketPrice', 'optionType', and 'expiry' columns.
|
|
36
|
+
|
|
37
|
+
Raises
|
|
38
|
+
------
|
|
39
|
+
ValueError
|
|
40
|
+
If any of the required columns are missing from `option_data`.
|
|
41
|
+
"""
|
|
42
|
+
required_cols = ["strike", "maturity", "marketPrice", "optionType", "expiry"]
|
|
43
|
+
if not all(col in option_data.columns for col in required_cols):
|
|
44
|
+
msg = (
|
|
45
|
+
"Input option_data is missing one "
|
|
46
|
+
f"of the required columns: {required_cols}"
|
|
47
|
+
)
|
|
48
|
+
raise ValueError(msg)
|
|
49
|
+
|
|
50
|
+
self.data = option_data[required_cols].copy()
|
|
51
|
+
self.surface: pd.DataFrame | None = None
|
|
52
|
+
self.iv_solver = BSMIVSolver()
|
|
53
|
+
|
|
54
|
+
def _calculate_ivs(
|
|
55
|
+
self,
|
|
56
|
+
stock: Stock,
|
|
57
|
+
rate: Rate,
|
|
58
|
+
prices_to_invert: pd.Series,
|
|
59
|
+
) -> np.ndarray:
|
|
60
|
+
"""Calculates IVs using the fast, vectorized BSM solver."""
|
|
61
|
+
ivs = self.iv_solver.solve(prices_to_invert.values, self.data, stock, rate)
|
|
62
|
+
return ivs
|
|
63
|
+
|
|
64
|
+
def calculate_market_iv(
|
|
65
|
+
self,
|
|
66
|
+
stock: Stock,
|
|
67
|
+
rate: Rate,
|
|
68
|
+
) -> VolatilitySurface:
|
|
69
|
+
"""
|
|
70
|
+
Calculates the market implied volatility surface from market prices.
|
|
71
|
+
|
|
72
|
+
This method inverts the Black-Scholes formula for each option's market
|
|
73
|
+
price to find the corresponding implied volatility. The results are
|
|
74
|
+
stored in the `self.surface` DataFrame.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
stock : Stock
|
|
79
|
+
The underlying asset's properties.
|
|
80
|
+
rate : Rate
|
|
81
|
+
The risk-free rate structure.
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
VolatilitySurface
|
|
86
|
+
The same instance of the class, allowing for method chaining.
|
|
87
|
+
"""
|
|
88
|
+
print("Calculating market implied volatility surface...")
|
|
89
|
+
market_ivs = self._calculate_ivs(stock, rate, self.data["marketPrice"])
|
|
90
|
+
self.surface = self.data.copy()
|
|
91
|
+
self.surface["iv"] = market_ivs
|
|
92
|
+
self.surface.dropna(inplace=True)
|
|
93
|
+
self.surface = self.surface[
|
|
94
|
+
(self.surface["iv"] > 1e-4) & (self.surface["iv"] < 2.0)
|
|
95
|
+
]
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def calculate_model_iv(
|
|
99
|
+
self,
|
|
100
|
+
stock: Stock,
|
|
101
|
+
rate: Rate,
|
|
102
|
+
model: BaseModel,
|
|
103
|
+
technique: BaseTechnique,
|
|
104
|
+
) -> VolatilitySurface:
|
|
105
|
+
"""
|
|
106
|
+
Calculates a model's implied volatility surface.
|
|
107
|
+
|
|
108
|
+
This method first prices every option in the dataset using the provided
|
|
109
|
+
model and technique. It then inverts the Black-Scholes formula for each
|
|
110
|
+
of these model prices to generate the model-implied volatility surface.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
stock : Stock
|
|
115
|
+
The underlying asset's properties.
|
|
116
|
+
rate : Rate
|
|
117
|
+
The risk-free rate structure.
|
|
118
|
+
model : BaseModel
|
|
119
|
+
The financial model to generate prices from.
|
|
120
|
+
technique : BaseTechnique
|
|
121
|
+
The pricing technique to use with the model.
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
VolatilitySurface
|
|
126
|
+
The same instance of the class, allowing for method chaining.
|
|
127
|
+
"""
|
|
128
|
+
print(f"Calculating {model.name} implied volatility surface...")
|
|
129
|
+
|
|
130
|
+
model_prices = np.array(
|
|
131
|
+
[
|
|
132
|
+
technique.price(
|
|
133
|
+
Option(
|
|
134
|
+
strike=row.strike,
|
|
135
|
+
maturity=row.maturity,
|
|
136
|
+
option_type=OptionType.CALL
|
|
137
|
+
if row.optionType == "call"
|
|
138
|
+
else OptionType.PUT,
|
|
139
|
+
),
|
|
140
|
+
stock,
|
|
141
|
+
model,
|
|
142
|
+
rate,
|
|
143
|
+
**model.params,
|
|
144
|
+
).price
|
|
145
|
+
for _, row in self.data.iterrows()
|
|
146
|
+
]
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
model_ivs = self._calculate_ivs(
|
|
150
|
+
stock, rate, pd.Series(model_prices, index=self.data.index)
|
|
151
|
+
)
|
|
152
|
+
self.surface = self.data.copy()
|
|
153
|
+
self.surface["iv"] = model_ivs
|
|
154
|
+
self.surface.dropna(inplace=True)
|
|
155
|
+
return self
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from quantfin.models.base import BaseModel
|
|
4
|
+
from quantfin.techniques.closed_form import ClosedFormTechnique
|
|
5
|
+
from quantfin.techniques.fft import FFTTechnique
|
|
6
|
+
from quantfin.techniques.monte_carlo import MonteCarloTechnique
|
|
7
|
+
|
|
8
|
+
__doc__ = """
|
|
9
|
+
Provides a utility function to select the most efficient pricing technique
|
|
10
|
+
for a given financial model based on its supported features.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def select_fastest_technique(model: BaseModel):
|
|
15
|
+
"""
|
|
16
|
+
Selects the fastest available pricing technique for a given model.
|
|
17
|
+
|
|
18
|
+
The function checks the capabilities of the model in a specific order of
|
|
19
|
+
preference, which generally corresponds to computational speed.
|
|
20
|
+
|
|
21
|
+
The order of preference is:
|
|
22
|
+
1. Closed-Form
|
|
23
|
+
2. Fast Fourier Transform (FFT)
|
|
24
|
+
3. Monte Carlo
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
model : BaseModel
|
|
29
|
+
The financial model for which to select a technique.
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
BaseTechnique
|
|
34
|
+
An instance of the fastest suitable pricing technique.
|
|
35
|
+
|
|
36
|
+
Raises
|
|
37
|
+
------
|
|
38
|
+
TypeError
|
|
39
|
+
If no suitable pricing technique can be found for the model.
|
|
40
|
+
"""
|
|
41
|
+
if model.has_closed_form:
|
|
42
|
+
return ClosedFormTechnique()
|
|
43
|
+
if model.supports_cf:
|
|
44
|
+
return FFTTechnique(n=12)
|
|
45
|
+
if model.supports_sde:
|
|
46
|
+
return MonteCarloTechnique(n_paths=5000, n_steps=50, antithetic=True)
|
|
47
|
+
raise TypeError(f"No suitable pricing technique found for model '{model.name}'")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from scipy.stats import norm
|
|
6
|
+
|
|
7
|
+
from quantfin.atoms import Rate, Stock
|
|
8
|
+
|
|
9
|
+
__doc__ = """
|
|
10
|
+
Defines a high-performance, vectorized solver for Black-Scholes-Merton
|
|
11
|
+
implied volatility.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BSMIVSolver:
|
|
16
|
+
"""
|
|
17
|
+
High-performance, vectorized Newton-Raphson solver for BSM implied volatility.
|
|
18
|
+
|
|
19
|
+
This solver is designed to calculate the implied volatility for a large
|
|
20
|
+
number of options simultaneously, leveraging NumPy for vectorized operations.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
max_iter: int = 20,
|
|
26
|
+
tolerance: float = 1e-6,
|
|
27
|
+
):
|
|
28
|
+
"""
|
|
29
|
+
Initializes the BSM implied volatility solver.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
max_iter : int, optional
|
|
34
|
+
The maximum number of iterations for the Newton-Raphson method,
|
|
35
|
+
by default 20.
|
|
36
|
+
tolerance : float, optional
|
|
37
|
+
The error tolerance to determine convergence, by default 1e-6.
|
|
38
|
+
"""
|
|
39
|
+
self.max_iter = max_iter
|
|
40
|
+
self.tolerance = tolerance
|
|
41
|
+
|
|
42
|
+
def solve(
|
|
43
|
+
self,
|
|
44
|
+
target_prices: np.ndarray,
|
|
45
|
+
options: pd.DataFrame,
|
|
46
|
+
stock: Stock,
|
|
47
|
+
rate: Rate,
|
|
48
|
+
) -> np.ndarray:
|
|
49
|
+
"""
|
|
50
|
+
Calculates implied volatility for an array of options.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
target_prices : np.ndarray
|
|
55
|
+
An array of market prices for which to find the implied volatility.
|
|
56
|
+
options : pd.DataFrame
|
|
57
|
+
A DataFrame of option contracts, must include 'strike', 'maturity',
|
|
58
|
+
and 'optionType' columns.
|
|
59
|
+
stock : Stock
|
|
60
|
+
The underlying asset's properties.
|
|
61
|
+
rate : Rate
|
|
62
|
+
The risk-free rate structure.
|
|
63
|
+
|
|
64
|
+
Returns
|
|
65
|
+
-------
|
|
66
|
+
np.ndarray
|
|
67
|
+
An array of calculated implied volatilities corresponding to the
|
|
68
|
+
target prices.
|
|
69
|
+
"""
|
|
70
|
+
S, q = stock.spot, stock.dividend
|
|
71
|
+
K, T = options["strike"].values, options["maturity"].values
|
|
72
|
+
r = rate.get_rate(T) # Use get_rate for term structure
|
|
73
|
+
is_call = options["optionType"].values == "call"
|
|
74
|
+
iv = np.full_like(target_prices, 0.20)
|
|
75
|
+
|
|
76
|
+
for _ in range(self.max_iter):
|
|
77
|
+
with np.errstate(all="ignore"):
|
|
78
|
+
sqrt_T = np.sqrt(T)
|
|
79
|
+
d1 = (np.log(S / K) + (r - q + 0.5 * iv**2) * T) / (iv * sqrt_T)
|
|
80
|
+
d2 = d1 - iv * sqrt_T
|
|
81
|
+
vega = S * np.exp(-q * T) * sqrt_T * norm.pdf(d1)
|
|
82
|
+
call_prices = S * np.exp(-q * T) * norm.cdf(d1) - K * np.exp(
|
|
83
|
+
-r * T
|
|
84
|
+
) * norm.cdf(d2)
|
|
85
|
+
put_prices = K * np.exp(-r * T) * norm.cdf(-d2) - S * np.exp(
|
|
86
|
+
-q * T
|
|
87
|
+
) * norm.cdf(-d1)
|
|
88
|
+
model_prices = np.where(is_call, call_prices, put_prices)
|
|
89
|
+
error = model_prices - target_prices
|
|
90
|
+
if np.all(np.abs(error) < self.tolerance):
|
|
91
|
+
break
|
|
92
|
+
iv = iv - error / np.maximum(vega, 1e-8)
|
|
93
|
+
return iv
|