voly 0.0.166__tar.gz → 0.0.167__tar.gz
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.
- {voly-0.0.166/src/voly.egg-info → voly-0.0.167}/PKG-INFO +1 -1
- {voly-0.0.166 → voly-0.0.167}/pyproject.toml +2 -2
- {voly-0.0.166 → voly-0.0.167}/src/voly/client.py +6 -11
- {voly-0.0.166 → voly-0.0.167}/src/voly/core/charts.py +1 -1
- {voly-0.0.166 → voly-0.0.167}/src/voly/core/data.py +1 -1
- voly-0.0.167/src/voly/core/fit.py +427 -0
- voly-0.0.167/src/voly/models.py +217 -0
- {voly-0.0.166 → voly-0.0.167/src/voly.egg-info}/PKG-INFO +1 -1
- voly-0.0.166/src/voly/core/fit.py +0 -241
- voly-0.0.166/src/voly/models.py +0 -81
- {voly-0.0.166 → voly-0.0.167}/LICENSE +0 -0
- {voly-0.0.166 → voly-0.0.167}/README.md +0 -0
- {voly-0.0.166 → voly-0.0.167}/setup.cfg +0 -0
- {voly-0.0.166 → voly-0.0.167}/setup.py +0 -0
- {voly-0.0.166 → voly-0.0.167}/src/voly/__init__.py +0 -0
- {voly-0.0.166 → voly-0.0.167}/src/voly/core/__init__.py +0 -0
- {voly-0.0.166 → voly-0.0.167}/src/voly/core/hd.py +0 -0
- {voly-0.0.166 → voly-0.0.167}/src/voly/core/interpolate.py +0 -0
- {voly-0.0.166 → voly-0.0.167}/src/voly/core/rnd.py +0 -0
- {voly-0.0.166 → voly-0.0.167}/src/voly/exceptions.py +0 -0
- {voly-0.0.166 → voly-0.0.167}/src/voly/formulas.py +0 -0
- {voly-0.0.166 → voly-0.0.167}/src/voly/utils/__init__.py +0 -0
- {voly-0.0.166 → voly-0.0.167}/src/voly/utils/density.py +0 -0
- {voly-0.0.166 → voly-0.0.167}/src/voly/utils/logger.py +0 -0
- {voly-0.0.166 → voly-0.0.167}/src/voly.egg-info/SOURCES.txt +0 -0
- {voly-0.0.166 → voly-0.0.167}/src/voly.egg-info/dependency_links.txt +0 -0
- {voly-0.0.166 → voly-0.0.167}/src/voly.egg-info/requires.txt +0 -0
- {voly-0.0.166 → voly-0.0.167}/src/voly.egg-info/top_level.txt +0 -0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
4
4
|
|
5
5
|
[project]
|
6
6
|
name = "voly"
|
7
|
-
version = "0.0.
|
7
|
+
version = "0.0.167"
|
8
8
|
description = "Options & volatility research package"
|
9
9
|
readme = "README.md"
|
10
10
|
authors = [
|
@@ -60,7 +60,7 @@ line_length = 100
|
|
60
60
|
multi_line_output = 3
|
61
61
|
|
62
62
|
[tool.mypy]
|
63
|
-
python_version = "0.0.
|
63
|
+
python_version = "0.0.167"
|
64
64
|
warn_return_any = true
|
65
65
|
warn_unused_configs = true
|
66
66
|
disallow_untyped_defs = true
|
@@ -165,29 +165,24 @@ class VolyClient:
|
|
165
165
|
|
166
166
|
@staticmethod
|
167
167
|
def fit_model(option_chain: pd.DataFrame,
|
168
|
-
|
169
|
-
|
170
|
-
param_bounds: Optional[Tuple] = None) -> Dict[str, Any]:
|
168
|
+
domain_params: Tuple[float, float, int] = (-2, 2, 500)
|
169
|
+
) -> Dict[str, Any]:
|
171
170
|
"""
|
172
171
|
Fit a volatility model to market data.
|
173
172
|
|
174
173
|
Parameters:
|
175
174
|
- option_chain: DataFrame with option market data
|
176
|
-
-
|
177
|
-
- initial_params: Optional initial parameters for optimization
|
178
|
-
- param_bounds: Optional parameter bounds for optimization
|
175
|
+
- domain_params: Tuple of (min, max, num_points) for the moneyness grid
|
179
176
|
|
180
177
|
Returns:
|
181
|
-
-
|
178
|
+
- Dataframe with fit_results
|
182
179
|
"""
|
183
|
-
logger.info(f"Fitting
|
180
|
+
logger.info(f"Fitting model to market data.")
|
184
181
|
|
185
182
|
# Fit the model
|
186
183
|
fit_results = fit_model(
|
187
184
|
option_chain=option_chain,
|
188
|
-
|
189
|
-
initial_params=initial_params,
|
190
|
-
param_bounds=param_bounds
|
185
|
+
domain_params=domain_params
|
191
186
|
)
|
192
187
|
|
193
188
|
return fit_results
|
@@ -139,7 +139,7 @@ def plot_raw_parameters(fit_results: pd.DataFrame) -> go.Figure:
|
|
139
139
|
- Plotly figure
|
140
140
|
"""
|
141
141
|
# Select parameters to plot
|
142
|
-
param_names = ['a', 'b', '
|
142
|
+
param_names = ['a', 'b', 'm', 'rho', 'sigma']
|
143
143
|
|
144
144
|
# Create subplots
|
145
145
|
fig = make_subplots(
|
@@ -259,7 +259,7 @@ def process_order_book_depth(option_chain, max_depth=5):
|
|
259
259
|
s = row['underlying_price']
|
260
260
|
k = row['strikes']
|
261
261
|
t = row['t']
|
262
|
-
r = row['
|
262
|
+
r = row['r'] if 'r' in row else 0.0
|
263
263
|
option_type = 'C' if row['option_type'] == 'call' else 'P'
|
264
264
|
|
265
265
|
# Process bid side
|
@@ -0,0 +1,427 @@
|
|
1
|
+
"""
|
2
|
+
Model fitting and calibration module for the Voly package.
|
3
|
+
|
4
|
+
This module handles fitting volatility models to market data and
|
5
|
+
calculating fitting statistics.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import numpy as np
|
9
|
+
import pandas as pd
|
10
|
+
from typing import List, Tuple, Dict, Optional, Union, Any
|
11
|
+
from scipy.optimize import least_squares
|
12
|
+
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
|
13
|
+
from voly.utils.logger import logger, catch_exception
|
14
|
+
from voly.formulas import get_domain
|
15
|
+
from voly.exceptions import VolyError
|
16
|
+
from voly.models import SVIModel
|
17
|
+
import warnings
|
18
|
+
|
19
|
+
warnings.filterwarnings("ignore")
|
20
|
+
|
21
|
+
|
22
|
+
@catch_exception
|
23
|
+
def fit_model(option_chain: pd.DataFrame,
|
24
|
+
domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000)) -> pd.DataFrame:
|
25
|
+
"""
|
26
|
+
Fit Voly's volatility model to market data.
|
27
|
+
|
28
|
+
Parameters:
|
29
|
+
- option_chain: DataFrame with market data
|
30
|
+
- domain_params : Tuple[float, float, int]
|
31
|
+
(min_log_moneyness, max_log_moneyness, num_points)
|
32
|
+
|
33
|
+
Returns:
|
34
|
+
- DataFrame with all fit results and performance metrics as columns, maturity_names as index
|
35
|
+
"""
|
36
|
+
|
37
|
+
# Define column names and their data types
|
38
|
+
column_dtypes = {
|
39
|
+
's': float,
|
40
|
+
't': float,
|
41
|
+
'r': float,
|
42
|
+
'maturity_date': 'datetime64[ns]',
|
43
|
+
'a': float,
|
44
|
+
'b': float,
|
45
|
+
'm': float,
|
46
|
+
'rho': float,
|
47
|
+
'sigma': float,
|
48
|
+
'nu': float,
|
49
|
+
'psi': float,
|
50
|
+
'p': float,
|
51
|
+
'c': float,
|
52
|
+
'nu_tilde': float,
|
53
|
+
'log_min_strike': float,
|
54
|
+
'usd_min_strike': float,
|
55
|
+
'fit_success': bool,
|
56
|
+
'butterfly_arbitrage_free': bool,
|
57
|
+
'calendar_arbitrage_free': bool,
|
58
|
+
'loss': float,
|
59
|
+
'rmse': float,
|
60
|
+
'mae': float,
|
61
|
+
'r2': float,
|
62
|
+
'max_error': float,
|
63
|
+
'n_points': int
|
64
|
+
}
|
65
|
+
|
66
|
+
# Get unique maturities and sort them
|
67
|
+
unique_ts = sorted(option_chain['t'].unique())
|
68
|
+
maturity_names = [option_chain[option_chain['t'] == t]['maturity_name'].iloc[0] for t in unique_ts]
|
69
|
+
|
70
|
+
# Store results in a dictionary first
|
71
|
+
results_data = {col: [] for col in column_dtypes.keys()}
|
72
|
+
|
73
|
+
# ANSI color codes for terminal output
|
74
|
+
GREEN, RED, RESET = '\033[32m', '\033[31m', '\033[0m'
|
75
|
+
|
76
|
+
s = option_chain['index_price'].iloc[-1]
|
77
|
+
|
78
|
+
# Dictionary to track fit results by maturity for arbitrage checks
|
79
|
+
fit_params_dict = {}
|
80
|
+
|
81
|
+
# First pass: Fit each maturity
|
82
|
+
for t in unique_ts:
|
83
|
+
# Get data for this maturity
|
84
|
+
maturity_data = option_chain[option_chain['t'] == t]
|
85
|
+
maturity_name = maturity_data['maturity_name'].iloc[0]
|
86
|
+
maturity_date = maturity_data['maturity_date'].iloc[0]
|
87
|
+
|
88
|
+
logger.info(f"Optimizing for {maturity_name}...")
|
89
|
+
|
90
|
+
# Extract data for fitting
|
91
|
+
k = maturity_data['log_moneyness'].values
|
92
|
+
iv = maturity_data['mark_iv'].values
|
93
|
+
vega = maturity_data['vega'].values if 'vega' in maturity_data else np.ones_like(iv)
|
94
|
+
|
95
|
+
# Apply mask to filter out invalid data
|
96
|
+
mask = ~np.isnan(iv) & ~np.isnan(k) & (iv > 0)
|
97
|
+
k_masked, iv_masked, vega_masked = k[mask], iv[mask], vega[mask]
|
98
|
+
|
99
|
+
# Check if we have enough valid points
|
100
|
+
if len(k_masked) <= 5:
|
101
|
+
logger.warning(f"Not enough valid data points for {maturity_name}, skipping.")
|
102
|
+
params = [np.nan] * 5
|
103
|
+
loss = np.inf
|
104
|
+
else:
|
105
|
+
# Calculate total implied variance (w = iv² * t)
|
106
|
+
w = (iv_masked ** 2) * t
|
107
|
+
|
108
|
+
# Fit using the improved SVI method
|
109
|
+
params, loss = SVIModel.fit(tiv=w, vega=vega_masked, k=k_masked, tau=t)
|
110
|
+
|
111
|
+
# Store the parameters for this maturity
|
112
|
+
fit_params_dict[maturity_date] = (t, params)
|
113
|
+
|
114
|
+
# Extract parameters (will be nan if fit failed)
|
115
|
+
a, b, m, rho, sigma = params
|
116
|
+
|
117
|
+
# Calculate statistics
|
118
|
+
fit_success = not np.isnan(a)
|
119
|
+
butterfly_arbitrage_free = True
|
120
|
+
calendar_arbitrage_free = True # Will check later
|
121
|
+
|
122
|
+
# Initialize default metrics
|
123
|
+
rmse = mae = r2 = max_error = np.nan
|
124
|
+
nu = psi = p = c = nu_tilde = np.nan
|
125
|
+
log_min_strike = usd_min_strike = np.nan
|
126
|
+
a_scaled = b_scaled = np.nan
|
127
|
+
|
128
|
+
if fit_success:
|
129
|
+
# Scale a and b by t
|
130
|
+
a_scaled, b_scaled = a * t, b * t
|
131
|
+
|
132
|
+
# Calculate Jump-Wing parameters
|
133
|
+
nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t)
|
134
|
+
|
135
|
+
# Calculate model predictions for statistics
|
136
|
+
w_model = np.array([SVIModel.svi(x, a_scaled, b_scaled, m, rho, sigma) for x in k_masked])
|
137
|
+
iv_model = np.sqrt(w_model / t)
|
138
|
+
iv_market = iv_masked
|
139
|
+
|
140
|
+
# Calculate statistics
|
141
|
+
rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
|
142
|
+
mae = mean_absolute_error(iv_market, iv_model)
|
143
|
+
r2 = r2_score(iv_market, iv_model)
|
144
|
+
max_error = np.max(np.abs(iv_market - iv_model))
|
145
|
+
|
146
|
+
# Calculate minimum strike
|
147
|
+
log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
|
148
|
+
usd_min_strike = s * np.exp(-log_min_strike) # Convert from log_moneyness to strike
|
149
|
+
|
150
|
+
# Check butterfly arbitrage
|
151
|
+
k_range = np.linspace(min(k_masked), max(k_masked), domain_params[2])
|
152
|
+
for k_val in k_range:
|
153
|
+
wk = SVIModel.svi(k_val, a_scaled, b_scaled, m, rho, sigma)
|
154
|
+
wp = SVIModel.svi_d(k_val, a_scaled, b_scaled, m, rho, sigma)
|
155
|
+
wpp = SVIModel.svi_dd(k_val, a_scaled, b_scaled, m, rho, sigma)
|
156
|
+
g = (1 - (k_val * wp) / (2 * wk)) ** 2 - (wp ** 2) / 4 * (1 / wk + 1 / 4) + wpp / 2
|
157
|
+
if g < 0:
|
158
|
+
butterfly_arbitrage_free = False
|
159
|
+
break
|
160
|
+
|
161
|
+
r = maturity_data['r'].iloc[0] if 'r' in maturity_data.columns else 0
|
162
|
+
|
163
|
+
# Store values in the results dictionary with proper types
|
164
|
+
results_data['s'].append(float(s))
|
165
|
+
results_data['t'].append(float(t))
|
166
|
+
results_data['r'].append(float(r))
|
167
|
+
results_data['maturity_date'].append(maturity_date)
|
168
|
+
results_data['a'].append(float(a_scaled) if fit_success else np.nan)
|
169
|
+
results_data['b'].append(float(b_scaled) if fit_success else np.nan)
|
170
|
+
results_data['sigma'].append(float(sigma) if fit_success else np.nan)
|
171
|
+
results_data['m'].append(float(m) if fit_success else np.nan)
|
172
|
+
results_data['rho'].append(float(rho) if fit_success else np.nan)
|
173
|
+
results_data['nu'].append(float(nu))
|
174
|
+
results_data['psi'].append(float(psi))
|
175
|
+
results_data['p'].append(float(p))
|
176
|
+
results_data['c'].append(float(c))
|
177
|
+
results_data['nu_tilde'].append(float(nu_tilde))
|
178
|
+
results_data['log_min_strike'].append(float(log_min_strike))
|
179
|
+
results_data['usd_min_strike'].append(float(usd_min_strike))
|
180
|
+
results_data['fit_success'].append(bool(fit_success))
|
181
|
+
results_data['butterfly_arbitrage_free'].append(bool(butterfly_arbitrage_free))
|
182
|
+
results_data['calendar_arbitrage_free'].append(bool(True)) # Will update in second pass
|
183
|
+
results_data['loss'].append(float(loss))
|
184
|
+
results_data['rmse'].append(float(rmse))
|
185
|
+
results_data['mae'].append(float(mae))
|
186
|
+
results_data['r2'].append(float(r2))
|
187
|
+
results_data['max_error'].append(float(max_error))
|
188
|
+
results_data['n_points'].append(int(len(k_masked)))
|
189
|
+
|
190
|
+
# Log result
|
191
|
+
status = f'{GREEN}SUCCESS{RESET}' if fit_success else f'{RED}FAILED{RESET}'
|
192
|
+
logger.info(f'Optimization for {maturity_name}: {status}')
|
193
|
+
logger.info('-------------------------------------')
|
194
|
+
|
195
|
+
# Create DataFrame with proper types
|
196
|
+
results_df = pd.DataFrame(results_data, index=maturity_names)
|
197
|
+
|
198
|
+
# Convert columns to appropriate types
|
199
|
+
for col, dtype in column_dtypes.items():
|
200
|
+
if col in results_df.columns:
|
201
|
+
try:
|
202
|
+
results_df[col] = results_df[col].astype(dtype)
|
203
|
+
except (ValueError, TypeError) as e:
|
204
|
+
logger.warning(f"Could not convert column {col} to {dtype}: {e}")
|
205
|
+
|
206
|
+
# Second pass: Check and correct for calendar arbitrage
|
207
|
+
logger.info("Checking for calendar arbitrage...")
|
208
|
+
sorted_maturities = sorted(fit_params_dict.keys())
|
209
|
+
k_grid = np.linspace(domain_params[0], domain_params[1], domain_params[2]) # Grid for arbitrage checking
|
210
|
+
|
211
|
+
# Check calendar arbitrage before correction
|
212
|
+
calendar_arbitrage_free = True
|
213
|
+
for i in range(len(sorted_maturities) - 1):
|
214
|
+
mat1, mat2 = sorted_maturities[i], sorted_maturities[i + 1]
|
215
|
+
t1, params1 = fit_params_dict[mat1]
|
216
|
+
t2, params2 = fit_params_dict[mat2]
|
217
|
+
a1, b1, sigma1, rho1, m1 = params1
|
218
|
+
a2, b2, sigma2, rho2, m2 = params2
|
219
|
+
|
220
|
+
if np.isnan(a1) or np.isnan(a2):
|
221
|
+
continue
|
222
|
+
|
223
|
+
# Check arbitrage on a grid of points
|
224
|
+
for k_val in k_grid:
|
225
|
+
w1 = SVIModel.svi(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
|
226
|
+
w2 = SVIModel.svi(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
|
227
|
+
if w2 < w1 - 1e-6:
|
228
|
+
calendar_arbitrage_free = False
|
229
|
+
logger.warning(
|
230
|
+
f"Calendar arbitrage detected between {sorted_maturities[i]} and {sorted_maturities[i + 1]}")
|
231
|
+
break
|
232
|
+
|
233
|
+
# Update results with calendar arbitrage status
|
234
|
+
for i, maturity_name in enumerate(maturity_names):
|
235
|
+
idx = results_df.index[i]
|
236
|
+
results_df.at[idx, 'calendar_arbitrage_free'] = calendar_arbitrage_free
|
237
|
+
|
238
|
+
# Correct calendar arbitrage if needed
|
239
|
+
if not calendar_arbitrage_free:
|
240
|
+
logger.info("Correcting calendar arbitrage...")
|
241
|
+
for i in range(1, len(sorted_maturities)):
|
242
|
+
mat1 = sorted_maturities[i - 1]
|
243
|
+
mat2 = sorted_maturities[i]
|
244
|
+
t1, params1 = fit_params_dict[mat1]
|
245
|
+
t2, params2 = fit_params_dict[mat2]
|
246
|
+
|
247
|
+
if np.isnan(params1[0]) or np.isnan(params2[0]):
|
248
|
+
continue
|
249
|
+
|
250
|
+
# Find the index in maturity_names that corresponds to mat2
|
251
|
+
maturity_idx = None
|
252
|
+
for j, maturity_name in enumerate(maturity_names):
|
253
|
+
if results_df.iloc[j]['maturity_date'] == mat2:
|
254
|
+
maturity_idx = j
|
255
|
+
break
|
256
|
+
|
257
|
+
if maturity_idx is None:
|
258
|
+
continue
|
259
|
+
|
260
|
+
# Get data for correction
|
261
|
+
idx = results_df.index[maturity_idx]
|
262
|
+
maturity_data = option_chain[option_chain['maturity_name'] == idx]
|
263
|
+
k = maturity_data['log_moneyness'].values
|
264
|
+
iv = maturity_data['mark_iv'].values
|
265
|
+
vega = maturity_data['vega'].values if 'vega' in maturity_data else np.ones_like(iv)
|
266
|
+
|
267
|
+
# Apply mask to filter out invalid data
|
268
|
+
mask = ~np.isnan(iv) & ~np.isnan(k) & (iv > 0)
|
269
|
+
k_masked, iv_masked, vega_masked = k[mask], iv[mask], vega[mask]
|
270
|
+
|
271
|
+
if len(k_masked) <= 5:
|
272
|
+
continue
|
273
|
+
|
274
|
+
# Calculate total implied variance
|
275
|
+
w = (iv_masked ** 2) * t2
|
276
|
+
|
277
|
+
# Apply calendar arbitrage correction
|
278
|
+
new_params = SVIModel.correct_calendar_arbitrage(
|
279
|
+
params=params2, t=t2, tiv=w, vega=vega_masked, k=k_masked,
|
280
|
+
prev_params=params1, prev_t=t1, k_grid=k_grid
|
281
|
+
)
|
282
|
+
|
283
|
+
# Update the parameters dictionary
|
284
|
+
fit_params_dict[mat2] = (t2, new_params)
|
285
|
+
|
286
|
+
# Extract corrected parameters
|
287
|
+
a, b, m, rho, sigma = new_params
|
288
|
+
|
289
|
+
# Calculate scaled parameters and Jump-Wing parameters
|
290
|
+
a_scaled, b_scaled = a * t2, b * t2
|
291
|
+
nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t2)
|
292
|
+
|
293
|
+
# Calculate model predictions for statistics
|
294
|
+
w_model = np.array([SVIModel.svi(x, a_scaled, b_scaled, m, rho, sigma) for x in k_masked])
|
295
|
+
iv_model = np.sqrt(w_model / t2)
|
296
|
+
iv_market = iv_masked
|
297
|
+
|
298
|
+
# Calculate statistics
|
299
|
+
rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
|
300
|
+
mae = mean_absolute_error(iv_market, iv_model)
|
301
|
+
r2 = r2_score(iv_market, iv_model)
|
302
|
+
max_error = np.max(np.abs(iv_market - iv_model))
|
303
|
+
|
304
|
+
# Calculate minimum strike
|
305
|
+
log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
|
306
|
+
usd_min_strike = s * np.exp(-log_min_strike)
|
307
|
+
|
308
|
+
# Check butterfly arbitrage
|
309
|
+
butterfly_arbitrage_free = True
|
310
|
+
k_range = np.linspace(min(k_masked), max(k_masked), domain_params[2])
|
311
|
+
for k_val in k_range:
|
312
|
+
wk = SVIModel.svi(k_val, a_scaled, b_scaled, m, rho, sigma)
|
313
|
+
wp = SVIModel.svi_d(k_val, a_scaled, b_scaled, m, rho, sigma)
|
314
|
+
wpp = SVIModel.svi_dd(k_val, a_scaled, b_scaled, m, rho, sigma)
|
315
|
+
g = (1 - (k_val * wp) / (2 * wk)) ** 2 - (wp ** 2) / 4 * (1 / wk + 1 / 4) + wpp / 2
|
316
|
+
if g < 0:
|
317
|
+
butterfly_arbitrage_free = False
|
318
|
+
break
|
319
|
+
|
320
|
+
# Update results in the DataFrame
|
321
|
+
results_df.at[idx, 'a'] = float(a_scaled)
|
322
|
+
results_df.at[idx, 'b'] = float(b_scaled)
|
323
|
+
results_df.at[idx, 'm'] = float(m)
|
324
|
+
results_df.at[idx, 'rho'] = float(rho)
|
325
|
+
results_df.at[idx, 'sigma'] = float(sigma)
|
326
|
+
results_df.at[idx, 'nu'] = float(nu)
|
327
|
+
results_df.at[idx, 'psi'] = float(psi)
|
328
|
+
results_df.at[idx, 'p'] = float(p)
|
329
|
+
results_df.at[idx, 'c'] = float(c)
|
330
|
+
results_df.at[idx, 'nu_tilde'] = float(nu_tilde)
|
331
|
+
results_df.at[idx, 'log_min_strike'] = float(log_min_strike)
|
332
|
+
results_df.at[idx, 'usd_min_strike'] = float(usd_min_strike)
|
333
|
+
results_df.at[idx, 'butterfly_arbitrage_free'] = bool(butterfly_arbitrage_free)
|
334
|
+
results_df.at[idx, 'rmse'] = float(rmse)
|
335
|
+
results_df.at[idx, 'mae'] = float(mae)
|
336
|
+
results_df.at[idx, 'r2'] = float(r2)
|
337
|
+
results_df.at[idx, 'max_error'] = float(max_error)
|
338
|
+
|
339
|
+
logger.info(
|
340
|
+
f"SVI parameters for {idx}: a={a_scaled:.4f}, b={b_scaled:.4f}, sigma={sigma:.4f}, rho={rho:.4f}, m={m:.4f}")
|
341
|
+
|
342
|
+
# Check calendar arbitrage after correction
|
343
|
+
calendar_arbitrage_free = True
|
344
|
+
for i in range(len(sorted_maturities) - 1):
|
345
|
+
mat1, mat2 = sorted_maturities[i], sorted_maturities[i + 1]
|
346
|
+
t1, params1 = fit_params_dict[mat1]
|
347
|
+
t2, params2 = fit_params_dict[mat2]
|
348
|
+
a1, b1, sigma1, rho1, m1 = params1
|
349
|
+
a2, b2, sigma2, rho2, m2 = params2
|
350
|
+
|
351
|
+
if np.isnan(a1) or np.isnan(a2):
|
352
|
+
continue
|
353
|
+
|
354
|
+
# Check arbitrage on a grid of points
|
355
|
+
for k_val in k_grid:
|
356
|
+
w1 = SVIModel.svi(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
|
357
|
+
w2 = SVIModel.svi(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
|
358
|
+
if w2 < w1 - 1e-6:
|
359
|
+
calendar_arbitrage_free = False
|
360
|
+
logger.warning(f"Calendar arbitrage still detected between {mat1} and {mat2} after correction")
|
361
|
+
break
|
362
|
+
|
363
|
+
# Update results with final calendar arbitrage status
|
364
|
+
for i, maturity_name in enumerate(maturity_names):
|
365
|
+
maturity_date = results_df.iloc[i]['maturity_date']
|
366
|
+
idx = results_df.index[i]
|
367
|
+
results_df.at[idx, 'calendar_arbitrage_free'] = calendar_arbitrage_free
|
368
|
+
|
369
|
+
logger.info("Model fitting complete.")
|
370
|
+
return results_df
|
371
|
+
|
372
|
+
|
373
|
+
@catch_exception
|
374
|
+
def get_iv_surface(model_results: pd.DataFrame,
|
375
|
+
domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000),
|
376
|
+
return_domain: str = 'log_moneyness') -> Tuple[Dict[str, np.ndarray], Dict[str, np.ndarray]]:
|
377
|
+
"""
|
378
|
+
Generate implied volatility surface using optimized SVI parameters.
|
379
|
+
|
380
|
+
Works with both regular fit_results and interpolated_results dataframes.
|
381
|
+
|
382
|
+
Parameters:
|
383
|
+
- model_results: DataFrame from fit_model() or interpolate_model(). Maturity names or DTM as Index
|
384
|
+
- domain_params: Tuple of (min, max, num_points) for the log-moneyness array
|
385
|
+
- return_domain: Domain for x-axis values ('log_moneyness', 'moneyness', 'returns', 'strikes', 'delta')
|
386
|
+
|
387
|
+
Returns:
|
388
|
+
- Tuple of (iv_surface, x_surface)
|
389
|
+
iv_surface: Dictionary mapping maturity/dtm names to IV arrays
|
390
|
+
x_surface: Dictionary mapping maturity/dtm names to requested x domain arrays
|
391
|
+
"""
|
392
|
+
# Check if required columns are present
|
393
|
+
required_columns = ['a', 'b', 'm', 'rho', 'sigma', 't']
|
394
|
+
missing_columns = [col for col in required_columns if col not in model_results.columns]
|
395
|
+
if missing_columns:
|
396
|
+
raise VolyError(f"Required columns missing in model_results: {missing_columns}")
|
397
|
+
|
398
|
+
# Generate implied volatility surface in log-moneyness domain
|
399
|
+
LM = np.linspace(domain_params[0], domain_params[1], domain_params[2])
|
400
|
+
|
401
|
+
iv_surface = {}
|
402
|
+
x_surface = {}
|
403
|
+
|
404
|
+
# Process each maturity/dtm
|
405
|
+
for i in model_results.index:
|
406
|
+
# Calculate SVI total implied variance and convert to IV
|
407
|
+
params = [
|
408
|
+
model_results.loc[i, 'a'],
|
409
|
+
model_results.loc[i, 'b'],
|
410
|
+
model_results.loc[i, 'm'],
|
411
|
+
model_results.loc[i, 'rho'],
|
412
|
+
model_results.loc[i, 'sigma']
|
413
|
+
]
|
414
|
+
s = model_results.loc[i, 's']
|
415
|
+
r = model_results.loc[i, 'r']
|
416
|
+
t = model_results.loc[i, 't']
|
417
|
+
|
418
|
+
# Calculate implied volatility
|
419
|
+
w = np.array([SVIModel.svi(x, *params) for x in LM])
|
420
|
+
o = np.sqrt(w / t)
|
421
|
+
iv_surface[i] = o
|
422
|
+
|
423
|
+
# Calculate x domain for this maturity/dtm
|
424
|
+
x = get_domain(domain_params, s, r, o, t, return_domain)
|
425
|
+
x_surface[i] = x
|
426
|
+
|
427
|
+
return iv_surface, x_surface
|
@@ -0,0 +1,217 @@
|
|
1
|
+
"""
|
2
|
+
Volatility models for the Voly package.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import numpy as np
|
6
|
+
from numpy.linalg import solve
|
7
|
+
from scipy.optimize import minimize
|
8
|
+
from typing import Tuple, Dict, List, Optional, Union
|
9
|
+
|
10
|
+
|
11
|
+
class SVIModel:
|
12
|
+
"""
|
13
|
+
Stochastic Volatility Inspired (SVI) model.
|
14
|
+
|
15
|
+
This class provides methods for calculating implied volatility using the
|
16
|
+
SVI parameterization, as well as its derivatives and related functions.
|
17
|
+
"""
|
18
|
+
|
19
|
+
# Parameter names for reference
|
20
|
+
PARAM_NAMES = ['a', 'b', 'm', 'rho', 'sigma']
|
21
|
+
JW_PARAM_NAMES = ['nu', 'psi', 'p', 'c', 'nu_tilde']
|
22
|
+
|
23
|
+
# Parameter descriptions for documentation
|
24
|
+
PARAM_DESCRIPTIONS = {
|
25
|
+
'a': 'Base level of total implied variance',
|
26
|
+
'b': 'Volatility skewness/smile modulation (controls wing slopes)',
|
27
|
+
'sigma': 'Convexity control of the volatility smile (reduces ATM curvature)',
|
28
|
+
'rho': 'Skewness/slope of the volatility smile (-1 to 1, rotates smile)',
|
29
|
+
'm': 'Horizontal shift of the smile peak',
|
30
|
+
'nu': 'ATM variance (level of ATM volatility)',
|
31
|
+
'psi': 'ATM volatility skew (affects the gradient of the curve at ATM point)',
|
32
|
+
'p': 'Slope of put wing (left side of curve)',
|
33
|
+
'c': 'Slope of call wing (right side of curve)',
|
34
|
+
'nu_tilde': 'Minimum implied total variance',
|
35
|
+
}
|
36
|
+
|
37
|
+
@staticmethod
|
38
|
+
def svi_raw(k, a, b, m, rho, sigma):
|
39
|
+
assert b >= 0, 'b must be non-negative'
|
40
|
+
assert abs(rho) <= 1, '|rho| must be <= 1'
|
41
|
+
assert sigma >= 0, 'sigma must be non-negative'
|
42
|
+
assert a + b * sigma * sqrt(1 - rho ** 2) >= 0, 'a + b*sigma*sqrt(1-rho^2) must be non-negative'
|
43
|
+
return a + b * (rho * (k - m) + sqrt((k - m) ** 2 + sigma ** 2))
|
44
|
+
|
45
|
+
@staticmethod
|
46
|
+
def svi(LM: float, a: float, b: float, sigma: float, rho: float, m: float) -> float:
|
47
|
+
return a + b * (rho * (LM - m) + np.sqrt((LM - m) ** 2 + sigma ** 2))
|
48
|
+
|
49
|
+
@staticmethod
|
50
|
+
def raw_to_jw_params(a: float, b: float, m: float, rho: float, sigma: float, t: float) -> Tuple[
|
51
|
+
float, float, float, float, float]:
|
52
|
+
nu = (a + b * ((-rho) * m + np.sqrt(m ** 2 + sigma ** 2))) / t
|
53
|
+
psi = (1 / np.sqrt(nu * t)) * (b / 2) * (rho - (m / np.sqrt(m ** 2 + sigma ** 2)))
|
54
|
+
p = (1 / np.sqrt(nu * t)) * b * (1 - rho)
|
55
|
+
c = (1 / np.sqrt(nu * t)) * b * (1 + rho)
|
56
|
+
nu_tilde = (1 / t) * (a + b * sigma * np.sqrt(1 - rho ** 2))
|
57
|
+
return nu, psi, p, c, nu_tilde
|
58
|
+
|
59
|
+
@classmethod
|
60
|
+
def calibration(cls, tiv, vega, k, m, sigma):
|
61
|
+
"""
|
62
|
+
Calibrate SVI parameters using a more stable approach.
|
63
|
+
|
64
|
+
Parameters:
|
65
|
+
- tiv: Total implied variance values
|
66
|
+
- vega: Option vega values (for weighting)
|
67
|
+
- k: Log-moneyness values
|
68
|
+
- m: Horizontal shift parameter
|
69
|
+
- sigma: Convexity parameter
|
70
|
+
|
71
|
+
Returns:
|
72
|
+
- c, d, a: Calibrated parameters
|
73
|
+
- loss: Calibration loss value
|
74
|
+
"""
|
75
|
+
sigma = max(sigma, 0.001)
|
76
|
+
vega = vega / vega.max() if vega.max() > 0 else np.ones_like(vega)
|
77
|
+
y = (k - m) / sigma
|
78
|
+
w = vega.mean()
|
79
|
+
y1 = (vega * y).mean()
|
80
|
+
y2 = (vega * y * y).mean()
|
81
|
+
y3 = (vega * np.sqrt(y * y + 1)).mean()
|
82
|
+
y4 = (vega * y * np.sqrt(y * y + 1)).mean()
|
83
|
+
y5 = (vega * (y * y + 1)).mean()
|
84
|
+
vy2 = (vega * tiv * np.sqrt(y * y + 1)).mean()
|
85
|
+
vy = (vega * tiv * y).mean()
|
86
|
+
v = (vega * tiv).mean()
|
87
|
+
|
88
|
+
matrix = [[y5, y4, y3], [y4, y2, y1], [y3, y1, w]]
|
89
|
+
vector = [vy2, vy, v]
|
90
|
+
c, d, a = solve(np.array(matrix), np.array(vector))
|
91
|
+
|
92
|
+
c = np.clip(c, 0, 4 * sigma)
|
93
|
+
a = max(a, 1e-6)
|
94
|
+
d = np.clip(d, -min(c, 4 * sigma - c), min(c, 4 * sigma - c))
|
95
|
+
|
96
|
+
loss = cls.loss(tiv, vega, y, c, d, a)
|
97
|
+
return c, d, a, loss
|
98
|
+
|
99
|
+
@staticmethod
|
100
|
+
def loss(tiv, vega, y, c, d, a):
|
101
|
+
"""Calculate weighted loss for SVI calibration."""
|
102
|
+
diff = tiv - (a + d * y + c * np.sqrt(y * y + 1))
|
103
|
+
return (vega * diff * diff).mean()
|
104
|
+
|
105
|
+
@classmethod
|
106
|
+
def fit(cls, tiv, vega, k, tau=1.0):
|
107
|
+
"""
|
108
|
+
Fit SVI model to market data using a more stable two-step approach.
|
109
|
+
|
110
|
+
Parameters:
|
111
|
+
- tiv: Total implied variance values
|
112
|
+
- vega: Option vega values (for weighting)
|
113
|
+
- k: Log-moneyness values
|
114
|
+
- tau: Time to expiry in years
|
115
|
+
|
116
|
+
Returns:
|
117
|
+
- params: [a, b, m, rho, sigma] parameters
|
118
|
+
- loss: Fitting loss value
|
119
|
+
"""
|
120
|
+
if len(k) <= 5:
|
121
|
+
return [np.nan] * 5, np.inf
|
122
|
+
|
123
|
+
vega = vega / vega.max() if vega.max() > 0 else np.ones_like(vega)
|
124
|
+
m_init = np.mean(k)
|
125
|
+
sigma_init = max(0.1, np.std(k) * 0.1)
|
126
|
+
|
127
|
+
def score(params):
|
128
|
+
sigma, m = params
|
129
|
+
c, d, a_calib, loss = cls.calibration(tiv, vega, k, m, sigma)
|
130
|
+
return loss
|
131
|
+
|
132
|
+
result = minimize(score, [sigma_init, m_init], bounds=[(0.001, None), (None, None)],
|
133
|
+
tol=1e-16, method="Nelder-Mead", options={'maxfun': 5000})
|
134
|
+
|
135
|
+
sigma, m = result.x
|
136
|
+
c, d, a_calib, loss = cls.calibration(tiv, vega, k, m, sigma)
|
137
|
+
a_calib = max(a_calib, 1e-6)
|
138
|
+
|
139
|
+
if c != 0:
|
140
|
+
a_svi = a_calib / tau
|
141
|
+
rho_svi = d / c
|
142
|
+
b_svi = c / (sigma * tau)
|
143
|
+
else:
|
144
|
+
a_svi = a_calib / tau
|
145
|
+
rho_svi = b_svi = 0
|
146
|
+
|
147
|
+
return [a_svi, b_svi, m, rho_svi, sigma], loss
|
148
|
+
|
149
|
+
@classmethod
|
150
|
+
def correct_calendar_arbitrage(cls, params, t, tiv, vega, k, prev_params, prev_t, domain_params):
|
151
|
+
"""
|
152
|
+
Correct calendar arbitrage by ensuring the current SVI surface stays above the previous one.
|
153
|
+
|
154
|
+
Parameters:
|
155
|
+
- params: Current SVI parameters [a, b, sigma, rho, m]
|
156
|
+
- t: Current time to expiry
|
157
|
+
- tiv: Current total implied variance values
|
158
|
+
- vega: Current vega values
|
159
|
+
- k: Current log-moneyness values
|
160
|
+
- prev_params: Previous SVI parameters
|
161
|
+
- prev_t: Previous time to expiry
|
162
|
+
- k_grid: Grid of log-moneyness values for arbitrage checking
|
163
|
+
|
164
|
+
Returns:
|
165
|
+
- New arbitrage-free parameters
|
166
|
+
"""
|
167
|
+
|
168
|
+
if np.any(np.isnan(params)) or np.any(np.isnan(prev_params)):
|
169
|
+
return params
|
170
|
+
|
171
|
+
a_init, b_init, sigma_init, rho_init, m_init = params
|
172
|
+
a_prev, b_prev, sigma_prev, rho_prev, m_prev = prev_params
|
173
|
+
k_constraint = np.unique(np.concatenate([k, np.linspace(min(k), max(k), domain_params[2])]))
|
174
|
+
|
175
|
+
def objective(x):
|
176
|
+
a, b, sigma, rho, m = x
|
177
|
+
w_model = cls.svi(k, a * t, b * t, sigma, rho, m)
|
178
|
+
fit_loss = ((w_model - tiv) ** 2 * vega).mean()
|
179
|
+
param_deviation = sum(((x[i] - params[i]) / max(abs(params[i]), 1e-6)) ** 2
|
180
|
+
for i in range(len(params)))
|
181
|
+
return fit_loss + 0.01 * param_deviation
|
182
|
+
|
183
|
+
def calendar_constraint(x):
|
184
|
+
a, b, sigma, rho, m = x
|
185
|
+
w_current = cls.svi(k_constraint, a * t, b * t, sigma, rho, m)
|
186
|
+
w_prev = cls.svi(k_constraint, a_prev * prev_t, b_prev * prev_t, sigma_prev, rho_prev, m_prev)
|
187
|
+
return w_current - w_prev
|
188
|
+
|
189
|
+
bounds = [
|
190
|
+
(max(a_init * 0.8, 1e-6), a_init * 1.2),
|
191
|
+
(max(b_init * 0.8, 0), b_init * 1.2),
|
192
|
+
(max(sigma_init * 0.8, 1e-6), sigma_init * 1.2),
|
193
|
+
(max(rho_init - 0.05, -1), min(rho_init + 0.05, 1)),
|
194
|
+
(m_init - 0.05, m_init + 0.05)
|
195
|
+
]
|
196
|
+
|
197
|
+
constraints = [
|
198
|
+
{'type': 'ineq', 'fun': calendar_constraint},
|
199
|
+
{'type': 'ineq', 'fun': lambda x: x[0] + x[1] * x[2] * np.sqrt(1 - x[3] ** 2)}
|
200
|
+
]
|
201
|
+
|
202
|
+
result = minimize(
|
203
|
+
objective, [a_init, b_init, sigma_init, rho_init, m_init],
|
204
|
+
bounds=bounds, constraints=constraints, method='SLSQP',
|
205
|
+
options={'disp': False, 'maxiter': 1000, 'ftol': 1e-8}
|
206
|
+
)
|
207
|
+
|
208
|
+
if result.success:
|
209
|
+
return result.x
|
210
|
+
|
211
|
+
return params
|
212
|
+
|
213
|
+
|
214
|
+
# Models dictionary for easy access
|
215
|
+
MODELS = {
|
216
|
+
'svi': SVIModel,
|
217
|
+
}
|
@@ -1,241 +0,0 @@
|
|
1
|
-
"""
|
2
|
-
Model fitting and calibration module for the Voly package.
|
3
|
-
|
4
|
-
This module handles fitting volatility models to market data and
|
5
|
-
calculating fitting statistics.
|
6
|
-
"""
|
7
|
-
|
8
|
-
import numpy as np
|
9
|
-
import pandas as pd
|
10
|
-
from typing import List, Tuple, Dict, Optional, Union, Any
|
11
|
-
from scipy.optimize import least_squares
|
12
|
-
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
|
13
|
-
from voly.utils.logger import logger, catch_exception
|
14
|
-
from voly.formulas import get_domain
|
15
|
-
from voly.exceptions import VolyError
|
16
|
-
from voly.models import SVIModel
|
17
|
-
import warnings
|
18
|
-
|
19
|
-
warnings.filterwarnings("ignore")
|
20
|
-
|
21
|
-
|
22
|
-
@catch_exception
|
23
|
-
def calculate_residuals(params: List[float], t: float, option_chain: pd.DataFrame,
|
24
|
-
model: Any = SVIModel) -> np.ndarray:
|
25
|
-
"""Calculate residuals between market and model implied volatilities."""
|
26
|
-
maturity_data = option_chain[option_chain['t'] == t]
|
27
|
-
w = np.array([model.svi(x, *params) for x in maturity_data['log_moneyness']])
|
28
|
-
iv_actual = maturity_data['mark_iv'].values
|
29
|
-
return iv_actual - np.sqrt(w / t)
|
30
|
-
|
31
|
-
|
32
|
-
@catch_exception
|
33
|
-
def fit_model(option_chain: pd.DataFrame,
|
34
|
-
model_name: str = 'svi',
|
35
|
-
initial_params: Optional[List[float]] = None,
|
36
|
-
param_bounds: Optional[Tuple] = None) -> pd.DataFrame:
|
37
|
-
"""
|
38
|
-
Fit a volatility model to market data.
|
39
|
-
|
40
|
-
Parameters:
|
41
|
-
- option_chain: DataFrame with market data
|
42
|
-
- model_name: Type of model to fit (default: 'svi')
|
43
|
-
- initial_params: Optional initial parameters for optimization (default: model's defaults)
|
44
|
-
- param_bounds: Optional parameter bounds for optimization (default: model's defaults)
|
45
|
-
|
46
|
-
Returns:
|
47
|
-
- DataFrame with all fit results and performance metrics as columns, maturity_names as index
|
48
|
-
"""
|
49
|
-
if model_name.lower() != 'svi':
|
50
|
-
raise VolyError(f"Model type '{model_name}' is not supported. Currently only 'svi' is available.")
|
51
|
-
|
52
|
-
# Use defaults if not provided
|
53
|
-
initial_params = initial_params or SVIModel.DEFAULT_INITIAL_PARAMS
|
54
|
-
param_bounds = param_bounds or SVIModel.DEFAULT_PARAM_BOUNDS
|
55
|
-
|
56
|
-
# Define column names and their data types
|
57
|
-
column_dtypes = {
|
58
|
-
's': float,
|
59
|
-
'u': float,
|
60
|
-
't': float,
|
61
|
-
'r': float,
|
62
|
-
'oi': float,
|
63
|
-
'volume': float,
|
64
|
-
'maturity_date': 'datetime64[ns]',
|
65
|
-
'a': float,
|
66
|
-
'b': float,
|
67
|
-
'sigma': float,
|
68
|
-
'rho': float,
|
69
|
-
'm': float,
|
70
|
-
'nu': float,
|
71
|
-
'psi': float,
|
72
|
-
'p': float,
|
73
|
-
'c': float,
|
74
|
-
'nu_tilde': float,
|
75
|
-
'fit_success': bool,
|
76
|
-
'cost': float,
|
77
|
-
'optimality': float,
|
78
|
-
'rmse': float,
|
79
|
-
'mae': float,
|
80
|
-
'r2': float,
|
81
|
-
'max_error': float,
|
82
|
-
'n_points': int
|
83
|
-
}
|
84
|
-
|
85
|
-
# Get unique maturities and sort them
|
86
|
-
unique_ts = sorted(option_chain['t'].unique())
|
87
|
-
maturity_names = [option_chain[option_chain['t'] == t]['maturity_name'].iloc[0] for t in unique_ts]
|
88
|
-
|
89
|
-
# Store results in a dictionary first
|
90
|
-
results_data = {col: [] for col in column_dtypes.keys()}
|
91
|
-
|
92
|
-
# ANSI color codes for terminal output
|
93
|
-
GREEN, RED, RESET = '\033[32m', '\033[31m', '\033[0m'
|
94
|
-
|
95
|
-
s = option_chain['index_price'].iloc[-1]
|
96
|
-
|
97
|
-
for t in unique_ts:
|
98
|
-
# Get data for this maturity
|
99
|
-
maturity_data = option_chain[option_chain['t'] == t]
|
100
|
-
maturity_name = maturity_data['maturity_name'].iloc[0]
|
101
|
-
|
102
|
-
logger.info(f"Optimizing for {maturity_name}...")
|
103
|
-
|
104
|
-
# Optimize SVI parameters
|
105
|
-
try:
|
106
|
-
result = least_squares(
|
107
|
-
calculate_residuals,
|
108
|
-
initial_params,
|
109
|
-
args=(t, option_chain, SVIModel),
|
110
|
-
bounds=param_bounds,
|
111
|
-
max_nfev=1000
|
112
|
-
)
|
113
|
-
except Exception as e:
|
114
|
-
raise VolyError(f"Optimization failed for {maturity_name}: {str(e)}")
|
115
|
-
|
116
|
-
# Extract raw parameters
|
117
|
-
a, b, sigma, rho, m = result.x
|
118
|
-
|
119
|
-
# Calculate model predictions for statistics
|
120
|
-
w = np.array([SVIModel.svi(x, *result.x) for x in maturity_data['log_moneyness']])
|
121
|
-
iv_model = np.sqrt(w / t)
|
122
|
-
iv_market = maturity_data['mark_iv'].values
|
123
|
-
|
124
|
-
# Calculate statistics
|
125
|
-
rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
|
126
|
-
mae = mean_absolute_error(iv_market, iv_model)
|
127
|
-
r2 = r2_score(iv_market, iv_model)
|
128
|
-
max_error = np.max(np.abs(iv_market - iv_model))
|
129
|
-
|
130
|
-
# Get or calculate additional required data
|
131
|
-
u = maturity_data['underlying_price'].iloc[0]
|
132
|
-
|
133
|
-
# Aggregate open interest and volume
|
134
|
-
oi = maturity_data['open_interest'].sum() if 'open_interest' in maturity_data.columns else 0
|
135
|
-
volume = maturity_data['volume'].sum() if 'volume' in maturity_data.columns else 0
|
136
|
-
r = maturity_data['interest_rate'].iloc[0] if 'interest_rate' in maturity_data.columns else 0
|
137
|
-
|
138
|
-
# Calculate Jump-Wing parameters
|
139
|
-
nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a, b, sigma, rho, m, t)
|
140
|
-
|
141
|
-
# Store values in the results dictionary with proper types
|
142
|
-
results_data['s'].append(float(s))
|
143
|
-
results_data['u'].append(float(u))
|
144
|
-
results_data['t'].append(float(t))
|
145
|
-
results_data['r'].append(float(r))
|
146
|
-
results_data['oi'].append(float(oi))
|
147
|
-
results_data['volume'].append(float(volume))
|
148
|
-
results_data['maturity_date'].append(maturity_data['maturity_date'].iloc[0])
|
149
|
-
results_data['a'].append(float(a))
|
150
|
-
results_data['b'].append(float(b))
|
151
|
-
results_data['sigma'].append(float(sigma))
|
152
|
-
results_data['m'].append(float(m))
|
153
|
-
results_data['rho'].append(float(rho))
|
154
|
-
results_data['nu'].append(float(nu))
|
155
|
-
results_data['psi'].append(float(psi))
|
156
|
-
results_data['p'].append(float(p))
|
157
|
-
results_data['c'].append(float(c))
|
158
|
-
results_data['nu_tilde'].append(float(nu_tilde))
|
159
|
-
results_data['fit_success'].append(bool(result.success))
|
160
|
-
results_data['cost'].append(float(result.cost))
|
161
|
-
results_data['optimality'].append(float(result.optimality))
|
162
|
-
results_data['rmse'].append(float(rmse))
|
163
|
-
results_data['mae'].append(float(mae))
|
164
|
-
results_data['r2'].append(float(r2))
|
165
|
-
results_data['max_error'].append(float(max_error))
|
166
|
-
results_data['n_points'].append(int(len(maturity_data)))
|
167
|
-
|
168
|
-
# Log result
|
169
|
-
status = f'{GREEN}SUCCESS{RESET}' if result.success else f'{RED}FAILED{RESET}'
|
170
|
-
logger.info(f'Optimization for {maturity_name}: {status}')
|
171
|
-
logger.info('-------------------------------------')
|
172
|
-
|
173
|
-
# Create DataFrame with proper types
|
174
|
-
results_df = pd.DataFrame(results_data, index=maturity_names)
|
175
|
-
|
176
|
-
# Convert columns to appropriate types
|
177
|
-
for col, dtype in column_dtypes.items():
|
178
|
-
if col in results_df.columns:
|
179
|
-
try:
|
180
|
-
results_df[col] = results_df[col].astype(dtype)
|
181
|
-
except (ValueError, TypeError) as e:
|
182
|
-
logger.warning(f"Could not convert column {col} to {dtype}: {e}")
|
183
|
-
|
184
|
-
return results_df
|
185
|
-
|
186
|
-
|
187
|
-
@catch_exception
|
188
|
-
def get_iv_surface(model_results: pd.DataFrame,
|
189
|
-
domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000),
|
190
|
-
return_domain: str = 'log_moneyness') -> Tuple[Dict[str, np.ndarray], Dict[str, np.ndarray]]:
|
191
|
-
"""
|
192
|
-
Generate implied volatility surface using optimized SVI parameters.
|
193
|
-
|
194
|
-
Works with both regular fit_results and interpolated_results dataframes.
|
195
|
-
|
196
|
-
Parameters:
|
197
|
-
- model_results: DataFrame from fit_model() or interpolate_model(). Maturity names or DTM as Index
|
198
|
-
- domain_params: Tuple of (min, max, num_points) for the log-moneyness array
|
199
|
-
- return_domain: Domain for x-axis values ('log_moneyness', 'moneyness', 'returns', 'strikes', 'delta')
|
200
|
-
|
201
|
-
Returns:
|
202
|
-
- Tuple of (iv_surface, x_surface)
|
203
|
-
iv_surface: Dictionary mapping maturity/dtm names to IV arrays
|
204
|
-
x_surface: Dictionary mapping maturity/dtm names to requested x domain arrays
|
205
|
-
"""
|
206
|
-
# Check if required columns are present
|
207
|
-
required_columns = ['a', 'b', 'sigma', 'rho', 'm', 't']
|
208
|
-
missing_columns = [col for col in required_columns if col not in model_results.columns]
|
209
|
-
if missing_columns:
|
210
|
-
raise VolyError(f"Required columns missing in model_results: {missing_columns}")
|
211
|
-
|
212
|
-
# Generate implied volatility surface in log-moneyness domain
|
213
|
-
LM = np.linspace(domain_params[0], domain_params[1], domain_params[2])
|
214
|
-
|
215
|
-
iv_surface = {}
|
216
|
-
x_surface = {}
|
217
|
-
|
218
|
-
# Process each maturity/dtm
|
219
|
-
for i in model_results.index:
|
220
|
-
# Calculate SVI total implied variance and convert to IV
|
221
|
-
params = [
|
222
|
-
model_results.loc[i, 'a'],
|
223
|
-
model_results.loc[i, 'b'],
|
224
|
-
model_results.loc[i, 'sigma'],
|
225
|
-
model_results.loc[i, 'rho'],
|
226
|
-
model_results.loc[i, 'm']
|
227
|
-
]
|
228
|
-
s = model_results.loc[i, 's']
|
229
|
-
r = model_results.loc[i, 'r']
|
230
|
-
t = model_results.loc[i, 't']
|
231
|
-
|
232
|
-
# Calculate implied volatility
|
233
|
-
w = np.array([SVIModel.svi(x, *params) for x in LM])
|
234
|
-
o = np.sqrt(w / t)
|
235
|
-
iv_surface[i] = o
|
236
|
-
|
237
|
-
# Calculate x domain for this maturity/dtm
|
238
|
-
x = get_domain(domain_params, s, r, o, t, return_domain)
|
239
|
-
x_surface[i] = x
|
240
|
-
|
241
|
-
return iv_surface, x_surface
|
voly-0.0.166/src/voly/models.py
DELETED
@@ -1,81 +0,0 @@
|
|
1
|
-
"""
|
2
|
-
Volatility models for the Voly package.
|
3
|
-
"""
|
4
|
-
|
5
|
-
import numpy as np
|
6
|
-
from typing import Tuple, Dict, List, Optional, Union
|
7
|
-
|
8
|
-
|
9
|
-
class SVIModel:
|
10
|
-
"""
|
11
|
-
Stochastic Volatility Inspired (SVI) model.
|
12
|
-
|
13
|
-
This class provides methods for calculating implied volatility using the
|
14
|
-
SVI parameterization, as well as its derivatives and related functions.
|
15
|
-
"""
|
16
|
-
|
17
|
-
# Default initial parameters and bounds
|
18
|
-
DEFAULT_INITIAL_PARAMS = [0.04, 0.1, 0.2, -0.5, 0.01]
|
19
|
-
DEFAULT_PARAM_BOUNDS = ([-np.inf, 0, 0, -1, -np.inf], [np.inf, np.inf, np.inf, 1, np.inf])
|
20
|
-
|
21
|
-
# Parameter names for reference
|
22
|
-
PARAM_NAMES = ['a', 'b', 'sigma', 'rho', 'm']
|
23
|
-
JW_PARAM_NAMES = ['nu', 'psi', 'p', 'c', 'nu_tilde']
|
24
|
-
|
25
|
-
# Parameter descriptions for documentation
|
26
|
-
PARAM_DESCRIPTIONS = {
|
27
|
-
'a': 'Base level of total implied variance',
|
28
|
-
'b': 'Volatility skewness/smile modulation (controls wing slopes)',
|
29
|
-
'sigma': 'Convexity control of the volatility smile (reduces ATM curvature)',
|
30
|
-
'rho': 'Skewness/slope of the volatility smile (-1 to 1, rotates smile)',
|
31
|
-
'm': 'Horizontal shift of the smile peak',
|
32
|
-
'nu': 'ATM variance (level of ATM volatility)',
|
33
|
-
'psi': 'ATM volatility skew (affects the gradient of the curve at ATM point)',
|
34
|
-
'p': 'Slope of put wing (left side of curve)',
|
35
|
-
'c': 'Slope of call wing (right side of curve)',
|
36
|
-
'nu_tilde': 'Minimum implied total variance',
|
37
|
-
}
|
38
|
-
|
39
|
-
@staticmethod
|
40
|
-
def svi(LM: float, a: float, b: float, sigma: float, rho: float, m: float) -> float:
|
41
|
-
return a + b * (rho * (LM - m) + np.sqrt((LM - m) ** 2 + sigma ** 2))
|
42
|
-
|
43
|
-
@staticmethod
|
44
|
-
def svi_d(LM: float, a: float, b: float, sigma: float, rho: float, m: float) -> float:
|
45
|
-
return b * (rho + ((LM - m) / np.sqrt((LM - m) ** 2 + sigma ** 2)))
|
46
|
-
|
47
|
-
@staticmethod
|
48
|
-
def svi_dd(LM: float, a: float, b: float, sigma: float, rho: float, m: float) -> float:
|
49
|
-
return b * LM ** 2 / ((LM - m) ** 2 + sigma ** 2) ** 1.5
|
50
|
-
|
51
|
-
@staticmethod
|
52
|
-
def svi_min_strike(sigma: float, rho: float, m: float) -> float:
|
53
|
-
return m - ((sigma * rho) / np.sqrt(1 - rho ** 2))
|
54
|
-
|
55
|
-
@staticmethod
|
56
|
-
def raw_to_jw_params(a: float, b: float, sigma: float, rho: float, m: float, t: float) -> Tuple[float, float, float, float, float]:
|
57
|
-
nu = (a + b * ((-rho) * m + np.sqrt(m ** 2 + sigma ** 2))) / t
|
58
|
-
psi = (1 / np.sqrt(nu * t)) * (b / 2) * (rho - (m / np.sqrt(m ** 2 + sigma ** 2)))
|
59
|
-
p = (1 / np.sqrt(nu * t)) * b * (1 - rho)
|
60
|
-
c = (1 / np.sqrt(nu * t)) * b * (1 + rho)
|
61
|
-
nu_tilde = (1 / t) * (a + b * sigma * np.sqrt(1 - rho ** 2))
|
62
|
-
return nu, psi, p, c, nu_tilde
|
63
|
-
|
64
|
-
@staticmethod
|
65
|
-
def jw_to_raw_params(nu: float, psi: float, p: float, c: float, nu_tilde: float, t: float) -> Tuple[float, float, float, float, float]:
|
66
|
-
w = nu * t
|
67
|
-
b = (c + p) / 2
|
68
|
-
rho = (c - p) / (c + p)
|
69
|
-
beta = rho - ((2 * w * psi) / b)
|
70
|
-
alpha = np.sign(beta) * (np.sqrt((1 / (beta ** 2)) - 1))
|
71
|
-
m = (((nu ** 2) - (nu_tilde ** 2)) * t) / (
|
72
|
-
b * ((-rho) + (np.sign(alpha) * np.sqrt(1 + alpha ** 2)) - (alpha * np.sqrt(1 - rho ** 2))))
|
73
|
-
sigma = alpha * m
|
74
|
-
a = ((nu_tilde ** 2) * t) - (b * sigma * np.sqrt(1 - rho ** 2))
|
75
|
-
return a, b, sigma, rho, m
|
76
|
-
|
77
|
-
|
78
|
-
# Models dictionary for easy access
|
79
|
-
MODELS = {
|
80
|
-
'svi': SVIModel,
|
81
|
-
}
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|