voly 0.0.166__py3-none-any.whl → 0.0.168__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
@@ -165,29 +165,24 @@ class VolyClient:
165
165
 
166
166
  @staticmethod
167
167
  def fit_model(option_chain: pd.DataFrame,
168
- model_name: str = 'svi',
169
- initial_params: Optional[List[float]] = None,
170
- param_bounds: Optional[Tuple] = None) -> Dict[str, Any]:
168
+ domain_params: Tuple[float, float, int] = (-2, 2, 500)
169
+ ) -> Dict[str, Any]:
171
170
  """
172
171
  Fit a volatility model to market data.
173
172
 
174
173
  Parameters:
175
174
  - option_chain: DataFrame with option market data
176
- - model_name: Name of model to fit (default: 'svi')
177
- - initial_params: Optional initial parameters for optimization
178
- - param_bounds: Optional parameter bounds for optimization
175
+ - domain_params: Tuple of (min, max, num_points) for the moneyness grid
179
176
 
180
177
  Returns:
181
- - Tuple of (fit_results, fit_performance)
178
+ - Dataframe with fit_results
182
179
  """
183
- logger.info(f"Fitting {model_name.upper()} model to market data")
180
+ logger.info(f"Fitting model to market data.")
184
181
 
185
182
  # Fit the model
186
183
  fit_results = fit_model(
187
184
  option_chain=option_chain,
188
- model_name=model_name,
189
- initial_params=initial_params,
190
- param_bounds=param_bounds
185
+ domain_params=domain_params
191
186
  )
192
187
 
193
188
  return fit_results
voly/core/charts.py CHANGED
@@ -139,7 +139,7 @@ def plot_raw_parameters(fit_results: pd.DataFrame) -> go.Figure:
139
139
  - Plotly figure
140
140
  """
141
141
  # Select parameters to plot
142
- param_names = ['a', 'b', 'sigma', 'rho', 'm']
142
+ param_names = ['a', 'b', 'm', 'rho', 'sigma']
143
143
 
144
144
  # Create subplots
145
145
  fig = make_subplots(
voly/core/fit.py CHANGED
@@ -19,62 +19,43 @@ import warnings
19
19
  warnings.filterwarnings("ignore")
20
20
 
21
21
 
22
- @catch_exception
23
- def calculate_residuals(params: List[float], t: float, option_chain: pd.DataFrame,
24
- model: Any = SVIModel) -> np.ndarray:
25
- """Calculate residuals between market and model implied volatilities."""
26
- maturity_data = option_chain[option_chain['t'] == t]
27
- w = np.array([model.svi(x, *params) for x in maturity_data['log_moneyness']])
28
- iv_actual = maturity_data['mark_iv'].values
29
- return iv_actual - np.sqrt(w / t)
30
-
31
-
32
22
  @catch_exception
33
23
  def fit_model(option_chain: pd.DataFrame,
34
- model_name: str = 'svi',
35
- initial_params: Optional[List[float]] = None,
36
- param_bounds: Optional[Tuple] = None) -> pd.DataFrame:
24
+ domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000)) -> pd.DataFrame:
37
25
  """
38
- Fit a volatility model to market data.
26
+ Fit Voly's volatility model to market data.
39
27
 
40
28
  Parameters:
41
29
  - option_chain: DataFrame with market data
42
- - model_name: Type of model to fit (default: 'svi')
43
- - initial_params: Optional initial parameters for optimization (default: model's defaults)
44
- - param_bounds: Optional parameter bounds for optimization (default: model's defaults)
30
+ - domain_params : Tuple[float, float, int]
31
+ (min_log_moneyness, max_log_moneyness, num_points)
45
32
 
46
33
  Returns:
47
34
  - DataFrame with all fit results and performance metrics as columns, maturity_names as index
48
35
  """
49
- if model_name.lower() != 'svi':
50
- raise VolyError(f"Model type '{model_name}' is not supported. Currently only 'svi' is available.")
51
-
52
- # Use defaults if not provided
53
- initial_params = initial_params or SVIModel.DEFAULT_INITIAL_PARAMS
54
- param_bounds = param_bounds or SVIModel.DEFAULT_PARAM_BOUNDS
55
36
 
56
37
  # Define column names and their data types
57
38
  column_dtypes = {
58
39
  's': float,
59
- 'u': float,
60
40
  't': float,
61
41
  'r': float,
62
- 'oi': float,
63
- 'volume': float,
64
42
  'maturity_date': 'datetime64[ns]',
65
43
  'a': float,
66
44
  'b': float,
67
- 'sigma': float,
68
- 'rho': float,
69
45
  'm': float,
46
+ 'rho': float,
47
+ 'sigma': float,
70
48
  'nu': float,
71
49
  'psi': float,
72
50
  'p': float,
73
51
  'c': float,
74
52
  'nu_tilde': float,
53
+ 'log_min_strike': float,
54
+ 'usd_min_strike': float,
75
55
  'fit_success': bool,
76
- 'cost': float,
77
- 'optimality': float,
56
+ 'butterfly_arbitrage_free': bool,
57
+ 'calendar_arbitrage_free': bool,
58
+ 'loss': float,
78
59
  'rmse': float,
79
60
  'mae': float,
80
61
  'r2': float,
@@ -94,79 +75,120 @@ def fit_model(option_chain: pd.DataFrame,
94
75
 
95
76
  s = option_chain['index_price'].iloc[-1]
96
77
 
78
+ # Dictionary to track fit results by maturity for arbitrage checks
79
+ fit_params_dict = {}
80
+
81
+ # First pass: Fit each maturity
97
82
  for t in unique_ts:
98
83
  # Get data for this maturity
99
84
  maturity_data = option_chain[option_chain['t'] == t]
100
85
  maturity_name = maturity_data['maturity_name'].iloc[0]
86
+ maturity_date = maturity_data['maturity_date'].iloc[0]
101
87
 
102
88
  logger.info(f"Optimizing for {maturity_name}...")
103
89
 
104
- # Optimize SVI parameters
105
- try:
106
- result = least_squares(
107
- calculate_residuals,
108
- initial_params,
109
- args=(t, option_chain, SVIModel),
110
- bounds=param_bounds,
111
- max_nfev=1000
112
- )
113
- except Exception as e:
114
- raise VolyError(f"Optimization failed for {maturity_name}: {str(e)}")
90
+ # Extract data for fitting
91
+ k = maturity_data['log_moneyness'].values
92
+ iv = maturity_data['mark_iv'].values
93
+ vega = maturity_data['vega'].values if 'vega' in maturity_data else np.ones_like(iv)
115
94
 
116
- # Extract raw parameters
117
- a, b, sigma, rho, m = result.x
95
+ # Apply mask to filter out invalid data
96
+ mask = ~np.isnan(iv) & ~np.isnan(k) & (iv > 0)
97
+ k_masked, iv_masked, vega_masked = k[mask], iv[mask], vega[mask]
118
98
 
119
- # Calculate model predictions for statistics
120
- w = np.array([SVIModel.svi(x, *result.x) for x in maturity_data['log_moneyness']])
121
- iv_model = np.sqrt(w / t)
122
- iv_market = maturity_data['mark_iv'].values
99
+ # Check if we have enough valid points
100
+ if len(k_masked) <= 5:
101
+ logger.warning(f"Not enough valid data points for {maturity_name}, skipping.")
102
+ params = [np.nan] * 5
103
+ loss = np.inf
104
+ else:
105
+ # Calculate total implied variance (w = iv² * t)
106
+ w = (iv_masked ** 2) * t
123
107
 
124
- # Calculate statistics
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))
108
+ # Fit using the improved SVI method
109
+ params, loss = SVIModel.fit(tiv=w, vega=vega_masked, k=k_masked, tau=t)
129
110
 
130
- # Get or calculate additional required data
131
- u = maturity_data['underlying_price'].iloc[0]
111
+ # Store the parameters for this maturity
112
+ fit_params_dict[maturity_date] = (t, params)
132
113
 
133
- # Aggregate open interest and volume
134
- oi = maturity_data['open_interest'].sum() if 'open_interest' in maturity_data.columns else 0
135
- volume = maturity_data['volume'].sum() if 'volume' in maturity_data.columns else 0
136
- r = maturity_data['interest_rate'].iloc[0] if 'interest_rate' in maturity_data.columns else 0
114
+ # Extract parameters (will be nan if fit failed)
115
+ a, b, m, rho, sigma = params
116
+
117
+ # Calculate statistics
118
+ fit_success = not np.isnan(a)
119
+ butterfly_arbitrage_free = True
120
+ calendar_arbitrage_free = True # Will check later
121
+
122
+ # Initialize default metrics
123
+ rmse = mae = r2 = max_error = np.nan
124
+ nu = psi = p = c = nu_tilde = np.nan
125
+ log_min_strike = usd_min_strike = np.nan
126
+ a_scaled = b_scaled = np.nan
127
+
128
+ if fit_success:
129
+ # Scale a and b by t
130
+ a_scaled, b_scaled = a * t, b * t
131
+
132
+ # Calculate Jump-Wing parameters
133
+ nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t)
134
+
135
+ # Calculate model predictions for statistics
136
+ w_model = np.array([SVIModel.svi(x, a_scaled, b_scaled, m, rho, sigma) for x in k_masked])
137
+ iv_model = np.sqrt(w_model / t)
138
+ iv_market = iv_masked
139
+
140
+ # Calculate statistics
141
+ rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
142
+ mae = mean_absolute_error(iv_market, iv_model)
143
+ r2 = r2_score(iv_market, iv_model)
144
+ max_error = np.max(np.abs(iv_market - iv_model))
145
+
146
+ # Calculate minimum strike
147
+ log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
148
+ usd_min_strike = s * np.exp(-log_min_strike) # Convert from log_moneyness to strike
149
+
150
+ # Check butterfly arbitrage
151
+ k_range = np.linspace(min(k_masked), max(k_masked), domain_params[2])
152
+ for k_val in k_range:
153
+ wk = SVIModel.svi(k_val, a_scaled, b_scaled, m, rho, sigma)
154
+ wp = SVIModel.svi_d(k_val, a_scaled, b_scaled, m, rho, sigma)
155
+ wpp = SVIModel.svi_dd(k_val, a_scaled, b_scaled, m, rho, sigma)
156
+ g = (1 - (k_val * wp) / (2 * wk)) ** 2 - (wp ** 2) / 4 * (1 / wk + 1 / 4) + wpp / 2
157
+ if g < 0:
158
+ butterfly_arbitrage_free = False
159
+ break
137
160
 
138
- # Calculate Jump-Wing parameters
139
- nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a, b, sigma, rho, m, t)
161
+ r = maturity_data['interest_rate'].iloc[0] if 'interest_rate' in maturity_data.columns else 0
140
162
 
141
163
  # Store values in the results dictionary with proper types
142
164
  results_data['s'].append(float(s))
143
- results_data['u'].append(float(u))
144
165
  results_data['t'].append(float(t))
145
166
  results_data['r'].append(float(r))
146
- results_data['oi'].append(float(oi))
147
- results_data['volume'].append(float(volume))
148
- results_data['maturity_date'].append(maturity_data['maturity_date'].iloc[0])
149
- results_data['a'].append(float(a))
150
- results_data['b'].append(float(b))
151
- results_data['sigma'].append(float(sigma))
152
- results_data['m'].append(float(m))
153
- results_data['rho'].append(float(rho))
167
+ results_data['maturity_date'].append(maturity_date)
168
+ results_data['a'].append(float(a_scaled) if fit_success else np.nan)
169
+ results_data['b'].append(float(b_scaled) if fit_success else np.nan)
170
+ results_data['sigma'].append(float(sigma) if fit_success else np.nan)
171
+ results_data['m'].append(float(m) if fit_success else np.nan)
172
+ results_data['rho'].append(float(rho) if fit_success else np.nan)
154
173
  results_data['nu'].append(float(nu))
155
174
  results_data['psi'].append(float(psi))
156
175
  results_data['p'].append(float(p))
157
176
  results_data['c'].append(float(c))
158
177
  results_data['nu_tilde'].append(float(nu_tilde))
159
- results_data['fit_success'].append(bool(result.success))
160
- results_data['cost'].append(float(result.cost))
161
- results_data['optimality'].append(float(result.optimality))
178
+ results_data['log_min_strike'].append(float(log_min_strike))
179
+ results_data['usd_min_strike'].append(float(usd_min_strike))
180
+ results_data['fit_success'].append(bool(fit_success))
181
+ results_data['butterfly_arbitrage_free'].append(bool(butterfly_arbitrage_free))
182
+ results_data['calendar_arbitrage_free'].append(bool(True)) # Will update in second pass
183
+ results_data['loss'].append(float(loss))
162
184
  results_data['rmse'].append(float(rmse))
163
185
  results_data['mae'].append(float(mae))
164
186
  results_data['r2'].append(float(r2))
165
187
  results_data['max_error'].append(float(max_error))
166
- results_data['n_points'].append(int(len(maturity_data)))
188
+ results_data['n_points'].append(int(len(k_masked)))
167
189
 
168
190
  # Log result
169
- status = f'{GREEN}SUCCESS{RESET}' if result.success else f'{RED}FAILED{RESET}'
191
+ status = f'{GREEN}SUCCESS{RESET}' if fit_success else f'{RED}FAILED{RESET}'
170
192
  logger.info(f'Optimization for {maturity_name}: {status}')
171
193
  logger.info('-------------------------------------')
172
194
 
@@ -181,6 +203,170 @@ def fit_model(option_chain: pd.DataFrame,
181
203
  except (ValueError, TypeError) as e:
182
204
  logger.warning(f"Could not convert column {col} to {dtype}: {e}")
183
205
 
206
+ # Second pass: Check and correct for calendar arbitrage
207
+ logger.info("Checking for calendar arbitrage...")
208
+ sorted_maturities = sorted(fit_params_dict.keys())
209
+ k_grid = np.linspace(domain_params[0], domain_params[1], domain_params[2]) # Grid for arbitrage checking
210
+
211
+ # Check calendar arbitrage before correction
212
+ calendar_arbitrage_free = True
213
+ for i in range(len(sorted_maturities) - 1):
214
+ mat1, mat2 = sorted_maturities[i], sorted_maturities[i + 1]
215
+ t1, params1 = fit_params_dict[mat1]
216
+ t2, params2 = fit_params_dict[mat2]
217
+ a1, b1, sigma1, rho1, m1 = params1
218
+ a2, b2, sigma2, rho2, m2 = params2
219
+
220
+ if np.isnan(a1) or np.isnan(a2):
221
+ continue
222
+
223
+ # Check arbitrage on a grid of points
224
+ for k_val in k_grid:
225
+ w1 = SVIModel.svi(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
226
+ w2 = SVIModel.svi(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
227
+ if w2 < w1 - 1e-6:
228
+ calendar_arbitrage_free = False
229
+ logger.warning(
230
+ f"Calendar arbitrage detected between {sorted_maturities[i]} and {sorted_maturities[i + 1]}")
231
+ break
232
+
233
+ # Update results with calendar arbitrage status
234
+ for i, maturity_name in enumerate(maturity_names):
235
+ idx = results_df.index[i]
236
+ results_df.at[idx, 'calendar_arbitrage_free'] = calendar_arbitrage_free
237
+
238
+ # Correct calendar arbitrage if needed
239
+ if not calendar_arbitrage_free:
240
+ logger.info("Correcting calendar arbitrage...")
241
+ for i in range(1, len(sorted_maturities)):
242
+ mat1 = sorted_maturities[i - 1]
243
+ mat2 = sorted_maturities[i]
244
+ t1, params1 = fit_params_dict[mat1]
245
+ t2, params2 = fit_params_dict[mat2]
246
+
247
+ if np.isnan(params1[0]) or np.isnan(params2[0]):
248
+ continue
249
+
250
+ # Find the index in maturity_names that corresponds to mat2
251
+ maturity_idx = None
252
+ for j, maturity_name in enumerate(maturity_names):
253
+ if results_df.iloc[j]['maturity_date'] == mat2:
254
+ maturity_idx = j
255
+ break
256
+
257
+ if maturity_idx is None:
258
+ continue
259
+
260
+ # Get data for correction
261
+ idx = results_df.index[maturity_idx]
262
+ maturity_data = option_chain[option_chain['maturity_name'] == idx]
263
+ k = maturity_data['log_moneyness'].values
264
+ iv = maturity_data['mark_iv'].values
265
+ vega = maturity_data['vega'].values if 'vega' in maturity_data else np.ones_like(iv)
266
+
267
+ # Apply mask to filter out invalid data
268
+ mask = ~np.isnan(iv) & ~np.isnan(k) & (iv > 0)
269
+ k_masked, iv_masked, vega_masked = k[mask], iv[mask], vega[mask]
270
+
271
+ if len(k_masked) <= 5:
272
+ continue
273
+
274
+ # Calculate total implied variance
275
+ w = (iv_masked ** 2) * t2
276
+
277
+ # Apply calendar arbitrage correction
278
+ new_params = SVIModel.correct_calendar_arbitrage(
279
+ params=params2, t=t2, tiv=w, vega=vega_masked, k=k_masked,
280
+ prev_params=params1, prev_t=t1, k_grid=k_grid
281
+ )
282
+
283
+ # Update the parameters dictionary
284
+ fit_params_dict[mat2] = (t2, new_params)
285
+
286
+ # Extract corrected parameters
287
+ a, b, m, rho, sigma = new_params
288
+
289
+ # Calculate scaled parameters and Jump-Wing parameters
290
+ a_scaled, b_scaled = a * t2, b * t2
291
+ nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t2)
292
+
293
+ # Calculate model predictions for statistics
294
+ w_model = np.array([SVIModel.svi(x, a_scaled, b_scaled, m, rho, sigma) for x in k_masked])
295
+ iv_model = np.sqrt(w_model / t2)
296
+ iv_market = iv_masked
297
+
298
+ # Calculate statistics
299
+ rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
300
+ mae = mean_absolute_error(iv_market, iv_model)
301
+ r2 = r2_score(iv_market, iv_model)
302
+ max_error = np.max(np.abs(iv_market - iv_model))
303
+
304
+ # Calculate minimum strike
305
+ log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
306
+ usd_min_strike = s * np.exp(-log_min_strike)
307
+
308
+ # Check butterfly arbitrage
309
+ butterfly_arbitrage_free = True
310
+ k_range = np.linspace(min(k_masked), max(k_masked), domain_params[2])
311
+ for k_val in k_range:
312
+ wk = SVIModel.svi(k_val, a_scaled, b_scaled, m, rho, sigma)
313
+ wp = SVIModel.svi_d(k_val, a_scaled, b_scaled, m, rho, sigma)
314
+ wpp = SVIModel.svi_dd(k_val, a_scaled, b_scaled, m, rho, sigma)
315
+ g = (1 - (k_val * wp) / (2 * wk)) ** 2 - (wp ** 2) / 4 * (1 / wk + 1 / 4) + wpp / 2
316
+ if g < 0:
317
+ butterfly_arbitrage_free = False
318
+ break
319
+
320
+ # Update results in the DataFrame
321
+ results_df.at[idx, 'a'] = float(a_scaled)
322
+ results_df.at[idx, 'b'] = float(b_scaled)
323
+ results_df.at[idx, 'm'] = float(m)
324
+ results_df.at[idx, 'rho'] = float(rho)
325
+ results_df.at[idx, 'sigma'] = float(sigma)
326
+ results_df.at[idx, 'nu'] = float(nu)
327
+ results_df.at[idx, 'psi'] = float(psi)
328
+ results_df.at[idx, 'p'] = float(p)
329
+ results_df.at[idx, 'c'] = float(c)
330
+ results_df.at[idx, 'nu_tilde'] = float(nu_tilde)
331
+ results_df.at[idx, 'log_min_strike'] = float(log_min_strike)
332
+ results_df.at[idx, 'usd_min_strike'] = float(usd_min_strike)
333
+ results_df.at[idx, 'butterfly_arbitrage_free'] = bool(butterfly_arbitrage_free)
334
+ results_df.at[idx, 'rmse'] = float(rmse)
335
+ results_df.at[idx, 'mae'] = float(mae)
336
+ results_df.at[idx, 'r2'] = float(r2)
337
+ results_df.at[idx, 'max_error'] = float(max_error)
338
+
339
+ logger.info(
340
+ f"SVI parameters for {idx}: a={a_scaled:.4f}, b={b_scaled:.4f}, sigma={sigma:.4f}, rho={rho:.4f}, m={m:.4f}")
341
+
342
+ # Check calendar arbitrage after correction
343
+ calendar_arbitrage_free = True
344
+ for i in range(len(sorted_maturities) - 1):
345
+ mat1, mat2 = sorted_maturities[i], sorted_maturities[i + 1]
346
+ t1, params1 = fit_params_dict[mat1]
347
+ t2, params2 = fit_params_dict[mat2]
348
+ a1, b1, sigma1, rho1, m1 = params1
349
+ a2, b2, sigma2, rho2, m2 = params2
350
+
351
+ if np.isnan(a1) or np.isnan(a2):
352
+ continue
353
+
354
+ # Check arbitrage on a grid of points
355
+ for k_val in k_grid:
356
+ w1 = SVIModel.svi(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
357
+ w2 = SVIModel.svi(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
358
+ if w2 < w1 - 1e-6:
359
+ calendar_arbitrage_free = False
360
+ logger.warning(f"Calendar arbitrage still detected between {mat1} and {mat2} after correction")
361
+ break
362
+
363
+ # Update results with final calendar arbitrage status
364
+ for i, maturity_name in enumerate(maturity_names):
365
+ maturity_date = results_df.iloc[i]['maturity_date']
366
+ idx = results_df.index[i]
367
+ results_df.at[idx, 'calendar_arbitrage_free'] = calendar_arbitrage_free
368
+
369
+ logger.info("Model fitting complete.")
184
370
  return results_df
185
371
 
186
372
 
@@ -204,7 +390,7 @@ def get_iv_surface(model_results: pd.DataFrame,
204
390
  x_surface: Dictionary mapping maturity/dtm names to requested x domain arrays
205
391
  """
206
392
  # Check if required columns are present
207
- required_columns = ['a', 'b', 'sigma', 'rho', 'm', 't']
393
+ required_columns = ['a', 'b', 'm', 'rho', 'sigma', 't']
208
394
  missing_columns = [col for col in required_columns if col not in model_results.columns]
209
395
  if missing_columns:
210
396
  raise VolyError(f"Required columns missing in model_results: {missing_columns}")
@@ -221,9 +407,9 @@ def get_iv_surface(model_results: pd.DataFrame,
221
407
  params = [
222
408
  model_results.loc[i, 'a'],
223
409
  model_results.loc[i, 'b'],
224
- model_results.loc[i, 'sigma'],
410
+ model_results.loc[i, 'm'],
225
411
  model_results.loc[i, 'rho'],
226
- model_results.loc[i, 'm']
412
+ model_results.loc[i, 'sigma']
227
413
  ]
228
414
  s = model_results.loc[i, 's']
229
415
  r = model_results.loc[i, 'r']
voly/models.py CHANGED
@@ -3,6 +3,8 @@ Volatility models for the Voly package.
3
3
  """
4
4
 
5
5
  import numpy as np
6
+ from numpy.linalg import solve
7
+ from scipy.optimize import minimize
6
8
  from typing import Tuple, Dict, List, Optional, Union
7
9
 
8
10
 
@@ -14,12 +16,8 @@ class SVIModel:
14
16
  SVI parameterization, as well as its derivatives and related functions.
15
17
  """
16
18
 
17
- # Default initial parameters and bounds
18
- DEFAULT_INITIAL_PARAMS = [0.04, 0.1, 0.2, -0.5, 0.01]
19
- DEFAULT_PARAM_BOUNDS = ([-np.inf, 0, 0, -1, -np.inf], [np.inf, np.inf, np.inf, 1, np.inf])
20
-
21
19
  # Parameter names for reference
22
- PARAM_NAMES = ['a', 'b', 'sigma', 'rho', 'm']
20
+ PARAM_NAMES = ['a', 'b', 'm', 'rho', 'sigma']
23
21
  JW_PARAM_NAMES = ['nu', 'psi', 'p', 'c', 'nu_tilde']
24
22
 
25
23
  # Parameter descriptions for documentation
@@ -37,23 +35,20 @@ class SVIModel:
37
35
  }
38
36
 
39
37
  @staticmethod
40
- def svi(LM: float, a: float, b: float, sigma: float, rho: float, m: float) -> float:
41
- return a + b * (rho * (LM - m) + np.sqrt((LM - m) ** 2 + sigma ** 2))
42
-
43
- @staticmethod
44
- def svi_d(LM: float, a: float, b: float, sigma: float, rho: float, m: float) -> float:
45
- return b * (rho + ((LM - m) / np.sqrt((LM - m) ** 2 + sigma ** 2)))
38
+ def svi_raw(k, a, b, m, rho, sigma):
39
+ assert b >= 0, 'b must be non-negative'
40
+ assert abs(rho) <= 1, '|rho| must be <= 1'
41
+ assert sigma >= 0, 'sigma must be non-negative'
42
+ assert a + b * sigma * sqrt(1 - rho ** 2) >= 0, 'a + b*sigma*sqrt(1-rho^2) must be non-negative'
43
+ return a + b * (rho * (k - m) + sqrt((k - m) ** 2 + sigma ** 2))
46
44
 
47
45
  @staticmethod
48
- def svi_dd(LM: float, a: float, b: float, sigma: float, rho: float, m: float) -> float:
49
- return b * LM ** 2 / ((LM - m) ** 2 + sigma ** 2) ** 1.5
50
-
51
- @staticmethod
52
- def svi_min_strike(sigma: float, rho: float, m: float) -> float:
53
- return m - ((sigma * rho) / np.sqrt(1 - rho ** 2))
46
+ def svi(LM: float, a: float, b: float, sigma: float, rho: float, m: float) -> float:
47
+ return a + b * (rho * (LM - m) + np.sqrt((LM - m) ** 2 + sigma ** 2))
54
48
 
55
49
  @staticmethod
56
- def raw_to_jw_params(a: float, b: float, sigma: float, rho: float, m: float, t: float) -> Tuple[float, float, float, float, float]:
50
+ def raw_to_jw_params(a: float, b: float, m: float, rho: float, sigma: float, t: float) -> Tuple[
51
+ float, float, float, float, float]:
57
52
  nu = (a + b * ((-rho) * m + np.sqrt(m ** 2 + sigma ** 2))) / t
58
53
  psi = (1 / np.sqrt(nu * t)) * (b / 2) * (rho - (m / np.sqrt(m ** 2 + sigma ** 2)))
59
54
  p = (1 / np.sqrt(nu * t)) * b * (1 - rho)
@@ -61,18 +56,159 @@ class SVIModel:
61
56
  nu_tilde = (1 / t) * (a + b * sigma * np.sqrt(1 - rho ** 2))
62
57
  return nu, psi, p, c, nu_tilde
63
58
 
59
+ @classmethod
60
+ def calibration(cls, tiv, vega, k, m, sigma):
61
+ """
62
+ Calibrate SVI parameters using a more stable approach.
63
+
64
+ Parameters:
65
+ - tiv: Total implied variance values
66
+ - vega: Option vega values (for weighting)
67
+ - k: Log-moneyness values
68
+ - m: Horizontal shift parameter
69
+ - sigma: Convexity parameter
70
+
71
+ Returns:
72
+ - c, d, a: Calibrated parameters
73
+ - loss: Calibration loss value
74
+ """
75
+ sigma = max(sigma, 0.001)
76
+ vega = vega / vega.max() if vega.max() > 0 else np.ones_like(vega)
77
+ y = (k - m) / sigma
78
+ w = vega.mean()
79
+ y1 = (vega * y).mean()
80
+ y2 = (vega * y * y).mean()
81
+ y3 = (vega * np.sqrt(y * y + 1)).mean()
82
+ y4 = (vega * y * np.sqrt(y * y + 1)).mean()
83
+ y5 = (vega * (y * y + 1)).mean()
84
+ vy2 = (vega * tiv * np.sqrt(y * y + 1)).mean()
85
+ vy = (vega * tiv * y).mean()
86
+ v = (vega * tiv).mean()
87
+
88
+ matrix = [[y5, y4, y3], [y4, y2, y1], [y3, y1, w]]
89
+ vector = [vy2, vy, v]
90
+ c, d, a = solve(np.array(matrix), np.array(vector))
91
+
92
+ c = np.clip(c, 0, 4 * sigma)
93
+ a = max(a, 1e-6)
94
+ d = np.clip(d, -min(c, 4 * sigma - c), min(c, 4 * sigma - c))
95
+
96
+ loss = cls.loss(tiv, vega, y, c, d, a)
97
+ return c, d, a, loss
98
+
64
99
  @staticmethod
65
- def jw_to_raw_params(nu: float, psi: float, p: float, c: float, nu_tilde: float, t: float) -> Tuple[float, float, float, float, float]:
66
- w = nu * t
67
- b = (c + p) / 2
68
- rho = (c - p) / (c + p)
69
- beta = rho - ((2 * w * psi) / b)
70
- alpha = np.sign(beta) * (np.sqrt((1 / (beta ** 2)) - 1))
71
- m = (((nu ** 2) - (nu_tilde ** 2)) * t) / (
72
- b * ((-rho) + (np.sign(alpha) * np.sqrt(1 + alpha ** 2)) - (alpha * np.sqrt(1 - rho ** 2))))
73
- sigma = alpha * m
74
- a = ((nu_tilde ** 2) * t) - (b * sigma * np.sqrt(1 - rho ** 2))
75
- return a, b, sigma, rho, m
100
+ def loss(tiv, vega, y, c, d, a):
101
+ """Calculate weighted loss for SVI calibration."""
102
+ diff = tiv - (a + d * y + c * np.sqrt(y * y + 1))
103
+ return (vega * diff * diff).mean()
104
+
105
+ @classmethod
106
+ def fit(cls, tiv, vega, k, tau=1.0):
107
+ """
108
+ Fit SVI model to market data using a more stable two-step approach.
109
+
110
+ Parameters:
111
+ - tiv: Total implied variance values
112
+ - vega: Option vega values (for weighting)
113
+ - k: Log-moneyness values
114
+ - tau: Time to expiry in years
115
+
116
+ Returns:
117
+ - params: [a, b, m, rho, sigma] parameters
118
+ - loss: Fitting loss value
119
+ """
120
+ if len(k) <= 5:
121
+ return [np.nan] * 5, np.inf
122
+
123
+ vega = vega / vega.max() if vega.max() > 0 else np.ones_like(vega)
124
+ m_init = np.mean(k)
125
+ sigma_init = max(0.1, np.std(k) * 0.1)
126
+
127
+ def score(params):
128
+ sigma, m = params
129
+ c, d, a_calib, loss = cls.calibration(tiv, vega, k, m, sigma)
130
+ return loss
131
+
132
+ result = minimize(score, [sigma_init, m_init], bounds=[(0.001, None), (None, None)],
133
+ tol=1e-16, method="Nelder-Mead", options={'maxfun': 5000})
134
+
135
+ sigma, m = result.x
136
+ c, d, a_calib, loss = cls.calibration(tiv, vega, k, m, sigma)
137
+ a_calib = max(a_calib, 1e-6)
138
+
139
+ if c != 0:
140
+ a_svi = a_calib / tau
141
+ rho_svi = d / c
142
+ b_svi = c / (sigma * tau)
143
+ else:
144
+ a_svi = a_calib / tau
145
+ rho_svi = b_svi = 0
146
+
147
+ return [a_svi, b_svi, m, rho_svi, sigma], loss
148
+
149
+ @classmethod
150
+ def correct_calendar_arbitrage(cls, params, t, tiv, vega, k, prev_params, prev_t, domain_params):
151
+ """
152
+ Correct calendar arbitrage by ensuring the current SVI surface stays above the previous one.
153
+
154
+ Parameters:
155
+ - params: Current SVI parameters [a, b, sigma, rho, m]
156
+ - t: Current time to expiry
157
+ - tiv: Current total implied variance values
158
+ - vega: Current vega values
159
+ - k: Current log-moneyness values
160
+ - prev_params: Previous SVI parameters
161
+ - prev_t: Previous time to expiry
162
+ - k_grid: Grid of log-moneyness values for arbitrage checking
163
+
164
+ Returns:
165
+ - New arbitrage-free parameters
166
+ """
167
+
168
+ if np.any(np.isnan(params)) or np.any(np.isnan(prev_params)):
169
+ return params
170
+
171
+ a_init, b_init, sigma_init, rho_init, m_init = params
172
+ a_prev, b_prev, sigma_prev, rho_prev, m_prev = prev_params
173
+ k_constraint = np.unique(np.concatenate([k, np.linspace(min(k), max(k), domain_params[2])]))
174
+
175
+ def objective(x):
176
+ a, b, sigma, rho, m = x
177
+ w_model = cls.svi(k, a * t, b * t, sigma, rho, m)
178
+ fit_loss = ((w_model - tiv) ** 2 * vega).mean()
179
+ param_deviation = sum(((x[i] - params[i]) / max(abs(params[i]), 1e-6)) ** 2
180
+ for i in range(len(params)))
181
+ return fit_loss + 0.01 * param_deviation
182
+
183
+ def calendar_constraint(x):
184
+ a, b, sigma, rho, m = x
185
+ w_current = cls.svi(k_constraint, a * t, b * t, sigma, rho, m)
186
+ w_prev = cls.svi(k_constraint, a_prev * prev_t, b_prev * prev_t, sigma_prev, rho_prev, m_prev)
187
+ return w_current - w_prev
188
+
189
+ bounds = [
190
+ (max(a_init * 0.8, 1e-6), a_init * 1.2),
191
+ (max(b_init * 0.8, 0), b_init * 1.2),
192
+ (max(sigma_init * 0.8, 1e-6), sigma_init * 1.2),
193
+ (max(rho_init - 0.05, -1), min(rho_init + 0.05, 1)),
194
+ (m_init - 0.05, m_init + 0.05)
195
+ ]
196
+
197
+ constraints = [
198
+ {'type': 'ineq', 'fun': calendar_constraint},
199
+ {'type': 'ineq', 'fun': lambda x: x[0] + x[1] * x[2] * np.sqrt(1 - x[3] ** 2)}
200
+ ]
201
+
202
+ result = minimize(
203
+ objective, [a_init, b_init, sigma_init, rho_init, m_init],
204
+ bounds=bounds, constraints=constraints, method='SLSQP',
205
+ options={'disp': False, 'maxiter': 1000, 'ftol': 1e-8}
206
+ )
207
+
208
+ if result.success:
209
+ return result.x
210
+
211
+ return params
76
212
 
77
213
 
78
214
  # Models dictionary for easy access
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voly
3
- Version: 0.0.166
3
+ Version: 0.0.168
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=-yE1_cBvjkK-BO_kKCYtn4WPbNOhAzT0hsfykU5LvQQ,14761
2
+ voly/client.py,sha256=6e9cX5JWeqoTIktKlT4yEN9FG5Cj8Icl7gHxhVayi24,14464
3
3
  voly/exceptions.py,sha256=PBsbn1vNMvKcCJwwJ4lBO6glD85jo1h2qiEmD7ArAjs,92
4
4
  voly/formulas.py,sha256=eDPw32xa3dwCIrE-zeZzznfGaplGFoVcA167uQngZ70,11209
5
- voly/models.py,sha256=o-pHujGfr5Gn8ItckMzLI4Q8yaX9FQaV8UjCxv2zgTY,3364
5
+ voly/models.py,sha256=f97VvGwzpzSXuDtXqdjSHL1RbzOaYajXc9cRH8WbXtk,7968
6
6
  voly/core/__init__.py,sha256=bu6fS2I1Pj9fPPnl-zY3L7NqrZSY5Zy6NY2uMUvdhKs,183
7
- voly/core/charts.py,sha256=E21OZB5lTY4YL2flgaFJ6s5g3_ExtAQT2zryZZxLPyM,12735
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=Tb9eeG7e_2dQTcqt6aqEwFrZdy6jR9rSNqe6tzOdVhQ,9245
9
+ voly/core/fit.py,sha256=qwJha1Ks-aVybVxSLA6iK2YcJ2_yF3_e96mU2Q6abUQ,17703
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.166.dist-info/licenses/LICENSE,sha256=wcHIVbE12jfcBOai_wqBKY6xvNQU5E909xL1zZNq_2Q,1065
17
- voly-0.0.166.dist-info/METADATA,sha256=ScXeB49Z10bjBwlid0Yt6Cu3_FjMgwGBIpioqjn-1EU,4115
18
- voly-0.0.166.dist-info/WHEEL,sha256=lTU6B6eIfYoiQJTZNc-fyaR6BpL6ehTzU3xGYxn2n8k,91
19
- voly-0.0.166.dist-info/top_level.txt,sha256=ZfLw2sSxF-LrKAkgGjOmeTcw6_gD-30zvtdEY5W4B7c,5
20
- voly-0.0.166.dist-info/RECORD,,
16
+ voly-0.0.168.dist-info/licenses/LICENSE,sha256=wcHIVbE12jfcBOai_wqBKY6xvNQU5E909xL1zZNq_2Q,1065
17
+ voly-0.0.168.dist-info/METADATA,sha256=OU3Zv1OJ2JfYjlUr5HHX3Tbh3MLmeMIyQup_AOee8p4,4115
18
+ voly-0.0.168.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
19
+ voly-0.0.168.dist-info/top_level.txt,sha256=ZfLw2sSxF-LrKAkgGjOmeTcw6_gD-30zvtdEY5W4B7c,5
20
+ voly-0.0.168.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (78.1.1)
2
+ Generator: setuptools (79.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5