voly 0.0.177__py3-none-any.whl → 0.0.179__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 CHANGED
@@ -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(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(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(
voly/core/fit.py CHANGED
@@ -8,7 +8,6 @@ calculating fitting statistics.
8
8
  import numpy as np
9
9
  import pandas as pd
10
10
  from typing import List, Tuple, Dict, Optional, Union, Any
11
- from scipy.optimize import least_squares
12
11
  from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
13
12
  from voly.utils.logger import logger, catch_exception
14
13
  from voly.formulas import get_domain
@@ -21,24 +20,25 @@ warnings.filterwarnings("ignore")
21
20
 
22
21
  @catch_exception
23
22
  def fit_model(option_chain: pd.DataFrame,
24
- domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000)) -> pd.DataFrame:
23
+ domain_params: Tuple[float, float, int] = (-2, 2, 1000)) -> pd.DataFrame:
25
24
  """
26
- Fit Voly's volatility model to market data.
25
+ Fit a volatility model to market data.
27
26
 
28
27
  Parameters:
29
28
  - option_chain: DataFrame with market data
30
- - domain_params : Tuple[float, float, int]
31
- (min_log_moneyness, max_log_moneyness, num_points)
29
+ - domain_params: Tuple of (min, max, num_points) for the log-moneyness grid
32
30
 
33
31
  Returns:
34
32
  - DataFrame with all fit results and performance metrics as columns, maturity_names as index
35
33
  """
36
-
37
34
  # Define column names and their data types
38
35
  column_dtypes = {
39
36
  's': float,
37
+ 'u': float,
40
38
  't': float,
41
39
  'r': float,
40
+ 'oi': float,
41
+ 'volume': float,
42
42
  'maturity_date': 'datetime64[ns]',
43
43
  'a': float,
44
44
  'b': float,
@@ -64,6 +64,7 @@ def fit_model(option_chain: pd.DataFrame,
64
64
  }
65
65
 
66
66
  # Get unique maturities and sort them
67
+ groups = option_chain.groupby('maturity_date')
67
68
  unique_ts = sorted(option_chain['t'].unique())
68
69
  maturity_names = [option_chain[option_chain['t'] == t]['maturity_name'].iloc[0] for t in unique_ts]
69
70
 
@@ -76,100 +77,90 @@ def fit_model(option_chain: pd.DataFrame,
76
77
  s = option_chain['index_price'].iloc[-1]
77
78
 
78
79
  # Dictionary to track fit results by maturity for arbitrage checks
79
- fit_params_dict = {}
80
+ params_dict = {}
80
81
 
81
- # First pass: Fit each maturity
82
+ # Calibrate and check arbitrage
82
83
  for t in unique_ts:
83
84
  # Get data for this maturity
84
85
  maturity_data = option_chain[option_chain['t'] == t]
85
86
  maturity_name = maturity_data['maturity_name'].iloc[0]
86
87
  maturity_date = maturity_data['maturity_date'].iloc[0]
87
88
 
88
- logger.info(f"Optimizing for {maturity_name}...")
89
+ logger.info(f"Processing maturity {maturity_date}, t={t:.4f}")
89
90
 
90
- # Extract data for fitting
91
- k = maturity_data['log_moneyness'].values
91
+ K = maturity_data['strikes'].values
92
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
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
+ params = [np.nan] * 5
100
+ loss = np.inf
124
101
  nu = psi = p = c = nu_tilde = np.nan
102
+ rmse = mae = r2 = max_error = np.nan
103
+ butterfly_arbitrage_free = True
104
+ u = s # Assume underlying_price is index_price
105
+ r = maturity_data['interest_rate'].iloc[0] if 'interest_rate' in maturity_data.columns else 0
106
+ oi = maturity_data['open_interest'].sum() if 'open_interest' in maturity_data.columns else 0
107
+ volume = maturity_data['volume'].sum() if 'volume' in maturity_data.columns else 0
125
108
  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_raw(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
109
 
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
110
+ if len(k) > 5:
111
+ params, loss = SVIModel.fit(tiv=w, vega=vega, k=k, tau=t)
112
+ if not np.isnan(params[0]):
113
+ params_dict[maturity_date] = (t, params)
114
+ a, b, m, rho, sigma = params
115
+ a_scaled, b_scaled = a * t, b * t
116
+
117
+ nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t)
118
+
119
+ # Compute fit statistics
120
+ w_model = np.array([SVIModel.svi(x, a_scaled, b_scaled, m, rho, sigma) for x in k])
121
+ iv_model = np.sqrt(w_model / t)
122
+ iv_market = iv
123
+ rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
124
+ mae = mean_absolute_error(iv_market, iv_model)
125
+ r2 = r2_score(iv_market, iv_model)
126
+ max_error = np.max(np.abs(iv_market - iv_model))
127
+
128
+ # Compute min strike
129
+ log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
130
+ usd_min_strike = np.exp(log_min_strike) * s
131
+
132
+ # Butterfly arbitrage check
133
+ k_range = np.linspace(min(k), max(k), domain_params[2])
134
+ w_k = lambda k: SVIModel.svi(k, a_scaled, b_scaled, m, rho, sigma)
135
+ w_prime = lambda k: b_scaled * (rho + (k - m) / np.sqrt((k - m) ** 2 + sigma ** 2))
136
+ w_double_prime = lambda k: b_scaled * sigma ** 2 / ((k - m) ** 2 + sigma ** 2) ** (3 / 2)
137
+
138
+ for k_val in k_range:
139
+ wk = w_k(k_val)
140
+ wp = w_prime(k_val)
141
+ wpp = w_double_prime(k_val)
142
+ g = (1 - (k_val * wp) / (2 * wk)) ** 2 - (wp ** 2) / 4 * (1 / wk + 1 / 4) + wpp / 2
143
+ if g < 0:
144
+ butterfly_arbitrage_free = False
145
+ break
149
146
 
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_raw(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
147
+ # Log result
148
+ status = f'{GREEN}SUCCESS{RESET}' if not np.isnan(params[0]) else f'{RED}FAILED{RESET}'
149
+ logger.info(f'Optimization for {maturity_name}: {status}')
150
+ logger.info("=================================================================")
162
151
 
163
- # Store values in the results dictionary with proper types
164
152
  results_data['s'].append(float(s))
153
+ results_data['u'].append(float(u))
165
154
  results_data['t'].append(float(t))
166
155
  results_data['r'].append(float(r))
156
+ results_data['oi'].append(float(oi))
157
+ results_data['volume'].append(float(volume))
167
158
  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)
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))
173
164
  results_data['nu'].append(float(nu))
174
165
  results_data['psi'].append(float(psi))
175
166
  results_data['p'].append(float(p))
@@ -177,22 +168,17 @@ def fit_model(option_chain: pd.DataFrame,
177
168
  results_data['nu_tilde'].append(float(nu_tilde))
178
169
  results_data['log_min_strike'].append(float(log_min_strike))
179
170
  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))
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
184
174
  results_data['rmse'].append(float(rmse))
185
175
  results_data['mae'].append(float(mae))
186
176
  results_data['r2'].append(float(r2))
187
177
  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('-------------------------------------')
178
+ results_data['loss'].append(float(loss))
179
+ results_data['n_points'].append(int(len(k)))
194
180
 
195
- # Create DataFrame with proper types
181
+ # Create results DataFrame
196
182
  results_df = pd.DataFrame(results_data, index=maturity_names)
197
183
 
198
184
  # Convert columns to appropriate types
@@ -203,168 +189,179 @@ def fit_model(option_chain: pd.DataFrame,
203
189
  except (ValueError, TypeError) as e:
204
190
  logger.warning(f"Could not convert column {col} to {dtype}: {e}")
205
191
 
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
192
+ # Calendar arbitrage check (pre-correction)
193
+ logger.info("\nChecking calendar arbitrage (pre-correction)...")
194
+ k_grid = np.linspace(domain_params[0], domain_params[1], domain_params[2])
195
+ sorted_maturities = sorted(params_dict.keys(), key=lambda x: params_dict[x][0])
212
196
  calendar_arbitrage_free = True
197
+
213
198
  for i in range(len(sorted_maturities) - 1):
214
199
  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
200
+ t1, params1 = params_dict[mat1]
201
+ t2, params2 = params_dict[mat2]
202
+ a1, b1, m1, rho1, sigma1 = params1
203
+ a2, b2, m2, rho2, sigma2 = params2
219
204
 
220
205
  if np.isnan(a1) or np.isnan(a2):
221
206
  continue
222
207
 
223
- # Check arbitrage on a grid of points
224
- for k_val in k_grid:
225
- w1 = SVIModel.svi_raw(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
226
- w2 = SVIModel.svi_raw(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
208
+ group = groups.get_group(mat2)
209
+ K = group['strikes'].values
210
+ s = group['index_price'].iloc[0]
211
+ k_market = np.log(K / s)
212
+ mask = ~np.isnan(k_market)
213
+ k_check = np.unique(
214
+ np.concatenate([k_market[mask], np.linspace(min(k_market[mask]), max(k_market[mask]), domain_params[2])]))
215
+
216
+ for k_val in k_check:
217
+ w1 = SVIModel.svi(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
218
+ w2 = SVIModel.svi(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
227
219
  if w2 < w1 - 1e-6:
228
- calendar_arbitrage_free = False
229
220
  logger.warning(
230
- f"Calendar arbitrage detected between {sorted_maturities[i]} and {sorted_maturities[i + 1]}")
221
+ f"Calendar arbitrage violation at t1={t1:.4f}, t2={t2:.4f}, k={k_val:.4f}: w1={w1:.6f}, w2={w2:.6f}")
222
+ calendar_arbitrage_free = False
231
223
  break
224
+ if not calendar_arbitrage_free:
225
+ break
232
226
 
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
227
+ for mat in sorted_maturities:
228
+ idx = None
229
+ for i, maturity_name in enumerate(maturity_names):
230
+ if results_df.iloc[i]['maturity_date'] == mat:
231
+ idx = results_df.index[i]
232
+ break
233
+ if idx is not None:
234
+ results_df.at[idx, 'calendar_arbitrage_free'] = calendar_arbitrage_free
237
235
 
238
- # Correct calendar arbitrage if needed
236
+ # Calendar arbitrage correction
239
237
  if not calendar_arbitrage_free:
240
- logger.info("Correcting calendar arbitrage...")
238
+ logger.info("\nPerforming calendar arbitrage correction...")
241
239
  for i in range(1, len(sorted_maturities)):
242
- mat1 = sorted_maturities[i - 1]
243
240
  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]
241
+ mat1 = sorted_maturities[i - 1]
242
+ t2, params2 = params_dict[mat2]
243
+ t1, params1 = params_dict[mat1]
270
244
 
271
- if len(k_masked) <= 5:
245
+ if np.any(np.isnan(params2)) or np.any(np.isnan(params1)):
272
246
  continue
273
247
 
274
- # Calculate total implied variance
275
- w = (iv_masked ** 2) * t2
248
+ group = groups.get_group(mat2)
249
+ s = group['index_price'].iloc[0]
250
+ K = group['strikes'].values
251
+ iv = group['mark_iv'].values
252
+ vega = group['vega'].values if 'vega' in group.columns else np.ones_like(iv)
253
+ k = np.log(K / s)
254
+ w = (iv ** 2) * t2
255
+ mask = ~np.isnan(w) & ~np.isnan(vega) & ~np.isnan(k) & (iv >= 0)
256
+ k, w, vega, iv = k[mask], w[mask], vega[mask], iv[mask]
276
257
 
277
- # Apply calendar arbitrage correction
278
258
  new_params = SVIModel.correct_calendar_arbitrage(
279
- params=params2, t=t2, tiv=w, vega=vega_masked, k=k_masked,
259
+ params=params2, t=t2, tiv=w, vega=vega, k=k,
280
260
  prev_params=params1, prev_t=t1, k_grid=k_grid
281
261
  )
282
262
 
283
- # Update the parameters dictionary
284
- fit_params_dict[mat2] = (t2, new_params)
263
+ params_dict[mat2] = (t2, new_params)
285
264
 
286
- # Extract corrected parameters
287
265
  a, b, m, rho, sigma = new_params
288
-
289
- # Calculate scaled parameters and Jump-Wing parameters
290
266
  a_scaled, b_scaled = a * t2, b * t2
291
267
  nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t2)
292
268
 
293
- # Calculate model predictions for statistics
294
- w_model = np.array([SVIModel.svi_raw(x, a_scaled, b_scaled, m, rho, sigma) for x in k_masked])
269
+ # Recompute fit statistics
270
+ w_model = np.array([SVIModel.svi(x, a_scaled, b_scaled, m, rho, sigma) for x in k])
295
271
  iv_model = np.sqrt(w_model / t2)
296
- iv_market = iv_masked
297
-
298
- # Calculate statistics
272
+ iv_market = iv
299
273
  rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
300
274
  mae = mean_absolute_error(iv_market, iv_model)
301
275
  r2 = r2_score(iv_market, iv_model)
302
276
  max_error = np.max(np.abs(iv_market - iv_model))
303
277
 
304
- # Calculate minimum strike
278
+ # Recompute min strike
305
279
  log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
306
- usd_min_strike = s * np.exp(-log_min_strike)
280
+ usd_min_strike = np.exp(log_min_strike) * s
307
281
 
308
- # Check butterfly arbitrage
282
+ # Update butterfly arbitrage check
309
283
  butterfly_arbitrage_free = True
310
- k_range = np.linspace(min(k_masked), max(k_masked), domain_params[2])
284
+ k_range = np.linspace(min(k), max(k), domain_params[2])
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
+
311
289
  for k_val in k_range:
312
- wk = SVIModel.svi_raw(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)
290
+ wk = w_k(k_val)
291
+ wp = w_prime(k_val)
292
+ wpp = w_double_prime(k_val)
315
293
  g = (1 - (k_val * wp) / (2 * wk)) ** 2 - (wp ** 2) / 4 * (1 / wk + 1 / 4) + wpp / 2
316
294
  if g < 0:
317
295
  butterfly_arbitrage_free = False
318
296
  break
319
297
 
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
298
+ # Find the correct index to update
299
+ idx = None
300
+ for j, maturity_name in enumerate(maturity_names):
301
+ if results_df.iloc[j]['maturity_date'] == mat2:
302
+ idx = results_df.index[j]
303
+ break
304
+
305
+ if idx is not None:
306
+ results_df.at[idx, 'a'] = float(a_scaled)
307
+ results_df.at[idx, 'b'] = float(b_scaled)
308
+ results_df.at[idx, 'm'] = float(m)
309
+ results_df.at[idx, 'rho'] = float(rho)
310
+ results_df.at[idx, 'sigma'] = float(sigma)
311
+ results_df.at[idx, 'nu'] = float(nu)
312
+ results_df.at[idx, 'psi'] = float(psi)
313
+ results_df.at[idx, 'p'] = float(p)
314
+ results_df.at[idx, 'c'] = float(c)
315
+ results_df.at[idx, 'nu_tilde'] = float(nu_tilde)
316
+ results_df.at[idx, 'rmse'] = float(rmse)
317
+ results_df.at[idx, 'mae'] = float(mae)
318
+ results_df.at[idx, 'r2'] = float(r2)
319
+ results_df.at[idx, 'max_error'] = float(max_error)
320
+ results_df.at[idx, 'log_min_strike'] = float(log_min_strike)
321
+ results_df.at[idx, 'usd_min_strike'] = float(usd_min_strike)
322
+ results_df.at[idx, 'butterfly_arbitrage_free'] = butterfly_arbitrage_free
323
+ results_df.at[idx, 'fit_success'] = bool(not np.isnan(a))
324
+
325
+ # Calendar arbitrage check (post-correction)
326
+ logger.info("\nChecking calendar arbitrage (post-correction)...")
343
327
  calendar_arbitrage_free = True
344
328
  for i in range(len(sorted_maturities) - 1):
345
329
  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
330
+ t1, params1 = params_dict[mat1]
331
+ t2, params2 = params_dict[mat2]
332
+ a1, b1, m1, rho1, sigma1 = params1
333
+ a2, b2, m2, rho2, sigma2 = params2
350
334
 
351
335
  if np.isnan(a1) or np.isnan(a2):
352
336
  continue
353
337
 
354
- # Check arbitrage on a grid of points
355
- for k_val in k_grid:
356
- w1 = SVIModel.svi_raw(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
357
- w2 = SVIModel.svi_raw(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
338
+ group = groups.get_group(mat2)
339
+ K = group['strikes'].values
340
+ s = group['index_price'].iloc[0]
341
+ k_market = np.log(K / s)
342
+ mask = ~np.isnan(k_market)
343
+ k_check = np.unique(np.concatenate(
344
+ [k_market[mask], np.linspace(min(k_market[mask]), max(k_market[mask]), domain_params[2])]))
345
+
346
+ for k_val in k_check:
347
+ w1 = SVIModel.svi(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
348
+ w2 = SVIModel.svi(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
358
349
  if w2 < w1 - 1e-6:
350
+ logger.warning(
351
+ f"Calendar arbitrage violation at t1={t1:.4f}, t2={t2:.4f}, k={k_val:.4f}: w1={w1:.6f}, w2={w2:.6f}")
359
352
  calendar_arbitrage_free = False
360
- logger.warning(f"Calendar arbitrage still detected between {mat1} and {mat2} after correction")
361
353
  break
354
+ if not calendar_arbitrage_free:
355
+ break
362
356
 
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
357
+ for mat in sorted_maturities:
358
+ idx = None
359
+ for j, maturity_name in enumerate(maturity_names):
360
+ if results_df.iloc[j]['maturity_date'] == mat:
361
+ idx = results_df.index[j]
362
+ break
363
+ if idx is not None:
364
+ results_df.at[idx, 'calendar_arbitrage_free'] = calendar_arbitrage_free
368
365
 
369
366
  logger.info("Model fitting complete.")
370
367
  return results_df
@@ -416,7 +413,7 @@ def get_iv_surface(model_results: pd.DataFrame,
416
413
  t = model_results.loc[i, 't']
417
414
 
418
415
  # Calculate implied volatility
419
- w = np.array([SVIModel.svi_raw(x, *params) for x in LM])
416
+ w = np.array([SVIModel.svi(x, *params) for x in LM])
420
417
  o = np.sqrt(w / t)
421
418
  iv_surface[i] = o
422
419
 
voly/formulas.py CHANGED
@@ -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
 
voly/models.py CHANGED
@@ -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
@@ -35,32 +31,10 @@ class SVIModel:
35
31
  }
36
32
 
37
33
  @staticmethod
38
- 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
- """
34
+ def svi(k, a, b, m, rho, sigma):
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,26 +117,11 @@ 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))]))
@@ -195,28 +129,29 @@ class SVIModel:
195
129
  def objective(x):
196
130
  a, b, m, rho, sigma = x
197
131
  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)))
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(k_constraint, a * t, b * t, m, rho, sigma)
141
+ w_prev = cls.svi(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(k_constraint, new_params[0] * t, new_params[1] * t, *new_params[2:])
166
+ w_prev = cls.svi(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.177
3
+ Version: 0.0.179
4
4
  Summary: Options & volatility research package
5
5
  Author-email: Manu de Cara <manu.de.cara@gmail.com>
6
6
  License: MIT
@@ -1,20 +1,20 @@
1
1
  voly/__init__.py,sha256=8xyDk7rFCn_MOD5hxuv5cxxKZvBVRiSIM7TgaMPpwpw,211
2
- voly/client.py,sha256=6e9cX5JWeqoTIktKlT4yEN9FG5Cj8Icl7gHxhVayi24,14464
2
+ voly/client.py,sha256=M_-d3ibPp2Uc8ucEkWfhKbqKOQ7xSuUO6dGHTIqmG3E,14587
3
3
  voly/exceptions.py,sha256=PBsbn1vNMvKcCJwwJ4lBO6glD85jo1h2qiEmD7ArAjs,92
4
- voly/formulas.py,sha256=eDPw32xa3dwCIrE-zeZzznfGaplGFoVcA167uQngZ70,11209
5
- voly/models.py,sha256=tg-u_6hnY9VQPIjoWfnJBIZRNAxNo1MRKHl0HcaaQQw,8908
4
+ voly/formulas.py,sha256=JnEs6G0wlfRNH6X_YEJMe2RtLH-ryhzufjsim73Bj3c,11176
5
+ voly/models.py,sha256=wxqf9T4D2ORO7g3KcwxUL0E78fG69W29xqer3ccoUXo,6994
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=9v9iuE2XdIIlzoRAB7q1ol7YghBzBsPGAiwZ11oDuis,13650
9
- voly/core/fit.py,sha256=Aafqzwdlq1wDh5BI-yT3nDI4e2Qegp0fROr4JtnwBeg,17739
9
+ voly/core/fit.py,sha256=RUFawNWRZR9RGLGNtYuJgQmaLmCEkrfVaAimUJruOGY,17937
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.177.dist-info/licenses/LICENSE,sha256=wcHIVbE12jfcBOai_wqBKY6xvNQU5E909xL1zZNq_2Q,1065
17
- voly-0.0.177.dist-info/METADATA,sha256=1FxR4996NiRdwMgQAfjcb5yjBvvfR9Fw3XJXI9mmWNY,4115
18
- voly-0.0.177.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
19
- voly-0.0.177.dist-info/top_level.txt,sha256=ZfLw2sSxF-LrKAkgGjOmeTcw6_gD-30zvtdEY5W4B7c,5
20
- voly-0.0.177.dist-info/RECORD,,
16
+ voly-0.0.179.dist-info/licenses/LICENSE,sha256=wcHIVbE12jfcBOai_wqBKY6xvNQU5E909xL1zZNq_2Q,1065
17
+ voly-0.0.179.dist-info/METADATA,sha256=r0MQ4zUUjTh4a6FvIMoO3XsAsen_cAXQJToJ2VQMlT0,4115
18
+ voly-0.0.179.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
19
+ voly-0.0.179.dist-info/top_level.txt,sha256=ZfLw2sSxF-LrKAkgGjOmeTcw6_gD-30zvtdEY5W4B7c,5
20
+ voly-0.0.179.dist-info/RECORD,,
File without changes