voly 0.0.165__py3-none-any.whl → 0.0.167__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voly/client.py +6 -11
- voly/core/charts.py +1 -1
- voly/core/data.py +1 -1
- voly/core/fit.py +263 -77
- voly/formulas.py +1 -2
- voly/models.py +165 -29
- {voly-0.0.165.dist-info → voly-0.0.167.dist-info}/METADATA +1 -1
- voly-0.0.167.dist-info/RECORD +20 -0
- {voly-0.0.165.dist-info → voly-0.0.167.dist-info}/WHEEL +1 -1
- voly-0.0.165.dist-info/RECORD +0 -20
- {voly-0.0.165.dist-info → voly-0.0.167.dist-info}/licenses/LICENSE +0 -0
- {voly-0.0.165.dist-info → voly-0.0.167.dist-info}/top_level.txt +0 -0
voly/client.py
CHANGED
@@ -165,29 +165,24 @@ class VolyClient:
|
|
165
165
|
|
166
166
|
@staticmethod
|
167
167
|
def fit_model(option_chain: pd.DataFrame,
|
168
|
-
|
169
|
-
|
170
|
-
param_bounds: Optional[Tuple] = None) -> Dict[str, Any]:
|
168
|
+
domain_params: Tuple[float, float, int] = (-2, 2, 500)
|
169
|
+
) -> Dict[str, Any]:
|
171
170
|
"""
|
172
171
|
Fit a volatility model to market data.
|
173
172
|
|
174
173
|
Parameters:
|
175
174
|
- option_chain: DataFrame with option market data
|
176
|
-
-
|
177
|
-
- initial_params: Optional initial parameters for optimization
|
178
|
-
- param_bounds: Optional parameter bounds for optimization
|
175
|
+
- domain_params: Tuple of (min, max, num_points) for the moneyness grid
|
179
176
|
|
180
177
|
Returns:
|
181
|
-
-
|
178
|
+
- Dataframe with fit_results
|
182
179
|
"""
|
183
|
-
logger.info(f"Fitting
|
180
|
+
logger.info(f"Fitting model to market data.")
|
184
181
|
|
185
182
|
# Fit the model
|
186
183
|
fit_results = fit_model(
|
187
184
|
option_chain=option_chain,
|
188
|
-
|
189
|
-
initial_params=initial_params,
|
190
|
-
param_bounds=param_bounds
|
185
|
+
domain_params=domain_params
|
191
186
|
)
|
192
187
|
|
193
188
|
return fit_results
|
voly/core/charts.py
CHANGED
@@ -139,7 +139,7 @@ def plot_raw_parameters(fit_results: pd.DataFrame) -> go.Figure:
|
|
139
139
|
- Plotly figure
|
140
140
|
"""
|
141
141
|
# Select parameters to plot
|
142
|
-
param_names = ['a', 'b', '
|
142
|
+
param_names = ['a', 'b', 'm', 'rho', 'sigma']
|
143
143
|
|
144
144
|
# Create subplots
|
145
145
|
fig = make_subplots(
|
voly/core/data.py
CHANGED
@@ -259,7 +259,7 @@ def process_order_book_depth(option_chain, max_depth=5):
|
|
259
259
|
s = row['underlying_price']
|
260
260
|
k = row['strikes']
|
261
261
|
t = row['t']
|
262
|
-
r = row['
|
262
|
+
r = row['r'] if 'r' in row else 0.0
|
263
263
|
option_type = 'C' if row['option_type'] == 'call' else 'P'
|
264
264
|
|
265
265
|
# Process bid side
|
voly/core/fit.py
CHANGED
@@ -19,62 +19,43 @@ import warnings
|
|
19
19
|
warnings.filterwarnings("ignore")
|
20
20
|
|
21
21
|
|
22
|
-
@catch_exception
|
23
|
-
def calculate_residuals(params: List[float], t: float, option_chain: pd.DataFrame,
|
24
|
-
model: Any = SVIModel) -> np.ndarray:
|
25
|
-
"""Calculate residuals between market and model implied volatilities."""
|
26
|
-
maturity_data = option_chain[option_chain['t'] == t]
|
27
|
-
w = np.array([model.svi(x, *params) for x in maturity_data['log_moneyness']])
|
28
|
-
iv_actual = maturity_data['mark_iv'].values
|
29
|
-
return iv_actual - np.sqrt(w / t)
|
30
|
-
|
31
|
-
|
32
22
|
@catch_exception
|
33
23
|
def fit_model(option_chain: pd.DataFrame,
|
34
|
-
|
35
|
-
initial_params: Optional[List[float]] = None,
|
36
|
-
param_bounds: Optional[Tuple] = None) -> pd.DataFrame:
|
24
|
+
domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000)) -> pd.DataFrame:
|
37
25
|
"""
|
38
|
-
Fit
|
26
|
+
Fit Voly's volatility model to market data.
|
39
27
|
|
40
28
|
Parameters:
|
41
29
|
- option_chain: DataFrame with market data
|
42
|
-
-
|
43
|
-
|
44
|
-
- param_bounds: Optional parameter bounds for optimization (default: model's defaults)
|
30
|
+
- domain_params : Tuple[float, float, int]
|
31
|
+
(min_log_moneyness, max_log_moneyness, num_points)
|
45
32
|
|
46
33
|
Returns:
|
47
34
|
- DataFrame with all fit results and performance metrics as columns, maturity_names as index
|
48
35
|
"""
|
49
|
-
if model_name.lower() != 'svi':
|
50
|
-
raise VolyError(f"Model type '{model_name}' is not supported. Currently only 'svi' is available.")
|
51
|
-
|
52
|
-
# Use defaults if not provided
|
53
|
-
initial_params = initial_params or SVIModel.DEFAULT_INITIAL_PARAMS
|
54
|
-
param_bounds = param_bounds or SVIModel.DEFAULT_PARAM_BOUNDS
|
55
36
|
|
56
37
|
# Define column names and their data types
|
57
38
|
column_dtypes = {
|
58
39
|
's': float,
|
59
|
-
'u': float,
|
60
40
|
't': float,
|
61
41
|
'r': float,
|
62
|
-
'oi': float,
|
63
|
-
'volume': float,
|
64
42
|
'maturity_date': 'datetime64[ns]',
|
65
43
|
'a': float,
|
66
44
|
'b': float,
|
67
|
-
'sigma': float,
|
68
|
-
'rho': float,
|
69
45
|
'm': float,
|
46
|
+
'rho': float,
|
47
|
+
'sigma': float,
|
70
48
|
'nu': float,
|
71
49
|
'psi': float,
|
72
50
|
'p': float,
|
73
51
|
'c': float,
|
74
52
|
'nu_tilde': float,
|
53
|
+
'log_min_strike': float,
|
54
|
+
'usd_min_strike': float,
|
75
55
|
'fit_success': bool,
|
76
|
-
'
|
77
|
-
'
|
56
|
+
'butterfly_arbitrage_free': bool,
|
57
|
+
'calendar_arbitrage_free': bool,
|
58
|
+
'loss': float,
|
78
59
|
'rmse': float,
|
79
60
|
'mae': float,
|
80
61
|
'r2': float,
|
@@ -94,79 +75,120 @@ def fit_model(option_chain: pd.DataFrame,
|
|
94
75
|
|
95
76
|
s = option_chain['index_price'].iloc[-1]
|
96
77
|
|
78
|
+
# Dictionary to track fit results by maturity for arbitrage checks
|
79
|
+
fit_params_dict = {}
|
80
|
+
|
81
|
+
# First pass: Fit each maturity
|
97
82
|
for t in unique_ts:
|
98
83
|
# Get data for this maturity
|
99
84
|
maturity_data = option_chain[option_chain['t'] == t]
|
100
85
|
maturity_name = maturity_data['maturity_name'].iloc[0]
|
86
|
+
maturity_date = maturity_data['maturity_date'].iloc[0]
|
101
87
|
|
102
88
|
logger.info(f"Optimizing for {maturity_name}...")
|
103
89
|
|
104
|
-
#
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
initial_params,
|
109
|
-
args=(t, option_chain, SVIModel),
|
110
|
-
bounds=param_bounds,
|
111
|
-
max_nfev=1000
|
112
|
-
)
|
113
|
-
except Exception as e:
|
114
|
-
raise VolyError(f"Optimization failed for {maturity_name}: {str(e)}")
|
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)
|
115
94
|
|
116
|
-
#
|
117
|
-
|
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]
|
118
98
|
|
119
|
-
#
|
120
|
-
|
121
|
-
|
122
|
-
|
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
|
123
107
|
|
124
|
-
|
125
|
-
|
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))
|
108
|
+
# Fit using the improved SVI method
|
109
|
+
params, loss = SVIModel.fit(tiv=w, vega=vega_masked, k=k_masked, tau=t)
|
129
110
|
|
130
|
-
#
|
131
|
-
|
111
|
+
# Store the parameters for this maturity
|
112
|
+
fit_params_dict[maturity_date] = (t, params)
|
132
113
|
|
133
|
-
#
|
134
|
-
|
135
|
-
volume = maturity_data['volume'].sum() if 'volume' in maturity_data.columns else 0
|
136
|
-
r = maturity_data['interest_rate'].iloc[0] if 'interest_rate' in maturity_data.columns else 0
|
114
|
+
# Extract parameters (will be nan if fit failed)
|
115
|
+
a, b, m, rho, sigma = params
|
137
116
|
|
138
|
-
# Calculate
|
139
|
-
|
117
|
+
# Calculate statistics
|
118
|
+
fit_success = not np.isnan(a)
|
119
|
+
butterfly_arbitrage_free = True
|
120
|
+
calendar_arbitrage_free = True # Will check later
|
121
|
+
|
122
|
+
# Initialize default metrics
|
123
|
+
rmse = mae = r2 = max_error = np.nan
|
124
|
+
nu = psi = p = c = nu_tilde = np.nan
|
125
|
+
log_min_strike = usd_min_strike = np.nan
|
126
|
+
a_scaled = b_scaled = np.nan
|
127
|
+
|
128
|
+
if fit_success:
|
129
|
+
# Scale a and b by t
|
130
|
+
a_scaled, b_scaled = a * t, b * t
|
131
|
+
|
132
|
+
# Calculate Jump-Wing parameters
|
133
|
+
nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t)
|
134
|
+
|
135
|
+
# Calculate model predictions for statistics
|
136
|
+
w_model = np.array([SVIModel.svi(x, a_scaled, b_scaled, m, rho, sigma) for x in k_masked])
|
137
|
+
iv_model = np.sqrt(w_model / t)
|
138
|
+
iv_market = iv_masked
|
139
|
+
|
140
|
+
# Calculate statistics
|
141
|
+
rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
|
142
|
+
mae = mean_absolute_error(iv_market, iv_model)
|
143
|
+
r2 = r2_score(iv_market, iv_model)
|
144
|
+
max_error = np.max(np.abs(iv_market - iv_model))
|
145
|
+
|
146
|
+
# Calculate minimum strike
|
147
|
+
log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
|
148
|
+
usd_min_strike = s * np.exp(-log_min_strike) # Convert from log_moneyness to strike
|
149
|
+
|
150
|
+
# Check butterfly arbitrage
|
151
|
+
k_range = np.linspace(min(k_masked), max(k_masked), domain_params[2])
|
152
|
+
for k_val in k_range:
|
153
|
+
wk = SVIModel.svi(k_val, a_scaled, b_scaled, m, rho, sigma)
|
154
|
+
wp = SVIModel.svi_d(k_val, a_scaled, b_scaled, m, rho, sigma)
|
155
|
+
wpp = SVIModel.svi_dd(k_val, a_scaled, b_scaled, m, rho, sigma)
|
156
|
+
g = (1 - (k_val * wp) / (2 * wk)) ** 2 - (wp ** 2) / 4 * (1 / wk + 1 / 4) + wpp / 2
|
157
|
+
if g < 0:
|
158
|
+
butterfly_arbitrage_free = False
|
159
|
+
break
|
160
|
+
|
161
|
+
r = maturity_data['r'].iloc[0] if 'r' in maturity_data.columns else 0
|
140
162
|
|
141
163
|
# Store values in the results dictionary with proper types
|
142
164
|
results_data['s'].append(float(s))
|
143
|
-
results_data['u'].append(float(u))
|
144
165
|
results_data['t'].append(float(t))
|
145
166
|
results_data['r'].append(float(r))
|
146
|
-
results_data['
|
147
|
-
results_data['
|
148
|
-
results_data['
|
149
|
-
results_data['
|
150
|
-
results_data['
|
151
|
-
results_data['
|
152
|
-
results_data['m'].append(float(m))
|
153
|
-
results_data['rho'].append(float(rho))
|
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)
|
154
173
|
results_data['nu'].append(float(nu))
|
155
174
|
results_data['psi'].append(float(psi))
|
156
175
|
results_data['p'].append(float(p))
|
157
176
|
results_data['c'].append(float(c))
|
158
177
|
results_data['nu_tilde'].append(float(nu_tilde))
|
159
|
-
results_data['
|
160
|
-
results_data['
|
161
|
-
results_data['
|
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))
|
162
184
|
results_data['rmse'].append(float(rmse))
|
163
185
|
results_data['mae'].append(float(mae))
|
164
186
|
results_data['r2'].append(float(r2))
|
165
187
|
results_data['max_error'].append(float(max_error))
|
166
|
-
results_data['n_points'].append(int(len(
|
188
|
+
results_data['n_points'].append(int(len(k_masked)))
|
167
189
|
|
168
190
|
# Log result
|
169
|
-
status = f'{GREEN}SUCCESS{RESET}' if
|
191
|
+
status = f'{GREEN}SUCCESS{RESET}' if fit_success else f'{RED}FAILED{RESET}'
|
170
192
|
logger.info(f'Optimization for {maturity_name}: {status}')
|
171
193
|
logger.info('-------------------------------------')
|
172
194
|
|
@@ -181,6 +203,170 @@ def fit_model(option_chain: pd.DataFrame,
|
|
181
203
|
except (ValueError, TypeError) as e:
|
182
204
|
logger.warning(f"Could not convert column {col} to {dtype}: {e}")
|
183
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.")
|
184
370
|
return results_df
|
185
371
|
|
186
372
|
|
@@ -204,7 +390,7 @@ def get_iv_surface(model_results: pd.DataFrame,
|
|
204
390
|
x_surface: Dictionary mapping maturity/dtm names to requested x domain arrays
|
205
391
|
"""
|
206
392
|
# Check if required columns are present
|
207
|
-
required_columns = ['a', 'b', '
|
393
|
+
required_columns = ['a', 'b', 'm', 'rho', 'sigma', 't']
|
208
394
|
missing_columns = [col for col in required_columns if col not in model_results.columns]
|
209
395
|
if missing_columns:
|
210
396
|
raise VolyError(f"Required columns missing in model_results: {missing_columns}")
|
@@ -221,9 +407,9 @@ def get_iv_surface(model_results: pd.DataFrame,
|
|
221
407
|
params = [
|
222
408
|
model_results.loc[i, 'a'],
|
223
409
|
model_results.loc[i, 'b'],
|
224
|
-
model_results.loc[i, '
|
410
|
+
model_results.loc[i, 'm'],
|
225
411
|
model_results.loc[i, 'rho'],
|
226
|
-
model_results.loc[i, '
|
412
|
+
model_results.loc[i, 'sigma']
|
227
413
|
]
|
228
414
|
s = model_results.loc[i, 's']
|
229
415
|
r = model_results.loc[i, 'r']
|
voly/formulas.py
CHANGED
@@ -225,7 +225,6 @@ def iv(option_price: float, s: float, K: float, r: float, t: float,
|
|
225
225
|
- Implied volatility
|
226
226
|
"""
|
227
227
|
|
228
|
-
'''
|
229
228
|
# Check if option price is within theoretical bounds
|
230
229
|
if option_type.lower() in ["call", "c"]:
|
231
230
|
intrinsic = max(0, s - K * np.exp(-r * t))
|
@@ -239,7 +238,7 @@ def iv(option_price: float, s: float, K: float, r: float, t: float,
|
|
239
238
|
return np.nan # Price below intrinsic value
|
240
239
|
if option_price >= K:
|
241
240
|
return np.inf # Price exceeds strike
|
242
|
-
|
241
|
+
|
243
242
|
flag = 'c' if option_type.lower() in ["call", "c"] else 'p'
|
244
243
|
|
245
244
|
iv_value = implied_volatility(
|
voly/models.py
CHANGED
@@ -3,6 +3,8 @@ Volatility models for the Voly package.
|
|
3
3
|
"""
|
4
4
|
|
5
5
|
import numpy as np
|
6
|
+
from numpy.linalg import solve
|
7
|
+
from scipy.optimize import minimize
|
6
8
|
from typing import Tuple, Dict, List, Optional, Union
|
7
9
|
|
8
10
|
|
@@ -14,12 +16,8 @@ class SVIModel:
|
|
14
16
|
SVI parameterization, as well as its derivatives and related functions.
|
15
17
|
"""
|
16
18
|
|
17
|
-
# Default initial parameters and bounds
|
18
|
-
DEFAULT_INITIAL_PARAMS = [0.04, 0.1, 0.2, -0.5, 0.01]
|
19
|
-
DEFAULT_PARAM_BOUNDS = ([-np.inf, 0, 0, -1, -np.inf], [np.inf, np.inf, np.inf, 1, np.inf])
|
20
|
-
|
21
19
|
# Parameter names for reference
|
22
|
-
PARAM_NAMES = ['a', 'b', '
|
20
|
+
PARAM_NAMES = ['a', 'b', 'm', 'rho', 'sigma']
|
23
21
|
JW_PARAM_NAMES = ['nu', 'psi', 'p', 'c', 'nu_tilde']
|
24
22
|
|
25
23
|
# Parameter descriptions for documentation
|
@@ -37,23 +35,20 @@ class SVIModel:
|
|
37
35
|
}
|
38
36
|
|
39
37
|
@staticmethod
|
40
|
-
def
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
return b * (rho
|
38
|
+
def svi_raw(k, a, b, m, rho, sigma):
|
39
|
+
assert b >= 0, 'b must be non-negative'
|
40
|
+
assert abs(rho) <= 1, '|rho| must be <= 1'
|
41
|
+
assert sigma >= 0, 'sigma must be non-negative'
|
42
|
+
assert a + b * sigma * sqrt(1 - rho ** 2) >= 0, 'a + b*sigma*sqrt(1-rho^2) must be non-negative'
|
43
|
+
return a + b * (rho * (k - m) + sqrt((k - m) ** 2 + sigma ** 2))
|
46
44
|
|
47
45
|
@staticmethod
|
48
|
-
def
|
49
|
-
return b * LM
|
50
|
-
|
51
|
-
@staticmethod
|
52
|
-
def svi_min_strike(sigma: float, rho: float, m: float) -> float:
|
53
|
-
return m - ((sigma * rho) / np.sqrt(1 - rho ** 2))
|
46
|
+
def svi(LM: float, a: float, b: float, sigma: float, rho: float, m: float) -> float:
|
47
|
+
return a + b * (rho * (LM - m) + np.sqrt((LM - m) ** 2 + sigma ** 2))
|
54
48
|
|
55
49
|
@staticmethod
|
56
|
-
def raw_to_jw_params(a: float, b: float,
|
50
|
+
def raw_to_jw_params(a: float, b: float, m: float, rho: float, sigma: float, t: float) -> Tuple[
|
51
|
+
float, float, float, float, float]:
|
57
52
|
nu = (a + b * ((-rho) * m + np.sqrt(m ** 2 + sigma ** 2))) / t
|
58
53
|
psi = (1 / np.sqrt(nu * t)) * (b / 2) * (rho - (m / np.sqrt(m ** 2 + sigma ** 2)))
|
59
54
|
p = (1 / np.sqrt(nu * t)) * b * (1 - rho)
|
@@ -61,18 +56,159 @@ class SVIModel:
|
|
61
56
|
nu_tilde = (1 / t) * (a + b * sigma * np.sqrt(1 - rho ** 2))
|
62
57
|
return nu, psi, p, c, nu_tilde
|
63
58
|
|
59
|
+
@classmethod
|
60
|
+
def calibration(cls, tiv, vega, k, m, sigma):
|
61
|
+
"""
|
62
|
+
Calibrate SVI parameters using a more stable approach.
|
63
|
+
|
64
|
+
Parameters:
|
65
|
+
- tiv: Total implied variance values
|
66
|
+
- vega: Option vega values (for weighting)
|
67
|
+
- k: Log-moneyness values
|
68
|
+
- m: Horizontal shift parameter
|
69
|
+
- sigma: Convexity parameter
|
70
|
+
|
71
|
+
Returns:
|
72
|
+
- c, d, a: Calibrated parameters
|
73
|
+
- loss: Calibration loss value
|
74
|
+
"""
|
75
|
+
sigma = max(sigma, 0.001)
|
76
|
+
vega = vega / vega.max() if vega.max() > 0 else np.ones_like(vega)
|
77
|
+
y = (k - m) / sigma
|
78
|
+
w = vega.mean()
|
79
|
+
y1 = (vega * y).mean()
|
80
|
+
y2 = (vega * y * y).mean()
|
81
|
+
y3 = (vega * np.sqrt(y * y + 1)).mean()
|
82
|
+
y4 = (vega * y * np.sqrt(y * y + 1)).mean()
|
83
|
+
y5 = (vega * (y * y + 1)).mean()
|
84
|
+
vy2 = (vega * tiv * np.sqrt(y * y + 1)).mean()
|
85
|
+
vy = (vega * tiv * y).mean()
|
86
|
+
v = (vega * tiv).mean()
|
87
|
+
|
88
|
+
matrix = [[y5, y4, y3], [y4, y2, y1], [y3, y1, w]]
|
89
|
+
vector = [vy2, vy, v]
|
90
|
+
c, d, a = solve(np.array(matrix), np.array(vector))
|
91
|
+
|
92
|
+
c = np.clip(c, 0, 4 * sigma)
|
93
|
+
a = max(a, 1e-6)
|
94
|
+
d = np.clip(d, -min(c, 4 * sigma - c), min(c, 4 * sigma - c))
|
95
|
+
|
96
|
+
loss = cls.loss(tiv, vega, y, c, d, a)
|
97
|
+
return c, d, a, loss
|
98
|
+
|
64
99
|
@staticmethod
|
65
|
-
def
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
100
|
+
def loss(tiv, vega, y, c, d, a):
|
101
|
+
"""Calculate weighted loss for SVI calibration."""
|
102
|
+
diff = tiv - (a + d * y + c * np.sqrt(y * y + 1))
|
103
|
+
return (vega * diff * diff).mean()
|
104
|
+
|
105
|
+
@classmethod
|
106
|
+
def fit(cls, tiv, vega, k, tau=1.0):
|
107
|
+
"""
|
108
|
+
Fit SVI model to market data using a more stable two-step approach.
|
109
|
+
|
110
|
+
Parameters:
|
111
|
+
- tiv: Total implied variance values
|
112
|
+
- vega: Option vega values (for weighting)
|
113
|
+
- k: Log-moneyness values
|
114
|
+
- tau: Time to expiry in years
|
115
|
+
|
116
|
+
Returns:
|
117
|
+
- params: [a, b, m, rho, sigma] parameters
|
118
|
+
- loss: Fitting loss value
|
119
|
+
"""
|
120
|
+
if len(k) <= 5:
|
121
|
+
return [np.nan] * 5, np.inf
|
122
|
+
|
123
|
+
vega = vega / vega.max() if vega.max() > 0 else np.ones_like(vega)
|
124
|
+
m_init = np.mean(k)
|
125
|
+
sigma_init = max(0.1, np.std(k) * 0.1)
|
126
|
+
|
127
|
+
def score(params):
|
128
|
+
sigma, m = params
|
129
|
+
c, d, a_calib, loss = cls.calibration(tiv, vega, k, m, sigma)
|
130
|
+
return loss
|
131
|
+
|
132
|
+
result = minimize(score, [sigma_init, m_init], bounds=[(0.001, None), (None, None)],
|
133
|
+
tol=1e-16, method="Nelder-Mead", options={'maxfun': 5000})
|
134
|
+
|
135
|
+
sigma, m = result.x
|
136
|
+
c, d, a_calib, loss = cls.calibration(tiv, vega, k, m, sigma)
|
137
|
+
a_calib = max(a_calib, 1e-6)
|
138
|
+
|
139
|
+
if c != 0:
|
140
|
+
a_svi = a_calib / tau
|
141
|
+
rho_svi = d / c
|
142
|
+
b_svi = c / (sigma * tau)
|
143
|
+
else:
|
144
|
+
a_svi = a_calib / tau
|
145
|
+
rho_svi = b_svi = 0
|
146
|
+
|
147
|
+
return [a_svi, b_svi, m, rho_svi, sigma], loss
|
148
|
+
|
149
|
+
@classmethod
|
150
|
+
def correct_calendar_arbitrage(cls, params, t, tiv, vega, k, prev_params, prev_t, domain_params):
|
151
|
+
"""
|
152
|
+
Correct calendar arbitrage by ensuring the current SVI surface stays above the previous one.
|
153
|
+
|
154
|
+
Parameters:
|
155
|
+
- params: Current SVI parameters [a, b, sigma, rho, m]
|
156
|
+
- t: Current time to expiry
|
157
|
+
- tiv: Current total implied variance values
|
158
|
+
- vega: Current vega values
|
159
|
+
- k: Current log-moneyness values
|
160
|
+
- prev_params: Previous SVI parameters
|
161
|
+
- prev_t: Previous time to expiry
|
162
|
+
- k_grid: Grid of log-moneyness values for arbitrage checking
|
163
|
+
|
164
|
+
Returns:
|
165
|
+
- New arbitrage-free parameters
|
166
|
+
"""
|
167
|
+
|
168
|
+
if np.any(np.isnan(params)) or np.any(np.isnan(prev_params)):
|
169
|
+
return params
|
170
|
+
|
171
|
+
a_init, b_init, sigma_init, rho_init, m_init = params
|
172
|
+
a_prev, b_prev, sigma_prev, rho_prev, m_prev = prev_params
|
173
|
+
k_constraint = np.unique(np.concatenate([k, np.linspace(min(k), max(k), domain_params[2])]))
|
174
|
+
|
175
|
+
def objective(x):
|
176
|
+
a, b, sigma, rho, m = x
|
177
|
+
w_model = cls.svi(k, a * t, b * t, sigma, rho, m)
|
178
|
+
fit_loss = ((w_model - tiv) ** 2 * vega).mean()
|
179
|
+
param_deviation = sum(((x[i] - params[i]) / max(abs(params[i]), 1e-6)) ** 2
|
180
|
+
for i in range(len(params)))
|
181
|
+
return fit_loss + 0.01 * param_deviation
|
182
|
+
|
183
|
+
def calendar_constraint(x):
|
184
|
+
a, b, sigma, rho, m = x
|
185
|
+
w_current = cls.svi(k_constraint, a * t, b * t, sigma, rho, m)
|
186
|
+
w_prev = cls.svi(k_constraint, a_prev * prev_t, b_prev * prev_t, sigma_prev, rho_prev, m_prev)
|
187
|
+
return w_current - w_prev
|
188
|
+
|
189
|
+
bounds = [
|
190
|
+
(max(a_init * 0.8, 1e-6), a_init * 1.2),
|
191
|
+
(max(b_init * 0.8, 0), b_init * 1.2),
|
192
|
+
(max(sigma_init * 0.8, 1e-6), sigma_init * 1.2),
|
193
|
+
(max(rho_init - 0.05, -1), min(rho_init + 0.05, 1)),
|
194
|
+
(m_init - 0.05, m_init + 0.05)
|
195
|
+
]
|
196
|
+
|
197
|
+
constraints = [
|
198
|
+
{'type': 'ineq', 'fun': calendar_constraint},
|
199
|
+
{'type': 'ineq', 'fun': lambda x: x[0] + x[1] * x[2] * np.sqrt(1 - x[3] ** 2)}
|
200
|
+
]
|
201
|
+
|
202
|
+
result = minimize(
|
203
|
+
objective, [a_init, b_init, sigma_init, rho_init, m_init],
|
204
|
+
bounds=bounds, constraints=constraints, method='SLSQP',
|
205
|
+
options={'disp': False, 'maxiter': 1000, 'ftol': 1e-8}
|
206
|
+
)
|
207
|
+
|
208
|
+
if result.success:
|
209
|
+
return result.x
|
210
|
+
|
211
|
+
return params
|
76
212
|
|
77
213
|
|
78
214
|
# Models dictionary for easy access
|
@@ -0,0 +1,20 @@
|
|
1
|
+
voly/__init__.py,sha256=8xyDk7rFCn_MOD5hxuv5cxxKZvBVRiSIM7TgaMPpwpw,211
|
2
|
+
voly/client.py,sha256=6e9cX5JWeqoTIktKlT4yEN9FG5Cj8Icl7gHxhVayi24,14464
|
3
|
+
voly/exceptions.py,sha256=PBsbn1vNMvKcCJwwJ4lBO6glD85jo1h2qiEmD7ArAjs,92
|
4
|
+
voly/formulas.py,sha256=eDPw32xa3dwCIrE-zeZzznfGaplGFoVcA167uQngZ70,11209
|
5
|
+
voly/models.py,sha256=f97VvGwzpzSXuDtXqdjSHL1RbzOaYajXc9cRH8WbXtk,7968
|
6
|
+
voly/core/__init__.py,sha256=bu6fS2I1Pj9fPPnl-zY3L7NqrZSY5Zy6NY2uMUvdhKs,183
|
7
|
+
voly/core/charts.py,sha256=2S-BfCo30aj1_xlNLqF-za5rQWxF_mWKIdtdOe5bgbw,12735
|
8
|
+
voly/core/data.py,sha256=bCx_AkU0L5hCxHMIJLOU921uppS9aHnzLRN-jfc8M5c,13626
|
9
|
+
voly/core/fit.py,sha256=MSAjySs58J4NUMH-ZoQNcBhtGRbBeHeh7fPwThmJH9E,17679
|
10
|
+
voly/core/hd.py,sha256=UFAyLncNUHivpPAcko6IK1bC55mudVtdlRFfXp63HXE,14771
|
11
|
+
voly/core/interpolate.py,sha256=JkK172-FXyhesW3hY4pEeuJWG3Bugq7QZXbeKoRpLuo,5305
|
12
|
+
voly/core/rnd.py,sha256=GoC3m1Q46Wnk5tV_mstr-3_aktHeue6BBLh4DQTciW0,13307
|
13
|
+
voly/utils/__init__.py,sha256=E05mWatyC-PDOsCxQV1p5Xi1IgpOomxrNURyCx_gB-w,200
|
14
|
+
voly/utils/density.py,sha256=q0fX4im9TGwMCZ32Hzdv8CNh56KnJo8bmG5w0gVWZH8,5879
|
15
|
+
voly/utils/logger.py,sha256=4-_2bVJmq17Q0d7Rd2mPg1AeR8gxv6EPvcmBDMFWcSM,1744
|
16
|
+
voly-0.0.167.dist-info/licenses/LICENSE,sha256=wcHIVbE12jfcBOai_wqBKY6xvNQU5E909xL1zZNq_2Q,1065
|
17
|
+
voly-0.0.167.dist-info/METADATA,sha256=x0agIivlVt6vgNnXaLI75xGKospNAUC7zAEuuL5kp5A,4115
|
18
|
+
voly-0.0.167.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
|
19
|
+
voly-0.0.167.dist-info/top_level.txt,sha256=ZfLw2sSxF-LrKAkgGjOmeTcw6_gD-30zvtdEY5W4B7c,5
|
20
|
+
voly-0.0.167.dist-info/RECORD,,
|
voly-0.0.165.dist-info/RECORD
DELETED
@@ -1,20 +0,0 @@
|
|
1
|
-
voly/__init__.py,sha256=8xyDk7rFCn_MOD5hxuv5cxxKZvBVRiSIM7TgaMPpwpw,211
|
2
|
-
voly/client.py,sha256=-yE1_cBvjkK-BO_kKCYtn4WPbNOhAzT0hsfykU5LvQQ,14761
|
3
|
-
voly/exceptions.py,sha256=PBsbn1vNMvKcCJwwJ4lBO6glD85jo1h2qiEmD7ArAjs,92
|
4
|
-
voly/formulas.py,sha256=rpIyMwz8WvK22dtnzOSw2J9omjrVj1yJjG44_3JZZE8,11224
|
5
|
-
voly/models.py,sha256=o-pHujGfr5Gn8ItckMzLI4Q8yaX9FQaV8UjCxv2zgTY,3364
|
6
|
-
voly/core/__init__.py,sha256=bu6fS2I1Pj9fPPnl-zY3L7NqrZSY5Zy6NY2uMUvdhKs,183
|
7
|
-
voly/core/charts.py,sha256=E21OZB5lTY4YL2flgaFJ6s5g3_ExtAQT2zryZZxLPyM,12735
|
8
|
-
voly/core/data.py,sha256=9v9iuE2XdIIlzoRAB7q1ol7YghBzBsPGAiwZ11oDuis,13650
|
9
|
-
voly/core/fit.py,sha256=Tb9eeG7e_2dQTcqt6aqEwFrZdy6jR9rSNqe6tzOdVhQ,9245
|
10
|
-
voly/core/hd.py,sha256=UFAyLncNUHivpPAcko6IK1bC55mudVtdlRFfXp63HXE,14771
|
11
|
-
voly/core/interpolate.py,sha256=JkK172-FXyhesW3hY4pEeuJWG3Bugq7QZXbeKoRpLuo,5305
|
12
|
-
voly/core/rnd.py,sha256=GoC3m1Q46Wnk5tV_mstr-3_aktHeue6BBLh4DQTciW0,13307
|
13
|
-
voly/utils/__init__.py,sha256=E05mWatyC-PDOsCxQV1p5Xi1IgpOomxrNURyCx_gB-w,200
|
14
|
-
voly/utils/density.py,sha256=q0fX4im9TGwMCZ32Hzdv8CNh56KnJo8bmG5w0gVWZH8,5879
|
15
|
-
voly/utils/logger.py,sha256=4-_2bVJmq17Q0d7Rd2mPg1AeR8gxv6EPvcmBDMFWcSM,1744
|
16
|
-
voly-0.0.165.dist-info/licenses/LICENSE,sha256=wcHIVbE12jfcBOai_wqBKY6xvNQU5E909xL1zZNq_2Q,1065
|
17
|
-
voly-0.0.165.dist-info/METADATA,sha256=wIA3r_Rk8BbWUUSTiFsuc12wY1WfOWYLXiP-qJCkri8,4115
|
18
|
-
voly-0.0.165.dist-info/WHEEL,sha256=lTU6B6eIfYoiQJTZNc-fyaR6BpL6ehTzU3xGYxn2n8k,91
|
19
|
-
voly-0.0.165.dist-info/top_level.txt,sha256=ZfLw2sSxF-LrKAkgGjOmeTcw6_gD-30zvtdEY5W4B7c,5
|
20
|
-
voly-0.0.165.dist-info/RECORD,,
|
File without changes
|
File without changes
|