voly 0.0.177__py3-none-any.whl → 0.0.179__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voly/client.py +8 -8
- voly/core/fit.py +199 -202
- voly/formulas.py +0 -1
- voly/models.py +24 -83
- {voly-0.0.177.dist-info → voly-0.0.179.dist-info}/METADATA +1 -1
- {voly-0.0.177.dist-info → voly-0.0.179.dist-info}/RECORD +9 -9
- {voly-0.0.177.dist-info → voly-0.0.179.dist-info}/WHEEL +0 -0
- {voly-0.0.177.dist-info → voly-0.0.179.dist-info}/licenses/LICENSE +0 -0
- {voly-0.0.177.dist-info → voly-0.0.179.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 svi(
|
95
|
-
|
94
|
+
def svi(k: float, a: float, b: float, m: float, rho: float, sigma: float) -> float:
|
95
|
+
"""Calculate SVI total implied variance using raw parameterization."""
|
96
|
+
return SVIModel.svi(k, a, b, m, rho, sigma)
|
96
97
|
|
97
98
|
@staticmethod
|
98
99
|
def d1(s: float, K: float, r: float, o: float, t: float,
|
@@ -165,19 +166,18 @@ class VolyClient:
|
|
165
166
|
|
166
167
|
@staticmethod
|
167
168
|
def fit_model(option_chain: pd.DataFrame,
|
168
|
-
domain_params: Tuple[float, float, int] = (-2, 2,
|
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,90 @@ def fit_model(option_chain: pd.DataFrame,
|
|
76
77
|
s = option_chain['index_price'].iloc[-1]
|
77
78
|
|
78
79
|
# Dictionary to track fit results by maturity for arbitrage checks
|
79
|
-
|
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
|
-
|
100
|
-
|
101
|
-
logger.warning(f"Not enough valid data points for {maturity_name}, skipping.")
|
102
|
-
params = [np.nan] * 5
|
103
|
-
loss = np.inf
|
104
|
-
else:
|
105
|
-
# Calculate total implied variance (w = iv² * t)
|
106
|
-
w = (iv_masked ** 2) * t
|
107
|
-
|
108
|
-
# Fit using the improved SVI method
|
109
|
-
params, loss = SVIModel.fit(tiv=w, vega=vega_masked, k=k_masked, tau=t)
|
110
|
-
|
111
|
-
# Store the parameters for this maturity
|
112
|
-
fit_params_dict[maturity_date] = (t, params)
|
113
|
-
|
114
|
-
# Extract parameters (will be nan if fit failed)
|
115
|
-
a, b, m, rho, sigma = params
|
116
|
-
|
117
|
-
# Calculate statistics
|
118
|
-
fit_success = not np.isnan(a)
|
119
|
-
butterfly_arbitrage_free = True
|
120
|
-
calendar_arbitrage_free = True # Will check later
|
121
|
-
|
122
|
-
# Initialize default metrics
|
123
|
-
rmse = mae = r2 = max_error = np.nan
|
93
|
+
vega = maturity_data['vega'].values if 'vega' in maturity_data.columns else np.ones_like(iv)
|
94
|
+
k = np.log(K / s)
|
95
|
+
w = (iv ** 2) * t
|
96
|
+
mask = ~np.isnan(w) & ~np.isnan(vega) & ~np.isnan(k) & (iv >= 0)
|
97
|
+
k, w, vega, iv = k[mask], w[mask], vega[mask], iv[mask]
|
98
|
+
|
99
|
+
params = [np.nan] * 5
|
100
|
+
loss = np.inf
|
124
101
|
nu = psi = p = c = nu_tilde = np.nan
|
102
|
+
rmse = mae = r2 = max_error = np.nan
|
103
|
+
butterfly_arbitrage_free = True
|
104
|
+
u = s # Assume underlying_price is index_price
|
105
|
+
r = maturity_data['interest_rate'].iloc[0] if 'interest_rate' in maturity_data.columns else 0
|
106
|
+
oi = maturity_data['open_interest'].sum() if 'open_interest' in maturity_data.columns else 0
|
107
|
+
volume = maturity_data['volume'].sum() if 'volume' in maturity_data.columns else 0
|
125
108
|
log_min_strike = usd_min_strike = np.nan
|
126
|
-
a_scaled = b_scaled = np.nan
|
127
|
-
|
128
|
-
if fit_success:
|
129
|
-
# Scale a and b by t
|
130
|
-
a_scaled, b_scaled = a * t, b * t
|
131
|
-
|
132
|
-
# Calculate Jump-Wing parameters
|
133
|
-
nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t)
|
134
|
-
|
135
|
-
# Calculate model predictions for statistics
|
136
|
-
w_model = np.array([SVIModel.svi_raw(x, a_scaled, b_scaled, m, rho, sigma) for x in k_masked])
|
137
|
-
iv_model = np.sqrt(w_model / t)
|
138
|
-
iv_market = iv_masked
|
139
|
-
|
140
|
-
# Calculate statistics
|
141
|
-
rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
|
142
|
-
mae = mean_absolute_error(iv_market, iv_model)
|
143
|
-
r2 = r2_score(iv_market, iv_model)
|
144
|
-
max_error = np.max(np.abs(iv_market - iv_model))
|
145
109
|
|
146
|
-
|
147
|
-
|
148
|
-
|
110
|
+
if len(k) > 5:
|
111
|
+
params, loss = SVIModel.fit(tiv=w, vega=vega, k=k, tau=t)
|
112
|
+
if not np.isnan(params[0]):
|
113
|
+
params_dict[maturity_date] = (t, params)
|
114
|
+
a, b, m, rho, sigma = params
|
115
|
+
a_scaled, b_scaled = a * t, b * t
|
116
|
+
|
117
|
+
nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t)
|
118
|
+
|
119
|
+
# Compute fit statistics
|
120
|
+
w_model = np.array([SVIModel.svi(x, a_scaled, b_scaled, m, rho, sigma) for x in k])
|
121
|
+
iv_model = np.sqrt(w_model / t)
|
122
|
+
iv_market = iv
|
123
|
+
rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
|
124
|
+
mae = mean_absolute_error(iv_market, iv_model)
|
125
|
+
r2 = r2_score(iv_market, iv_model)
|
126
|
+
max_error = np.max(np.abs(iv_market - iv_model))
|
127
|
+
|
128
|
+
# Compute min strike
|
129
|
+
log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
|
130
|
+
usd_min_strike = np.exp(log_min_strike) * s
|
131
|
+
|
132
|
+
# Butterfly arbitrage check
|
133
|
+
k_range = np.linspace(min(k), max(k), domain_params[2])
|
134
|
+
w_k = lambda k: SVIModel.svi(k, a_scaled, b_scaled, m, rho, sigma)
|
135
|
+
w_prime = lambda k: b_scaled * (rho + (k - m) / np.sqrt((k - m) ** 2 + sigma ** 2))
|
136
|
+
w_double_prime = lambda k: b_scaled * sigma ** 2 / ((k - m) ** 2 + sigma ** 2) ** (3 / 2)
|
137
|
+
|
138
|
+
for k_val in k_range:
|
139
|
+
wk = w_k(k_val)
|
140
|
+
wp = w_prime(k_val)
|
141
|
+
wpp = w_double_prime(k_val)
|
142
|
+
g = (1 - (k_val * wp) / (2 * wk)) ** 2 - (wp ** 2) / 4 * (1 / wk + 1 / 4) + wpp / 2
|
143
|
+
if g < 0:
|
144
|
+
butterfly_arbitrage_free = False
|
145
|
+
break
|
149
146
|
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
wp = SVIModel.svi_d(k_val, a_scaled, b_scaled, m, rho, sigma)
|
155
|
-
wpp = SVIModel.svi_dd(k_val, a_scaled, b_scaled, m, rho, sigma)
|
156
|
-
g = (1 - (k_val * wp) / (2 * wk)) ** 2 - (wp ** 2) / 4 * (1 / wk + 1 / 4) + wpp / 2
|
157
|
-
if g < 0:
|
158
|
-
butterfly_arbitrage_free = False
|
159
|
-
break
|
160
|
-
|
161
|
-
r = maturity_data['interest_rate'].iloc[0] if 'interest_rate' in maturity_data.columns else 0
|
147
|
+
# Log result
|
148
|
+
status = f'{GREEN}SUCCESS{RESET}' if not np.isnan(params[0]) else f'{RED}FAILED{RESET}'
|
149
|
+
logger.info(f'Optimization for {maturity_name}: {status}')
|
150
|
+
logger.info("=================================================================")
|
162
151
|
|
163
|
-
# Store values in the results dictionary with proper types
|
164
152
|
results_data['s'].append(float(s))
|
153
|
+
results_data['u'].append(float(u))
|
165
154
|
results_data['t'].append(float(t))
|
166
155
|
results_data['r'].append(float(r))
|
156
|
+
results_data['oi'].append(float(oi))
|
157
|
+
results_data['volume'].append(float(volume))
|
167
158
|
results_data['maturity_date'].append(maturity_date)
|
168
|
-
results_data['a'].append(float(a_scaled) if
|
169
|
-
results_data['b'].append(float(b_scaled) if
|
170
|
-
results_data['
|
171
|
-
results_data['
|
172
|
-
results_data['
|
159
|
+
results_data['a'].append(float(a_scaled) if not np.isnan(params[0]) else np.nan)
|
160
|
+
results_data['b'].append(float(b_scaled) if not np.isnan(params[0]) else np.nan)
|
161
|
+
results_data['m'].append(float(m))
|
162
|
+
results_data['rho'].append(float(rho))
|
163
|
+
results_data['sigma'].append(float(sigma))
|
173
164
|
results_data['nu'].append(float(nu))
|
174
165
|
results_data['psi'].append(float(psi))
|
175
166
|
results_data['p'].append(float(p))
|
@@ -177,22 +168,17 @@ def fit_model(option_chain: pd.DataFrame,
|
|
177
168
|
results_data['nu_tilde'].append(float(nu_tilde))
|
178
169
|
results_data['log_min_strike'].append(float(log_min_strike))
|
179
170
|
results_data['usd_min_strike'].append(float(usd_min_strike))
|
180
|
-
results_data['fit_success'].append(bool(
|
181
|
-
results_data['butterfly_arbitrage_free'].append(
|
182
|
-
results_data['calendar_arbitrage_free'].append(
|
183
|
-
results_data['loss'].append(float(loss))
|
171
|
+
results_data['fit_success'].append(bool(not np.isnan(params[0])))
|
172
|
+
results_data['butterfly_arbitrage_free'].append(butterfly_arbitrage_free)
|
173
|
+
results_data['calendar_arbitrage_free'].append(True) # Updated after check
|
184
174
|
results_data['rmse'].append(float(rmse))
|
185
175
|
results_data['mae'].append(float(mae))
|
186
176
|
results_data['r2'].append(float(r2))
|
187
177
|
results_data['max_error'].append(float(max_error))
|
188
|
-
results_data['
|
189
|
-
|
190
|
-
# Log result
|
191
|
-
status = f'{GREEN}SUCCESS{RESET}' if fit_success else f'{RED}FAILED{RESET}'
|
192
|
-
logger.info(f'Optimization for {maturity_name}: {status}')
|
193
|
-
logger.info('-------------------------------------')
|
178
|
+
results_data['loss'].append(float(loss))
|
179
|
+
results_data['n_points'].append(int(len(k)))
|
194
180
|
|
195
|
-
# Create DataFrame
|
181
|
+
# Create results DataFrame
|
196
182
|
results_df = pd.DataFrame(results_data, index=maturity_names)
|
197
183
|
|
198
184
|
# Convert columns to appropriate types
|
@@ -203,168 +189,179 @@ def fit_model(option_chain: pd.DataFrame,
|
|
203
189
|
except (ValueError, TypeError) as e:
|
204
190
|
logger.warning(f"Could not convert column {col} to {dtype}: {e}")
|
205
191
|
|
206
|
-
#
|
207
|
-
logger.info("
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
# Check calendar arbitrage before correction
|
192
|
+
# Calendar arbitrage check (pre-correction)
|
193
|
+
logger.info("\nChecking calendar arbitrage (pre-correction)...")
|
194
|
+
k_grid = np.linspace(domain_params[0], domain_params[1], domain_params[2])
|
195
|
+
sorted_maturities = sorted(params_dict.keys(), key=lambda x: params_dict[x][0])
|
212
196
|
calendar_arbitrage_free = True
|
197
|
+
|
213
198
|
for i in range(len(sorted_maturities) - 1):
|
214
199
|
mat1, mat2 = sorted_maturities[i], sorted_maturities[i + 1]
|
215
|
-
t1, params1 =
|
216
|
-
t2, params2 =
|
217
|
-
a1, b1,
|
218
|
-
a2, b2,
|
200
|
+
t1, params1 = params_dict[mat1]
|
201
|
+
t2, params2 = params_dict[mat2]
|
202
|
+
a1, b1, m1, rho1, sigma1 = params1
|
203
|
+
a2, b2, m2, rho2, sigma2 = params2
|
219
204
|
|
220
205
|
if np.isnan(a1) or np.isnan(a2):
|
221
206
|
continue
|
222
207
|
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
208
|
+
group = groups.get_group(mat2)
|
209
|
+
K = group['strikes'].values
|
210
|
+
s = group['index_price'].iloc[0]
|
211
|
+
k_market = np.log(K / s)
|
212
|
+
mask = ~np.isnan(k_market)
|
213
|
+
k_check = np.unique(
|
214
|
+
np.concatenate([k_market[mask], np.linspace(min(k_market[mask]), max(k_market[mask]), domain_params[2])]))
|
215
|
+
|
216
|
+
for k_val in k_check:
|
217
|
+
w1 = SVIModel.svi(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
|
218
|
+
w2 = SVIModel.svi(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
|
227
219
|
if w2 < w1 - 1e-6:
|
228
|
-
calendar_arbitrage_free = False
|
229
220
|
logger.warning(
|
230
|
-
f"Calendar arbitrage
|
221
|
+
f"Calendar arbitrage violation at t1={t1:.4f}, t2={t2:.4f}, k={k_val:.4f}: w1={w1:.6f}, w2={w2:.6f}")
|
222
|
+
calendar_arbitrage_free = False
|
231
223
|
break
|
224
|
+
if not calendar_arbitrage_free:
|
225
|
+
break
|
232
226
|
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
227
|
+
for mat in sorted_maturities:
|
228
|
+
idx = None
|
229
|
+
for i, maturity_name in enumerate(maturity_names):
|
230
|
+
if results_df.iloc[i]['maturity_date'] == mat:
|
231
|
+
idx = results_df.index[i]
|
232
|
+
break
|
233
|
+
if idx is not None:
|
234
|
+
results_df.at[idx, 'calendar_arbitrage_free'] = calendar_arbitrage_free
|
237
235
|
|
238
|
-
#
|
236
|
+
# Calendar arbitrage correction
|
239
237
|
if not calendar_arbitrage_free:
|
240
|
-
logger.info("
|
238
|
+
logger.info("\nPerforming calendar arbitrage correction...")
|
241
239
|
for i in range(1, len(sorted_maturities)):
|
242
|
-
mat1 = sorted_maturities[i - 1]
|
243
240
|
mat2 = sorted_maturities[i]
|
244
|
-
|
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]
|
241
|
+
mat1 = sorted_maturities[i - 1]
|
242
|
+
t2, params2 = params_dict[mat2]
|
243
|
+
t1, params1 = params_dict[mat1]
|
270
244
|
|
271
|
-
if
|
245
|
+
if np.any(np.isnan(params2)) or np.any(np.isnan(params1)):
|
272
246
|
continue
|
273
247
|
|
274
|
-
|
275
|
-
|
248
|
+
group = groups.get_group(mat2)
|
249
|
+
s = group['index_price'].iloc[0]
|
250
|
+
K = group['strikes'].values
|
251
|
+
iv = group['mark_iv'].values
|
252
|
+
vega = group['vega'].values if 'vega' in group.columns else np.ones_like(iv)
|
253
|
+
k = np.log(K / s)
|
254
|
+
w = (iv ** 2) * t2
|
255
|
+
mask = ~np.isnan(w) & ~np.isnan(vega) & ~np.isnan(k) & (iv >= 0)
|
256
|
+
k, w, vega, iv = k[mask], w[mask], vega[mask], iv[mask]
|
276
257
|
|
277
|
-
# Apply calendar arbitrage correction
|
278
258
|
new_params = SVIModel.correct_calendar_arbitrage(
|
279
|
-
params=params2, t=t2, tiv=w, vega=
|
259
|
+
params=params2, t=t2, tiv=w, vega=vega, k=k,
|
280
260
|
prev_params=params1, prev_t=t1, k_grid=k_grid
|
281
261
|
)
|
282
262
|
|
283
|
-
|
284
|
-
fit_params_dict[mat2] = (t2, new_params)
|
263
|
+
params_dict[mat2] = (t2, new_params)
|
285
264
|
|
286
|
-
# Extract corrected parameters
|
287
265
|
a, b, m, rho, sigma = new_params
|
288
|
-
|
289
|
-
# Calculate scaled parameters and Jump-Wing parameters
|
290
266
|
a_scaled, b_scaled = a * t2, b * t2
|
291
267
|
nu, psi, p, c, nu_tilde = SVIModel.raw_to_jw_params(a_scaled, b_scaled, m, rho, sigma, t2)
|
292
268
|
|
293
|
-
#
|
294
|
-
w_model = np.array([SVIModel.
|
269
|
+
# Recompute fit statistics
|
270
|
+
w_model = np.array([SVIModel.svi(x, a_scaled, b_scaled, m, rho, sigma) for x in k])
|
295
271
|
iv_model = np.sqrt(w_model / t2)
|
296
|
-
iv_market =
|
297
|
-
|
298
|
-
# Calculate statistics
|
272
|
+
iv_market = iv
|
299
273
|
rmse = np.sqrt(mean_squared_error(iv_market, iv_model))
|
300
274
|
mae = mean_absolute_error(iv_market, iv_model)
|
301
275
|
r2 = r2_score(iv_market, iv_model)
|
302
276
|
max_error = np.max(np.abs(iv_market - iv_model))
|
303
277
|
|
304
|
-
#
|
278
|
+
# Recompute min strike
|
305
279
|
log_min_strike = SVIModel.svi_min_strike(sigma, rho, m)
|
306
|
-
usd_min_strike =
|
280
|
+
usd_min_strike = np.exp(log_min_strike) * s
|
307
281
|
|
308
|
-
#
|
282
|
+
# Update butterfly arbitrage check
|
309
283
|
butterfly_arbitrage_free = True
|
310
|
-
k_range = np.linspace(min(
|
284
|
+
k_range = np.linspace(min(k), max(k), domain_params[2])
|
285
|
+
w_k = lambda k: SVIModel.svi(k, a_scaled, b_scaled, m, rho, sigma)
|
286
|
+
w_prime = lambda k: b_scaled * (rho + (k - m) / np.sqrt((k - m) ** 2 + sigma ** 2))
|
287
|
+
w_double_prime = lambda k: b_scaled * sigma ** 2 / ((k - m) ** 2 + sigma ** 2) ** (3 / 2)
|
288
|
+
|
311
289
|
for k_val in k_range:
|
312
|
-
wk =
|
313
|
-
wp =
|
314
|
-
wpp =
|
290
|
+
wk = w_k(k_val)
|
291
|
+
wp = w_prime(k_val)
|
292
|
+
wpp = w_double_prime(k_val)
|
315
293
|
g = (1 - (k_val * wp) / (2 * wk)) ** 2 - (wp ** 2) / 4 * (1 / wk + 1 / 4) + wpp / 2
|
316
294
|
if g < 0:
|
317
295
|
butterfly_arbitrage_free = False
|
318
296
|
break
|
319
297
|
|
320
|
-
#
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
298
|
+
# Find the correct index to update
|
299
|
+
idx = None
|
300
|
+
for j, maturity_name in enumerate(maturity_names):
|
301
|
+
if results_df.iloc[j]['maturity_date'] == mat2:
|
302
|
+
idx = results_df.index[j]
|
303
|
+
break
|
304
|
+
|
305
|
+
if idx is not None:
|
306
|
+
results_df.at[idx, 'a'] = float(a_scaled)
|
307
|
+
results_df.at[idx, 'b'] = float(b_scaled)
|
308
|
+
results_df.at[idx, 'm'] = float(m)
|
309
|
+
results_df.at[idx, 'rho'] = float(rho)
|
310
|
+
results_df.at[idx, 'sigma'] = float(sigma)
|
311
|
+
results_df.at[idx, 'nu'] = float(nu)
|
312
|
+
results_df.at[idx, 'psi'] = float(psi)
|
313
|
+
results_df.at[idx, 'p'] = float(p)
|
314
|
+
results_df.at[idx, 'c'] = float(c)
|
315
|
+
results_df.at[idx, 'nu_tilde'] = float(nu_tilde)
|
316
|
+
results_df.at[idx, 'rmse'] = float(rmse)
|
317
|
+
results_df.at[idx, 'mae'] = float(mae)
|
318
|
+
results_df.at[idx, 'r2'] = float(r2)
|
319
|
+
results_df.at[idx, 'max_error'] = float(max_error)
|
320
|
+
results_df.at[idx, 'log_min_strike'] = float(log_min_strike)
|
321
|
+
results_df.at[idx, 'usd_min_strike'] = float(usd_min_strike)
|
322
|
+
results_df.at[idx, 'butterfly_arbitrage_free'] = butterfly_arbitrage_free
|
323
|
+
results_df.at[idx, 'fit_success'] = bool(not np.isnan(a))
|
324
|
+
|
325
|
+
# Calendar arbitrage check (post-correction)
|
326
|
+
logger.info("\nChecking calendar arbitrage (post-correction)...")
|
343
327
|
calendar_arbitrage_free = True
|
344
328
|
for i in range(len(sorted_maturities) - 1):
|
345
329
|
mat1, mat2 = sorted_maturities[i], sorted_maturities[i + 1]
|
346
|
-
t1, params1 =
|
347
|
-
t2, params2 =
|
348
|
-
a1, b1,
|
349
|
-
a2, b2,
|
330
|
+
t1, params1 = params_dict[mat1]
|
331
|
+
t2, params2 = params_dict[mat2]
|
332
|
+
a1, b1, m1, rho1, sigma1 = params1
|
333
|
+
a2, b2, m2, rho2, sigma2 = params2
|
350
334
|
|
351
335
|
if np.isnan(a1) or np.isnan(a2):
|
352
336
|
continue
|
353
337
|
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
338
|
+
group = groups.get_group(mat2)
|
339
|
+
K = group['strikes'].values
|
340
|
+
s = group['index_price'].iloc[0]
|
341
|
+
k_market = np.log(K / s)
|
342
|
+
mask = ~np.isnan(k_market)
|
343
|
+
k_check = np.unique(np.concatenate(
|
344
|
+
[k_market[mask], np.linspace(min(k_market[mask]), max(k_market[mask]), domain_params[2])]))
|
345
|
+
|
346
|
+
for k_val in k_check:
|
347
|
+
w1 = SVIModel.svi(k_val, a1 * t1, b1 * t1, m1, rho1, sigma1)
|
348
|
+
w2 = SVIModel.svi(k_val, a2 * t2, b2 * t2, m2, rho2, sigma2)
|
358
349
|
if w2 < w1 - 1e-6:
|
350
|
+
logger.warning(
|
351
|
+
f"Calendar arbitrage violation at t1={t1:.4f}, t2={t2:.4f}, k={k_val:.4f}: w1={w1:.6f}, w2={w2:.6f}")
|
359
352
|
calendar_arbitrage_free = False
|
360
|
-
logger.warning(f"Calendar arbitrage still detected between {mat1} and {mat2} after correction")
|
361
353
|
break
|
354
|
+
if not calendar_arbitrage_free:
|
355
|
+
break
|
362
356
|
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
357
|
+
for mat in sorted_maturities:
|
358
|
+
idx = None
|
359
|
+
for j, maturity_name in enumerate(maturity_names):
|
360
|
+
if results_df.iloc[j]['maturity_date'] == mat:
|
361
|
+
idx = results_df.index[j]
|
362
|
+
break
|
363
|
+
if idx is not None:
|
364
|
+
results_df.at[idx, 'calendar_arbitrage_free'] = calendar_arbitrage_free
|
368
365
|
|
369
366
|
logger.info("Model fitting complete.")
|
370
367
|
return results_df
|
@@ -416,7 +413,7 @@ def get_iv_surface(model_results: pd.DataFrame,
|
|
416
413
|
t = model_results.loc[i, 't']
|
417
414
|
|
418
415
|
# Calculate implied volatility
|
419
|
-
w = np.array([SVIModel.
|
416
|
+
w = np.array([SVIModel.svi(x, *params) for x in LM])
|
420
417
|
o = np.sqrt(w / t)
|
421
418
|
iv_surface[i] = o
|
422
419
|
|
voly/formulas.py
CHANGED
@@ -8,7 +8,6 @@ from scipy.stats import norm
|
|
8
8
|
from py_vollib.black_scholes.implied_volatility import implied_volatility
|
9
9
|
from typing import Tuple, Dict, Union, List, Optional
|
10
10
|
from voly.utils.logger import catch_exception
|
11
|
-
from voly.models import SVIModel
|
12
11
|
from typing import Tuple
|
13
12
|
|
14
13
|
|
voly/models.py
CHANGED
@@ -4,16 +4,12 @@ Volatility models for the Voly package.
|
|
4
4
|
|
5
5
|
import numpy as np
|
6
6
|
from numpy.linalg import solve
|
7
|
-
from scipy.optimize import minimize
|
8
7
|
from typing import Tuple, Dict, List, Optional, Union
|
9
8
|
|
10
9
|
|
11
10
|
class SVIModel:
|
12
11
|
"""
|
13
12
|
Stochastic Volatility Inspired (SVI) model.
|
14
|
-
|
15
|
-
This class provides methods for calculating implied volatility using the
|
16
|
-
SVI parameterization, as well as its derivatives and related functions.
|
17
13
|
"""
|
18
14
|
|
19
15
|
# Parameter names for reference
|
@@ -35,32 +31,10 @@ class SVIModel:
|
|
35
31
|
}
|
36
32
|
|
37
33
|
@staticmethod
|
38
|
-
def
|
39
|
-
"""
|
40
|
-
Calculate SVI total implied variance using raw parameterization.
|
41
|
-
This is the original function name from the user's code.
|
42
|
-
"""
|
34
|
+
def svi(k, a, b, m, rho, sigma):
|
43
35
|
assert b >= 0 and abs(rho) <= 1 and sigma >= 0 and a + b * sigma * np.sqrt(1 - rho ** 2) >= 0
|
44
36
|
return a + b * (rho * (k - m) + np.sqrt((k - m) ** 2 + sigma ** 2))
|
45
37
|
|
46
|
-
@staticmethod
|
47
|
-
def svi(LM: float, a: float, b: float, m: float, rho: float, sigma: float) -> float:
|
48
|
-
"""
|
49
|
-
Calculate SVI total implied variance at a given log-moneyness.
|
50
|
-
This version maintains compatibility with the original Voly package.
|
51
|
-
"""
|
52
|
-
return a + b * (rho * (LM - m) + np.sqrt((LM - m) ** 2 + sigma ** 2))
|
53
|
-
|
54
|
-
@staticmethod
|
55
|
-
def svi_d(LM: float, a: float, b: float, m: float, rho: float, sigma: float) -> float:
|
56
|
-
"""Calculate first derivative of SVI function with respect to log-moneyness."""
|
57
|
-
return b * (rho + ((LM - m) / np.sqrt((LM - m) ** 2 + sigma ** 2)))
|
58
|
-
|
59
|
-
@staticmethod
|
60
|
-
def svi_dd(LM: float, a: float, b: float, m: float, rho: float, sigma: float) -> float:
|
61
|
-
"""Calculate second derivative of SVI function with respect to log-moneyness."""
|
62
|
-
return b * sigma ** 2 / ((LM - m) ** 2 + sigma ** 2) ** 1.5
|
63
|
-
|
64
38
|
@staticmethod
|
65
39
|
def svi_min_strike(sigma: float, rho: float, m: float) -> float:
|
66
40
|
"""Calculate the minimum valid log-strike for this SVI parameterization."""
|
@@ -69,6 +43,7 @@ class SVIModel:
|
|
69
43
|
@staticmethod
|
70
44
|
def raw_to_jw_params(a: float, b: float, m: float, rho: float, sigma: float, t: float) -> Tuple[
|
71
45
|
float, float, float, float, float]:
|
46
|
+
"""Convert raw SVI parameters to Jump-Wing parameters."""
|
72
47
|
nu = (a + b * ((-rho) * m + np.sqrt(m ** 2 + sigma ** 2))) / t
|
73
48
|
psi = (1 / np.sqrt(nu * t)) * (b / 2) * (rho - (m / np.sqrt(m ** 2 + sigma ** 2)))
|
74
49
|
p = (1 / np.sqrt(nu * t)) * b * (1 - rho)
|
@@ -78,20 +53,6 @@ class SVIModel:
|
|
78
53
|
|
79
54
|
@classmethod
|
80
55
|
def calibration(cls, tiv, vega, k, m, sigma):
|
81
|
-
"""
|
82
|
-
Calibrate SVI parameters using a more stable approach.
|
83
|
-
|
84
|
-
Parameters:
|
85
|
-
- tiv: Total implied variance values
|
86
|
-
- vega: Option vega values (for weighting)
|
87
|
-
- k: Log-moneyness values
|
88
|
-
- m: Horizontal shift parameter
|
89
|
-
- sigma: Convexity parameter
|
90
|
-
|
91
|
-
Returns:
|
92
|
-
- c, d, a: Calibrated parameters
|
93
|
-
- loss: Calibration loss value
|
94
|
-
"""
|
95
56
|
sigma = max(sigma, 0.001)
|
96
57
|
vega = vega / vega.max() if vega.max() > 0 else np.ones_like(vega)
|
97
58
|
y = (k - m) / sigma
|
@@ -118,28 +79,16 @@ class SVIModel:
|
|
118
79
|
|
119
80
|
@staticmethod
|
120
81
|
def loss(tiv, vega, y, c, d, a):
|
121
|
-
"""Calculate weighted loss for SVI calibration."""
|
122
82
|
diff = tiv - (a + d * y + c * np.sqrt(y * y + 1))
|
123
83
|
return (vega * diff * diff).mean()
|
124
84
|
|
125
85
|
@classmethod
|
126
86
|
def fit(cls, tiv, vega, k, tau=1.0):
|
127
|
-
"""
|
128
|
-
Fit SVI model to market data using a more stable two-step approach.
|
129
|
-
|
130
|
-
Parameters:
|
131
|
-
- tiv: Total implied variance values
|
132
|
-
- vega: Option vega values (for weighting)
|
133
|
-
- k: Log-moneyness values
|
134
|
-
- tau: Time to expiry in years
|
135
|
-
|
136
|
-
Returns:
|
137
|
-
- params: [a, b, m, rho, sigma] parameters
|
138
|
-
- loss: Fitting loss value
|
139
|
-
"""
|
140
87
|
if len(k) <= 5:
|
141
88
|
return [np.nan] * 5, np.inf
|
142
89
|
|
90
|
+
from scipy.optimize import minimize
|
91
|
+
|
143
92
|
vega = vega / vega.max() if vega.max() > 0 else np.ones_like(vega)
|
144
93
|
m_init = np.mean(k)
|
145
94
|
sigma_init = max(0.1, np.std(k) * 0.1)
|
@@ -168,26 +117,11 @@ class SVIModel:
|
|
168
117
|
|
169
118
|
@classmethod
|
170
119
|
def correct_calendar_arbitrage(cls, params, t, tiv, vega, k, prev_params, prev_t, k_grid):
|
171
|
-
"""
|
172
|
-
Correct calendar arbitrage by ensuring the current SVI surface stays above the previous one.
|
173
|
-
|
174
|
-
Parameters:
|
175
|
-
- params: Current SVI parameters [a, b, sigma, rho, m]
|
176
|
-
- t: Current time to expiry
|
177
|
-
- tiv: Current total implied variance values
|
178
|
-
- vega: Current vega values
|
179
|
-
- k: Current log-moneyness values
|
180
|
-
- prev_params: Previous SVI parameters
|
181
|
-
- prev_t: Previous time to expiry
|
182
|
-
- k_grid: Grid of log-moneyness values for arbitrage checking
|
183
|
-
|
184
|
-
Returns:
|
185
|
-
- New arbitrage-free parameters
|
186
|
-
"""
|
187
|
-
|
188
120
|
if np.any(np.isnan(params)) or np.any(np.isnan(prev_params)):
|
189
121
|
return params
|
190
122
|
|
123
|
+
from scipy.optimize import minimize
|
124
|
+
|
191
125
|
a_init, b_init, m_init, rho_init, sigma_init = params
|
192
126
|
a_prev, b_prev, m_prev, rho_prev, sigma_prev = prev_params
|
193
127
|
k_constraint = np.unique(np.concatenate([k, np.linspace(min(k), max(k), len(k_grid))]))
|
@@ -195,28 +129,29 @@ class SVIModel:
|
|
195
129
|
def objective(x):
|
196
130
|
a, b, m, rho, sigma = x
|
197
131
|
w_model = cls.svi(k, a * t, b * t, m, rho, sigma)
|
198
|
-
|
199
|
-
|
200
|
-
|
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.svi(k_constraint, a * t, b * t,
|
206
|
-
w_prev = cls.svi(k_constraint, a_prev * prev_t, b_prev * prev_t,
|
139
|
+
a, b, m, rho, sigma = x
|
140
|
+
w_current = cls.svi(k_constraint, a * t, b * t, m, rho, sigma)
|
141
|
+
w_prev = cls.svi(k_constraint, a_prev * prev_t, b_prev * prev_t, m_prev, rho_prev, sigma_prev)
|
207
142
|
return w_current - w_prev
|
208
143
|
|
209
144
|
bounds = [
|
210
145
|
(max(a_init * 0.8, 1e-6), a_init * 1.2),
|
211
146
|
(max(b_init * 0.8, 0), b_init * 1.2),
|
212
|
-
(
|
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(k_constraint, new_params[0] * t, new_params[1] * t, *new_params[2:])
|
166
|
+
w_prev = cls.svi(k_constraint, a_prev * prev_t, b_prev * prev_t, m_prev, rho_prev, sigma_prev)
|
167
|
+
violation = np.min(w_current - w_prev)
|
168
|
+
print(f"Calendar arbitrage correction {'successful' if violation >= -1e-6 else 'failed'} for t={t:.4f}, "
|
169
|
+
f"min margin={violation:.6f}")
|
170
|
+
return new_params
|
171
|
+
print(f"Calendar arbitrage correction failed for t={t:.4f}")
|
231
172
|
return params
|
232
173
|
|
233
174
|
|
@@ -1,20 +1,20 @@
|
|
1
1
|
voly/__init__.py,sha256=8xyDk7rFCn_MOD5hxuv5cxxKZvBVRiSIM7TgaMPpwpw,211
|
2
|
-
voly/client.py,sha256=
|
2
|
+
voly/client.py,sha256=M_-d3ibPp2Uc8ucEkWfhKbqKOQ7xSuUO6dGHTIqmG3E,14587
|
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=wxqf9T4D2ORO7g3KcwxUL0E78fG69W29xqer3ccoUXo,6994
|
6
6
|
voly/core/__init__.py,sha256=bu6fS2I1Pj9fPPnl-zY3L7NqrZSY5Zy6NY2uMUvdhKs,183
|
7
7
|
voly/core/charts.py,sha256=2S-BfCo30aj1_xlNLqF-za5rQWxF_mWKIdtdOe5bgbw,12735
|
8
8
|
voly/core/data.py,sha256=9v9iuE2XdIIlzoRAB7q1ol7YghBzBsPGAiwZ11oDuis,13650
|
9
|
-
voly/core/fit.py,sha256=
|
9
|
+
voly/core/fit.py,sha256=RUFawNWRZR9RGLGNtYuJgQmaLmCEkrfVaAimUJruOGY,17937
|
10
10
|
voly/core/hd.py,sha256=UFAyLncNUHivpPAcko6IK1bC55mudVtdlRFfXp63HXE,14771
|
11
11
|
voly/core/interpolate.py,sha256=JkK172-FXyhesW3hY4pEeuJWG3Bugq7QZXbeKoRpLuo,5305
|
12
12
|
voly/core/rnd.py,sha256=GoC3m1Q46Wnk5tV_mstr-3_aktHeue6BBLh4DQTciW0,13307
|
13
13
|
voly/utils/__init__.py,sha256=E05mWatyC-PDOsCxQV1p5Xi1IgpOomxrNURyCx_gB-w,200
|
14
14
|
voly/utils/density.py,sha256=q0fX4im9TGwMCZ32Hzdv8CNh56KnJo8bmG5w0gVWZH8,5879
|
15
15
|
voly/utils/logger.py,sha256=4-_2bVJmq17Q0d7Rd2mPg1AeR8gxv6EPvcmBDMFWcSM,1744
|
16
|
-
voly-0.0.
|
17
|
-
voly-0.0.
|
18
|
-
voly-0.0.
|
19
|
-
voly-0.0.
|
20
|
-
voly-0.0.
|
16
|
+
voly-0.0.179.dist-info/licenses/LICENSE,sha256=wcHIVbE12jfcBOai_wqBKY6xvNQU5E909xL1zZNq_2Q,1065
|
17
|
+
voly-0.0.179.dist-info/METADATA,sha256=r0MQ4zUUjTh4a6FvIMoO3XsAsen_cAXQJToJ2VQMlT0,4115
|
18
|
+
voly-0.0.179.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
|
19
|
+
voly-0.0.179.dist-info/top_level.txt,sha256=ZfLw2sSxF-LrKAkgGjOmeTcw6_gD-30zvtdEY5W4B7c,5
|
20
|
+
voly-0.0.179.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|