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 +8 -8
- voly/core/fit.py +196 -194
- voly/formulas.py +0 -1
- voly/models.py +24 -83
- {voly-0.0.177.dist-info → voly-0.0.178.dist-info}/METADATA +1 -1
- {voly-0.0.177.dist-info → voly-0.0.178.dist-info}/RECORD +9 -9
- {voly-0.0.177.dist-info → voly-0.0.178.dist-info}/WHEEL +0 -0
- {voly-0.0.177.dist-info → voly-0.0.178.dist-info}/licenses/LICENSE +0 -0
- {voly-0.0.177.dist-info → voly-0.0.178.dist-info}/top_level.txt +0 -0
voly/client.py
CHANGED
@@ -91,8 +91,9 @@ class VolyClient:
|
|
91
91
|
# -------------------------------------------------------------------------
|
92
92
|
|
93
93
|
@staticmethod
|
94
|
-
def
|
95
|
-
|
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,
|
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
|
-
-
|
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] = (-
|
23
|
+
domain_params: Tuple[float, float, int] = (-2, 2, 1000)) -> pd.DataFrame:
|
25
24
|
"""
|
26
|
-
Fit
|
25
|
+
Fit a volatility model to market data.
|
27
26
|
|
28
27
|
Parameters:
|
29
28
|
- option_chain: DataFrame with market data
|
30
|
-
- domain_params
|
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
|
-
|
80
|
+
params_dict = {}
|
80
81
|
|
81
|
-
#
|
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"
|
89
|
+
logger.info(f"Processing maturity {maturity_date}, t={t:.4f}")
|
89
90
|
|
90
|
-
|
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
|
-
|
96
|
-
mask = ~np.isnan(
|
97
|
-
|
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
|
-
|
115
|
-
a, b, m, rho, sigma = params
|
99
|
+
logger.info(f"Points after filtering: {len(k)}")
|
116
100
|
|
117
|
-
|
118
|
-
|
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
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
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
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
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
|
169
|
-
results_data['b'].append(float(b_scaled) if
|
170
|
-
results_data['
|
171
|
-
results_data['
|
172
|
-
results_data['
|
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(
|
181
|
-
results_data['butterfly_arbitrage_free'].append(
|
182
|
-
results_data['calendar_arbitrage_free'].append(
|
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['
|
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
|
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
|
-
#
|
207
|
-
logger.info("
|
208
|
-
|
209
|
-
|
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 =
|
216
|
-
t2, params2 =
|
217
|
-
a1, b1,
|
218
|
-
a2, b2,
|
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
|
-
|
224
|
-
|
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
|
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
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
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
|
-
#
|
241
|
+
# Calendar arbitrage correction
|
239
242
|
if not calendar_arbitrage_free:
|
240
|
-
logger.info("
|
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
|
-
|
245
|
-
t2, params2 =
|
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
|
250
|
+
if np.any(np.isnan(params2)) or np.any(np.isnan(params1)):
|
272
251
|
continue
|
273
252
|
|
274
|
-
|
275
|
-
|
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=
|
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
|
-
|
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
|
-
#
|
294
|
-
w_model = np.array([SVIModel.svi_raw(x, a_scaled, b_scaled, m, rho, sigma) for x in
|
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 =
|
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
|
-
#
|
283
|
+
# Recompute min strike
|
305
284
|
log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
|
306
|
-
usd_min_strike =
|
285
|
+
usd_min_strike = np.exp(log_min_strike) * s
|
307
286
|
|
308
|
-
#
|
287
|
+
# Update butterfly arbitrage check
|
309
288
|
butterfly_arbitrage_free = True
|
310
|
-
k_range = np.linspace(min(
|
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 =
|
313
|
-
wp =
|
314
|
-
wpp =
|
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
|
-
#
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
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
|
-
|
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 =
|
347
|
-
t2, params2 =
|
348
|
-
a1, b1,
|
349
|
-
a2, b2,
|
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
|
-
|
355
|
-
|
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
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
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.
|
198
|
-
|
199
|
-
|
200
|
-
|
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,
|
205
|
-
w_current = cls.
|
206
|
-
w_prev = cls.
|
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
|
-
(
|
147
|
+
(m_init - 0.05, m_init + 0.05),
|
213
148
|
(max(rho_init - 0.05, -1), min(rho_init + 0.05, 1)),
|
214
|
-
(
|
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[
|
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
|
-
|
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,20 +1,20 @@
|
|
1
1
|
voly/__init__.py,sha256=8xyDk7rFCn_MOD5hxuv5cxxKZvBVRiSIM7TgaMPpwpw,211
|
2
|
-
voly/client.py,sha256=
|
2
|
+
voly/client.py,sha256=Ge0-aBvKd32aHqMCaA1xgxOS_Z-bi4neYYAGil89A6I,14595
|
3
3
|
voly/exceptions.py,sha256=PBsbn1vNMvKcCJwwJ4lBO6glD85jo1h2qiEmD7ArAjs,92
|
4
|
-
voly/formulas.py,sha256=
|
5
|
-
voly/models.py,sha256=
|
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=
|
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.
|
17
|
-
voly-0.0.
|
18
|
-
voly-0.0.
|
19
|
-
voly-0.0.
|
20
|
-
voly-0.0.
|
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
|
File without changes
|
File without changes
|