voly 0.0.203__tar.gz → 0.0.205__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.203/src/voly.egg-info → voly-0.0.205}/PKG-INFO +1 -1
- {voly-0.0.203 → voly-0.0.205}/pyproject.toml +2 -2
- {voly-0.0.203 → voly-0.0.205}/src/voly/core/charts.py +1 -1
- {voly-0.0.203 → voly-0.0.205}/src/voly/core/data.py +12 -3
- voly-0.0.205/src/voly/core/fit.py +352 -0
- {voly-0.0.203 → voly-0.0.205}/src/voly/models.py +84 -31
- {voly-0.0.203 → voly-0.0.205/src/voly.egg-info}/PKG-INFO +1 -1
- voly-0.0.203/src/voly/core/fit.py +0 -410
- {voly-0.0.203 → voly-0.0.205}/LICENSE +0 -0
- {voly-0.0.203 → voly-0.0.205}/README.md +0 -0
- {voly-0.0.203 → voly-0.0.205}/setup.cfg +0 -0
- {voly-0.0.203 → voly-0.0.205}/setup.py +0 -0
- {voly-0.0.203 → voly-0.0.205}/src/voly/__init__.py +0 -0
- {voly-0.0.203 → voly-0.0.205}/src/voly/client.py +0 -0
- {voly-0.0.203 → voly-0.0.205}/src/voly/core/__init__.py +0 -0
- {voly-0.0.203 → voly-0.0.205}/src/voly/core/hd.py +0 -0
- {voly-0.0.203 → voly-0.0.205}/src/voly/core/interpolate.py +0 -0
- {voly-0.0.203 → voly-0.0.205}/src/voly/core/rnd.py +0 -0
- {voly-0.0.203 → voly-0.0.205}/src/voly/exceptions.py +0 -0
- {voly-0.0.203 → voly-0.0.205}/src/voly/formulas.py +0 -0
- {voly-0.0.203 → voly-0.0.205}/src/voly/utils/__init__.py +0 -0
- {voly-0.0.203 → voly-0.0.205}/src/voly/utils/density.py +0 -0
- {voly-0.0.203 → voly-0.0.205}/src/voly/utils/logger.py +0 -0
- {voly-0.0.203 → voly-0.0.205}/src/voly.egg-info/SOURCES.txt +0 -0
- {voly-0.0.203 → voly-0.0.205}/src/voly.egg-info/dependency_links.txt +0 -0
- {voly-0.0.203 → voly-0.0.205}/src/voly.egg-info/requires.txt +0 -0
- {voly-0.0.203 → voly-0.0.205}/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.205"
|
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.205"
|
64
64
|
warn_return_any = true
|
65
65
|
warn_unused_configs = true
|
66
66
|
disallow_untyped_defs = true
|
@@ -68,7 +68,7 @@ def plot_volatility_smile(x_array: np.ndarray,
|
|
68
68
|
|
69
69
|
if not maturity_data.empty:
|
70
70
|
# Add bid and ask IVs if available
|
71
|
-
for iv_type in ['
|
71
|
+
for iv_type in ['bid_iv', 'ask_iv']:
|
72
72
|
if iv_type in maturity_data.columns:
|
73
73
|
fig.add_trace(
|
74
74
|
go.Scatter(
|
@@ -198,9 +198,11 @@ def process_option_chain(df: pd.DataFrame, currency: str) -> pd.DataFrame:
|
|
198
198
|
|
199
199
|
# Apply extraction to create new columns
|
200
200
|
splits = df['instrument_name'].str.split('-')
|
201
|
-
df['currency'] = splits.str[0]
|
202
201
|
df['maturity_name'] = splits.str[1]
|
203
|
-
|
202
|
+
if currency == 'XRP':
|
203
|
+
df['strikes'] = splits.str[2].str.replace('d', '.', regex=False).astype(float)
|
204
|
+
else:
|
205
|
+
df['strikes'] = splits.str[2].astype(float)
|
204
206
|
df['option_type'] = splits.str[3]
|
205
207
|
|
206
208
|
# Create maturity date at 8:00 AM UTC
|
@@ -356,7 +358,14 @@ async def fetch_option_chain(exchange: str = 'deribit',
|
|
356
358
|
raise VolyError(f"Exchange '{exchange}' is not supported. Currently only 'deribit' is available.")
|
357
359
|
|
358
360
|
# Get raw data
|
359
|
-
|
361
|
+
if currency not in ['BTC', 'ETH']:
|
362
|
+
new_currency = 'USDC'
|
363
|
+
raw_data = await get_deribit_data(currency=new_currency)
|
364
|
+
raw_data['currency'] = raw_data['instrument_name'].str.split('-').str[0].str.split('_').str[0]
|
365
|
+
raw_data = raw_data[raw_data['currency'] == currency]
|
366
|
+
else:
|
367
|
+
raw_data = await get_deribit_data(currency=currency)
|
368
|
+
raw_data['currency'] = currency
|
360
369
|
|
361
370
|
# Process data
|
362
371
|
processed_data = process_option_chain(raw_data, currency)
|
@@ -0,0 +1,352 @@
|
|
1
|
+
"""
|
2
|
+
Model fitting and calibration module for the Voly package.
|
3
|
+
|
4
|
+
This module handles fitting volatility models to market data, calculating fitting statistics,
|
5
|
+
and generating visualizations.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import numpy as np
|
9
|
+
import pandas as pd
|
10
|
+
from typing import List, Tuple, Dict, Optional, Union, Any
|
11
|
+
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
|
12
|
+
from voly.utils.logger import logger, catch_exception
|
13
|
+
from voly.formulas import get_domain
|
14
|
+
from voly.exceptions import VolyError
|
15
|
+
from voly.models import SVIModel
|
16
|
+
from concurrent.futures import ThreadPoolExecutor
|
17
|
+
import warnings
|
18
|
+
import time
|
19
|
+
|
20
|
+
warnings.filterwarnings("ignore")
|
21
|
+
|
22
|
+
|
23
|
+
class SVICalibrator:
|
24
|
+
"""Handles the SVI calibration process"""
|
25
|
+
|
26
|
+
def __init__(self, option_chain, currency, num_points=2000):
|
27
|
+
self.option_chain = option_chain
|
28
|
+
self.currency = currency
|
29
|
+
self.s = option_chain['index_price'].iloc[0]
|
30
|
+
self.groups = option_chain.groupby('maturity_date')
|
31
|
+
self.params_dict = {}
|
32
|
+
self.results_data = {}
|
33
|
+
self.num_points = num_points
|
34
|
+
|
35
|
+
# Initialize results data template
|
36
|
+
self.field_names = [
|
37
|
+
's', 't', 'maturity_date', 'maturity_name', 'a', 'b', 'm', 'rho', 'sigma',
|
38
|
+
'nu', 'psi', 'p', 'c', 'nu_tilde', 'log_min_strike', 'usd_min_strike',
|
39
|
+
'fit_success', 'butterfly_arbitrage_free', 'calendar_arbitrage_free',
|
40
|
+
'rmse', 'mae', 'r2', 'max_error', 'loss', 'n_points'
|
41
|
+
]
|
42
|
+
|
43
|
+
# Create empty lists for each field
|
44
|
+
for field in self.field_names:
|
45
|
+
self.results_data[field] = []
|
46
|
+
|
47
|
+
def failed_calibration(self, maturity, maturity_name, t, n_points):
|
48
|
+
"""Create an empty result for failed calibration"""
|
49
|
+
return {
|
50
|
+
's': float(self.s),
|
51
|
+
't': float(t),
|
52
|
+
'maturity_date': maturity,
|
53
|
+
'maturity_name': maturity_name,
|
54
|
+
'fit_success': False,
|
55
|
+
'calendar_arbitrage_free': True, # Updated later
|
56
|
+
'loss': float(np.inf),
|
57
|
+
'n_points': int(n_points),
|
58
|
+
'a': np.nan, 'b': np.nan, 'm': np.nan, 'rho': np.nan, 'sigma': np.nan,
|
59
|
+
'nu': np.nan, 'psi': np.nan, 'p': np.nan, 'c': np.nan, 'nu_tilde': np.nan,
|
60
|
+
'log_min_strike': np.nan, 'usd_min_strike': np.nan,
|
61
|
+
'butterfly_arbitrage_free': False,
|
62
|
+
'rmse': np.nan, 'mae': np.nan, 'r2': np.nan, 'max_error': np.nan
|
63
|
+
}
|
64
|
+
|
65
|
+
def filter_market_data(self, group):
|
66
|
+
"""Filter and prepare market data"""
|
67
|
+
# Filter for call options only
|
68
|
+
group = group[group['option_type'] == 'C']
|
69
|
+
|
70
|
+
# Handle duplicated IVs by keeping the row closest to log_moneyness=0
|
71
|
+
duplicated_iv = group[group.duplicated('mark_iv', keep=False)]
|
72
|
+
if not duplicated_iv.empty:
|
73
|
+
cleaned_dupes = duplicated_iv.groupby('mark_iv').apply(
|
74
|
+
lambda g: g.loc[[g['log_moneyness'].abs().idxmin()]]
|
75
|
+
).reset_index(drop=True)
|
76
|
+
|
77
|
+
# Combine cleaned duplicates with unique rows
|
78
|
+
unique_iv = group.drop_duplicates('mark_iv', keep=False)
|
79
|
+
group = pd.concat([unique_iv, cleaned_dupes])
|
80
|
+
|
81
|
+
# Extract basic data
|
82
|
+
maturity_name = group['maturity_name'].iloc[0]
|
83
|
+
t = group['t'].iloc[0]
|
84
|
+
K = group['strikes'].values
|
85
|
+
iv = group['mark_iv'].values
|
86
|
+
vega = group['vega'].values if 'vega' in group.columns else np.ones_like(iv)
|
87
|
+
k = np.log(K / self.s)
|
88
|
+
|
89
|
+
# Filter out invalid data
|
90
|
+
w = (iv ** 2) * t
|
91
|
+
mask = ~np.isnan(w) & ~np.isnan(vega) & ~np.isnan(k) & (iv >= 0)
|
92
|
+
k, w, vega, iv, K = k[mask], w[mask], vega[mask], iv[mask], K[mask]
|
93
|
+
|
94
|
+
return maturity_name, t, k, w, vega, iv, K
|
95
|
+
|
96
|
+
def calculate_model_stats(self, params, t, k, iv):
|
97
|
+
"""Calculate all model statistics from parameters"""
|
98
|
+
a, b, m, rho, sigma = params
|
99
|
+
a_scaled, b_scaled = a * t, b * t
|
100
|
+
|
101
|
+
# Jump-Wing parameters
|
102
|
+
jw_params = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t)
|
103
|
+
|
104
|
+
# Fit statistics
|
105
|
+
w_model = np.array([SVIModel.svi(x, a_scaled, b_scaled, m, rho, sigma) for x in k])
|
106
|
+
iv_model = np.sqrt(w_model / t)
|
107
|
+
rmse = np.sqrt(mean_squared_error(iv, iv_model))
|
108
|
+
mae = mean_absolute_error(iv, iv_model)
|
109
|
+
r2 = r2_score(iv, iv_model)
|
110
|
+
max_error = np.max(np.abs(iv - iv_model))
|
111
|
+
|
112
|
+
# Minimum strike
|
113
|
+
log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
|
114
|
+
usd_min_strike = np.exp(log_min_strike) * self.s
|
115
|
+
|
116
|
+
# Butterfly arbitrage check
|
117
|
+
k_range = np.linspace(min(k), max(k), self.num_points)
|
118
|
+
butterfly_arbitrage_free = SVIModel.check_butterfly_arbitrage(a_scaled, b_scaled, m, rho, sigma, k_range)
|
119
|
+
|
120
|
+
return {
|
121
|
+
'a': float(a_scaled),
|
122
|
+
'b': float(b_scaled),
|
123
|
+
'm': float(m),
|
124
|
+
'rho': float(rho),
|
125
|
+
'sigma': float(sigma),
|
126
|
+
'nu': float(jw_params[0]),
|
127
|
+
'psi': float(jw_params[1]),
|
128
|
+
'p': float(jw_params[2]),
|
129
|
+
'c': float(jw_params[3]),
|
130
|
+
'nu_tilde': float(jw_params[4]),
|
131
|
+
'log_min_strike': float(log_min_strike),
|
132
|
+
'usd_min_strike': float(usd_min_strike),
|
133
|
+
'butterfly_arbitrage_free': butterfly_arbitrage_free,
|
134
|
+
'rmse': float(rmse),
|
135
|
+
'mae': float(mae),
|
136
|
+
'r2': float(r2),
|
137
|
+
'max_error': float(max_error)
|
138
|
+
}
|
139
|
+
|
140
|
+
def process_maturity(self, maturity, group):
|
141
|
+
"""Process single maturity for SVI calibration"""
|
142
|
+
# Clean and prepare market data
|
143
|
+
maturity_name, t, k, w, vega, iv, K = self.filter_market_data(group)
|
144
|
+
|
145
|
+
# Not enough data points for fitting
|
146
|
+
if len(k) <= 5:
|
147
|
+
result = self.failed_calibration(maturity, maturity_name, t, len(k))
|
148
|
+
logger.error(f'FAILED for {maturity} (insufficient data points)')
|
149
|
+
self.update_results(result)
|
150
|
+
return maturity
|
151
|
+
|
152
|
+
# Perform SVI fitting
|
153
|
+
params, loss = SVIModel.fit(tiv=w, vega=vega, k=k, tau=t)
|
154
|
+
|
155
|
+
# If fitting failed
|
156
|
+
if np.isnan(params[0]):
|
157
|
+
result = self.failed_calibration(maturity, maturity_name, t, len(k))
|
158
|
+
logger.error(f'FAILED for {maturity}')
|
159
|
+
self.update_results(result)
|
160
|
+
return maturity
|
161
|
+
|
162
|
+
# Successful fitting
|
163
|
+
self.params_dict[maturity] = (t, params)
|
164
|
+
|
165
|
+
# Calculate all model statistics
|
166
|
+
stats = self.calculate_model_stats(params, t, k, iv)
|
167
|
+
|
168
|
+
# Create result dictionary
|
169
|
+
result = {
|
170
|
+
's': float(self.s),
|
171
|
+
't': float(t),
|
172
|
+
'maturity_date': maturity,
|
173
|
+
'maturity_name': maturity_name,
|
174
|
+
'fit_success': True,
|
175
|
+
'calendar_arbitrage_free': True, # Updated later
|
176
|
+
'loss': float(loss),
|
177
|
+
'n_points': int(len(k)),
|
178
|
+
**stats
|
179
|
+
}
|
180
|
+
|
181
|
+
logger.info(
|
182
|
+
f'SUCCESS for {maturity}: a={stats["a"]:.4f}, b={stats["b"]:.4f}, m={stats["m"]:.4f}, rho={stats["rho"]:.4f}, sigma={stats["sigma"]:.4f}')
|
183
|
+
|
184
|
+
self.update_results(result)
|
185
|
+
return maturity
|
186
|
+
|
187
|
+
def update_results(self, result_row):
|
188
|
+
"""Update results data dictionary"""
|
189
|
+
for key, value in result_row.items():
|
190
|
+
if key in self.results_data:
|
191
|
+
self.results_data[key].append(value)
|
192
|
+
|
193
|
+
def fit_model(self):
|
194
|
+
"""Execute full SVI calibration process"""
|
195
|
+
start_time = time.time()
|
196
|
+
logger.info(f"Processing {self.currency} option chain data...")
|
197
|
+
|
198
|
+
# Process all maturities in parallel
|
199
|
+
with ThreadPoolExecutor() as executor:
|
200
|
+
futures = [
|
201
|
+
executor.submit(self.process_maturity, maturity, group)
|
202
|
+
for maturity, group in self.groups
|
203
|
+
]
|
204
|
+
for future in futures:
|
205
|
+
future.result()
|
206
|
+
|
207
|
+
# Create results DataFrame and mapping for updates
|
208
|
+
fit_results = pd.DataFrame(self.results_data, index=self.results_data['maturity_name'])
|
209
|
+
fit_results = fit_results.sort_values(by='t')
|
210
|
+
maturity_name_dict = {row['maturity_date']: idx for idx, row in fit_results.iterrows()}
|
211
|
+
|
212
|
+
# Check for calendar arbitrage
|
213
|
+
sorted_maturities = sorted(self.params_dict.keys(), key=lambda x: self.params_dict[x][0])
|
214
|
+
calendar_arbitrage_free = SVIModel.check_calendar_arbitrage(
|
215
|
+
sorted_maturities, self.params_dict, self.groups, self.s, self.num_points
|
216
|
+
)
|
217
|
+
|
218
|
+
# Update calendar arbitrage status
|
219
|
+
for mat in sorted_maturities:
|
220
|
+
mat_name = maturity_name_dict[mat]
|
221
|
+
fit_results.at[mat_name, 'calendar_arbitrage_free'] = calendar_arbitrage_free
|
222
|
+
|
223
|
+
# Correct calendar arbitrage violations
|
224
|
+
self.correct_calendar_arbitrage(sorted_maturities, fit_results, maturity_name_dict)
|
225
|
+
|
226
|
+
# Clean up results and report execution time
|
227
|
+
fit_results = fit_results.drop(columns='maturity_name')
|
228
|
+
end_time = time.time()
|
229
|
+
logger.info(f"Total model execution time: {end_time - start_time:.4f} seconds")
|
230
|
+
|
231
|
+
return fit_results
|
232
|
+
|
233
|
+
def correct_calendar_arbitrage(self, sorted_maturities, fit_results, maturity_name_dict):
|
234
|
+
"""Handle calendar arbitrage corrections"""
|
235
|
+
for i in range(1, len(sorted_maturities)):
|
236
|
+
mat2 = sorted_maturities[i]
|
237
|
+
mat1 = sorted_maturities[i - 1]
|
238
|
+
t2, params2 = self.params_dict[mat2]
|
239
|
+
t1, params1 = self.params_dict[mat1]
|
240
|
+
|
241
|
+
if np.any(np.isnan(params2)) or np.any(np.isnan(params1)):
|
242
|
+
continue
|
243
|
+
|
244
|
+
# Get clean data for correction
|
245
|
+
_, _, k, w, vega, iv, _ = self.filter_market_data(self.groups.get_group(mat2))
|
246
|
+
|
247
|
+
# Apply correction
|
248
|
+
k_constraint = np.unique(np.concatenate([k, np.linspace(min(k), max(k), self.num_points)]))
|
249
|
+
new_params = SVIModel.correct_calendar_arbitrage(
|
250
|
+
params=params2, t=t2, tiv=w, vega=vega, k=k,
|
251
|
+
prev_params=params1, prev_t=t1, k_constraint=k_constraint
|
252
|
+
)
|
253
|
+
|
254
|
+
# Update params dictionary
|
255
|
+
self.params_dict[mat2] = (t2, new_params)
|
256
|
+
|
257
|
+
# Calculate new stats and update results
|
258
|
+
stats = self.calculate_model_stats(new_params, t2, k, iv)
|
259
|
+
mat2_name = maturity_name_dict[mat2]
|
260
|
+
|
261
|
+
# Update all stats at once
|
262
|
+
for key, value in stats.items():
|
263
|
+
fit_results.at[mat2_name, key] = value
|
264
|
+
fit_results.at[mat2_name, 'fit_success'] = True
|
265
|
+
|
266
|
+
# Final calendar arbitrage check
|
267
|
+
calendar_arbitrage_free = SVIModel.check_calendar_arbitrage(
|
268
|
+
sorted_maturities, self.params_dict, self.groups, self.s, self.num_points
|
269
|
+
)
|
270
|
+
|
271
|
+
# Update final status
|
272
|
+
for mat in sorted_maturities:
|
273
|
+
mat_name = maturity_name_dict[mat]
|
274
|
+
fit_results.at[mat_name, 'calendar_arbitrage_free'] = calendar_arbitrage_free
|
275
|
+
|
276
|
+
|
277
|
+
@catch_exception
|
278
|
+
def fit_model(option_chain: pd.DataFrame, num_points: int = 2000) -> pd.DataFrame:
|
279
|
+
"""
|
280
|
+
Fit a volatility model to market data with parallel processing.
|
281
|
+
|
282
|
+
Parameters:
|
283
|
+
- option_chain: DataFrame with market data
|
284
|
+
- num_points: Number of points for k_grid and plotting
|
285
|
+
|
286
|
+
Returns:
|
287
|
+
- fit_results: DataFrame with all fit results and performance metrics as columns, maturity_names as index
|
288
|
+
"""
|
289
|
+
currency = option_chain['currency'].iloc[0] if 'currency' in option_chain.columns else 'Unknown'
|
290
|
+
|
291
|
+
# Instantiate the calibrator and run the fitting
|
292
|
+
calibrator = SVICalibrator(option_chain, currency, num_points)
|
293
|
+
fit_results = calibrator.fit_model()
|
294
|
+
|
295
|
+
return fit_results
|
296
|
+
|
297
|
+
|
298
|
+
@catch_exception
|
299
|
+
def get_iv_surface(model_results: pd.DataFrame,
|
300
|
+
domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000),
|
301
|
+
return_domain: str = 'log_moneyness') -> Tuple[Dict[str, np.ndarray], Dict[str, np.ndarray]]:
|
302
|
+
"""
|
303
|
+
Generate implied volatility surface using optimized SVI parameters.
|
304
|
+
|
305
|
+
Works with both regular fit_results and interpolated_results dataframes.
|
306
|
+
|
307
|
+
Parameters:
|
308
|
+
- model_results: DataFrame from fit_model() or interpolate_model(). Maturity names or DTM as Index
|
309
|
+
- domain_params: Tuple of (min, max, num_points) for the log-moneyness array
|
310
|
+
- return_domain: Domain for x-axis values ('log_moneyness', 'moneyness', 'returns', 'strikes', 'delta')
|
311
|
+
|
312
|
+
Returns:
|
313
|
+
- Tuple of (iv_surface, x_surface)
|
314
|
+
iv_surface: Dictionary mapping maturity to IV arrays
|
315
|
+
x_surface: Dictionary mapping maturity to requested x domain arrays
|
316
|
+
"""
|
317
|
+
# Check if required columns are present
|
318
|
+
required_columns = ['a', 'b', 'm', 'rho', 'sigma', 't', 's']
|
319
|
+
missing_columns = [col for col in required_columns if col not in model_results.columns]
|
320
|
+
if missing_columns:
|
321
|
+
raise VolyError(f"Required columns missing in model_results: {missing_columns}")
|
322
|
+
|
323
|
+
# Generate implied volatility surface in log-moneyness domain
|
324
|
+
LM = np.linspace(domain_params[0], domain_params[1], domain_params[2])
|
325
|
+
|
326
|
+
iv_surface = {}
|
327
|
+
x_surface = {}
|
328
|
+
|
329
|
+
# Process each maturity/dtm
|
330
|
+
for i in model_results.index:
|
331
|
+
# Calculate SVI total implied variance and convert to IV
|
332
|
+
params = [
|
333
|
+
model_results.loc[i, 'a'],
|
334
|
+
model_results.loc[i, 'b'],
|
335
|
+
model_results.loc[i, 'm'],
|
336
|
+
model_results.loc[i, 'rho'],
|
337
|
+
model_results.loc[i, 'sigma']
|
338
|
+
]
|
339
|
+
s = model_results.loc[i, 's']
|
340
|
+
t = model_results.loc[i, 't']
|
341
|
+
r = model_results.loc[i, 'r'] if 'r' in model_results.columns else 0
|
342
|
+
|
343
|
+
# Calculate implied volatility
|
344
|
+
w = np.array([SVIModel.svi(x, *params) for x in LM])
|
345
|
+
o = np.sqrt(w / t)
|
346
|
+
iv_surface[i] = o
|
347
|
+
|
348
|
+
# Calculate x domain for this maturity/dtm
|
349
|
+
x = get_domain(domain_params, s, r, o, t, return_domain)
|
350
|
+
x_surface[i] = x
|
351
|
+
|
352
|
+
return iv_surface, x_surface
|
@@ -40,18 +40,28 @@ class SVIModel:
|
|
40
40
|
return a + b * (rho * (k - m) + np.sqrt((k - m) ** 2 + sigma ** 2))
|
41
41
|
|
42
42
|
@staticmethod
|
43
|
-
def
|
43
|
+
def svi_d(k, b, m, rho, sigma):
|
44
|
+
"""Compute the derivative of SVI over K"""
|
45
|
+
return b * (rho + (k - m) / np.sqrt((k - m) ** 2 + sigma ** 2))
|
46
|
+
|
47
|
+
@staticmethod
|
48
|
+
def svi_dd(k, b, m, sigma):
|
49
|
+
"""Compute the second derivative of SVI over K"""
|
50
|
+
return b * sigma ** 2 / ((k - m) ** 2 + sigma ** 2) ** (3 / 2)
|
51
|
+
|
52
|
+
@staticmethod
|
53
|
+
def svi_min_strike(sigma, rho, m):
|
44
54
|
"""Calculate the minimum valid log-strike for this SVI parameterization."""
|
45
55
|
return m - ((sigma * rho) / np.sqrt(1 - rho ** 2))
|
46
56
|
|
47
57
|
@staticmethod
|
48
|
-
def raw_to_jw_params(a
|
49
|
-
float, float, float, float, float]:
|
58
|
+
def raw_to_jw_params(a, b, m, rho, sigma, t):
|
50
59
|
"""Convert raw SVI to Jump-Wing parameters."""
|
51
60
|
nu = (a + b * ((-rho) * m + np.sqrt(m ** 2 + sigma ** 2))) / t
|
52
|
-
|
53
|
-
|
54
|
-
|
61
|
+
sqrt_nu_t = np.sqrt(nu * t)
|
62
|
+
psi = (1 / sqrt_nu_t) * (b / 2) * (rho - (m / np.sqrt(m ** 2 + sigma ** 2)))
|
63
|
+
p = (1 / sqrt_nu_t) * b * (1 - rho)
|
64
|
+
c = (1 / sqrt_nu_t) * b * (1 + rho)
|
55
65
|
nu_tilde = (1 / t) * (a + b * sigma * np.sqrt(1 - rho ** 2))
|
56
66
|
return nu, psi, p, c, nu_tilde
|
57
67
|
|
@@ -67,6 +77,8 @@ class SVIModel:
|
|
67
77
|
sigma = max(sigma, 0.001)
|
68
78
|
vega = vega / vega.max() if vega.max() > 0 else np.ones_like(vega)
|
69
79
|
y = (k - m) / sigma
|
80
|
+
|
81
|
+
# Calculate means for matrix construction
|
70
82
|
w = vega.mean()
|
71
83
|
y1 = (vega * y).mean()
|
72
84
|
y2 = (vega * y * y).mean()
|
@@ -77,38 +89,40 @@ class SVIModel:
|
|
77
89
|
vy = (vega * tiv * y).mean()
|
78
90
|
v = (vega * tiv).mean()
|
79
91
|
|
80
|
-
|
81
|
-
|
82
|
-
|
92
|
+
# Solve the linear system
|
93
|
+
matrix = np.array([[y5, y4, y3], [y4, y2, y1], [y3, y1, w]])
|
94
|
+
vector = np.array([vy2, vy, v])
|
95
|
+
c, d, a = solve(matrix, vector)
|
83
96
|
|
97
|
+
# Clip parameters to ensure validity
|
84
98
|
c = np.clip(c, 0, 4 * sigma)
|
85
99
|
a = max(a, 1e-6)
|
86
100
|
d = np.clip(d, -min(c, 4 * sigma - c), min(c, 4 * sigma - c))
|
87
101
|
|
88
|
-
|
89
|
-
return c, d, a, loss
|
102
|
+
return c, d, a, cls.loss(tiv, vega, y, c, d, a)
|
90
103
|
|
91
104
|
@classmethod
|
92
105
|
def fit(cls, tiv, vega, k, tau=1.0):
|
93
106
|
"""Fit SVI model."""
|
94
107
|
if len(k) <= 5:
|
95
108
|
return [np.nan] * 5, np.inf
|
109
|
+
|
96
110
|
vega = vega / vega.max() if vega.max() > 0 else np.ones_like(vega)
|
97
111
|
m_init = np.mean(k)
|
98
112
|
sigma_init = max(0.1, np.std(k) * 0.1)
|
99
113
|
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
tol=1e-16, method="SLSQP", options={'maxfun': 5000})
|
114
|
+
result = minimize(
|
115
|
+
lambda params: cls.calibration(tiv, vega, k, params[1], params[0])[3],
|
116
|
+
[sigma_init, m_init],
|
117
|
+
bounds=[(0.001, None), (None, None)],
|
118
|
+
tol=1e-16, method="SLSQP", options={'maxfun': 5000}
|
119
|
+
)
|
107
120
|
|
108
121
|
sigma, m = result.x
|
109
122
|
c, d, a_calib, loss = cls.calibration(tiv, vega, k, m, sigma)
|
110
123
|
a_calib = max(a_calib, 1e-6)
|
111
124
|
|
125
|
+
# Convert to SVI parameters
|
112
126
|
if c != 0:
|
113
127
|
a_svi = a_calib / tau
|
114
128
|
rho_svi = d / c
|
@@ -120,13 +134,13 @@ class SVIModel:
|
|
120
134
|
return [a_svi, b_svi, m, rho_svi, sigma], loss
|
121
135
|
|
122
136
|
@classmethod
|
123
|
-
def correct_calendar_arbitrage(cls, params, t, tiv, vega, k, prev_params, prev_t,
|
137
|
+
def correct_calendar_arbitrage(cls, params, t, tiv, vega, k, prev_params, prev_t, k_constraint):
|
138
|
+
"""Correct calendar arbitrage with relaxed bounds."""
|
124
139
|
if np.any(np.isnan(params)) or np.any(np.isnan(prev_params)):
|
125
140
|
return params
|
126
141
|
|
127
142
|
a_init, b_init, m_init, rho_init, sigma_init = params
|
128
143
|
a_prev, b_prev, m_prev, rho_prev, sigma_prev = prev_params
|
129
|
-
k_constraint = np.unique(np.concatenate([k, np.linspace(min(k), max(k), len(k_grid))]))
|
130
144
|
|
131
145
|
def objective(x):
|
132
146
|
a, b, m, rho, sigma = x
|
@@ -136,12 +150,6 @@ class SVIModel:
|
|
136
150
|
for i, x_init in enumerate([a_init, b_init, m_init, rho_init, sigma_init]))
|
137
151
|
return fit_loss + 0.01 * param_deviation
|
138
152
|
|
139
|
-
def calendar_constraint(x):
|
140
|
-
a, b, m, rho, sigma = x
|
141
|
-
w_current = cls.svi(k_constraint, a * t, b * t, m, rho, sigma)
|
142
|
-
w_prev = cls.svi(k_constraint, a_prev * prev_t, b_prev * prev_t, m_prev, rho_prev, sigma_prev)
|
143
|
-
return w_current - w_prev
|
144
|
-
|
145
153
|
bounds = [
|
146
154
|
(max(a_init * 0.8, 1e-6), a_init * 1.2),
|
147
155
|
(max(b_init * 0.8, 0), b_init * 1.2),
|
@@ -151,7 +159,9 @@ class SVIModel:
|
|
151
159
|
]
|
152
160
|
|
153
161
|
constraints = [
|
154
|
-
{'type': 'ineq', 'fun':
|
162
|
+
{'type': 'ineq', 'fun': lambda x: cls.svi(k_constraint, x[0] * t, x[1] * t, x[2], x[3], x[4]) -
|
163
|
+
cls.svi(k_constraint, a_prev * prev_t, b_prev * prev_t, m_prev, rho_prev,
|
164
|
+
sigma_prev)},
|
155
165
|
{'type': 'ineq', 'fun': lambda x: x[0] + x[1] * x[4] * np.sqrt(1 - x[3] ** 2)}
|
156
166
|
]
|
157
167
|
|
@@ -166,13 +176,56 @@ class SVIModel:
|
|
166
176
|
w_current = cls.svi(k_constraint, new_params[0] * t, new_params[1] * t, *new_params[2:])
|
167
177
|
w_prev = cls.svi(k_constraint, a_prev * prev_t, b_prev * prev_t, m_prev, rho_prev, sigma_prev)
|
168
178
|
violation = np.min(w_current - w_prev)
|
169
|
-
|
170
|
-
|
171
|
-
f"min margin={violation:.6f}")
|
179
|
+
print(f"Calendar arbitrage correction {'successful' if violation >= -1e-6 else 'failed'} for t={t:.4f}, "
|
180
|
+
f"min margin={violation:.6f}")
|
172
181
|
return new_params
|
173
|
-
|
182
|
+
|
183
|
+
print(f"Calendar arbitrage correction failed for t={t:.4f}")
|
174
184
|
return params
|
175
185
|
|
186
|
+
@classmethod
|
187
|
+
def check_butterfly_arbitrage(cls, a, b, m, rho, sigma, k_range):
|
188
|
+
"""Check for butterfly arbitrage violations."""
|
189
|
+
for k_val in k_range:
|
190
|
+
w_k = cls.svi(k_val, a, b, m, rho, sigma)
|
191
|
+
w_d_k = cls.svi_d(k_val, b, m, rho, sigma)
|
192
|
+
w_dd_k = cls.svi_dd(k_val, b, m, sigma)
|
193
|
+
g = (1 - (k_val * w_d_k) / (2 * w_k)) ** 2 - (w_d_k ** 2) / 4 * (1 / w_k + 1 / 4) + w_dd_k / 2
|
194
|
+
if g < 0:
|
195
|
+
return False
|
196
|
+
return True
|
197
|
+
|
198
|
+
@classmethod
|
199
|
+
def check_calendar_arbitrage(cls, sorted_maturities, params_dict, groups, s, num_points):
|
200
|
+
"""Check for calendar arbitrage violations."""
|
201
|
+
for i in range(len(sorted_maturities) - 1):
|
202
|
+
mat1, mat2 = sorted_maturities[i], sorted_maturities[i + 1]
|
203
|
+
t1, params1 = params_dict[mat1]
|
204
|
+
t2, params2 = params_dict[mat2]
|
205
|
+
a1, b1, m1, rho1, sigma1 = params1
|
206
|
+
a2, b2, m2, rho2, sigma2 = params2
|
207
|
+
|
208
|
+
if np.isnan(a1) or np.isnan(a2):
|
209
|
+
continue
|
210
|
+
|
211
|
+
# Get strike range for checking
|
212
|
+
group = groups.get_group(mat2)
|
213
|
+
K = group['strikes'].values
|
214
|
+
k_market = np.log(K / s)
|
215
|
+
mask = ~np.isnan(k_market)
|
216
|
+
k_check = np.unique(
|
217
|
+
np.concatenate([k_market[mask], np.linspace(min(k_market[mask]), max(k_market[mask]), num_points)]))
|
218
|
+
|
219
|
+
# Check for violations
|
220
|
+
for k_val in k_check:
|
221
|
+
w1 = cls.svi(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
|
222
|
+
w2 = cls.svi(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
|
223
|
+
if w2 < w1 - 1e-6:
|
224
|
+
print(
|
225
|
+
f"Calendar arbitrage violation at t1={t1:.4f}, t2={t2:.4f}, k={k_val:.4f}: w1={w1:.6f}, w2={w2:.6f}")
|
226
|
+
return False
|
227
|
+
return True
|
228
|
+
|
176
229
|
|
177
230
|
# Models dictionary for easy access
|
178
231
|
MODELS = {
|
@@ -1,410 +0,0 @@
|
|
1
|
-
"""
|
2
|
-
Model fitting and calibration module for the Voly package.
|
3
|
-
|
4
|
-
This module handles fitting volatility models to market data, calculating fitting statistics,
|
5
|
-
and generating visualizations.
|
6
|
-
"""
|
7
|
-
|
8
|
-
import numpy as np
|
9
|
-
import pandas as pd
|
10
|
-
from typing import List, Tuple, Dict, Optional, Union, Any
|
11
|
-
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
|
12
|
-
from voly.utils.logger import logger, catch_exception
|
13
|
-
from voly.formulas import get_domain
|
14
|
-
from voly.exceptions import VolyError
|
15
|
-
from voly.models import SVIModel
|
16
|
-
from concurrent.futures import ThreadPoolExecutor
|
17
|
-
import warnings
|
18
|
-
import time
|
19
|
-
import plotly.graph_objects as go
|
20
|
-
from plotly.subplots import make_subplots
|
21
|
-
|
22
|
-
warnings.filterwarnings("ignore")
|
23
|
-
|
24
|
-
|
25
|
-
@catch_exception
|
26
|
-
def fit_model(option_chain: pd.DataFrame, num_points: int = 2000) -> Tuple[pd.DataFrame, Dict]:
|
27
|
-
"""
|
28
|
-
Fit a volatility model to market data with parallel processing and generate visualizations.
|
29
|
-
|
30
|
-
Parameters:
|
31
|
-
- option_chain: DataFrame with market data
|
32
|
-
- num_points: Number of points for k_grid and plotting
|
33
|
-
|
34
|
-
Returns:
|
35
|
-
- results_df: DataFrame with all fit results and performance metrics as columns, maturity_names as index
|
36
|
-
"""
|
37
|
-
# Start overall timer
|
38
|
-
start_total = time.time()
|
39
|
-
|
40
|
-
# Define column names and their data types
|
41
|
-
column_dtypes = {
|
42
|
-
's': float,
|
43
|
-
't': float,
|
44
|
-
'maturity_date': 'datetime64[ns]',
|
45
|
-
'a': float,
|
46
|
-
'b': float,
|
47
|
-
'm': float,
|
48
|
-
'rho': float,
|
49
|
-
'sigma': float,
|
50
|
-
'nu': float,
|
51
|
-
'psi': float,
|
52
|
-
'p': float,
|
53
|
-
'c': float,
|
54
|
-
'nu_tilde': float,
|
55
|
-
'log_min_strike': float,
|
56
|
-
'usd_min_strike': float,
|
57
|
-
'fit_success': bool,
|
58
|
-
'butterfly_arbitrage_free': bool,
|
59
|
-
'calendar_arbitrage_free': bool,
|
60
|
-
'rmse': float,
|
61
|
-
'mae': float,
|
62
|
-
'r2': float,
|
63
|
-
'max_error': float,
|
64
|
-
'loss': float,
|
65
|
-
'n_points': int
|
66
|
-
}
|
67
|
-
|
68
|
-
s = option_chain['index_price'].iloc[0]
|
69
|
-
maturity_data_groups = option_chain.groupby('maturity_date')
|
70
|
-
params_dict = {}
|
71
|
-
results_data = {col: [] for col in column_dtypes.keys()}
|
72
|
-
results_data['maturity_name'] = []
|
73
|
-
|
74
|
-
def process_maturity(maturity, maturity_data):
|
75
|
-
"""Process single maturity for SVI calibration."""
|
76
|
-
maturity_data = maturity_data[maturity_data['option_type'] == 'C']
|
77
|
-
duplicated_iv = maturity_data[maturity_data.duplicated('mark_iv', keep=False)]
|
78
|
-
|
79
|
-
# For each duplicated IV, keep the row closest to log_moneyness=0
|
80
|
-
def keep_closest_to_zero(subgroup):
|
81
|
-
idx = (subgroup['log_moneyness'].abs()).idxmin()
|
82
|
-
return subgroup.loc[[idx]]
|
83
|
-
|
84
|
-
# Apply the function to each duplicated mark_iv group
|
85
|
-
cleaned_duplicated_iv = (
|
86
|
-
duplicated_iv.groupby('mark_iv', group_keys=False)
|
87
|
-
.apply(keep_closest_to_zero)
|
88
|
-
)
|
89
|
-
|
90
|
-
# Get rows with unique mark_iv (no duplicates)
|
91
|
-
unique_iv = maturity_data.drop_duplicates('mark_iv', keep=False)
|
92
|
-
|
93
|
-
# Combine cleaned duplicates and unique rows
|
94
|
-
maturity_data = pd.concat([unique_iv, cleaned_duplicated_iv])
|
95
|
-
maturity_date = maturity_data['maturity_date'].iloc[0]
|
96
|
-
maturity_name = maturity_data['maturity_name'].iloc[0]
|
97
|
-
|
98
|
-
t = maturity_data['t'].iloc[0]
|
99
|
-
K = maturity_data['strikes'].values
|
100
|
-
iv = maturity_data['mark_iv'].values
|
101
|
-
vega = maturity_data['vega'].values if 'vega' in maturity_data.columns else np.ones_like(iv)
|
102
|
-
k = np.log(K / s)
|
103
|
-
w = (iv ** 2) * t
|
104
|
-
mask = ~np.isnan(w) & ~np.isnan(vega) & ~np.isnan(k) & (iv >= 0)
|
105
|
-
k, w, vega, iv = k[mask], w[mask], vega[mask], iv[mask]
|
106
|
-
|
107
|
-
params = [np.nan] * 5
|
108
|
-
loss = np.inf
|
109
|
-
nu = psi = p = c = nu_tilde = np.nan
|
110
|
-
rmse = mae = r2 = max_error = np.nan
|
111
|
-
butterfly_arbitrage_free = True
|
112
|
-
log_min_strike = usd_min_strike = np.nan
|
113
|
-
|
114
|
-
if len(k) > 5:
|
115
|
-
params, loss = SVIModel.fit(tiv=w, vega=vega, k=k, tau=t)
|
116
|
-
if not np.isnan(params[0]):
|
117
|
-
params_dict[maturity_date] = (t, params)
|
118
|
-
a, b, m, rho, sigma = params
|
119
|
-
a_scaled, b_scaled = a * t, b * t
|
120
|
-
nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t)
|
121
|
-
|
122
|
-
# Compute fit statistics
|
123
|
-
w_model = np.array([SVIModel.svi(x, a_scaled, b_scaled, m, rho, sigma) for x in k])
|
124
|
-
iv_model = np.sqrt(w_model / t)
|
125
|
-
iv_market = iv
|
126
|
-
rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
|
127
|
-
mae = mean_absolute_error(iv_market, iv_model)
|
128
|
-
r2 = r2_score(iv_market, iv_model)
|
129
|
-
max_error = np.max(np.abs(iv_market - iv_model))
|
130
|
-
|
131
|
-
# Compute min strike
|
132
|
-
log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
|
133
|
-
usd_min_strike = np.exp(log_min_strike) * s
|
134
|
-
|
135
|
-
# Butterfly arbitrage check
|
136
|
-
k_range = np.linspace(min(k), max(k), num_points)
|
137
|
-
w_k = lambda k: SVIModel.svi(k, a_scaled, b_scaled, m, rho, sigma)
|
138
|
-
w_prime = lambda k: b_scaled * (rho + (k - m) / np.sqrt((k - m)**2 + sigma**2))
|
139
|
-
w_double_prime = lambda k: b_scaled * sigma**2 / ((k - m)**2 + sigma**2)**(3/2)
|
140
|
-
for k_val in k_range:
|
141
|
-
wk = w_k(k_val)
|
142
|
-
wp = w_prime(k_val)
|
143
|
-
wpp = w_double_prime(k_val)
|
144
|
-
g = (1 - (k_val * wp) / (2 * wk))**2 - (wp**2) / 4 * (1 / wk + 1/4) + wpp / 2
|
145
|
-
if g < 0:
|
146
|
-
butterfly_arbitrage_free = False
|
147
|
-
break
|
148
|
-
|
149
|
-
# Log result
|
150
|
-
GREEN, RED, RESET = '\033[32m', '\033[31m', '\033[0m'
|
151
|
-
status = f'{GREEN}SUCCESS{RESET}' if not np.isnan(params[0]) else f'{RED}FAILED{RESET}'
|
152
|
-
logger.info(f'Optimization for {maturity_date}: {status}')
|
153
|
-
|
154
|
-
# Store results
|
155
|
-
results_data['s'].append(float(s))
|
156
|
-
results_data['t'].append(float(t))
|
157
|
-
results_data['maturity_date'].append(maturity_date)
|
158
|
-
results_data['maturity_name'].append(maturity_name)
|
159
|
-
results_data['a'].append(float(a_scaled) if not np.isnan(params[0]) else np.nan)
|
160
|
-
results_data['b'].append(float(b_scaled) if not np.isnan(params[0]) else np.nan)
|
161
|
-
results_data['m'].append(float(m))
|
162
|
-
results_data['rho'].append(float(rho))
|
163
|
-
results_data['sigma'].append(float(sigma))
|
164
|
-
results_data['nu'].append(float(nu))
|
165
|
-
results_data['psi'].append(float(psi))
|
166
|
-
results_data['p'].append(float(p))
|
167
|
-
results_data['c'].append(float(c))
|
168
|
-
results_data['nu_tilde'].append(float(nu_tilde))
|
169
|
-
results_data['log_min_strike'].append(float(log_min_strike))
|
170
|
-
results_data['usd_min_strike'].append(float(usd_min_strike))
|
171
|
-
results_data['fit_success'].append(bool(not np.isnan(params[0])))
|
172
|
-
results_data['butterfly_arbitrage_free'].append(butterfly_arbitrage_free)
|
173
|
-
results_data['calendar_arbitrage_free'].append(True) # Updated after check
|
174
|
-
results_data['rmse'].append(float(rmse))
|
175
|
-
results_data['mae'].append(float(mae))
|
176
|
-
results_data['r2'].append(float(r2))
|
177
|
-
results_data['max_error'].append(float(max_error))
|
178
|
-
results_data['loss'].append(float(loss))
|
179
|
-
results_data['n_points'].append(int(len(k)))
|
180
|
-
|
181
|
-
return maturity_name
|
182
|
-
|
183
|
-
# Parallel processing of maturities
|
184
|
-
with ThreadPoolExecutor() as executor:
|
185
|
-
futures = [executor.submit(process_maturity, maturity, maturity_data)
|
186
|
-
for maturity, maturity_data in maturity_data_groups]
|
187
|
-
for future in futures:
|
188
|
-
future.result()
|
189
|
-
|
190
|
-
# Create results DataFrame
|
191
|
-
results_df = pd.DataFrame(results_data, index=results_data['maturity_name'])
|
192
|
-
|
193
|
-
# Map maturity_date to maturity_name for indexing
|
194
|
-
date_to_name = dict(zip(results_data['maturity_date'], results_data['maturity_name']))
|
195
|
-
|
196
|
-
# Convert columns to appropriate types
|
197
|
-
for col, dtype in column_dtypes.items():
|
198
|
-
if col in results_df.columns:
|
199
|
-
try:
|
200
|
-
results_df[col] = results_df[col].astype(dtype)
|
201
|
-
except (ValueError, TypeError) as e:
|
202
|
-
logger.warning(f"Could not convert column {col} to {dtype}: {e}")
|
203
|
-
|
204
|
-
# Sort by time to maturity
|
205
|
-
results_df = results_df.sort_values(by='t')
|
206
|
-
|
207
|
-
# Calendar arbitrage check (pre-correction)
|
208
|
-
k_grid = np.linspace(-2, 2, num_points)
|
209
|
-
sorted_maturities = sorted(params_dict.keys(), key=lambda x: params_dict[x][0])
|
210
|
-
calendar_arbitrage_free = True
|
211
|
-
for i in range(len(sorted_maturities) - 1):
|
212
|
-
mat1, mat2 = sorted_maturities[i], sorted_maturities[i + 1]
|
213
|
-
t1, params1 = params_dict[mat1]
|
214
|
-
t2, params2 = params_dict[mat2]
|
215
|
-
a1, b1, m1, rho1, sigma1 = params1
|
216
|
-
a2, b2, m2, rho2, sigma2 = params2
|
217
|
-
|
218
|
-
if np.isnan(a1) or np.isnan(a2):
|
219
|
-
continue
|
220
|
-
|
221
|
-
maturity_data = maturity_data_groups.get_group(mat2)
|
222
|
-
K = maturity_data['strikes'].values
|
223
|
-
k_market = np.log(K / s)
|
224
|
-
mask = ~np.isnan(k_market)
|
225
|
-
k_check = np.unique(np.concatenate([k_market[mask], np.linspace(min(k_market[mask]), max(k_market[mask]), num_points)]))
|
226
|
-
|
227
|
-
for k_val in k_check:
|
228
|
-
w1 = SVIModel.svi(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
|
229
|
-
w2 = SVIModel.svi(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
|
230
|
-
if w2 < w1 - 1e-6:
|
231
|
-
logger.warning(f"Calendar arbitrage violation at t1={t1:.4f}, t2={t2:.4f}, k={k_val:.4f}: w1={w1:.6f}, w2={w2:.6f}")
|
232
|
-
calendar_arbitrage_free = False
|
233
|
-
break
|
234
|
-
if not calendar_arbitrage_free:
|
235
|
-
break
|
236
|
-
|
237
|
-
for mat in sorted_maturities:
|
238
|
-
results_df.at[date_to_name[mat], 'calendar_arbitrage_free'] = calendar_arbitrage_free
|
239
|
-
|
240
|
-
# Calendar arbitrage correction
|
241
|
-
for i in range(1, len(sorted_maturities)):
|
242
|
-
mat2 = sorted_maturities[i]
|
243
|
-
mat1 = sorted_maturities[i - 1]
|
244
|
-
t2, params2 = params_dict[mat2]
|
245
|
-
t1, params1 = params_dict[mat1]
|
246
|
-
|
247
|
-
if np.any(np.isnan(params2)) or np.any(np.isnan(params1)):
|
248
|
-
continue
|
249
|
-
|
250
|
-
maturity_data = maturity_data_groups.get_group(mat2)
|
251
|
-
K = maturity_data['strikes'].values
|
252
|
-
iv = maturity_data['mark_iv'].values
|
253
|
-
vega = maturity_data['vega'].values if 'vega' in maturity_data.columns else np.ones_like(iv)
|
254
|
-
k = np.log(K / s)
|
255
|
-
w = (iv ** 2) * t2
|
256
|
-
mask = ~np.isnan(w) & ~np.isnan(vega) & ~np.isnan(k) & (iv >= 0)
|
257
|
-
k, w, vega, iv = k[mask], w[mask], vega[mask], iv[mask]
|
258
|
-
|
259
|
-
new_params = SVIModel.correct_calendar_arbitrage(
|
260
|
-
params=params2, t=t2, tiv=w, vega=vega, k=k,
|
261
|
-
prev_params=params1, prev_t=t1, k_grid=k_grid
|
262
|
-
)
|
263
|
-
|
264
|
-
params_dict[mat2] = (t2, new_params)
|
265
|
-
a, b, m, rho, sigma = new_params
|
266
|
-
a_scaled, b_scaled = a * t2, b * t2
|
267
|
-
nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, sigma, rho, m, t2)
|
268
|
-
|
269
|
-
# Recompute fit statistics
|
270
|
-
w_model = np.array([SVIModel.svi(x, a_scaled, b_scaled, m, rho, sigma) for x in k])
|
271
|
-
iv_model = np.sqrt(w_model / t2)
|
272
|
-
iv_market = iv
|
273
|
-
rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
|
274
|
-
mae = mean_absolute_error(iv_market, iv_model)
|
275
|
-
r2 = r2_score(iv_market, iv_model)
|
276
|
-
max_error = np.max(np.abs(iv_market - iv_model))
|
277
|
-
|
278
|
-
# Recompute min strike
|
279
|
-
log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
|
280
|
-
usd_min_strike = np.exp(log_min_strike) * s
|
281
|
-
|
282
|
-
# Update butterfly arbitrage check
|
283
|
-
butterfly_arbitrage_free = True
|
284
|
-
k_range = np.linspace(min(k), max(k), num_points)
|
285
|
-
w_k = lambda k: SVIModel.svi(k, a_scaled, b_scaled, m, rho, sigma)
|
286
|
-
w_prime = lambda k: b_scaled * (rho + (k - m) / np.sqrt((k - m)**2 + sigma**2))
|
287
|
-
w_double_prime = lambda k: b_scaled * sigma**2 / ((k - m)**2 + sigma**2)**(3/2)
|
288
|
-
for k_val in k_range:
|
289
|
-
wk = w_k(k_val)
|
290
|
-
wp = w_prime(k_val)
|
291
|
-
wpp = w_double_prime(k_val)
|
292
|
-
g = (1 - (k_val * wp) / (2 * wk))**2 - (wp**2) / 4 * (1 / wk + 1/4) + wpp / 2
|
293
|
-
if g < 0:
|
294
|
-
butterfly_arbitrage_free = False
|
295
|
-
break
|
296
|
-
|
297
|
-
results_df.at[date_to_name[mat2], 'a'] = float(a_scaled)
|
298
|
-
results_df.at[date_to_name[mat2], 'b'] = float(b_scaled)
|
299
|
-
results_df.at[date_to_name[mat2], 'm'] = float(m)
|
300
|
-
results_df.at[date_to_name[mat2], 'rho'] = float(rho)
|
301
|
-
results_df.at[date_to_name[mat2], 'sigma'] = float(sigma)
|
302
|
-
results_df.at[date_to_name[mat2], 'nu'] = float(nu)
|
303
|
-
results_df.at[date_to_name[mat2], 'psi'] = float(psi)
|
304
|
-
results_df.at[date_to_name[mat2], 'p'] = float(p)
|
305
|
-
results_df.at[date_to_name[mat2], 'c'] = float(c)
|
306
|
-
results_df.at[date_to_name[mat2], 'nu_tilde'] = float(nu_tilde)
|
307
|
-
results_df.at[date_to_name[mat2], 'rmse'] = float(rmse)
|
308
|
-
results_df.at[date_to_name[mat2], 'mae'] = float(mae)
|
309
|
-
results_df.at[date_to_name[mat2], 'r2'] = float(r2)
|
310
|
-
results_df.at[date_to_name[mat2], 'max_error'] = float(max_error)
|
311
|
-
results_df.at[date_to_name[mat2], 'log_min_strike'] = float(log_min_strike)
|
312
|
-
results_df.at[date_to_name[mat2], 'usd_min_strike'] = float(usd_min_strike)
|
313
|
-
results_df.at[date_to_name[mat2], 'butterfly_arbitrage_free'] = butterfly_arbitrage_free
|
314
|
-
results_df.at[date_to_name[mat2], 'fit_success'] = bool(not np.isnan(a))
|
315
|
-
|
316
|
-
# Calendar arbitrage check (post-correction)
|
317
|
-
calendar_arbitrage_free = True
|
318
|
-
for i in range(len(sorted_maturities) - 1):
|
319
|
-
mat1, mat2 = sorted_maturities[i], sorted_maturities[i + 1]
|
320
|
-
t1, params1 = params_dict[mat1]
|
321
|
-
t2, params2 = params_dict[mat2]
|
322
|
-
a1, b1, m1, rho1, sigma1 = params1
|
323
|
-
a2, b2, m2, rho2, sigma2 = params2
|
324
|
-
|
325
|
-
if np.isnan(a1) or np.isnan(a2):
|
326
|
-
continue
|
327
|
-
|
328
|
-
maturity_data = maturity_data_groups.get_group(mat2)
|
329
|
-
K = maturity_data['strikes'].values
|
330
|
-
k_market = np.log(K / s)
|
331
|
-
mask = ~np.isnan(k_market)
|
332
|
-
k_check = np.unique(np.concatenate([k_market[mask], np.linspace(min(k_market[mask]), max(k_market[mask]), num_points)]))
|
333
|
-
|
334
|
-
for k_val in k_check:
|
335
|
-
w1 = SVIModel.svi(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
|
336
|
-
w2 = SVIModel.svi(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
|
337
|
-
if w2 < w1 - 1e-6:
|
338
|
-
logger.warning(f"Calendar arbitrage violation at t1={t1:.4f}, t2={t2:.4f}, k={k_val:.4f}: w1={w1:.6f}, w2={w2:.6f}")
|
339
|
-
calendar_arbitrage_free = False
|
340
|
-
break
|
341
|
-
if not calendar_arbitrage_free:
|
342
|
-
break
|
343
|
-
|
344
|
-
for mat in sorted_maturities:
|
345
|
-
results_df.at[date_to_name[mat], 'calendar_arbitrage_free'] = calendar_arbitrage_free
|
346
|
-
|
347
|
-
# End overall timer and print total time
|
348
|
-
end_total = time.time()
|
349
|
-
logger.info(f"Total execution time for the model: {end_total - start_total:.4f} seconds")
|
350
|
-
|
351
|
-
logger.info("Model fitting complete.")
|
352
|
-
results_df = results_df.drop(columns='maturity_name')
|
353
|
-
return results_df
|
354
|
-
|
355
|
-
|
356
|
-
@catch_exception
|
357
|
-
def get_iv_surface(model_results: pd.DataFrame,
|
358
|
-
domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000),
|
359
|
-
return_domain: str = 'log_moneyness') -> Tuple[Dict[str, np.ndarray], Dict[str, np.ndarray]]:
|
360
|
-
"""
|
361
|
-
Generate implied volatility surface using optimized SVI parameters.
|
362
|
-
|
363
|
-
Works with both regular fit_results and interpolated_results dataframes.
|
364
|
-
|
365
|
-
Parameters:
|
366
|
-
- model_results: DataFrame from fit_model() or interpolate_model(). Maturity names or DTM as Index
|
367
|
-
- domain_params: Tuple of (min, max, num_points) for the log-moneyness array
|
368
|
-
- return_domain: Domain for x-axis values ('log_moneyness', 'moneyness', 'returns', 'strikes', 'delta')
|
369
|
-
|
370
|
-
Returns:
|
371
|
-
- Tuple of (iv_surface, x_surface)
|
372
|
-
iv_surface: Dictionary mapping maturity to IV arrays
|
373
|
-
x_surface: Dictionary mapping maturity to requested x domain arrays
|
374
|
-
"""
|
375
|
-
# Check if required columns are present
|
376
|
-
required_columns = ['a', 'b', 'm', 'rho', 'sigma', 't', 's']
|
377
|
-
missing_columns = [col for col in required_columns if col not in model_results.columns]
|
378
|
-
if missing_columns:
|
379
|
-
raise VolyError(f"Required columns missing in model_results: {missing_columns}")
|
380
|
-
|
381
|
-
# Generate implied volatility surface in log-moneyness domain
|
382
|
-
LM = np.linspace(domain_params[0], domain_params[1], domain_params[2])
|
383
|
-
|
384
|
-
iv_surface = {}
|
385
|
-
x_surface = {}
|
386
|
-
|
387
|
-
# Process each maturity/dtm
|
388
|
-
for i in model_results.index:
|
389
|
-
# Calculate SVI total implied variance and convert to IV
|
390
|
-
params = [
|
391
|
-
model_results.loc[i, 'a'],
|
392
|
-
model_results.loc[i, 'b'],
|
393
|
-
model_results.loc[i, 'm'],
|
394
|
-
model_results.loc[i, 'rho'],
|
395
|
-
model_results.loc[i, 'sigma']
|
396
|
-
]
|
397
|
-
s = model_results.loc[i, 's']
|
398
|
-
t = model_results.loc[i, 't']
|
399
|
-
r = model_results.loc[i, 'r'] if 'r' in model_results.columns else 0
|
400
|
-
|
401
|
-
# Calculate implied volatility
|
402
|
-
w = np.array([SVIModel.svi(x, *params) for x in LM])
|
403
|
-
o = np.sqrt(w / t)
|
404
|
-
iv_surface[i] = o
|
405
|
-
|
406
|
-
# Calculate x domain for this maturity/dtm
|
407
|
-
x = get_domain(domain_params, s, r, o, t, return_domain)
|
408
|
-
x_surface[i] = x
|
409
|
-
|
410
|
-
return iv_surface, x_surface
|
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
|
File without changes
|