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.
Files changed (27) hide show
  1. {voly-0.0.176/src/voly.egg-info → voly-0.0.178}/PKG-INFO +1 -1
  2. {voly-0.0.176 → voly-0.0.178}/pyproject.toml +2 -2
  3. {voly-0.0.176 → voly-0.0.178}/src/voly/client.py +8 -8
  4. voly-0.0.178/src/voly/core/fit.py +429 -0
  5. {voly-0.0.176 → voly-0.0.178}/src/voly/formulas.py +0 -1
  6. {voly-0.0.176 → voly-0.0.178}/src/voly/models.py +24 -83
  7. {voly-0.0.176 → voly-0.0.178/src/voly.egg-info}/PKG-INFO +1 -1
  8. voly-0.0.176/src/voly/core/fit.py +0 -427
  9. {voly-0.0.176 → voly-0.0.178}/LICENSE +0 -0
  10. {voly-0.0.176 → voly-0.0.178}/README.md +0 -0
  11. {voly-0.0.176 → voly-0.0.178}/setup.cfg +0 -0
  12. {voly-0.0.176 → voly-0.0.178}/setup.py +0 -0
  13. {voly-0.0.176 → voly-0.0.178}/src/voly/__init__.py +0 -0
  14. {voly-0.0.176 → voly-0.0.178}/src/voly/core/__init__.py +0 -0
  15. {voly-0.0.176 → voly-0.0.178}/src/voly/core/charts.py +0 -0
  16. {voly-0.0.176 → voly-0.0.178}/src/voly/core/data.py +0 -0
  17. {voly-0.0.176 → voly-0.0.178}/src/voly/core/hd.py +0 -0
  18. {voly-0.0.176 → voly-0.0.178}/src/voly/core/interpolate.py +0 -0
  19. {voly-0.0.176 → voly-0.0.178}/src/voly/core/rnd.py +0 -0
  20. {voly-0.0.176 → voly-0.0.178}/src/voly/exceptions.py +0 -0
  21. {voly-0.0.176 → voly-0.0.178}/src/voly/utils/__init__.py +0 -0
  22. {voly-0.0.176 → voly-0.0.178}/src/voly/utils/density.py +0 -0
  23. {voly-0.0.176 → voly-0.0.178}/src/voly/utils/logger.py +0 -0
  24. {voly-0.0.176 → voly-0.0.178}/src/voly.egg-info/SOURCES.txt +0 -0
  25. {voly-0.0.176 → voly-0.0.178}/src/voly.egg-info/dependency_links.txt +0 -0
  26. {voly-0.0.176 → voly-0.0.178}/src/voly.egg-info/requires.txt +0 -0
  27. {voly-0.0.176 → voly-0.0.178}/src/voly.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voly
3
- Version: 0.0.176
3
+ Version: 0.0.178
4
4
  Summary: Options & volatility research package
5
5
  Author-email: Manu de Cara <manu.de.cara@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "voly"
7
- version = "0.0.176"
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.176"
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 svi(LM: float, a: float, b: float, sigma: float, rho: float, m: float) -> float:
95
- return SVIModel.svi(LM, a, b, sigma, rho, m)
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, 500)
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
- - Dataframe with fit_results
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.svi(k, a * t, b * t, m, rho, sigma)
198
- fit_loss = ((w_model - tiv) ** 2 * vega).mean()
199
- param_deviation = sum(((x[i] - params[i]) / max(abs(params[i]), 1e-6)) ** 2
200
- for i in range(len(params)))
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, sigma, rho, m = x
205
- w_current = cls.svi(k_constraint, a * t, b * t, sigma, rho, m)
206
- w_prev = cls.svi(k_constraint, a_prev * prev_t, b_prev * prev_t, sigma_prev, rho_prev, m_prev)
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
- (max(sigma_init * 0.8, 1e-6), sigma_init * 1.2),
147
+ (m_init - 0.05, m_init + 0.05),
213
148
  (max(rho_init - 0.05, -1), min(rho_init + 0.05, 1)),
214
- (m_init - 0.05, m_init + 0.05)
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[2] * np.sqrt(1 - x[3] ** 2)}
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
- return result.x
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voly
3
- Version: 0.0.176
3
+ Version: 0.0.178
4
4
  Summary: Options & volatility research package
5
5
  Author-email: Manu de Cara <manu.de.cara@gmail.com>
6
6
  License: MIT
@@ -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