voly 0.0.177__py3-none-any.whl → 0.0.178__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_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(
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,95 @@ 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)
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]
113
98
 
114
- # Extract parameters (will be nan if fit failed)
115
- a, b, m, rho, sigma = params
99
+ logger.info(f"Points after filtering: {len(k)}")
116
100
 
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
101
+ params = [np.nan] * 5
102
+ loss = np.inf
124
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
125
110
  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
111
 
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))
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
145
148
 
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_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
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("=================================================================")
162
156
 
163
- # Store values in the results dictionary with proper types
164
157
  results_data['s'].append(float(s))
158
+ results_data['u'].append(float(u))
165
159
  results_data['t'].append(float(t))
166
160
  results_data['r'].append(float(r))
161
+ results_data['oi'].append(float(oi))
162
+ results_data['volume'].append(float(volume))
167
163
  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)
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))
173
169
  results_data['nu'].append(float(nu))
174
170
  results_data['psi'].append(float(psi))
175
171
  results_data['p'].append(float(p))
@@ -177,22 +173,17 @@ def fit_model(option_chain: pd.DataFrame,
177
173
  results_data['nu_tilde'].append(float(nu_tilde))
178
174
  results_data['log_min_strike'].append(float(log_min_strike))
179
175
  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))
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
184
179
  results_data['rmse'].append(float(rmse))
185
180
  results_data['mae'].append(float(mae))
186
181
  results_data['r2'].append(float(r2))
187
182
  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('-------------------------------------')
183
+ results_data['loss'].append(float(loss))
184
+ results_data['n_points'].append(int(len(k)))
194
185
 
195
- # Create DataFrame with proper types
186
+ # Create results DataFrame
196
187
  results_df = pd.DataFrame(results_data, index=maturity_names)
197
188
 
198
189
  # Convert columns to appropriate types
@@ -203,168 +194,179 @@ def fit_model(option_chain: pd.DataFrame,
203
194
  except (ValueError, TypeError) as e:
204
195
  logger.warning(f"Could not convert column {col} to {dtype}: {e}")
205
196
 
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
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])
212
201
  calendar_arbitrage_free = True
202
+
213
203
  for i in range(len(sorted_maturities) - 1):
214
204
  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
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
219
209
 
220
210
  if np.isnan(a1) or np.isnan(a2):
221
211
  continue
222
212
 
223
- # Check arbitrage on a grid of points
224
- for k_val in k_grid:
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:
225
222
  w1 = SVIModel.svi_raw(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
226
223
  w2 = SVIModel.svi_raw(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
227
224
  if w2 < w1 - 1e-6:
228
- calendar_arbitrage_free = False
229
225
  logger.warning(
230
- f"Calendar arbitrage detected between {sorted_maturities[i]} and {sorted_maturities[i + 1]}")
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
231
228
  break
229
+ if not calendar_arbitrage_free:
230
+ break
232
231
 
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
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
237
240
 
238
- # Correct calendar arbitrage if needed
241
+ # Calendar arbitrage correction
239
242
  if not calendar_arbitrage_free:
240
- logger.info("Correcting calendar arbitrage...")
243
+ logger.info("\nPerforming calendar arbitrage correction...")
241
244
  for i in range(1, len(sorted_maturities)):
242
- mat1 = sorted_maturities[i - 1]
243
245
  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]
246
+ mat1 = sorted_maturities[i - 1]
247
+ t2, params2 = params_dict[mat2]
248
+ t1, params1 = params_dict[mat1]
270
249
 
271
- if len(k_masked) <= 5:
250
+ if np.any(np.isnan(params2)) or np.any(np.isnan(params1)):
272
251
  continue
273
252
 
274
- # Calculate total implied variance
275
- w = (iv_masked ** 2) * t2
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]
276
262
 
277
- # Apply calendar arbitrage correction
278
263
  new_params = SVIModel.correct_calendar_arbitrage(
279
- params=params2, t=t2, tiv=w, vega=vega_masked, k=k_masked,
264
+ params=params2, t=t2, tiv=w, vega=vega, k=k,
280
265
  prev_params=params1, prev_t=t1, k_grid=k_grid
281
266
  )
282
267
 
283
- # Update the parameters dictionary
284
- fit_params_dict[mat2] = (t2, new_params)
268
+ params_dict[mat2] = (t2, new_params)
285
269
 
286
- # Extract corrected parameters
287
270
  a, b, m, rho, sigma = new_params
288
-
289
- # Calculate scaled parameters and Jump-Wing parameters
290
271
  a_scaled, b_scaled = a * t2, b * t2
291
272
  nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t2)
292
273
 
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])
274
+ # Recompute fit statistics
275
+ w_model = np.array([SVIModel.svi_raw(x, a_scaled, b_scaled, m, rho, sigma) for x in k])
295
276
  iv_model = np.sqrt(w_model / t2)
296
- iv_market = iv_masked
297
-
298
- # Calculate statistics
277
+ iv_market = iv
299
278
  rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
300
279
  mae = mean_absolute_error(iv_market, iv_model)
301
280
  r2 = r2_score(iv_market, iv_model)
302
281
  max_error = np.max(np.abs(iv_market - iv_model))
303
282
 
304
- # Calculate minimum strike
283
+ # Recompute min strike
305
284
  log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
306
- usd_min_strike = s * np.exp(-log_min_strike)
285
+ usd_min_strike = np.exp(log_min_strike) * s
307
286
 
308
- # Check butterfly arbitrage
287
+ # Update butterfly arbitrage check
309
288
  butterfly_arbitrage_free = True
310
- k_range = np.linspace(min(k_masked), max(k_masked), domain_params[2])
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
+
311
294
  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)
295
+ wk = w_k(k_val)
296
+ wp = w_prime(k_val)
297
+ wpp = w_double_prime(k_val)
315
298
  g = (1 - (k_val * wp) / (2 * wk)) ** 2 - (wp ** 2) / 4 * (1 / wk + 1 / 4) + wpp / 2
316
299
  if g < 0:
317
300
  butterfly_arbitrage_free = False
318
301
  break
319
302
 
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}")
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
341
309
 
342
- # Check calendar arbitrage after correction
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)...")
343
332
  calendar_arbitrage_free = True
344
333
  for i in range(len(sorted_maturities) - 1):
345
334
  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
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
350
339
 
351
340
  if np.isnan(a1) or np.isnan(a2):
352
341
  continue
353
342
 
354
- # Check arbitrage on a grid of points
355
- for k_val in k_grid:
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:
356
352
  w1 = SVIModel.svi_raw(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
357
353
  w2 = SVIModel.svi_raw(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
358
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}")
359
357
  calendar_arbitrage_free = False
360
- logger.warning(f"Calendar arbitrage still detected between {mat1} and {mat2} after correction")
361
358
  break
359
+ if not calendar_arbitrage_free:
360
+ break
362
361
 
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
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
368
370
 
369
371
  logger.info("Model fitting complete.")
370
372
  return results_df
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
@@ -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.177
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,20 +1,20 @@
1
1
  voly/__init__.py,sha256=8xyDk7rFCn_MOD5hxuv5cxxKZvBVRiSIM7TgaMPpwpw,211
2
- voly/client.py,sha256=6e9cX5JWeqoTIktKlT4yEN9FG5Cj8Icl7gHxhVayi24,14464
2
+ voly/client.py,sha256=Ge0-aBvKd32aHqMCaA1xgxOS_Z-bi4neYYAGil89A6I,14595
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=ydfhdCELDsoFJF9VKq8bnRiO2lRsmC3iQGo9Jx_jjVc,7018
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=eYTXkoSZIdHYgjW1GQ-qD4Qe3DsZVfuMzmv1Gj_yJfY,18218
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.178.dist-info/licenses/LICENSE,sha256=wcHIVbE12jfcBOai_wqBKY6xvNQU5E909xL1zZNq_2Q,1065
17
+ voly-0.0.178.dist-info/METADATA,sha256=_NfVcF39Ez-Ytgo5sP1fxpujPIUzhASwcRjmrNCY9S4,4115
18
+ voly-0.0.178.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
19
+ voly-0.0.178.dist-info/top_level.txt,sha256=ZfLw2sSxF-LrKAkgGjOmeTcw6_gD-30zvtdEY5W4B7c,5
20
+ voly-0.0.178.dist-info/RECORD,,
File without changes