voly 0.0.204__py3-none-any.whl → 0.0.205__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/core/fit.py CHANGED
@@ -16,341 +16,283 @@ from voly.models import SVIModel
16
16
  from concurrent.futures import ThreadPoolExecutor
17
17
  import warnings
18
18
  import time
19
- import plotly.graph_objects as go
20
- from plotly.subplots import make_subplots
21
19
 
22
20
  warnings.filterwarnings("ignore")
23
21
 
24
22
 
23
+ class SVICalibrator:
24
+ """Handles the SVI calibration process"""
25
+
26
+ def __init__(self, option_chain, currency, num_points=2000):
27
+ self.option_chain = option_chain
28
+ self.currency = currency
29
+ self.s = option_chain['index_price'].iloc[0]
30
+ self.groups = option_chain.groupby('maturity_date')
31
+ self.params_dict = {}
32
+ self.results_data = {}
33
+ self.num_points = num_points
34
+
35
+ # Initialize results data template
36
+ self.field_names = [
37
+ 's', 't', 'maturity_date', 'maturity_name', 'a', 'b', 'm', 'rho', 'sigma',
38
+ 'nu', 'psi', 'p', 'c', 'nu_tilde', 'log_min_strike', 'usd_min_strike',
39
+ 'fit_success', 'butterfly_arbitrage_free', 'calendar_arbitrage_free',
40
+ 'rmse', 'mae', 'r2', 'max_error', 'loss', 'n_points'
41
+ ]
42
+
43
+ # Create empty lists for each field
44
+ for field in self.field_names:
45
+ self.results_data[field] = []
46
+
47
+ def failed_calibration(self, maturity, maturity_name, t, n_points):
48
+ """Create an empty result for failed calibration"""
49
+ return {
50
+ 's': float(self.s),
51
+ 't': float(t),
52
+ 'maturity_date': maturity,
53
+ 'maturity_name': maturity_name,
54
+ 'fit_success': False,
55
+ 'calendar_arbitrage_free': True, # Updated later
56
+ 'loss': float(np.inf),
57
+ 'n_points': int(n_points),
58
+ 'a': np.nan, 'b': np.nan, 'm': np.nan, 'rho': np.nan, 'sigma': np.nan,
59
+ 'nu': np.nan, 'psi': np.nan, 'p': np.nan, 'c': np.nan, 'nu_tilde': np.nan,
60
+ 'log_min_strike': np.nan, 'usd_min_strike': np.nan,
61
+ 'butterfly_arbitrage_free': False,
62
+ 'rmse': np.nan, 'mae': np.nan, 'r2': np.nan, 'max_error': np.nan
63
+ }
64
+
65
+ def filter_market_data(self, group):
66
+ """Filter and prepare market data"""
67
+ # Filter for call options only
68
+ group = group[group['option_type'] == 'C']
69
+
70
+ # Handle duplicated IVs by keeping the row closest to log_moneyness=0
71
+ duplicated_iv = group[group.duplicated('mark_iv', keep=False)]
72
+ if not duplicated_iv.empty:
73
+ cleaned_dupes = duplicated_iv.groupby('mark_iv').apply(
74
+ lambda g: g.loc[[g['log_moneyness'].abs().idxmin()]]
75
+ ).reset_index(drop=True)
76
+
77
+ # Combine cleaned duplicates with unique rows
78
+ unique_iv = group.drop_duplicates('mark_iv', keep=False)
79
+ group = pd.concat([unique_iv, cleaned_dupes])
80
+
81
+ # Extract basic data
82
+ maturity_name = group['maturity_name'].iloc[0]
83
+ t = group['t'].iloc[0]
84
+ K = group['strikes'].values
85
+ iv = group['mark_iv'].values
86
+ vega = group['vega'].values if 'vega' in group.columns else np.ones_like(iv)
87
+ k = np.log(K / self.s)
88
+
89
+ # Filter out invalid data
90
+ w = (iv ** 2) * t
91
+ mask = ~np.isnan(w) & ~np.isnan(vega) & ~np.isnan(k) & (iv >= 0)
92
+ k, w, vega, iv, K = k[mask], w[mask], vega[mask], iv[mask], K[mask]
93
+
94
+ return maturity_name, t, k, w, vega, iv, K
95
+
96
+ def calculate_model_stats(self, params, t, k, iv):
97
+ """Calculate all model statistics from parameters"""
98
+ a, b, m, rho, sigma = params
99
+ a_scaled, b_scaled = a * t, b * t
100
+
101
+ # Jump-Wing parameters
102
+ jw_params = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t)
103
+
104
+ # Fit statistics
105
+ w_model = np.array([SVIModel.svi(x, a_scaled, b_scaled, m, rho, sigma) for x in k])
106
+ iv_model = np.sqrt(w_model / t)
107
+ rmse = np.sqrt(mean_squared_error(iv, iv_model))
108
+ mae = mean_absolute_error(iv, iv_model)
109
+ r2 = r2_score(iv, iv_model)
110
+ max_error = np.max(np.abs(iv - iv_model))
111
+
112
+ # Minimum strike
113
+ log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
114
+ usd_min_strike = np.exp(log_min_strike) * self.s
115
+
116
+ # Butterfly arbitrage check
117
+ k_range = np.linspace(min(k), max(k), self.num_points)
118
+ butterfly_arbitrage_free = SVIModel.check_butterfly_arbitrage(a_scaled, b_scaled, m, rho, sigma, k_range)
119
+
120
+ return {
121
+ 'a': float(a_scaled),
122
+ 'b': float(b_scaled),
123
+ 'm': float(m),
124
+ 'rho': float(rho),
125
+ 'sigma': float(sigma),
126
+ 'nu': float(jw_params[0]),
127
+ 'psi': float(jw_params[1]),
128
+ 'p': float(jw_params[2]),
129
+ 'c': float(jw_params[3]),
130
+ 'nu_tilde': float(jw_params[4]),
131
+ 'log_min_strike': float(log_min_strike),
132
+ 'usd_min_strike': float(usd_min_strike),
133
+ 'butterfly_arbitrage_free': butterfly_arbitrage_free,
134
+ 'rmse': float(rmse),
135
+ 'mae': float(mae),
136
+ 'r2': float(r2),
137
+ 'max_error': float(max_error)
138
+ }
139
+
140
+ def process_maturity(self, maturity, group):
141
+ """Process single maturity for SVI calibration"""
142
+ # Clean and prepare market data
143
+ maturity_name, t, k, w, vega, iv, K = self.filter_market_data(group)
144
+
145
+ # Not enough data points for fitting
146
+ if len(k) <= 5:
147
+ result = self.failed_calibration(maturity, maturity_name, t, len(k))
148
+ logger.error(f'FAILED for {maturity} (insufficient data points)')
149
+ self.update_results(result)
150
+ return maturity
151
+
152
+ # Perform SVI fitting
153
+ params, loss = SVIModel.fit(tiv=w, vega=vega, k=k, tau=t)
154
+
155
+ # If fitting failed
156
+ if np.isnan(params[0]):
157
+ result = self.failed_calibration(maturity, maturity_name, t, len(k))
158
+ logger.error(f'FAILED for {maturity}')
159
+ self.update_results(result)
160
+ return maturity
161
+
162
+ # Successful fitting
163
+ self.params_dict[maturity] = (t, params)
164
+
165
+ # Calculate all model statistics
166
+ stats = self.calculate_model_stats(params, t, k, iv)
167
+
168
+ # Create result dictionary
169
+ result = {
170
+ 's': float(self.s),
171
+ 't': float(t),
172
+ 'maturity_date': maturity,
173
+ 'maturity_name': maturity_name,
174
+ 'fit_success': True,
175
+ 'calendar_arbitrage_free': True, # Updated later
176
+ 'loss': float(loss),
177
+ 'n_points': int(len(k)),
178
+ **stats
179
+ }
180
+
181
+ logger.info(
182
+ f'SUCCESS for {maturity}: a={stats["a"]:.4f}, b={stats["b"]:.4f}, m={stats["m"]:.4f}, rho={stats["rho"]:.4f}, sigma={stats["sigma"]:.4f}')
183
+
184
+ self.update_results(result)
185
+ return maturity
186
+
187
+ def update_results(self, result_row):
188
+ """Update results data dictionary"""
189
+ for key, value in result_row.items():
190
+ if key in self.results_data:
191
+ self.results_data[key].append(value)
192
+
193
+ def fit_model(self):
194
+ """Execute full SVI calibration process"""
195
+ start_time = time.time()
196
+ logger.info(f"Processing {self.currency} option chain data...")
197
+
198
+ # Process all maturities in parallel
199
+ with ThreadPoolExecutor() as executor:
200
+ futures = [
201
+ executor.submit(self.process_maturity, maturity, group)
202
+ for maturity, group in self.groups
203
+ ]
204
+ for future in futures:
205
+ future.result()
206
+
207
+ # Create results DataFrame and mapping for updates
208
+ fit_results = pd.DataFrame(self.results_data, index=self.results_data['maturity_name'])
209
+ fit_results = fit_results.sort_values(by='t')
210
+ maturity_name_dict = {row['maturity_date']: idx for idx, row in fit_results.iterrows()}
211
+
212
+ # Check for calendar arbitrage
213
+ sorted_maturities = sorted(self.params_dict.keys(), key=lambda x: self.params_dict[x][0])
214
+ calendar_arbitrage_free = SVIModel.check_calendar_arbitrage(
215
+ sorted_maturities, self.params_dict, self.groups, self.s, self.num_points
216
+ )
217
+
218
+ # Update calendar arbitrage status
219
+ for mat in sorted_maturities:
220
+ mat_name = maturity_name_dict[mat]
221
+ fit_results.at[mat_name, 'calendar_arbitrage_free'] = calendar_arbitrage_free
222
+
223
+ # Correct calendar arbitrage violations
224
+ self.correct_calendar_arbitrage(sorted_maturities, fit_results, maturity_name_dict)
225
+
226
+ # Clean up results and report execution time
227
+ fit_results = fit_results.drop(columns='maturity_name')
228
+ end_time = time.time()
229
+ logger.info(f"Total model execution time: {end_time - start_time:.4f} seconds")
230
+
231
+ return fit_results
232
+
233
+ def correct_calendar_arbitrage(self, sorted_maturities, fit_results, maturity_name_dict):
234
+ """Handle calendar arbitrage corrections"""
235
+ for i in range(1, len(sorted_maturities)):
236
+ mat2 = sorted_maturities[i]
237
+ mat1 = sorted_maturities[i - 1]
238
+ t2, params2 = self.params_dict[mat2]
239
+ t1, params1 = self.params_dict[mat1]
240
+
241
+ if np.any(np.isnan(params2)) or np.any(np.isnan(params1)):
242
+ continue
243
+
244
+ # Get clean data for correction
245
+ _, _, k, w, vega, iv, _ = self.filter_market_data(self.groups.get_group(mat2))
246
+
247
+ # Apply correction
248
+ k_constraint = np.unique(np.concatenate([k, np.linspace(min(k), max(k), self.num_points)]))
249
+ new_params = SVIModel.correct_calendar_arbitrage(
250
+ params=params2, t=t2, tiv=w, vega=vega, k=k,
251
+ prev_params=params1, prev_t=t1, k_constraint=k_constraint
252
+ )
253
+
254
+ # Update params dictionary
255
+ self.params_dict[mat2] = (t2, new_params)
256
+
257
+ # Calculate new stats and update results
258
+ stats = self.calculate_model_stats(new_params, t2, k, iv)
259
+ mat2_name = maturity_name_dict[mat2]
260
+
261
+ # Update all stats at once
262
+ for key, value in stats.items():
263
+ fit_results.at[mat2_name, key] = value
264
+ fit_results.at[mat2_name, 'fit_success'] = True
265
+
266
+ # Final calendar arbitrage check
267
+ calendar_arbitrage_free = SVIModel.check_calendar_arbitrage(
268
+ sorted_maturities, self.params_dict, self.groups, self.s, self.num_points
269
+ )
270
+
271
+ # Update final status
272
+ for mat in sorted_maturities:
273
+ mat_name = maturity_name_dict[mat]
274
+ fit_results.at[mat_name, 'calendar_arbitrage_free'] = calendar_arbitrage_free
275
+
276
+
25
277
  @catch_exception
26
- def fit_model(option_chain: pd.DataFrame, num_points: int = 2000) -> Tuple[pd.DataFrame, Dict]:
278
+ def fit_model(option_chain: pd.DataFrame, num_points: int = 2000) -> pd.DataFrame:
27
279
  """
28
- Fit a volatility model to market data with parallel processing and generate visualizations.
280
+ Fit a volatility model to market data with parallel processing.
29
281
 
30
282
  Parameters:
31
283
  - option_chain: DataFrame with market data
32
284
  - num_points: Number of points for k_grid and plotting
33
285
 
34
286
  Returns:
35
- - results_df: DataFrame with all fit results and performance metrics as columns, maturity_names as index
287
+ - fit_results: DataFrame with all fit results and performance metrics as columns, maturity_names as index
36
288
  """
37
- # Start overall timer
38
- start_total = time.time()
39
-
40
- # Define column names and their data types
41
- column_dtypes = {
42
- 's': float,
43
- 't': float,
44
- 'maturity_date': 'datetime64[ns]',
45
- 'a': float,
46
- 'b': float,
47
- 'm': float,
48
- 'rho': float,
49
- 'sigma': float,
50
- 'nu': float,
51
- 'psi': float,
52
- 'p': float,
53
- 'c': float,
54
- 'nu_tilde': float,
55
- 'log_min_strike': float,
56
- 'usd_min_strike': float,
57
- 'fit_success': bool,
58
- 'butterfly_arbitrage_free': bool,
59
- 'calendar_arbitrage_free': bool,
60
- 'rmse': float,
61
- 'mae': float,
62
- 'r2': float,
63
- 'max_error': float,
64
- 'loss': float,
65
- 'n_points': int
66
- }
67
-
68
- s = option_chain['index_price'].iloc[0]
69
- maturity_data_groups = option_chain.groupby('maturity_date')
70
- params_dict = {}
71
- results_data = {col: [] for col in column_dtypes.keys()}
72
- results_data['maturity_name'] = []
73
-
74
- def process_maturity(maturity, maturity_data):
75
- """Process single maturity for SVI calibration."""
76
- maturity_data = maturity_data[maturity_data['option_type'] == 'C']
77
- duplicated_iv = maturity_data[maturity_data.duplicated('mark_iv', keep=False)]
78
-
79
- # For each duplicated IV, keep the row closest to log_moneyness=0
80
- def keep_closest_to_zero(subgroup):
81
- idx = (subgroup['log_moneyness'].abs()).idxmin()
82
- return subgroup.loc[[idx]]
83
-
84
- # Apply the function to each duplicated mark_iv group
85
- cleaned_duplicated_iv = (
86
- duplicated_iv.groupby('mark_iv', group_keys=False)
87
- .apply(keep_closest_to_zero)
88
- )
89
-
90
- # Get rows with unique mark_iv (no duplicates)
91
- unique_iv = maturity_data.drop_duplicates('mark_iv', keep=False)
92
-
93
- # Combine cleaned duplicates and unique rows
94
- maturity_data = pd.concat([unique_iv, cleaned_duplicated_iv])
95
- maturity_date = maturity_data['maturity_date'].iloc[0]
96
- maturity_name = maturity_data['maturity_name'].iloc[0]
97
-
98
- t = maturity_data['t'].iloc[0]
99
- K = maturity_data['strikes'].values
100
- iv = maturity_data['mark_iv'].values
101
- vega = maturity_data['vega'].values if 'vega' in maturity_data.columns else np.ones_like(iv)
102
- k = np.log(K / s)
103
- w = (iv ** 2) * t
104
- mask = ~np.isnan(w) & ~np.isnan(vega) & ~np.isnan(k) & (iv >= 0)
105
- k, w, vega, iv = k[mask], w[mask], vega[mask], iv[mask]
106
-
107
- params = [np.nan] * 5
108
- loss = np.inf
109
- nu = psi = p = c = nu_tilde = np.nan
110
- rmse = mae = r2 = max_error = np.nan
111
- butterfly_arbitrage_free = True
112
- log_min_strike = usd_min_strike = np.nan
113
-
114
- if len(k) > 5:
115
- params, loss = SVIModel.fit(tiv=w, vega=vega, k=k, tau=t)
116
- if not np.isnan(params[0]):
117
- params_dict[maturity_date] = (t, params)
118
- a, b, m, rho, sigma = params
119
- a_scaled, b_scaled = a * t, b * t
120
- nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t)
121
-
122
- # Compute fit statistics
123
- w_model = np.array([SVIModel.svi(x, a_scaled, b_scaled, m, rho, sigma) for x in k])
124
- iv_model = np.sqrt(w_model / t)
125
- iv_market = iv
126
- rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
127
- mae = mean_absolute_error(iv_market, iv_model)
128
- r2 = r2_score(iv_market, iv_model)
129
- max_error = np.max(np.abs(iv_market - iv_model))
130
-
131
- # Compute min strike
132
- log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
133
- usd_min_strike = np.exp(log_min_strike) * s
134
-
135
- # Butterfly arbitrage check
136
- k_range = np.linspace(min(k), max(k), num_points)
137
- w_k = lambda k: SVIModel.svi(k, a_scaled, b_scaled, m, rho, sigma)
138
- w_prime = lambda k: b_scaled * (rho + (k - m) / np.sqrt((k - m)**2 + sigma**2))
139
- w_double_prime = lambda k: b_scaled * sigma**2 / ((k - m)**2 + sigma**2)**(3/2)
140
- for k_val in k_range:
141
- wk = w_k(k_val)
142
- wp = w_prime(k_val)
143
- wpp = w_double_prime(k_val)
144
- g = (1 - (k_val * wp) / (2 * wk))**2 - (wp**2) / 4 * (1 / wk + 1/4) + wpp / 2
145
- if g < 0:
146
- butterfly_arbitrage_free = False
147
- break
148
-
149
- # Log result
150
- GREEN, RED, RESET = '\033[32m', '\033[31m', '\033[0m'
151
- status = f'{GREEN}SUCCESS{RESET}' if not np.isnan(params[0]) else f'{RED}FAILED{RESET}'
152
- logger.info(f'Optimization for {maturity_date}: {status}')
153
-
154
- # Store results
155
- results_data['s'].append(float(s))
156
- results_data['t'].append(float(t))
157
- results_data['maturity_date'].append(maturity_date)
158
- results_data['maturity_name'].append(maturity_name)
159
- results_data['a'].append(float(a_scaled) if not np.isnan(params[0]) else np.nan)
160
- results_data['b'].append(float(b_scaled) if not np.isnan(params[0]) else np.nan)
161
- results_data['m'].append(float(m))
162
- results_data['rho'].append(float(rho))
163
- results_data['sigma'].append(float(sigma))
164
- results_data['nu'].append(float(nu))
165
- results_data['psi'].append(float(psi))
166
- results_data['p'].append(float(p))
167
- results_data['c'].append(float(c))
168
- results_data['nu_tilde'].append(float(nu_tilde))
169
- results_data['log_min_strike'].append(float(log_min_strike))
170
- results_data['usd_min_strike'].append(float(usd_min_strike))
171
- results_data['fit_success'].append(bool(not np.isnan(params[0])))
172
- results_data['butterfly_arbitrage_free'].append(butterfly_arbitrage_free)
173
- results_data['calendar_arbitrage_free'].append(True) # Updated after check
174
- results_data['rmse'].append(float(rmse))
175
- results_data['mae'].append(float(mae))
176
- results_data['r2'].append(float(r2))
177
- results_data['max_error'].append(float(max_error))
178
- results_data['loss'].append(float(loss))
179
- results_data['n_points'].append(int(len(k)))
180
-
181
- return maturity_name
182
-
183
- # Parallel processing of maturities
184
- with ThreadPoolExecutor() as executor:
185
- futures = [executor.submit(process_maturity, maturity, maturity_data)
186
- for maturity, maturity_data in maturity_data_groups]
187
- for future in futures:
188
- future.result()
189
-
190
- # Create results DataFrame
191
- results_df = pd.DataFrame(results_data, index=results_data['maturity_name'])
192
-
193
- # Map maturity_date to maturity_name for indexing
194
- date_to_name = dict(zip(results_data['maturity_date'], results_data['maturity_name']))
195
-
196
- # Convert columns to appropriate types
197
- for col, dtype in column_dtypes.items():
198
- if col in results_df.columns:
199
- try:
200
- results_df[col] = results_df[col].astype(dtype)
201
- except (ValueError, TypeError) as e:
202
- logger.warning(f"Could not convert column {col} to {dtype}: {e}")
203
-
204
- # Sort by time to maturity
205
- results_df = results_df.sort_values(by='t')
206
-
207
- # Calendar arbitrage check (pre-correction)
208
- k_grid = np.linspace(-2, 2, num_points)
209
- sorted_maturities = sorted(params_dict.keys(), key=lambda x: params_dict[x][0])
210
- calendar_arbitrage_free = True
211
- for i in range(len(sorted_maturities) - 1):
212
- mat1, mat2 = sorted_maturities[i], sorted_maturities[i + 1]
213
- t1, params1 = params_dict[mat1]
214
- t2, params2 = params_dict[mat2]
215
- a1, b1, m1, rho1, sigma1 = params1
216
- a2, b2, m2, rho2, sigma2 = params2
217
-
218
- if np.isnan(a1) or np.isnan(a2):
219
- continue
220
-
221
- maturity_data = maturity_data_groups.get_group(mat2)
222
- K = maturity_data['strikes'].values
223
- k_market = np.log(K / s)
224
- mask = ~np.isnan(k_market)
225
- k_check = np.unique(np.concatenate([k_market[mask], np.linspace(min(k_market[mask]), max(k_market[mask]), num_points)]))
226
-
227
- for k_val in k_check:
228
- w1 = SVIModel.svi(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
229
- w2 = SVIModel.svi(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
230
- if w2 < w1 - 1e-6:
231
- logger.warning(f"Calendar arbitrage violation at t1={t1:.4f}, t2={t2:.4f}, k={k_val:.4f}: w1={w1:.6f}, w2={w2:.6f}")
232
- calendar_arbitrage_free = False
233
- break
234
- if not calendar_arbitrage_free:
235
- break
236
-
237
- for mat in sorted_maturities:
238
- results_df.at[date_to_name[mat], 'calendar_arbitrage_free'] = calendar_arbitrage_free
239
-
240
- # Calendar arbitrage correction
241
- for i in range(1, len(sorted_maturities)):
242
- mat2 = sorted_maturities[i]
243
- mat1 = sorted_maturities[i - 1]
244
- t2, params2 = params_dict[mat2]
245
- t1, params1 = params_dict[mat1]
246
-
247
- if np.any(np.isnan(params2)) or np.any(np.isnan(params1)):
248
- continue
249
-
250
- maturity_data = maturity_data_groups.get_group(mat2)
251
- K = maturity_data['strikes'].values
252
- iv = maturity_data['mark_iv'].values
253
- vega = maturity_data['vega'].values if 'vega' in maturity_data.columns else np.ones_like(iv)
254
- k = np.log(K / s)
255
- w = (iv ** 2) * t2
256
- mask = ~np.isnan(w) & ~np.isnan(vega) & ~np.isnan(k) & (iv >= 0)
257
- k, w, vega, iv = k[mask], w[mask], vega[mask], iv[mask]
289
+ currency = option_chain['currency'].iloc[0] if 'currency' in option_chain.columns else 'Unknown'
258
290
 
259
- new_params = SVIModel.correct_calendar_arbitrage(
260
- params=params2, t=t2, tiv=w, vega=vega, k=k,
261
- prev_params=params1, prev_t=t1, k_grid=k_grid
262
- )
291
+ # Instantiate the calibrator and run the fitting
292
+ calibrator = SVICalibrator(option_chain, currency, num_points)
293
+ fit_results = calibrator.fit_model()
263
294
 
264
- params_dict[mat2] = (t2, new_params)
265
- a, b, m, rho, sigma = new_params
266
- a_scaled, b_scaled = a * t2, b * t2
267
- nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, sigma, rho, m, t2)
268
-
269
- # Recompute fit statistics
270
- w_model = np.array([SVIModel.svi(x, a_scaled, b_scaled, m, rho, sigma) for x in k])
271
- iv_model = np.sqrt(w_model / t2)
272
- iv_market = iv
273
- rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
274
- mae = mean_absolute_error(iv_market, iv_model)
275
- r2 = r2_score(iv_market, iv_model)
276
- max_error = np.max(np.abs(iv_market - iv_model))
277
-
278
- # Recompute min strike
279
- log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
280
- usd_min_strike = np.exp(log_min_strike) * s
281
-
282
- # Update butterfly arbitrage check
283
- butterfly_arbitrage_free = True
284
- k_range = np.linspace(min(k), max(k), num_points)
285
- w_k = lambda k: SVIModel.svi(k, a_scaled, b_scaled, m, rho, sigma)
286
- w_prime = lambda k: b_scaled * (rho + (k - m) / np.sqrt((k - m)**2 + sigma**2))
287
- w_double_prime = lambda k: b_scaled * sigma**2 / ((k - m)**2 + sigma**2)**(3/2)
288
- for k_val in k_range:
289
- wk = w_k(k_val)
290
- wp = w_prime(k_val)
291
- wpp = w_double_prime(k_val)
292
- g = (1 - (k_val * wp) / (2 * wk))**2 - (wp**2) / 4 * (1 / wk + 1/4) + wpp / 2
293
- if g < 0:
294
- butterfly_arbitrage_free = False
295
- break
296
-
297
- results_df.at[date_to_name[mat2], 'a'] = float(a_scaled)
298
- results_df.at[date_to_name[mat2], 'b'] = float(b_scaled)
299
- results_df.at[date_to_name[mat2], 'm'] = float(m)
300
- results_df.at[date_to_name[mat2], 'rho'] = float(rho)
301
- results_df.at[date_to_name[mat2], 'sigma'] = float(sigma)
302
- results_df.at[date_to_name[mat2], 'nu'] = float(nu)
303
- results_df.at[date_to_name[mat2], 'psi'] = float(psi)
304
- results_df.at[date_to_name[mat2], 'p'] = float(p)
305
- results_df.at[date_to_name[mat2], 'c'] = float(c)
306
- results_df.at[date_to_name[mat2], 'nu_tilde'] = float(nu_tilde)
307
- results_df.at[date_to_name[mat2], 'rmse'] = float(rmse)
308
- results_df.at[date_to_name[mat2], 'mae'] = float(mae)
309
- results_df.at[date_to_name[mat2], 'r2'] = float(r2)
310
- results_df.at[date_to_name[mat2], 'max_error'] = float(max_error)
311
- results_df.at[date_to_name[mat2], 'log_min_strike'] = float(log_min_strike)
312
- results_df.at[date_to_name[mat2], 'usd_min_strike'] = float(usd_min_strike)
313
- results_df.at[date_to_name[mat2], 'butterfly_arbitrage_free'] = butterfly_arbitrage_free
314
- results_df.at[date_to_name[mat2], 'fit_success'] = bool(not np.isnan(a))
315
-
316
- # Calendar arbitrage check (post-correction)
317
- calendar_arbitrage_free = True
318
- for i in range(len(sorted_maturities) - 1):
319
- mat1, mat2 = sorted_maturities[i], sorted_maturities[i + 1]
320
- t1, params1 = params_dict[mat1]
321
- t2, params2 = params_dict[mat2]
322
- a1, b1, m1, rho1, sigma1 = params1
323
- a2, b2, m2, rho2, sigma2 = params2
324
-
325
- if np.isnan(a1) or np.isnan(a2):
326
- continue
327
-
328
- maturity_data = maturity_data_groups.get_group(mat2)
329
- K = maturity_data['strikes'].values
330
- k_market = np.log(K / s)
331
- mask = ~np.isnan(k_market)
332
- k_check = np.unique(np.concatenate([k_market[mask], np.linspace(min(k_market[mask]), max(k_market[mask]), num_points)]))
333
-
334
- for k_val in k_check:
335
- w1 = SVIModel.svi(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
336
- w2 = SVIModel.svi(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
337
- if w2 < w1 - 1e-6:
338
- logger.warning(f"Calendar arbitrage violation at t1={t1:.4f}, t2={t2:.4f}, k={k_val:.4f}: w1={w1:.6f}, w2={w2:.6f}")
339
- calendar_arbitrage_free = False
340
- break
341
- if not calendar_arbitrage_free:
342
- break
343
-
344
- for mat in sorted_maturities:
345
- results_df.at[date_to_name[mat], 'calendar_arbitrage_free'] = calendar_arbitrage_free
346
-
347
- # End overall timer and print total time
348
- end_total = time.time()
349
- logger.info(f"Total execution time for the model: {end_total - start_total:.4f} seconds")
350
-
351
- logger.info("Model fitting complete.")
352
- results_df = results_df.drop(columns='maturity_name')
353
- return results_df
295
+ return fit_results
354
296
 
355
297
 
356
298
  @catch_exception
voly/models.py CHANGED
@@ -40,18 +40,28 @@ class SVIModel:
40
40
  return a + b * (rho * (k - m) + np.sqrt((k - m) ** 2 + sigma ** 2))
41
41
 
42
42
  @staticmethod
43
- def svi_min_strike(sigma: float, rho: float, m: float) -> float:
43
+ def svi_d(k, b, m, rho, sigma):
44
+ """Compute the derivative of SVI over K"""
45
+ return b * (rho + (k - m) / np.sqrt((k - m) ** 2 + sigma ** 2))
46
+
47
+ @staticmethod
48
+ def svi_dd(k, b, m, sigma):
49
+ """Compute the second derivative of SVI over K"""
50
+ return b * sigma ** 2 / ((k - m) ** 2 + sigma ** 2) ** (3 / 2)
51
+
52
+ @staticmethod
53
+ def svi_min_strike(sigma, rho, m):
44
54
  """Calculate the minimum valid log-strike for this SVI parameterization."""
45
55
  return m - ((sigma * rho) / np.sqrt(1 - rho ** 2))
46
56
 
47
57
  @staticmethod
48
- def raw_to_jw_params(a: float, b: float, m: float, rho: float, sigma: float, t: float) -> Tuple[
49
- float, float, float, float, float]:
58
+ def raw_to_jw_params(a, b, m, rho, sigma, t):
50
59
  """Convert raw SVI to Jump-Wing parameters."""
51
60
  nu = (a + b * ((-rho) * m + np.sqrt(m ** 2 + sigma ** 2))) / t
52
- psi = (1 / np.sqrt(nu * t)) * (b / 2) * (rho - (m / np.sqrt(m ** 2 + sigma ** 2)))
53
- p = (1 / np.sqrt(nu * t)) * b * (1 - rho)
54
- c = (1 / np.sqrt(nu * t)) * b * (1 + rho)
61
+ sqrt_nu_t = np.sqrt(nu * t)
62
+ psi = (1 / sqrt_nu_t) * (b / 2) * (rho - (m / np.sqrt(m ** 2 + sigma ** 2)))
63
+ p = (1 / sqrt_nu_t) * b * (1 - rho)
64
+ c = (1 / sqrt_nu_t) * b * (1 + rho)
55
65
  nu_tilde = (1 / t) * (a + b * sigma * np.sqrt(1 - rho ** 2))
56
66
  return nu, psi, p, c, nu_tilde
57
67
 
@@ -67,6 +77,8 @@ class SVIModel:
67
77
  sigma = max(sigma, 0.001)
68
78
  vega = vega / vega.max() if vega.max() > 0 else np.ones_like(vega)
69
79
  y = (k - m) / sigma
80
+
81
+ # Calculate means for matrix construction
70
82
  w = vega.mean()
71
83
  y1 = (vega * y).mean()
72
84
  y2 = (vega * y * y).mean()
@@ -77,38 +89,40 @@ class SVIModel:
77
89
  vy = (vega * tiv * y).mean()
78
90
  v = (vega * tiv).mean()
79
91
 
80
- matrix = [[y5, y4, y3], [y4, y2, y1], [y3, y1, w]]
81
- vector = [vy2, vy, v]
82
- c, d, a = solve(np.array(matrix), np.array(vector))
92
+ # Solve the linear system
93
+ matrix = np.array([[y5, y4, y3], [y4, y2, y1], [y3, y1, w]])
94
+ vector = np.array([vy2, vy, v])
95
+ c, d, a = solve(matrix, vector)
83
96
 
97
+ # Clip parameters to ensure validity
84
98
  c = np.clip(c, 0, 4 * sigma)
85
99
  a = max(a, 1e-6)
86
100
  d = np.clip(d, -min(c, 4 * sigma - c), min(c, 4 * sigma - c))
87
101
 
88
- loss = cls.loss(tiv, vega, y, c, d, a)
89
- return c, d, a, loss
102
+ return c, d, a, cls.loss(tiv, vega, y, c, d, a)
90
103
 
91
104
  @classmethod
92
105
  def fit(cls, tiv, vega, k, tau=1.0):
93
106
  """Fit SVI model."""
94
107
  if len(k) <= 5:
95
108
  return [np.nan] * 5, np.inf
109
+
96
110
  vega = vega / vega.max() if vega.max() > 0 else np.ones_like(vega)
97
111
  m_init = np.mean(k)
98
112
  sigma_init = max(0.1, np.std(k) * 0.1)
99
113
 
100
- def score(params):
101
- sigma, m = params
102
- c, d, a_calib, loss = cls.calibration(tiv, vega, k, m, sigma)
103
- return loss
104
-
105
- result = minimize(score, [sigma_init, m_init], bounds=[(0.001, None), (None, None)],
106
- tol=1e-16, method="SLSQP", options={'maxfun': 5000})
114
+ result = minimize(
115
+ lambda params: cls.calibration(tiv, vega, k, params[1], params[0])[3],
116
+ [sigma_init, m_init],
117
+ bounds=[(0.001, None), (None, None)],
118
+ tol=1e-16, method="SLSQP", options={'maxfun': 5000}
119
+ )
107
120
 
108
121
  sigma, m = result.x
109
122
  c, d, a_calib, loss = cls.calibration(tiv, vega, k, m, sigma)
110
123
  a_calib = max(a_calib, 1e-6)
111
124
 
125
+ # Convert to SVI parameters
112
126
  if c != 0:
113
127
  a_svi = a_calib / tau
114
128
  rho_svi = d / c
@@ -120,13 +134,13 @@ class SVIModel:
120
134
  return [a_svi, b_svi, m, rho_svi, sigma], loss
121
135
 
122
136
  @classmethod
123
- def correct_calendar_arbitrage(cls, params, t, tiv, vega, k, prev_params, prev_t, k_grid):
137
+ def correct_calendar_arbitrage(cls, params, t, tiv, vega, k, prev_params, prev_t, k_constraint):
138
+ """Correct calendar arbitrage with relaxed bounds."""
124
139
  if np.any(np.isnan(params)) or np.any(np.isnan(prev_params)):
125
140
  return params
126
141
 
127
142
  a_init, b_init, m_init, rho_init, sigma_init = params
128
143
  a_prev, b_prev, m_prev, rho_prev, sigma_prev = prev_params
129
- k_constraint = np.unique(np.concatenate([k, np.linspace(min(k), max(k), len(k_grid))]))
130
144
 
131
145
  def objective(x):
132
146
  a, b, m, rho, sigma = x
@@ -136,12 +150,6 @@ class SVIModel:
136
150
  for i, x_init in enumerate([a_init, b_init, m_init, rho_init, sigma_init]))
137
151
  return fit_loss + 0.01 * param_deviation
138
152
 
139
- def calendar_constraint(x):
140
- a, b, m, rho, sigma = x
141
- w_current = cls.svi(k_constraint, a * t, b * t, m, rho, sigma)
142
- w_prev = cls.svi(k_constraint, a_prev * prev_t, b_prev * prev_t, m_prev, rho_prev, sigma_prev)
143
- return w_current - w_prev
144
-
145
153
  bounds = [
146
154
  (max(a_init * 0.8, 1e-6), a_init * 1.2),
147
155
  (max(b_init * 0.8, 0), b_init * 1.2),
@@ -151,7 +159,9 @@ class SVIModel:
151
159
  ]
152
160
 
153
161
  constraints = [
154
- {'type': 'ineq', 'fun': calendar_constraint},
162
+ {'type': 'ineq', 'fun': lambda x: cls.svi(k_constraint, x[0] * t, x[1] * t, x[2], x[3], x[4]) -
163
+ cls.svi(k_constraint, a_prev * prev_t, b_prev * prev_t, m_prev, rho_prev,
164
+ sigma_prev)},
155
165
  {'type': 'ineq', 'fun': lambda x: x[0] + x[1] * x[4] * np.sqrt(1 - x[3] ** 2)}
156
166
  ]
157
167
 
@@ -166,13 +176,56 @@ class SVIModel:
166
176
  w_current = cls.svi(k_constraint, new_params[0] * t, new_params[1] * t, *new_params[2:])
167
177
  w_prev = cls.svi(k_constraint, a_prev * prev_t, b_prev * prev_t, m_prev, rho_prev, sigma_prev)
168
178
  violation = np.min(w_current - w_prev)
169
- logger.info(
170
- f"Calendar arbitrage correction {'successful' if violation >= -1e-6 else 'failed'} for t={t:.4f}, "
171
- f"min margin={violation:.6f}")
179
+ print(f"Calendar arbitrage correction {'successful' if violation >= -1e-6 else 'failed'} for t={t:.4f}, "
180
+ f"min margin={violation:.6f}")
172
181
  return new_params
173
- logger.warning(f"Calendar arbitrage correction failed for t={t:.4f}")
182
+
183
+ print(f"Calendar arbitrage correction failed for t={t:.4f}")
174
184
  return params
175
185
 
186
+ @classmethod
187
+ def check_butterfly_arbitrage(cls, a, b, m, rho, sigma, k_range):
188
+ """Check for butterfly arbitrage violations."""
189
+ for k_val in k_range:
190
+ w_k = cls.svi(k_val, a, b, m, rho, sigma)
191
+ w_d_k = cls.svi_d(k_val, b, m, rho, sigma)
192
+ w_dd_k = cls.svi_dd(k_val, b, m, sigma)
193
+ g = (1 - (k_val * w_d_k) / (2 * w_k)) ** 2 - (w_d_k ** 2) / 4 * (1 / w_k + 1 / 4) + w_dd_k / 2
194
+ if g < 0:
195
+ return False
196
+ return True
197
+
198
+ @classmethod
199
+ def check_calendar_arbitrage(cls, sorted_maturities, params_dict, groups, s, num_points):
200
+ """Check for calendar arbitrage violations."""
201
+ for i in range(len(sorted_maturities) - 1):
202
+ mat1, mat2 = sorted_maturities[i], sorted_maturities[i + 1]
203
+ t1, params1 = params_dict[mat1]
204
+ t2, params2 = params_dict[mat2]
205
+ a1, b1, m1, rho1, sigma1 = params1
206
+ a2, b2, m2, rho2, sigma2 = params2
207
+
208
+ if np.isnan(a1) or np.isnan(a2):
209
+ continue
210
+
211
+ # Get strike range for checking
212
+ group = groups.get_group(mat2)
213
+ K = group['strikes'].values
214
+ k_market = np.log(K / s)
215
+ mask = ~np.isnan(k_market)
216
+ k_check = np.unique(
217
+ np.concatenate([k_market[mask], np.linspace(min(k_market[mask]), max(k_market[mask]), num_points)]))
218
+
219
+ # Check for violations
220
+ for k_val in k_check:
221
+ w1 = cls.svi(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
222
+ w2 = cls.svi(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
223
+ if w2 < w1 - 1e-6:
224
+ print(
225
+ f"Calendar arbitrage violation at t1={t1:.4f}, t2={t2:.4f}, k={k_val:.4f}: w1={w1:.6f}, w2={w2:.6f}")
226
+ return False
227
+ return True
228
+
176
229
 
177
230
  # Models dictionary for easy access
178
231
  MODELS = {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voly
3
- Version: 0.0.204
3
+ Version: 0.0.205
4
4
  Summary: Options & volatility research package
5
5
  Author-email: Manu de Cara <manu.de.cara@gmail.com>
6
6
  License: MIT
@@ -2,19 +2,19 @@ voly/__init__.py,sha256=8xyDk7rFCn_MOD5hxuv5cxxKZvBVRiSIM7TgaMPpwpw,211
2
2
  voly/client.py,sha256=0kp2_I-imcJ6uxMcvS0BipS2PgWdFaCPZG5335Vg75Q,14383
3
3
  voly/exceptions.py,sha256=PBsbn1vNMvKcCJwwJ4lBO6glD85jo1h2qiEmD7ArAjs,92
4
4
  voly/formulas.py,sha256=JnEs6G0wlfRNH6X_YEJMe2RtLH-ryhzufjsim73Bj3c,11176
5
- voly/models.py,sha256=Wop6gZbvAOXSmI0JpYapjVSeJub_i8RLif2PzxRsfFE,7185
5
+ voly/models.py,sha256=IFI_V0EyK2BuGQRQKnK8t474jqSw-bZ7SiZoDGJDMps,9277
6
6
  voly/core/__init__.py,sha256=bu6fS2I1Pj9fPPnl-zY3L7NqrZSY5Zy6NY2uMUvdhKs,183
7
7
  voly/core/charts.py,sha256=2S-BfCo30aj1_xlNLqF-za5rQWxF_mWKIdtdOe5bgbw,12735
8
8
  voly/core/data.py,sha256=s2XzV72YC19QJjZAng_G2QZ0_FJvg9E2I-dV4Of3EK8,14095
9
- voly/core/fit.py,sha256=Ya2g8tNHw_5QFqD66_U4b7z18iWYSF-q_SBeRqBSjU8,17443
9
+ voly/core/fit.py,sha256=WcZh7ZJIMOMnr3A8LbmMdBirHcbkrqRtIRCqH2DdsIQ,13903
10
10
  voly/core/hd.py,sha256=UFAyLncNUHivpPAcko6IK1bC55mudVtdlRFfXp63HXE,14771
11
11
  voly/core/interpolate.py,sha256=JkK172-FXyhesW3hY4pEeuJWG3Bugq7QZXbeKoRpLuo,5305
12
12
  voly/core/rnd.py,sha256=GoC3m1Q46Wnk5tV_mstr-3_aktHeue6BBLh4DQTciW0,13307
13
13
  voly/utils/__init__.py,sha256=E05mWatyC-PDOsCxQV1p5Xi1IgpOomxrNURyCx_gB-w,200
14
14
  voly/utils/density.py,sha256=q0fX4im9TGwMCZ32Hzdv8CNh56KnJo8bmG5w0gVWZH8,5879
15
15
  voly/utils/logger.py,sha256=4-_2bVJmq17Q0d7Rd2mPg1AeR8gxv6EPvcmBDMFWcSM,1744
16
- voly-0.0.204.dist-info/licenses/LICENSE,sha256=wcHIVbE12jfcBOai_wqBKY6xvNQU5E909xL1zZNq_2Q,1065
17
- voly-0.0.204.dist-info/METADATA,sha256=NryL9Zo0j6JzL6VYoM_Zb7GQjWTCs5UAcd9W7IOvUAk,4115
18
- voly-0.0.204.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
19
- voly-0.0.204.dist-info/top_level.txt,sha256=ZfLw2sSxF-LrKAkgGjOmeTcw6_gD-30zvtdEY5W4B7c,5
20
- voly-0.0.204.dist-info/RECORD,,
16
+ voly-0.0.205.dist-info/licenses/LICENSE,sha256=wcHIVbE12jfcBOai_wqBKY6xvNQU5E909xL1zZNq_2Q,1065
17
+ voly-0.0.205.dist-info/METADATA,sha256=FvjgdoH6vzowW1n3tOxGdInYYZYPaUEKryXV2Kz-tIs,4115
18
+ voly-0.0.205.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
19
+ voly-0.0.205.dist-info/top_level.txt,sha256=ZfLw2sSxF-LrKAkgGjOmeTcw6_gD-30zvtdEY5W4B7c,5
20
+ voly-0.0.205.dist-info/RECORD,,
File without changes