voly 0.0.176__tar.gz → 0.0.178__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.176/src/voly.egg-info → voly-0.0.178}/PKG-INFO +1 -1
- {voly-0.0.176 → voly-0.0.178}/pyproject.toml +2 -2
- {voly-0.0.176 → voly-0.0.178}/src/voly/client.py +8 -8
- voly-0.0.178/src/voly/core/fit.py +429 -0
- {voly-0.0.176 → voly-0.0.178}/src/voly/formulas.py +0 -1
- {voly-0.0.176 → voly-0.0.178}/src/voly/models.py +24 -83
- {voly-0.0.176 → voly-0.0.178/src/voly.egg-info}/PKG-INFO +1 -1
- voly-0.0.176/src/voly/core/fit.py +0 -427
- {voly-0.0.176 → voly-0.0.178}/LICENSE +0 -0
- {voly-0.0.176 → voly-0.0.178}/README.md +0 -0
- {voly-0.0.176 → voly-0.0.178}/setup.cfg +0 -0
- {voly-0.0.176 → voly-0.0.178}/setup.py +0 -0
- {voly-0.0.176 → voly-0.0.178}/src/voly/__init__.py +0 -0
- {voly-0.0.176 → voly-0.0.178}/src/voly/core/__init__.py +0 -0
- {voly-0.0.176 → voly-0.0.178}/src/voly/core/charts.py +0 -0
- {voly-0.0.176 → voly-0.0.178}/src/voly/core/data.py +0 -0
- {voly-0.0.176 → voly-0.0.178}/src/voly/core/hd.py +0 -0
- {voly-0.0.176 → voly-0.0.178}/src/voly/core/interpolate.py +0 -0
- {voly-0.0.176 → voly-0.0.178}/src/voly/core/rnd.py +0 -0
- {voly-0.0.176 → voly-0.0.178}/src/voly/exceptions.py +0 -0
- {voly-0.0.176 → voly-0.0.178}/src/voly/utils/__init__.py +0 -0
- {voly-0.0.176 → voly-0.0.178}/src/voly/utils/density.py +0 -0
- {voly-0.0.176 → voly-0.0.178}/src/voly/utils/logger.py +0 -0
- {voly-0.0.176 → voly-0.0.178}/src/voly.egg-info/SOURCES.txt +0 -0
- {voly-0.0.176 → voly-0.0.178}/src/voly.egg-info/dependency_links.txt +0 -0
- {voly-0.0.176 → voly-0.0.178}/src/voly.egg-info/requires.txt +0 -0
- {voly-0.0.176 → voly-0.0.178}/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.178"
|
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.178"
|
64
64
|
warn_return_any = true
|
65
65
|
warn_unused_configs = true
|
66
66
|
disallow_untyped_defs = true
|
@@ -91,8 +91,9 @@ class VolyClient:
|
|
91
91
|
# -------------------------------------------------------------------------
|
92
92
|
|
93
93
|
@staticmethod
|
94
|
-
def
|
95
|
-
|
94
|
+
def svi_raw(k: float, a: float, b: float, m: float, rho: float, sigma: float) -> float:
|
95
|
+
"""Calculate SVI total implied variance using raw parameterization."""
|
96
|
+
return SVIModel.svi_raw(k, a, b, m, rho, sigma)
|
96
97
|
|
97
98
|
@staticmethod
|
98
99
|
def d1(s: float, K: float, r: float, o: float, t: float,
|
@@ -165,19 +166,18 @@ class VolyClient:
|
|
165
166
|
|
166
167
|
@staticmethod
|
167
168
|
def fit_model(option_chain: pd.DataFrame,
|
168
|
-
domain_params: Tuple[float, float, int] = (-2, 2,
|
169
|
-
) -> Dict[str, Any]:
|
169
|
+
domain_params: Tuple[float, float, int] = (-2, 2, 1000)) -> pd.DataFrame:
|
170
170
|
"""
|
171
|
-
Fit a volatility model to market data.
|
171
|
+
Fit a volatility model to market data using the improved SVI approach.
|
172
172
|
|
173
173
|
Parameters:
|
174
174
|
- option_chain: DataFrame with option market data
|
175
|
-
- domain_params: Tuple of (min, max, num_points) for the moneyness grid
|
175
|
+
- domain_params: Tuple of (min, max, num_points) for the log-moneyness grid
|
176
176
|
|
177
177
|
Returns:
|
178
|
-
-
|
178
|
+
- DataFrame with fit results including arbitrage checks
|
179
179
|
"""
|
180
|
-
logger.info(f"Fitting model to market data
|
180
|
+
logger.info(f"Fitting SVI model to market data")
|
181
181
|
|
182
182
|
# Fit the model
|
183
183
|
fit_results = fit_model(
|
@@ -0,0 +1,429 @@
|
|
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 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
|
+
import warnings
|
17
|
+
|
18
|
+
warnings.filterwarnings("ignore")
|
19
|
+
|
20
|
+
|
21
|
+
@catch_exception
|
22
|
+
def fit_model(option_chain: pd.DataFrame,
|
23
|
+
domain_params: Tuple[float, float, int] = (-2, 2, 1000)) -> pd.DataFrame:
|
24
|
+
"""
|
25
|
+
Fit a volatility model to market data.
|
26
|
+
|
27
|
+
Parameters:
|
28
|
+
- option_chain: DataFrame with market data
|
29
|
+
- domain_params: Tuple of (min, max, num_points) for the log-moneyness grid
|
30
|
+
|
31
|
+
Returns:
|
32
|
+
- DataFrame with all fit results and performance metrics as columns, maturity_names as index
|
33
|
+
"""
|
34
|
+
# Define column names and their data types
|
35
|
+
column_dtypes = {
|
36
|
+
's': float,
|
37
|
+
'u': float,
|
38
|
+
't': float,
|
39
|
+
'r': float,
|
40
|
+
'oi': float,
|
41
|
+
'volume': 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
|
+
groups = option_chain.groupby('maturity_date')
|
68
|
+
unique_ts = sorted(option_chain['t'].unique())
|
69
|
+
maturity_names = [option_chain[option_chain['t'] == t]['maturity_name'].iloc[0] for t in unique_ts]
|
70
|
+
|
71
|
+
# Store results in a dictionary first
|
72
|
+
results_data = {col: [] for col in column_dtypes.keys()}
|
73
|
+
|
74
|
+
# ANSI color codes for terminal output
|
75
|
+
GREEN, RED, RESET = '\033[32m', '\033[31m', '\033[0m'
|
76
|
+
|
77
|
+
s = option_chain['index_price'].iloc[-1]
|
78
|
+
|
79
|
+
# Dictionary to track fit results by maturity for arbitrage checks
|
80
|
+
params_dict = {}
|
81
|
+
|
82
|
+
# Calibrate and check arbitrage
|
83
|
+
for t in unique_ts:
|
84
|
+
# Get data for this maturity
|
85
|
+
maturity_data = option_chain[option_chain['t'] == t]
|
86
|
+
maturity_name = maturity_data['maturity_name'].iloc[0]
|
87
|
+
maturity_date = maturity_data['maturity_date'].iloc[0]
|
88
|
+
|
89
|
+
logger.info(f"Processing maturity {maturity_date}, t={t:.4f}")
|
90
|
+
|
91
|
+
K = maturity_data['strikes'].values
|
92
|
+
iv = maturity_data['mark_iv'].values
|
93
|
+
vega = maturity_data['vega'].values if 'vega' in maturity_data.columns else np.ones_like(iv)
|
94
|
+
k = np.log(K / s)
|
95
|
+
w = (iv ** 2) * t
|
96
|
+
mask = ~np.isnan(w) & ~np.isnan(vega) & ~np.isnan(k) & (iv >= 0)
|
97
|
+
k, w, vega, iv = k[mask], w[mask], vega[mask], iv[mask]
|
98
|
+
|
99
|
+
logger.info(f"Points after filtering: {len(k)}")
|
100
|
+
|
101
|
+
params = [np.nan] * 5
|
102
|
+
loss = np.inf
|
103
|
+
nu = psi = p = c = nu_tilde = np.nan
|
104
|
+
rmse = mae = r2 = max_error = np.nan
|
105
|
+
butterfly_arbitrage_free = True
|
106
|
+
u = s # Assume underlying_price is index_price
|
107
|
+
r = maturity_data['interest_rate'].iloc[0] if 'interest_rate' in maturity_data.columns else 0
|
108
|
+
oi = maturity_data['open_interest'].sum() if 'open_interest' in maturity_data.columns else 0
|
109
|
+
volume = maturity_data['volume'].sum() if 'volume' in maturity_data.columns else 0
|
110
|
+
log_min_strike = usd_min_strike = np.nan
|
111
|
+
|
112
|
+
if len(k) > 5:
|
113
|
+
params, loss = SVIModel.fit(tiv=w, vega=vega, k=k, tau=t)
|
114
|
+
if not np.isnan(params[0]):
|
115
|
+
params_dict[maturity_date] = (t, params)
|
116
|
+
a, b, m, rho, sigma = params
|
117
|
+
a_scaled, b_scaled = a * t, b * t
|
118
|
+
|
119
|
+
nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t)
|
120
|
+
|
121
|
+
# Compute fit statistics
|
122
|
+
w_model = np.array([SVIModel.svi_raw(x, a_scaled, b_scaled, m, rho, sigma) for x in k])
|
123
|
+
iv_model = np.sqrt(w_model / t)
|
124
|
+
iv_market = iv
|
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
|
+
# Compute min strike
|
131
|
+
log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
|
132
|
+
usd_min_strike = np.exp(log_min_strike) * s
|
133
|
+
|
134
|
+
# Butterfly arbitrage check
|
135
|
+
k_range = np.linspace(min(k), max(k), domain_params[2])
|
136
|
+
w_k = lambda k: SVIModel.svi_raw(k, a_scaled, b_scaled, m, rho, sigma)
|
137
|
+
w_prime = lambda k: b_scaled * (rho + (k - m) / np.sqrt((k - m) ** 2 + sigma ** 2))
|
138
|
+
w_double_prime = lambda k: b_scaled * sigma ** 2 / ((k - m) ** 2 + sigma ** 2) ** (3 / 2)
|
139
|
+
|
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
|
+
status = f'{GREEN}SUCCESS{RESET}' if not np.isnan(params[0]) else f'{RED}FAILED{RESET}'
|
151
|
+
logger.info(f'Optimization for {maturity_name}: {status}')
|
152
|
+
if not np.isnan(params[0]):
|
153
|
+
logger.info(
|
154
|
+
f"Maturity {maturity_name}: a={a_scaled:.4f}, b={b_scaled:.4f}, m={m:.4f}, rho={rho:.4f}, sigma={sigma:.4f}")
|
155
|
+
logger.info("=================================================================")
|
156
|
+
|
157
|
+
results_data['s'].append(float(s))
|
158
|
+
results_data['u'].append(float(u))
|
159
|
+
results_data['t'].append(float(t))
|
160
|
+
results_data['r'].append(float(r))
|
161
|
+
results_data['oi'].append(float(oi))
|
162
|
+
results_data['volume'].append(float(volume))
|
163
|
+
results_data['maturity_date'].append(maturity_date)
|
164
|
+
results_data['a'].append(float(a_scaled) if not np.isnan(params[0]) else np.nan)
|
165
|
+
results_data['b'].append(float(b_scaled) if not np.isnan(params[0]) else np.nan)
|
166
|
+
results_data['m'].append(float(m))
|
167
|
+
results_data['rho'].append(float(rho))
|
168
|
+
results_data['sigma'].append(float(sigma))
|
169
|
+
results_data['nu'].append(float(nu))
|
170
|
+
results_data['psi'].append(float(psi))
|
171
|
+
results_data['p'].append(float(p))
|
172
|
+
results_data['c'].append(float(c))
|
173
|
+
results_data['nu_tilde'].append(float(nu_tilde))
|
174
|
+
results_data['log_min_strike'].append(float(log_min_strike))
|
175
|
+
results_data['usd_min_strike'].append(float(usd_min_strike))
|
176
|
+
results_data['fit_success'].append(bool(not np.isnan(params[0])))
|
177
|
+
results_data['butterfly_arbitrage_free'].append(butterfly_arbitrage_free)
|
178
|
+
results_data['calendar_arbitrage_free'].append(True) # Updated after check
|
179
|
+
results_data['rmse'].append(float(rmse))
|
180
|
+
results_data['mae'].append(float(mae))
|
181
|
+
results_data['r2'].append(float(r2))
|
182
|
+
results_data['max_error'].append(float(max_error))
|
183
|
+
results_data['loss'].append(float(loss))
|
184
|
+
results_data['n_points'].append(int(len(k)))
|
185
|
+
|
186
|
+
# Create results DataFrame
|
187
|
+
results_df = pd.DataFrame(results_data, index=maturity_names)
|
188
|
+
|
189
|
+
# Convert columns to appropriate types
|
190
|
+
for col, dtype in column_dtypes.items():
|
191
|
+
if col in results_df.columns:
|
192
|
+
try:
|
193
|
+
results_df[col] = results_df[col].astype(dtype)
|
194
|
+
except (ValueError, TypeError) as e:
|
195
|
+
logger.warning(f"Could not convert column {col} to {dtype}: {e}")
|
196
|
+
|
197
|
+
# Calendar arbitrage check (pre-correction)
|
198
|
+
logger.info("\nChecking calendar arbitrage (pre-correction)...")
|
199
|
+
k_grid = np.linspace(domain_params[0], domain_params[1], domain_params[2])
|
200
|
+
sorted_maturities = sorted(params_dict.keys(), key=lambda x: params_dict[x][0])
|
201
|
+
calendar_arbitrage_free = True
|
202
|
+
|
203
|
+
for i in range(len(sorted_maturities) - 1):
|
204
|
+
mat1, mat2 = sorted_maturities[i], sorted_maturities[i + 1]
|
205
|
+
t1, params1 = params_dict[mat1]
|
206
|
+
t2, params2 = params_dict[mat2]
|
207
|
+
a1, b1, m1, rho1, sigma1 = params1
|
208
|
+
a2, b2, m2, rho2, sigma2 = params2
|
209
|
+
|
210
|
+
if np.isnan(a1) or np.isnan(a2):
|
211
|
+
continue
|
212
|
+
|
213
|
+
group = groups.get_group(mat2)
|
214
|
+
K = group['strikes'].values
|
215
|
+
s = group['index_price'].iloc[0]
|
216
|
+
k_market = np.log(K / s)
|
217
|
+
mask = ~np.isnan(k_market)
|
218
|
+
k_check = np.unique(
|
219
|
+
np.concatenate([k_market[mask], np.linspace(min(k_market[mask]), max(k_market[mask]), domain_params[2])]))
|
220
|
+
|
221
|
+
for k_val in k_check:
|
222
|
+
w1 = SVIModel.svi_raw(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
|
223
|
+
w2 = SVIModel.svi_raw(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
|
224
|
+
if w2 < w1 - 1e-6:
|
225
|
+
logger.warning(
|
226
|
+
f"Calendar arbitrage violation at t1={t1:.4f}, t2={t2:.4f}, k={k_val:.4f}: w1={w1:.6f}, w2={w2:.6f}")
|
227
|
+
calendar_arbitrage_free = False
|
228
|
+
break
|
229
|
+
if not calendar_arbitrage_free:
|
230
|
+
break
|
231
|
+
|
232
|
+
for mat in sorted_maturities:
|
233
|
+
idx = None
|
234
|
+
for i, maturity_name in enumerate(maturity_names):
|
235
|
+
if results_df.iloc[i]['maturity_date'] == mat:
|
236
|
+
idx = results_df.index[i]
|
237
|
+
break
|
238
|
+
if idx is not None:
|
239
|
+
results_df.at[idx, 'calendar_arbitrage_free'] = calendar_arbitrage_free
|
240
|
+
|
241
|
+
# Calendar arbitrage correction
|
242
|
+
if not calendar_arbitrage_free:
|
243
|
+
logger.info("\nPerforming calendar arbitrage correction...")
|
244
|
+
for i in range(1, len(sorted_maturities)):
|
245
|
+
mat2 = sorted_maturities[i]
|
246
|
+
mat1 = sorted_maturities[i - 1]
|
247
|
+
t2, params2 = params_dict[mat2]
|
248
|
+
t1, params1 = params_dict[mat1]
|
249
|
+
|
250
|
+
if np.any(np.isnan(params2)) or np.any(np.isnan(params1)):
|
251
|
+
continue
|
252
|
+
|
253
|
+
group = groups.get_group(mat2)
|
254
|
+
s = group['index_price'].iloc[0]
|
255
|
+
K = group['strikes'].values
|
256
|
+
iv = group['mark_iv'].values
|
257
|
+
vega = group['vega'].values if 'vega' in group.columns else np.ones_like(iv)
|
258
|
+
k = np.log(K / s)
|
259
|
+
w = (iv ** 2) * t2
|
260
|
+
mask = ~np.isnan(w) & ~np.isnan(vega) & ~np.isnan(k) & (iv >= 0)
|
261
|
+
k, w, vega, iv = k[mask], w[mask], vega[mask], iv[mask]
|
262
|
+
|
263
|
+
new_params = SVIModel.correct_calendar_arbitrage(
|
264
|
+
params=params2, t=t2, tiv=w, vega=vega, k=k,
|
265
|
+
prev_params=params1, prev_t=t1, k_grid=k_grid
|
266
|
+
)
|
267
|
+
|
268
|
+
params_dict[mat2] = (t2, new_params)
|
269
|
+
|
270
|
+
a, b, m, rho, sigma = new_params
|
271
|
+
a_scaled, b_scaled = a * t2, b * t2
|
272
|
+
nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t2)
|
273
|
+
|
274
|
+
# Recompute fit statistics
|
275
|
+
w_model = np.array([SVIModel.svi_raw(x, a_scaled, b_scaled, m, rho, sigma) for x in k])
|
276
|
+
iv_model = np.sqrt(w_model / t2)
|
277
|
+
iv_market = iv
|
278
|
+
rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
|
279
|
+
mae = mean_absolute_error(iv_market, iv_model)
|
280
|
+
r2 = r2_score(iv_market, iv_model)
|
281
|
+
max_error = np.max(np.abs(iv_market - iv_model))
|
282
|
+
|
283
|
+
# Recompute min strike
|
284
|
+
log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
|
285
|
+
usd_min_strike = np.exp(log_min_strike) * s
|
286
|
+
|
287
|
+
# Update butterfly arbitrage check
|
288
|
+
butterfly_arbitrage_free = True
|
289
|
+
k_range = np.linspace(min(k), max(k), domain_params[2])
|
290
|
+
w_k = lambda k: SVIModel.svi_raw(k, a_scaled, b_scaled, m, rho, sigma)
|
291
|
+
w_prime = lambda k: b_scaled * (rho + (k - m) / np.sqrt((k - m) ** 2 + sigma ** 2))
|
292
|
+
w_double_prime = lambda k: b_scaled * sigma ** 2 / ((k - m) ** 2 + sigma ** 2) ** (3 / 2)
|
293
|
+
|
294
|
+
for k_val in k_range:
|
295
|
+
wk = w_k(k_val)
|
296
|
+
wp = w_prime(k_val)
|
297
|
+
wpp = w_double_prime(k_val)
|
298
|
+
g = (1 - (k_val * wp) / (2 * wk)) ** 2 - (wp ** 2) / 4 * (1 / wk + 1 / 4) + wpp / 2
|
299
|
+
if g < 0:
|
300
|
+
butterfly_arbitrage_free = False
|
301
|
+
break
|
302
|
+
|
303
|
+
# Find the correct index to update
|
304
|
+
idx = None
|
305
|
+
for j, maturity_name in enumerate(maturity_names):
|
306
|
+
if results_df.iloc[j]['maturity_date'] == mat2:
|
307
|
+
idx = results_df.index[j]
|
308
|
+
break
|
309
|
+
|
310
|
+
if idx is not None:
|
311
|
+
results_df.at[idx, 'a'] = float(a_scaled)
|
312
|
+
results_df.at[idx, 'b'] = float(b_scaled)
|
313
|
+
results_df.at[idx, 'm'] = float(m)
|
314
|
+
results_df.at[idx, 'rho'] = float(rho)
|
315
|
+
results_df.at[idx, 'sigma'] = float(sigma)
|
316
|
+
results_df.at[idx, 'nu'] = float(nu)
|
317
|
+
results_df.at[idx, 'psi'] = float(psi)
|
318
|
+
results_df.at[idx, 'p'] = float(p)
|
319
|
+
results_df.at[idx, 'c'] = float(c)
|
320
|
+
results_df.at[idx, 'nu_tilde'] = float(nu_tilde)
|
321
|
+
results_df.at[idx, 'rmse'] = float(rmse)
|
322
|
+
results_df.at[idx, 'mae'] = float(mae)
|
323
|
+
results_df.at[idx, 'r2'] = float(r2)
|
324
|
+
results_df.at[idx, 'max_error'] = float(max_error)
|
325
|
+
results_df.at[idx, 'log_min_strike'] = float(log_min_strike)
|
326
|
+
results_df.at[idx, 'usd_min_strike'] = float(usd_min_strike)
|
327
|
+
results_df.at[idx, 'butterfly_arbitrage_free'] = butterfly_arbitrage_free
|
328
|
+
results_df.at[idx, 'fit_success'] = bool(not np.isnan(a))
|
329
|
+
|
330
|
+
# Calendar arbitrage check (post-correction)
|
331
|
+
logger.info("\nChecking calendar arbitrage (post-correction)...")
|
332
|
+
calendar_arbitrage_free = True
|
333
|
+
for i in range(len(sorted_maturities) - 1):
|
334
|
+
mat1, mat2 = sorted_maturities[i], sorted_maturities[i + 1]
|
335
|
+
t1, params1 = params_dict[mat1]
|
336
|
+
t2, params2 = params_dict[mat2]
|
337
|
+
a1, b1, m1, rho1, sigma1 = params1
|
338
|
+
a2, b2, m2, rho2, sigma2 = params2
|
339
|
+
|
340
|
+
if np.isnan(a1) or np.isnan(a2):
|
341
|
+
continue
|
342
|
+
|
343
|
+
group = groups.get_group(mat2)
|
344
|
+
K = group['strikes'].values
|
345
|
+
s = group['index_price'].iloc[0]
|
346
|
+
k_market = np.log(K / s)
|
347
|
+
mask = ~np.isnan(k_market)
|
348
|
+
k_check = np.unique(np.concatenate(
|
349
|
+
[k_market[mask], np.linspace(min(k_market[mask]), max(k_market[mask]), domain_params[2])]))
|
350
|
+
|
351
|
+
for k_val in k_check:
|
352
|
+
w1 = SVIModel.svi_raw(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
|
353
|
+
w2 = SVIModel.svi_raw(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
|
354
|
+
if w2 < w1 - 1e-6:
|
355
|
+
logger.warning(
|
356
|
+
f"Calendar arbitrage violation at t1={t1:.4f}, t2={t2:.4f}, k={k_val:.4f}: w1={w1:.6f}, w2={w2:.6f}")
|
357
|
+
calendar_arbitrage_free = False
|
358
|
+
break
|
359
|
+
if not calendar_arbitrage_free:
|
360
|
+
break
|
361
|
+
|
362
|
+
for mat in sorted_maturities:
|
363
|
+
idx = None
|
364
|
+
for j, maturity_name in enumerate(maturity_names):
|
365
|
+
if results_df.iloc[j]['maturity_date'] == mat:
|
366
|
+
idx = results_df.index[j]
|
367
|
+
break
|
368
|
+
if idx is not None:
|
369
|
+
results_df.at[idx, 'calendar_arbitrage_free'] = calendar_arbitrage_free
|
370
|
+
|
371
|
+
logger.info("Model fitting complete.")
|
372
|
+
return results_df
|
373
|
+
|
374
|
+
|
375
|
+
@catch_exception
|
376
|
+
def get_iv_surface(model_results: pd.DataFrame,
|
377
|
+
domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000),
|
378
|
+
return_domain: str = 'log_moneyness') -> Tuple[Dict[str, np.ndarray], Dict[str, np.ndarray]]:
|
379
|
+
"""
|
380
|
+
Generate implied volatility surface using optimized SVI parameters.
|
381
|
+
|
382
|
+
Works with both regular fit_results and interpolated_results dataframes.
|
383
|
+
|
384
|
+
Parameters:
|
385
|
+
- model_results: DataFrame from fit_model() or interpolate_model(). Maturity names or DTM as Index
|
386
|
+
- domain_params: Tuple of (min, max, num_points) for the log-moneyness array
|
387
|
+
- return_domain: Domain for x-axis values ('log_moneyness', 'moneyness', 'returns', 'strikes', 'delta')
|
388
|
+
|
389
|
+
Returns:
|
390
|
+
- Tuple of (iv_surface, x_surface)
|
391
|
+
iv_surface: Dictionary mapping maturity/dtm names to IV arrays
|
392
|
+
x_surface: Dictionary mapping maturity/dtm names to requested x domain arrays
|
393
|
+
"""
|
394
|
+
# Check if required columns are present
|
395
|
+
required_columns = ['a', 'b', 'm', 'rho', 'sigma', 't']
|
396
|
+
missing_columns = [col for col in required_columns if col not in model_results.columns]
|
397
|
+
if missing_columns:
|
398
|
+
raise VolyError(f"Required columns missing in model_results: {missing_columns}")
|
399
|
+
|
400
|
+
# Generate implied volatility surface in log-moneyness domain
|
401
|
+
LM = np.linspace(domain_params[0], domain_params[1], domain_params[2])
|
402
|
+
|
403
|
+
iv_surface = {}
|
404
|
+
x_surface = {}
|
405
|
+
|
406
|
+
# Process each maturity/dtm
|
407
|
+
for i in model_results.index:
|
408
|
+
# Calculate SVI total implied variance and convert to IV
|
409
|
+
params = [
|
410
|
+
model_results.loc[i, 'a'],
|
411
|
+
model_results.loc[i, 'b'],
|
412
|
+
model_results.loc[i, 'm'],
|
413
|
+
model_results.loc[i, 'rho'],
|
414
|
+
model_results.loc[i, 'sigma']
|
415
|
+
]
|
416
|
+
s = model_results.loc[i, 's']
|
417
|
+
r = model_results.loc[i, 'r']
|
418
|
+
t = model_results.loc[i, 't']
|
419
|
+
|
420
|
+
# Calculate implied volatility
|
421
|
+
w = np.array([SVIModel.svi_raw(x, *params) for x in LM])
|
422
|
+
o = np.sqrt(w / t)
|
423
|
+
iv_surface[i] = o
|
424
|
+
|
425
|
+
# Calculate x domain for this maturity/dtm
|
426
|
+
x = get_domain(domain_params, s, r, o, t, return_domain)
|
427
|
+
x_surface[i] = x
|
428
|
+
|
429
|
+
return iv_surface, x_surface
|
@@ -8,7 +8,6 @@ from scipy.stats import norm
|
|
8
8
|
from py_vollib.black_scholes.implied_volatility import implied_volatility
|
9
9
|
from typing import Tuple, Dict, Union, List, Optional
|
10
10
|
from voly.utils.logger import catch_exception
|
11
|
-
from voly.models import SVIModel
|
12
11
|
from typing import Tuple
|
13
12
|
|
14
13
|
|
@@ -4,16 +4,12 @@ Volatility models for the Voly package.
|
|
4
4
|
|
5
5
|
import numpy as np
|
6
6
|
from numpy.linalg import solve
|
7
|
-
from scipy.optimize import minimize
|
8
7
|
from typing import Tuple, Dict, List, Optional, Union
|
9
8
|
|
10
9
|
|
11
10
|
class SVIModel:
|
12
11
|
"""
|
13
12
|
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
13
|
"""
|
18
14
|
|
19
15
|
# Parameter names for reference
|
@@ -36,31 +32,9 @@ class SVIModel:
|
|
36
32
|
|
37
33
|
@staticmethod
|
38
34
|
def svi_raw(k, a, b, m, rho, sigma):
|
39
|
-
"""
|
40
|
-
Calculate SVI total implied variance using raw parameterization.
|
41
|
-
This is the original function name from the user's code.
|
42
|
-
"""
|
43
35
|
assert b >= 0 and abs(rho) <= 1 and sigma >= 0 and a + b * sigma * np.sqrt(1 - rho ** 2) >= 0
|
44
36
|
return a + b * (rho * (k - m) + np.sqrt((k - m) ** 2 + sigma ** 2))
|
45
37
|
|
46
|
-
@staticmethod
|
47
|
-
def svi(LM: float, a: float, b: float, m: float, rho: float, sigma: float) -> float:
|
48
|
-
"""
|
49
|
-
Calculate SVI total implied variance at a given log-moneyness.
|
50
|
-
This version maintains compatibility with the original Voly package.
|
51
|
-
"""
|
52
|
-
return a + b * (rho * (LM - m) + np.sqrt((LM - m) ** 2 + sigma ** 2))
|
53
|
-
|
54
|
-
@staticmethod
|
55
|
-
def svi_d(LM: float, a: float, b: float, m: float, rho: float, sigma: float) -> float:
|
56
|
-
"""Calculate first derivative of SVI function with respect to log-moneyness."""
|
57
|
-
return b * (rho + ((LM - m) / np.sqrt((LM - m) ** 2 + sigma ** 2)))
|
58
|
-
|
59
|
-
@staticmethod
|
60
|
-
def svi_dd(LM: float, a: float, b: float, m: float, rho: float, sigma: float) -> float:
|
61
|
-
"""Calculate second derivative of SVI function with respect to log-moneyness."""
|
62
|
-
return b * sigma ** 2 / ((LM - m) ** 2 + sigma ** 2) ** 1.5
|
63
|
-
|
64
38
|
@staticmethod
|
65
39
|
def svi_min_strike(sigma: float, rho: float, m: float) -> float:
|
66
40
|
"""Calculate the minimum valid log-strike for this SVI parameterization."""
|
@@ -69,6 +43,7 @@ class SVIModel:
|
|
69
43
|
@staticmethod
|
70
44
|
def raw_to_jw_params(a: float, b: float, m: float, rho: float, sigma: float, t: float) -> Tuple[
|
71
45
|
float, float, float, float, float]:
|
46
|
+
"""Convert raw SVI parameters to Jump-Wing parameters."""
|
72
47
|
nu = (a + b * ((-rho) * m + np.sqrt(m ** 2 + sigma ** 2))) / t
|
73
48
|
psi = (1 / np.sqrt(nu * t)) * (b / 2) * (rho - (m / np.sqrt(m ** 2 + sigma ** 2)))
|
74
49
|
p = (1 / np.sqrt(nu * t)) * b * (1 - rho)
|
@@ -78,20 +53,6 @@ class SVIModel:
|
|
78
53
|
|
79
54
|
@classmethod
|
80
55
|
def calibration(cls, tiv, vega, k, m, sigma):
|
81
|
-
"""
|
82
|
-
Calibrate SVI parameters using a more stable approach.
|
83
|
-
|
84
|
-
Parameters:
|
85
|
-
- tiv: Total implied variance values
|
86
|
-
- vega: Option vega values (for weighting)
|
87
|
-
- k: Log-moneyness values
|
88
|
-
- m: Horizontal shift parameter
|
89
|
-
- sigma: Convexity parameter
|
90
|
-
|
91
|
-
Returns:
|
92
|
-
- c, d, a: Calibrated parameters
|
93
|
-
- loss: Calibration loss value
|
94
|
-
"""
|
95
56
|
sigma = max(sigma, 0.001)
|
96
57
|
vega = vega / vega.max() if vega.max() > 0 else np.ones_like(vega)
|
97
58
|
y = (k - m) / sigma
|
@@ -118,28 +79,16 @@ class SVIModel:
|
|
118
79
|
|
119
80
|
@staticmethod
|
120
81
|
def loss(tiv, vega, y, c, d, a):
|
121
|
-
"""Calculate weighted loss for SVI calibration."""
|
122
82
|
diff = tiv - (a + d * y + c * np.sqrt(y * y + 1))
|
123
83
|
return (vega * diff * diff).mean()
|
124
84
|
|
125
85
|
@classmethod
|
126
86
|
def fit(cls, tiv, vega, k, tau=1.0):
|
127
|
-
"""
|
128
|
-
Fit SVI model to market data using a more stable two-step approach.
|
129
|
-
|
130
|
-
Parameters:
|
131
|
-
- tiv: Total implied variance values
|
132
|
-
- vega: Option vega values (for weighting)
|
133
|
-
- k: Log-moneyness values
|
134
|
-
- tau: Time to expiry in years
|
135
|
-
|
136
|
-
Returns:
|
137
|
-
- params: [a, b, m, rho, sigma] parameters
|
138
|
-
- loss: Fitting loss value
|
139
|
-
"""
|
140
87
|
if len(k) <= 5:
|
141
88
|
return [np.nan] * 5, np.inf
|
142
89
|
|
90
|
+
from scipy.optimize import minimize
|
91
|
+
|
143
92
|
vega = vega / vega.max() if vega.max() > 0 else np.ones_like(vega)
|
144
93
|
m_init = np.mean(k)
|
145
94
|
sigma_init = max(0.1, np.std(k) * 0.1)
|
@@ -168,55 +117,41 @@ class SVIModel:
|
|
168
117
|
|
169
118
|
@classmethod
|
170
119
|
def correct_calendar_arbitrage(cls, params, t, tiv, vega, k, prev_params, prev_t, k_grid):
|
171
|
-
"""
|
172
|
-
Correct calendar arbitrage by ensuring the current SVI surface stays above the previous one.
|
173
|
-
|
174
|
-
Parameters:
|
175
|
-
- params: Current SVI parameters [a, b, sigma, rho, m]
|
176
|
-
- t: Current time to expiry
|
177
|
-
- tiv: Current total implied variance values
|
178
|
-
- vega: Current vega values
|
179
|
-
- k: Current log-moneyness values
|
180
|
-
- prev_params: Previous SVI parameters
|
181
|
-
- prev_t: Previous time to expiry
|
182
|
-
- k_grid: Grid of log-moneyness values for arbitrage checking
|
183
|
-
|
184
|
-
Returns:
|
185
|
-
- New arbitrage-free parameters
|
186
|
-
"""
|
187
|
-
|
188
120
|
if np.any(np.isnan(params)) or np.any(np.isnan(prev_params)):
|
189
121
|
return params
|
190
122
|
|
123
|
+
from scipy.optimize import minimize
|
124
|
+
|
191
125
|
a_init, b_init, m_init, rho_init, sigma_init = params
|
192
126
|
a_prev, b_prev, m_prev, rho_prev, sigma_prev = prev_params
|
193
127
|
k_constraint = np.unique(np.concatenate([k, np.linspace(min(k), max(k), len(k_grid))]))
|
194
128
|
|
195
129
|
def objective(x):
|
196
130
|
a, b, m, rho, sigma = x
|
197
|
-
w_model = cls.
|
198
|
-
|
199
|
-
|
200
|
-
|
131
|
+
w_model = cls.svi_raw(k, a * t, b * t, m, rho, sigma)
|
132
|
+
from sklearn.metrics import mean_squared_error
|
133
|
+
fit_loss = mean_squared_error(tiv, w_model, sample_weight=vega)
|
134
|
+
param_deviation = sum(((x[i] - x_init) / max(abs(x_init), 1e-6)) ** 2
|
135
|
+
for i, x_init in enumerate([a_init, b_init, m_init, rho_init, sigma_init]))
|
201
136
|
return fit_loss + 0.01 * param_deviation
|
202
137
|
|
203
138
|
def calendar_constraint(x):
|
204
|
-
a, b,
|
205
|
-
w_current = cls.
|
206
|
-
w_prev = cls.
|
139
|
+
a, b, m, rho, sigma = x
|
140
|
+
w_current = cls.svi_raw(k_constraint, a * t, b * t, m, rho, sigma)
|
141
|
+
w_prev = cls.svi_raw(k_constraint, a_prev * prev_t, b_prev * prev_t, m_prev, rho_prev, sigma_prev)
|
207
142
|
return w_current - w_prev
|
208
143
|
|
209
144
|
bounds = [
|
210
145
|
(max(a_init * 0.8, 1e-6), a_init * 1.2),
|
211
146
|
(max(b_init * 0.8, 0), b_init * 1.2),
|
212
|
-
(
|
147
|
+
(m_init - 0.05, m_init + 0.05),
|
213
148
|
(max(rho_init - 0.05, -1), min(rho_init + 0.05, 1)),
|
214
|
-
(
|
149
|
+
(max(sigma_init * 0.8, 1e-6), sigma_init * 1.2)
|
215
150
|
]
|
216
151
|
|
217
152
|
constraints = [
|
218
153
|
{'type': 'ineq', 'fun': calendar_constraint},
|
219
|
-
{'type': 'ineq', 'fun': lambda x: x[0] + x[1] * x[
|
154
|
+
{'type': 'ineq', 'fun': lambda x: x[0] + x[1] * x[4] * np.sqrt(1 - x[3] ** 2)}
|
220
155
|
]
|
221
156
|
|
222
157
|
result = minimize(
|
@@ -226,8 +161,14 @@ class SVIModel:
|
|
226
161
|
)
|
227
162
|
|
228
163
|
if result.success:
|
229
|
-
|
230
|
-
|
164
|
+
new_params = result.x
|
165
|
+
w_current = cls.svi_raw(k_constraint, new_params[0] * t, new_params[1] * t, *new_params[2:])
|
166
|
+
w_prev = cls.svi_raw(k_constraint, a_prev * prev_t, b_prev * prev_t, m_prev, rho_prev, sigma_prev)
|
167
|
+
violation = np.min(w_current - w_prev)
|
168
|
+
print(f"Calendar arbitrage correction {'successful' if violation >= -1e-6 else 'failed'} for t={t:.4f}, "
|
169
|
+
f"min margin={violation:.6f}")
|
170
|
+
return new_params
|
171
|
+
print(f"Calendar arbitrage correction failed for t={t:.4f}")
|
231
172
|
return params
|
232
173
|
|
233
174
|
|
@@ -1,427 +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 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['interest_rate'].iloc[0] if 'interest_rate' 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
|
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
|