voly 0.0.138__py3-none-any.whl → 0.0.140__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 +19 -40
- voly/core/hd.py +246 -357
- voly/core/rnd.py +6 -1
- {voly-0.0.138.dist-info → voly-0.0.140.dist-info}/METADATA +1 -1
- {voly-0.0.138.dist-info → voly-0.0.140.dist-info}/RECORD +8 -8
- {voly-0.0.138.dist-info → voly-0.0.140.dist-info}/WHEEL +0 -0
- {voly-0.0.138.dist-info → voly-0.0.140.dist-info}/licenses/LICENSE +0 -0
- {voly-0.0.138.dist-info → voly-0.0.140.dist-info}/top_level.txt +0 -0
voly/client.py
CHANGED
|
@@ -20,7 +20,7 @@ from voly.formulas import (
|
|
|
20
20
|
from voly.core.data import fetch_option_chain, process_option_chain
|
|
21
21
|
from voly.core.fit import fit_model, get_iv_surface
|
|
22
22
|
from voly.core.rnd import get_rnd_surface
|
|
23
|
-
from voly.core.hd import get_historical_data, get_hd_surface
|
|
23
|
+
from voly.core.hd import get_historical_data, get_hd_surface
|
|
24
24
|
from voly.core.interpolate import interpolate_model
|
|
25
25
|
from voly.core.charts import (
|
|
26
26
|
plot_all_smiles, plot_raw_parameters, plot_jw_parameters, plot_fit_performance, plot_3d_surface,
|
|
@@ -342,59 +342,38 @@ class VolyClient:
|
|
|
342
342
|
def get_hd_surface(model_results: pd.DataFrame,
|
|
343
343
|
df_hist: pd.DataFrame,
|
|
344
344
|
domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000),
|
|
345
|
-
return_domain: str = 'log_moneyness'
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
return get_hd_surface(
|
|
350
|
-
model_results=model_results,
|
|
351
|
-
df_hist=df_hist,
|
|
352
|
-
domain_params=domain_params,
|
|
353
|
-
return_domain=return_domain
|
|
354
|
-
)
|
|
355
|
-
|
|
356
|
-
@staticmethod
|
|
357
|
-
def get_garch_hd_surface(model_results: pd.DataFrame,
|
|
358
|
-
df_hist: pd.DataFrame,
|
|
359
|
-
domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000),
|
|
360
|
-
return_domain: str = 'log_moneyness',
|
|
361
|
-
n_fits: int = 400,
|
|
362
|
-
simulations: int = 5000,
|
|
363
|
-
window_length: int = 365,
|
|
364
|
-
variate_parameters: bool = True,
|
|
365
|
-
bandwidth: float = 0.15) -> Dict[str, Any]:
|
|
345
|
+
return_domain: str = 'log_moneyness',
|
|
346
|
+
method: str = 'garch',
|
|
347
|
+
**kwargs) -> Dict[str, Any]:
|
|
366
348
|
"""
|
|
367
|
-
Generate historical density
|
|
368
|
-
|
|
369
|
-
This method implements the approach from SPD Trading, using:
|
|
370
|
-
1. GARCH(1,1) model fit with sliding windows
|
|
371
|
-
2. Monte Carlo simulation with innovation resampling
|
|
372
|
-
3. Kernel density estimation of terminal prices
|
|
349
|
+
Generate historical density surface from historical price data.
|
|
373
350
|
|
|
374
351
|
Parameters:
|
|
375
352
|
model_results: DataFrame with model parameters and maturities
|
|
376
353
|
df_hist: DataFrame with historical price data
|
|
377
354
|
domain_params: Tuple of (min, max, num_points) for x-domain
|
|
378
355
|
return_domain: Domain for x-axis values ('log_moneyness', 'moneyness', 'returns', 'strikes')
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
356
|
+
method: Method to use for HD estimation ('hist_returns' or 'garch')
|
|
357
|
+
**kwargs: Additional parameters for specific methods:
|
|
358
|
+
For 'garch' method:
|
|
359
|
+
n_fits: Number of sliding windows (default: 400)
|
|
360
|
+
simulations: Number of Monte Carlo simulations (default: 5000)
|
|
361
|
+
window_length: Length of sliding windows (default: 365)
|
|
362
|
+
variate_parameters: Whether to vary GARCH parameters (default: True)
|
|
363
|
+
bandwidth: KDE bandwidth (default: 'silverman')
|
|
364
|
+
For 'hist_returns' method:
|
|
365
|
+
bandwidth: KDE bandwidth (default: 'silverman')
|
|
384
366
|
|
|
385
367
|
Returns:
|
|
386
368
|
Dictionary containing pdf_surface, cdf_surface, x_surface, and moments
|
|
387
369
|
"""
|
|
388
|
-
logger.info("Calculating
|
|
370
|
+
logger.info(f"Calculating historical density surface using {method} method")
|
|
389
371
|
|
|
390
|
-
return
|
|
372
|
+
return get_hd_surface(
|
|
391
373
|
model_results=model_results,
|
|
392
374
|
df_hist=df_hist,
|
|
393
375
|
domain_params=domain_params,
|
|
394
376
|
return_domain=return_domain,
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
window_length=window_length,
|
|
398
|
-
variate_parameters=variate_parameters,
|
|
399
|
-
bandwidth=bandwidth
|
|
377
|
+
method=method,
|
|
378
|
+
**kwargs
|
|
400
379
|
)
|
voly/core/hd.py
CHANGED
|
@@ -79,189 +79,40 @@ def get_historical_data(currency, lookback_days, granularity, exchange_name):
|
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
@catch_exception
|
|
82
|
-
def
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return_domain: str = 'log_moneyness') -> Tuple[
|
|
86
|
-
Dict[str, np.ndarray], Dict[str, np.ndarray], Dict[str, np.ndarray], pd.DataFrame]:
|
|
87
|
-
|
|
88
|
-
# Check if required columns are present
|
|
89
|
-
required_columns = ['s', 't', 'r']
|
|
90
|
-
missing_columns = [col for col in required_columns if col not in model_results.columns]
|
|
91
|
-
if missing_columns:
|
|
92
|
-
raise VolyError(f"Required columns missing in model_results: {missing_columns}")
|
|
93
|
-
|
|
94
|
-
# Determine granularity from df_hist
|
|
95
|
-
if len(df_hist) > 1:
|
|
96
|
-
# Calculate minutes between consecutive timestamps
|
|
97
|
-
minutes_diff = (df_hist.index[1] - df_hist.index[0]).total_seconds() / 60
|
|
98
|
-
minutes_per_period = int(minutes_diff)
|
|
99
|
-
else:
|
|
100
|
-
VolyError("Cannot determine granularity from df_hist.")
|
|
101
|
-
return
|
|
102
|
-
|
|
103
|
-
pdf_surface = {}
|
|
104
|
-
cdf_surface = {}
|
|
105
|
-
x_surface = {}
|
|
106
|
-
all_moments = {}
|
|
107
|
-
|
|
108
|
-
# Process each maturity
|
|
109
|
-
for i in model_results.index:
|
|
110
|
-
# Get parameters for this maturity
|
|
111
|
-
s = model_results.loc[i, 's']
|
|
112
|
-
r = model_results.loc[i, 'r']
|
|
113
|
-
t = model_results.loc[i, 't']
|
|
114
|
-
|
|
115
|
-
LM = get_domain(domain_params, s, r, None, t, 'log_moneyness')
|
|
116
|
-
M = get_domain(domain_params, s, r, None, t, 'moneyness')
|
|
117
|
-
R = get_domain(domain_params, s, r, None, t, 'returns')
|
|
118
|
-
K = get_domain(domain_params, s, r, None, t, 'log_moneyness')
|
|
119
|
-
|
|
120
|
-
# Filter historical data for this maturity's lookback period
|
|
121
|
-
start_date = dt.datetime.now() - dt.timedelta(days=int(t * 365.25))
|
|
122
|
-
maturity_hist = df_hist[df_hist.index >= start_date].copy()
|
|
123
|
-
|
|
124
|
-
if len(maturity_hist) < 2:
|
|
125
|
-
logger.warning(f"Not enough historical data for maturity {i}, skipping.")
|
|
126
|
-
continue
|
|
127
|
-
|
|
128
|
-
# Calculate the number of periods that match the time to expiry
|
|
129
|
-
n_periods = int(t * 365.25 * 24 * 60 / minutes_per_period)
|
|
130
|
-
|
|
131
|
-
# Compute returns and weights
|
|
132
|
-
maturity_hist['returns'] = np.log(maturity_hist['close'] / maturity_hist['close'].shift(1)) * np.sqrt(n_periods)
|
|
133
|
-
maturity_hist = maturity_hist.dropna()
|
|
134
|
-
|
|
135
|
-
returns = maturity_hist['returns'].values
|
|
136
|
-
|
|
137
|
-
if len(returns) < 10:
|
|
138
|
-
logger.warning(f"Not enough valid returns for maturity {i}, skipping.")
|
|
139
|
-
continue
|
|
140
|
-
|
|
141
|
-
mu_scaled = returns.mean()
|
|
142
|
-
sigma_scaled = returns.std()
|
|
143
|
-
|
|
144
|
-
# Correct Girsanov adjustment to match the risk-neutral mean
|
|
145
|
-
expected_risk_neutral_mean = (r - 0.5 * sigma_scaled ** 2) * np.sqrt(t)
|
|
146
|
-
adjustment = mu_scaled - expected_risk_neutral_mean
|
|
147
|
-
adj_returns = returns - adjustment # Shift the mean to risk-neutral
|
|
148
|
-
|
|
149
|
-
# Create HD and Normalize
|
|
150
|
-
f = stats.gaussian_kde(adj_returns, bw_method='silverman')
|
|
151
|
-
hd_lm = f(LM)
|
|
152
|
-
hd_lm = np.maximum(hd_lm, 0)
|
|
153
|
-
total_area = np.trapz(hd_lm, LM)
|
|
154
|
-
if total_area > 0:
|
|
155
|
-
pdf_lm = hd_lm / total_area
|
|
156
|
-
else:
|
|
157
|
-
logger.warning(f"Total area is zero for maturity {i}, skipping.")
|
|
158
|
-
continue
|
|
159
|
-
|
|
160
|
-
pdf_k = pdf_lm / K
|
|
161
|
-
pdf_m = pdf_k * s
|
|
162
|
-
pdf_r = pdf_lm / (1 + R)
|
|
82
|
+
def fit_garch_model(log_returns, n_fits=400, window_length=365):
|
|
83
|
+
"""
|
|
84
|
+
Fit a GARCH(1,1) model to log returns.
|
|
163
85
|
|
|
164
|
-
|
|
86
|
+
Args:
|
|
87
|
+
log_returns: Array of log returns
|
|
88
|
+
n_fits: Number of sliding windows
|
|
89
|
+
window_length: Length of each window
|
|
165
90
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
moments = get_all_moments(x, pdf)
|
|
170
|
-
elif return_domain == 'moneyness':
|
|
171
|
-
x = M
|
|
172
|
-
pdf = pdf_m
|
|
173
|
-
moments = get_all_moments(x, pdf)
|
|
174
|
-
elif return_domain == 'returns':
|
|
175
|
-
x = R
|
|
176
|
-
pdf = pdf_r
|
|
177
|
-
moments = get_all_moments(x, pdf)
|
|
178
|
-
elif return_domain == 'strikes':
|
|
179
|
-
x = K
|
|
180
|
-
pdf = pdf_k
|
|
181
|
-
moments = get_all_moments(x, pdf)
|
|
91
|
+
Returns:
|
|
92
|
+
Dict with GARCH parameters and processes
|
|
93
|
+
"""
|
|
182
94
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
cdf_surface[i] = cdf
|
|
186
|
-
x_surface[i] = x
|
|
187
|
-
all_moments[i] = moments
|
|
95
|
+
if len(log_returns) < window_length + n_fits:
|
|
96
|
+
raise VolyError(f"Not enough data points. Need at least {window_length + n_fits}, got {len(log_returns)}")
|
|
188
97
|
|
|
189
|
-
#
|
|
190
|
-
|
|
98
|
+
# Adjust window sizes if necessary
|
|
99
|
+
n_fits = min(n_fits, len(log_returns) // 3)
|
|
100
|
+
window_length = min(window_length, len(log_returns) // 3)
|
|
191
101
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
'cdf_surface': cdf_surface,
|
|
195
|
-
'x_surface': x_surface,
|
|
196
|
-
'moments': moments
|
|
197
|
-
}
|
|
102
|
+
start = window_length + n_fits
|
|
103
|
+
end = n_fits
|
|
198
104
|
|
|
105
|
+
parameters = np.zeros((n_fits, 4)) # [mu, omega, alpha, beta]
|
|
106
|
+
z_process = []
|
|
199
107
|
|
|
200
|
-
|
|
201
|
-
"""
|
|
202
|
-
GARCH(1,1) model for volatility modeling and simulation.
|
|
108
|
+
logger.info(f"Fitting GARCH model with {n_fits} windows...")
|
|
203
109
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
110
|
+
for i in range(n_fits):
|
|
111
|
+
window = log_returns[end - i - 1:start - i - 1]
|
|
112
|
+
data = window - np.mean(window)
|
|
207
113
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
data_name: str,
|
|
211
|
-
n_fits: int = 400,
|
|
212
|
-
window_length: int = 365,
|
|
213
|
-
z_h: float = 0.1):
|
|
214
|
-
"""
|
|
215
|
-
Initialize the GARCH model.
|
|
216
|
-
|
|
217
|
-
Args:
|
|
218
|
-
data: Array of log returns
|
|
219
|
-
data_name: Identifier for the dataset
|
|
220
|
-
n_fits: Number of sliding windows to use for parameter estimation
|
|
221
|
-
window_length: Length of each sliding window
|
|
222
|
-
z_h: Bandwidth factor for kernel density estimation of innovations
|
|
223
|
-
"""
|
|
224
|
-
self.data = data
|
|
225
|
-
self.data_name = data_name
|
|
226
|
-
self.n_fits = n_fits
|
|
227
|
-
self.window_length = window_length
|
|
228
|
-
self.z_h = z_h
|
|
229
|
-
|
|
230
|
-
# Parameters to be created during fitting and simulation
|
|
231
|
-
self.parameters = None
|
|
232
|
-
self.e_process = None
|
|
233
|
-
self.z_process = None
|
|
234
|
-
self.sigma2_process = None
|
|
235
|
-
self.z_dens = None
|
|
236
|
-
self.simulated_log_returns = None
|
|
237
|
-
self.simulated_tau_mu = None
|
|
238
|
-
|
|
239
|
-
def fit(self):
|
|
240
|
-
"""
|
|
241
|
-
Fit GARCH(1,1) model to historical data using sliding windows.
|
|
242
|
-
|
|
243
|
-
For each window, estimates parameters (ω, α, β) and extracts innovations.
|
|
244
|
-
"""
|
|
245
|
-
|
|
246
|
-
if len(self.data) < self.window_length + self.n_fits:
|
|
247
|
-
raise VolyError(
|
|
248
|
-
f"Not enough data points. Need at least {self.window_length + self.n_fits}, got {len(self.data)}")
|
|
249
|
-
|
|
250
|
-
start = self.window_length + self.n_fits
|
|
251
|
-
end = self.n_fits
|
|
252
|
-
|
|
253
|
-
parameters = np.zeros((self.n_fits, 4))
|
|
254
|
-
z_process = []
|
|
255
|
-
e_process = []
|
|
256
|
-
sigma2_process = []
|
|
257
|
-
|
|
258
|
-
logger.info(f"Fitting GARCH model with {self.n_fits} windows...")
|
|
259
|
-
|
|
260
|
-
for i in range(self.n_fits):
|
|
261
|
-
window = self.data[end - i - 1:start - i - 1]
|
|
262
|
-
data = window - np.mean(window)
|
|
263
|
-
|
|
264
|
-
model = arch_model(data, vol='GARCH', p=1, q=1)
|
|
114
|
+
model = arch_model(data, vol='GARCH', p=1, q=1)
|
|
115
|
+
try:
|
|
265
116
|
GARCH_fit = model.fit(disp='off')
|
|
266
117
|
|
|
267
118
|
mu, omega, alpha, beta = [
|
|
@@ -272,157 +123,132 @@ class GARCHModel:
|
|
|
272
123
|
]
|
|
273
124
|
parameters[i, :] = [mu, omega, alpha, beta]
|
|
274
125
|
|
|
126
|
+
# Calculate sigma2 and innovations for last observation
|
|
275
127
|
if i == 0:
|
|
276
128
|
sigma2_tm1 = omega / (1 - alpha - beta)
|
|
277
129
|
else:
|
|
278
|
-
|
|
130
|
+
e_tm1 = data.tolist()[-2]
|
|
131
|
+
sigma2_tm1 = omega + alpha * e_tm1 ** 2 + beta * sigma2_tm1
|
|
279
132
|
|
|
280
|
-
e_t = data.tolist()[-1]
|
|
281
|
-
|
|
282
|
-
sigma2_t = omega + alpha * e_tm1 ** 2 + beta * sigma2_tm1
|
|
133
|
+
e_t = data.tolist()[-1]
|
|
134
|
+
sigma2_t = omega + alpha * data.tolist()[-2] ** 2 + beta * sigma2_tm1
|
|
283
135
|
z_t = e_t / np.sqrt(sigma2_t)
|
|
284
|
-
|
|
285
|
-
e_process.append(e_t)
|
|
286
136
|
z_process.append(z_t)
|
|
287
|
-
sigma2_process.append(sigma2_t)
|
|
288
|
-
|
|
289
|
-
self.parameters = parameters
|
|
290
|
-
self.e_process = e_process
|
|
291
|
-
self.z_process = z_process
|
|
292
|
-
self.sigma2_process = sigma2_process
|
|
293
|
-
|
|
294
|
-
# Kernel density estimation for innovations
|
|
295
|
-
z_dens_x = np.linspace(min(self.z_process), max(self.z_process), 500)
|
|
296
|
-
h_dyn = self.z_h * (np.max(z_process) - np.min(z_process))
|
|
297
|
-
|
|
298
|
-
# Use scipy's gaussian_kde for innovation distribution
|
|
299
|
-
kde = stats.gaussian_kde(np.array(z_process), bw_method=h_dyn)
|
|
300
|
-
z_dens_y = kde(z_dens_x)
|
|
301
|
-
|
|
302
|
-
self.z_dens = {"x": z_dens_x, "y": z_dens_y}
|
|
303
|
-
|
|
304
|
-
logger.info("GARCH model fitting complete")
|
|
305
137
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
Simulate a single GARCH path to specified horizon.
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.warning(f"GARCH fit failed for window {i}: {str(e)}")
|
|
309
140
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
141
|
+
# Clean up any failed fits
|
|
142
|
+
if len(z_process) < n_fits / 2:
|
|
143
|
+
raise VolyError("Too many GARCH fits failed. Check your data.")
|
|
313
144
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
"""
|
|
317
|
-
mu, omega, alpha, beta = pars
|
|
318
|
-
burnin = horizon * 2
|
|
319
|
-
sigma2 = [omega / (1 - alpha - beta)]
|
|
320
|
-
e = [self.data.tolist()[-1] - mu] # last observed log-return mean adjusted
|
|
145
|
+
avg_params = np.mean(parameters, axis=0)
|
|
146
|
+
std_params = np.std(parameters, axis=0)
|
|
321
147
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
z_tp1 = np.random.choice(self.z_dens["x"], 1, p=weights)[0]
|
|
329
|
-
e_tp1 = z_tp1 * np.sqrt(sigma2_tp1)
|
|
330
|
-
sigma2.append(sigma2_tp1)
|
|
331
|
-
e.append(e_tp1)
|
|
332
|
-
|
|
333
|
-
return sigma2[-horizon:], e[-horizon:]
|
|
148
|
+
return {
|
|
149
|
+
'parameters': parameters,
|
|
150
|
+
'avg_params': avg_params,
|
|
151
|
+
'std_params': std_params,
|
|
152
|
+
'z_process': np.array(z_process)
|
|
153
|
+
}
|
|
334
154
|
|
|
335
|
-
def _variate_pars(self, pars, bounds):
|
|
336
|
-
"""
|
|
337
|
-
Add variation to GARCH parameters for simulation uncertainty.
|
|
338
155
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
156
|
+
@catch_exception
|
|
157
|
+
def simulate_garch_paths(garch_model, horizon, simulations=5000, variate_parameters=True):
|
|
158
|
+
"""
|
|
159
|
+
Simulate future paths using a fitted GARCH model.
|
|
342
160
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
var = bound ** 2 / self.n_fits
|
|
349
|
-
new_par = np.random.normal(par, var, 1)[0]
|
|
350
|
-
if (new_par <= 0) and (i >= 1):
|
|
351
|
-
new_par = 0.01
|
|
352
|
-
new_pars.append(new_par)
|
|
353
|
-
return new_pars
|
|
161
|
+
Args:
|
|
162
|
+
garch_model: Dict with GARCH model parameters
|
|
163
|
+
horizon: Number of steps to simulate
|
|
164
|
+
simulations: Number of paths to simulate
|
|
165
|
+
variate_parameters: Whether to vary parameters between simulations
|
|
354
166
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
167
|
+
Returns:
|
|
168
|
+
Array of simulated log returns
|
|
169
|
+
"""
|
|
170
|
+
parameters = garch_model['parameters']
|
|
171
|
+
z_process = garch_model['z_process']
|
|
358
172
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
variate_parameters: Whether to add variation to GARCH parameters
|
|
173
|
+
# Use mean parameters as starting point
|
|
174
|
+
pars = garch_model['avg_params'].copy() # [mu, omega, alpha, beta]
|
|
175
|
+
bounds = garch_model['std_params'].copy()
|
|
363
176
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
"""
|
|
367
|
-
if self.parameters is None:
|
|
368
|
-
self.fit()
|
|
177
|
+
mu, omega, alpha, beta = pars
|
|
178
|
+
logger.info(f"GARCH parameters: mu={mu:.6f}, omega={omega:.6f}, alpha={alpha:.6f}, beta={beta:.6f}")
|
|
369
179
|
|
|
370
|
-
|
|
371
|
-
|
|
180
|
+
# Create KDE for innovations
|
|
181
|
+
kde = stats.gaussian_kde(z_process)
|
|
182
|
+
z_range = np.linspace(min(z_process), max(z_process), 1000)
|
|
183
|
+
z_prob = kde(z_range)
|
|
184
|
+
z_prob = z_prob / np.sum(z_prob)
|
|
372
185
|
|
|
373
|
-
|
|
374
|
-
|
|
186
|
+
# Simulate paths
|
|
187
|
+
simulated_returns = np.zeros(simulations)
|
|
375
188
|
|
|
376
|
-
|
|
189
|
+
for i in range(simulations):
|
|
190
|
+
if (i + 1) % (simulations // 10) == 0:
|
|
191
|
+
logger.info(f"Simulation progress: {i + 1}/{simulations}")
|
|
377
192
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
193
|
+
# Optionally vary parameters
|
|
194
|
+
if variate_parameters and (i + 1) % (simulations // 20) == 0:
|
|
195
|
+
new_pars = []
|
|
196
|
+
for j, (par, bound) in enumerate(zip(pars, bounds)):
|
|
197
|
+
var = bound ** 2 / len(parameters)
|
|
198
|
+
new_par = np.random.normal(par, var)
|
|
199
|
+
if j >= 1 and new_par <= 0: # Ensure omega, alpha, beta are positive
|
|
200
|
+
new_par = 0.01
|
|
201
|
+
new_pars.append(new_par)
|
|
202
|
+
mu, omega, alpha, beta = new_pars
|
|
381
203
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
204
|
+
# Initial values
|
|
205
|
+
sigma2 = omega / (1 - alpha - beta)
|
|
206
|
+
returns_sum = 0
|
|
385
207
|
|
|
386
|
-
|
|
387
|
-
|
|
208
|
+
# Simulate path
|
|
209
|
+
for _ in range(horizon):
|
|
210
|
+
# Sample from innovation distribution
|
|
211
|
+
z = np.random.choice(z_range, p=z_prob)
|
|
388
212
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
213
|
+
# Calculate return and update volatility
|
|
214
|
+
e = z * np.sqrt(sigma2)
|
|
215
|
+
returns_sum += e + mu
|
|
216
|
+
sigma2 = omega + alpha * e ** 2 + beta * sigma2
|
|
392
217
|
|
|
393
|
-
|
|
394
|
-
self.simulated_tau_mu = simulated_tau_mu
|
|
218
|
+
simulated_returns[i] = returns_sum
|
|
395
219
|
|
|
396
|
-
|
|
220
|
+
return simulated_returns, mu * horizon
|
|
397
221
|
|
|
398
222
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
simulations: int = 5000,
|
|
406
|
-
window_length: int = 365,
|
|
407
|
-
variate_parameters: bool = True,
|
|
408
|
-
bandwidth: float = 0.15) -> Dict[str, Any]:
|
|
223
|
+
def get_hd_surface(model_results: pd.DataFrame,
|
|
224
|
+
df_hist: pd.DataFrame,
|
|
225
|
+
domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000),
|
|
226
|
+
return_domain: str = 'log_moneyness',
|
|
227
|
+
method: str = 'garch',
|
|
228
|
+
**kwargs) -> Dict[str, Any]:
|
|
409
229
|
"""
|
|
410
|
-
Generate historical density surface
|
|
230
|
+
Generate historical density surface from historical price data.
|
|
411
231
|
|
|
412
232
|
Parameters:
|
|
413
233
|
model_results: DataFrame with model parameters and maturities
|
|
414
|
-
df_hist: DataFrame with historical price data
|
|
234
|
+
df_hist: DataFrame with historical price data
|
|
415
235
|
domain_params: Tuple of (min, max, num_points) for x-domain
|
|
416
236
|
return_domain: Domain for x-axis values ('log_moneyness', 'moneyness', 'returns', 'strikes')
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
237
|
+
method: Method to use for HD estimation ('hist_returns' or 'garch')
|
|
238
|
+
**kwargs: Additional parameters for specific methods:
|
|
239
|
+
For 'garch' method:
|
|
240
|
+
n_fits: Number of sliding windows (default: 400)
|
|
241
|
+
simulations: Number of Monte Carlo simulations (default: 5000)
|
|
242
|
+
window_length: Length of sliding windows (default: 365)
|
|
243
|
+
variate_parameters: Whether to vary GARCH parameters (default: True)
|
|
244
|
+
bandwidth: KDE bandwidth (default: 'silverman')
|
|
245
|
+
For 'hist_returns' method:
|
|
246
|
+
bandwidth: KDE bandwidth (default: 'silverman')
|
|
422
247
|
|
|
423
248
|
Returns:
|
|
424
249
|
Dictionary containing pdf_surface, cdf_surface, x_surface, and moments
|
|
425
250
|
"""
|
|
251
|
+
|
|
426
252
|
# Check if required columns are present
|
|
427
253
|
required_columns = ['s', 't', 'r']
|
|
428
254
|
missing_columns = [col for col in required_columns if col not in model_results.columns]
|
|
@@ -437,10 +263,29 @@ def get_garch_hd_surface(model_results: pd.DataFrame,
|
|
|
437
263
|
else:
|
|
438
264
|
raise VolyError("Cannot determine granularity from df_hist.")
|
|
439
265
|
|
|
440
|
-
#
|
|
266
|
+
# Get method-specific parameters
|
|
267
|
+
if method == 'garch':
|
|
268
|
+
n_fits = kwargs.get('n_fits', 400)
|
|
269
|
+
simulations = kwargs.get('simulations', 5000)
|
|
270
|
+
window_length = kwargs.get('window_length', 365)
|
|
271
|
+
variate_parameters = kwargs.get('variate_parameters', True)
|
|
272
|
+
bandwidth = kwargs.get('bandwidth', 'silverman')
|
|
273
|
+
logger.info(f"Using GARCH method with {n_fits} fits, {simulations} simulations")
|
|
274
|
+
elif method == 'hist_returns':
|
|
275
|
+
bandwidth = kwargs.get('bandwidth', 'silverman')
|
|
276
|
+
logger.info(f"Using returns-based KDE method with bandwidth {bandwidth}")
|
|
277
|
+
else:
|
|
278
|
+
raise VolyError(f"Unknown method: {method}. Use 'hist_returns' or 'garch'.")
|
|
279
|
+
|
|
280
|
+
# Calculate log returns from price history
|
|
441
281
|
log_returns = np.log(df_hist['close'] / df_hist['close'].shift(1)) * 100
|
|
442
282
|
log_returns = log_returns.dropna().values
|
|
443
283
|
|
|
284
|
+
# Fit GARCH model once if using garch method
|
|
285
|
+
garch_model = None
|
|
286
|
+
if method == 'garch':
|
|
287
|
+
garch_model = fit_garch_model(log_returns, n_fits, window_length)
|
|
288
|
+
|
|
444
289
|
pdf_surface = {}
|
|
445
290
|
cdf_surface = {}
|
|
446
291
|
x_surface = {}
|
|
@@ -453,97 +298,141 @@ def get_garch_hd_surface(model_results: pd.DataFrame,
|
|
|
453
298
|
r = model_results.loc[i, 'r'] # Risk-free rate
|
|
454
299
|
t = model_results.loc[i, 't'] # Time to maturity in years
|
|
455
300
|
|
|
456
|
-
#
|
|
457
|
-
tau_days_float = t * 365.25 # Exact number of days (as float)
|
|
458
|
-
tau_day = max(1, int(tau_days_float)) # Ensure minimum of 1 day for simulation
|
|
459
|
-
|
|
460
|
-
logger.info(f"Processing GARCH HD for maturity {i} (t={t:.4f} years, {tau_days_float:.2f} days)")
|
|
461
|
-
|
|
462
|
-
# Calculate the number of periods that match the time to expiry
|
|
463
|
-
n_periods = max(1, int(t * 365.25 * 24 * 60 / minutes_per_period))
|
|
464
|
-
|
|
465
|
-
# Initialize GARCH model
|
|
466
|
-
garch_model = GARCHModel(
|
|
467
|
-
data=log_returns,
|
|
468
|
-
data_name=str(i),
|
|
469
|
-
n_fits=min(n_fits, len(log_returns) // 3),
|
|
470
|
-
window_length=min(window_length, len(log_returns) // 3),
|
|
471
|
-
z_h=0.1
|
|
472
|
-
)
|
|
473
|
-
|
|
474
|
-
# Simulate paths
|
|
475
|
-
simulated_log_returns, simulated_tau_mu = garch_model.simulate_paths(
|
|
476
|
-
horizon=tau_day,
|
|
477
|
-
simulations=simulations,
|
|
478
|
-
variate_parameters=variate_parameters
|
|
479
|
-
)
|
|
480
|
-
|
|
481
|
-
# Scale the simulated returns to match target time horizon
|
|
482
|
-
# Use floating-point days to avoid division by zero
|
|
483
|
-
scaling_factor = np.sqrt(n_periods / tau_days_float)
|
|
484
|
-
scaled_log_returns = simulated_log_returns * scaling_factor
|
|
485
|
-
|
|
486
|
-
# Risk-neutral adjustment (Girsanov transformation)
|
|
487
|
-
# Calculate empirical mean and volatility of the scaled returns
|
|
488
|
-
mu_scaled = scaled_log_returns.mean()
|
|
489
|
-
sigma_scaled = scaled_log_returns.std()
|
|
490
|
-
|
|
491
|
-
# Expected risk-neutral drift
|
|
492
|
-
expected_risk_neutral_mean = (r - 0.5 * (sigma_scaled / 100) ** 2) * 100 * np.sqrt(t)
|
|
493
|
-
|
|
494
|
-
# Calculate adjustment to shift physical to risk-neutral measure
|
|
495
|
-
adjustment = mu_scaled - expected_risk_neutral_mean
|
|
496
|
-
|
|
497
|
-
# Adjust the returns to the risk-neutral measure
|
|
498
|
-
risk_neutral_log_returns = scaled_log_returns - adjustment
|
|
499
|
-
|
|
500
|
-
# Convert to terminal prices using the risk-neutral returns
|
|
501
|
-
simulated_prices = s * np.exp(risk_neutral_log_returns / 100)
|
|
502
|
-
|
|
503
|
-
# Convert to moneyness domain
|
|
504
|
-
simulated_moneyness = s / simulated_prices
|
|
505
|
-
|
|
506
|
-
# Get x domain grid based on requested return_domain
|
|
301
|
+
# Get domain grids
|
|
507
302
|
LM = np.linspace(domain_params[0], domain_params[1], domain_params[2])
|
|
508
303
|
M = np.exp(LM) # Moneyness
|
|
509
304
|
R = M - 1 # Returns
|
|
510
305
|
K = s / M # Strike prices
|
|
511
306
|
|
|
512
|
-
#
|
|
513
|
-
|
|
514
|
-
|
|
307
|
+
# For time scaling calculations
|
|
308
|
+
tau_days_float = t * 365.25 # Exact number of days
|
|
309
|
+
n_periods = max(1, int(t * 365.25 * 24 * 60 / minutes_per_period))
|
|
310
|
+
|
|
311
|
+
logger.info(f"Processing HD for maturity {i} (t={t:.4f} years, {tau_days_float:.2f} days)")
|
|
312
|
+
|
|
313
|
+
if method == 'hist_returns':
|
|
314
|
+
# Standard returns-based method (your existing implementation)
|
|
315
|
+
# Filter historical data for this maturity's lookback period
|
|
316
|
+
start_date = pd.Timestamp.now() - pd.Timedelta(days=int(t * 365.25))
|
|
317
|
+
maturity_hist = df_hist[df_hist.index >= start_date].copy()
|
|
318
|
+
|
|
319
|
+
if len(maturity_hist) < 10:
|
|
320
|
+
logger.warning(f"Not enough historical data for maturity {i}, skipping.")
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
# Calculate scaled returns
|
|
324
|
+
maturity_hist['log_returns'] = np.log(maturity_hist['close'] / maturity_hist['close'].shift(1)) * np.sqrt(
|
|
325
|
+
n_periods)
|
|
326
|
+
maturity_hist = maturity_hist.dropna()
|
|
327
|
+
|
|
328
|
+
returns = maturity_hist['log_returns'].values
|
|
329
|
+
if len(returns) < 2:
|
|
330
|
+
logger.warning(f"Not enough valid returns for maturity {i}, skipping.")
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
# Girsanov adjustment to shift to risk-neutral measure
|
|
334
|
+
mu_scaled = returns.mean()
|
|
335
|
+
sigma_scaled = returns.std()
|
|
336
|
+
expected_risk_neutral_mean = (r - 0.5 * sigma_scaled ** 2) * np.sqrt(t)
|
|
337
|
+
adjustment = mu_scaled - expected_risk_neutral_mean
|
|
338
|
+
adj_returns = returns - adjustment
|
|
339
|
+
|
|
340
|
+
# Create HD and normalize
|
|
341
|
+
f = stats.gaussian_kde(adj_returns, bw_method=bandwidth)
|
|
342
|
+
pdf_values = f(LM)
|
|
343
|
+
|
|
344
|
+
elif method == 'garch':
|
|
345
|
+
# GARCH-based method
|
|
346
|
+
if garch_model is None:
|
|
347
|
+
logger.warning(f"GARCH model fitting failed, skipping maturity {i}")
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
# Simulate paths with the GARCH model
|
|
351
|
+
horizon = max(1, int(tau_days_float))
|
|
352
|
+
simulated_returns, simulated_mu = simulate_garch_paths(
|
|
353
|
+
garch_model,
|
|
354
|
+
horizon,
|
|
355
|
+
simulations,
|
|
356
|
+
variate_parameters
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Scale the simulated returns to match target time horizon
|
|
360
|
+
scaling_factor = np.sqrt(n_periods / tau_days_float)
|
|
361
|
+
scaled_returns = simulated_returns * scaling_factor
|
|
362
|
+
|
|
363
|
+
# Risk-neutral adjustment
|
|
364
|
+
mu_scaled = scaled_returns.mean()
|
|
365
|
+
sigma_scaled = scaled_returns.std()
|
|
366
|
+
expected_risk_neutral_mean = (r - 0.5 * (sigma_scaled / 100) ** 2) * 100 * np.sqrt(t)
|
|
367
|
+
adjustment = mu_scaled - expected_risk_neutral_mean
|
|
368
|
+
risk_neutral_returns = scaled_returns - adjustment
|
|
369
|
+
|
|
370
|
+
# Convert to terminal prices
|
|
371
|
+
simulated_prices = s * np.exp(risk_neutral_returns / 100)
|
|
372
|
+
|
|
373
|
+
# Convert to moneyness domain
|
|
374
|
+
simulated_moneyness = s / simulated_prices
|
|
375
|
+
|
|
376
|
+
# Perform KDE to get PDF
|
|
377
|
+
kde = stats.gaussian_kde(simulated_moneyness, bw_method=bandwidth)
|
|
378
|
+
pdf_values = kde(M)
|
|
379
|
+
|
|
380
|
+
# Include GARCH params in moments
|
|
381
|
+
avg_params = garch_model['avg_params']
|
|
382
|
+
model_params = {
|
|
383
|
+
'mu': avg_params[0],
|
|
384
|
+
'omega': avg_params[1],
|
|
385
|
+
'alpha': avg_params[2],
|
|
386
|
+
'beta': avg_params[3],
|
|
387
|
+
'persistence': avg_params[2] + avg_params[3]
|
|
388
|
+
}
|
|
389
|
+
else:
|
|
390
|
+
continue # Skip this maturity if method is invalid
|
|
515
391
|
|
|
516
392
|
# Ensure density integrates to 1
|
|
517
393
|
dx = LM[1] - LM[0]
|
|
518
|
-
total_area = np.sum(
|
|
519
|
-
|
|
394
|
+
total_area = np.sum(pdf_values * dx)
|
|
395
|
+
if total_area <= 0:
|
|
396
|
+
logger.warning(f"Invalid density (area <= 0) for maturity {i}, skipping.")
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
pdf_values = pdf_values / total_area
|
|
400
|
+
|
|
401
|
+
# Common processing for both methods
|
|
520
402
|
|
|
521
|
-
# Transform to
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
403
|
+
# Transform densities to various domains
|
|
404
|
+
if method == 'hist_returns':
|
|
405
|
+
pdf_lm = pdf_values
|
|
406
|
+
pdf_m = pdf_lm / M
|
|
407
|
+
pdf_k = pdf_lm / K
|
|
408
|
+
pdf_r = pdf_lm / (1 + R)
|
|
409
|
+
else: # 'garch'
|
|
410
|
+
pdf_m = pdf_values
|
|
411
|
+
pdf_lm = pdf_m * M
|
|
412
|
+
pdf_k = pdf_lm / K
|
|
413
|
+
pdf_r = pdf_lm / (1 + R)
|
|
525
414
|
|
|
526
415
|
# Calculate CDF
|
|
527
|
-
cdf = np.cumsum(pdf_lm
|
|
528
|
-
cdf = np.minimum(cdf / cdf[-1], 1.0)
|
|
416
|
+
cdf = np.cumsum(pdf_lm * dx)
|
|
417
|
+
cdf = np.minimum(cdf / cdf[-1], 1.0)
|
|
529
418
|
|
|
530
|
-
# Select appropriate domain
|
|
419
|
+
# Select appropriate domain and calculate moments
|
|
531
420
|
if return_domain == 'log_moneyness':
|
|
532
421
|
x = LM
|
|
533
422
|
pdf = pdf_lm
|
|
534
|
-
moments = get_all_moments(x, pdf)
|
|
423
|
+
moments = get_all_moments(x, pdf, model_params if method == 'garch' else None)
|
|
535
424
|
elif return_domain == 'moneyness':
|
|
536
425
|
x = M
|
|
537
426
|
pdf = pdf_m
|
|
538
|
-
moments = get_all_moments(x, pdf)
|
|
427
|
+
moments = get_all_moments(x, pdf, model_params if method == 'garch' else None)
|
|
539
428
|
elif return_domain == 'returns':
|
|
540
429
|
x = R
|
|
541
430
|
pdf = pdf_r
|
|
542
|
-
moments = get_all_moments(x, pdf)
|
|
431
|
+
moments = get_all_moments(x, pdf, model_params if method == 'garch' else None)
|
|
543
432
|
elif return_domain == 'strikes':
|
|
544
433
|
x = K
|
|
545
434
|
pdf = pdf_k
|
|
546
|
-
moments = get_all_moments(x, pdf)
|
|
435
|
+
moments = get_all_moments(x, pdf, model_params if method == 'garch' else None)
|
|
547
436
|
else:
|
|
548
437
|
raise VolyError(f"Unsupported return_domain: {return_domain}")
|
|
549
438
|
|
|
@@ -556,7 +445,7 @@ def get_garch_hd_surface(model_results: pd.DataFrame,
|
|
|
556
445
|
# Create DataFrame with moments
|
|
557
446
|
moments = pd.DataFrame(all_moments).T
|
|
558
447
|
|
|
559
|
-
logger.info("
|
|
448
|
+
logger.info(f"Historical density calculation complete using {method} method")
|
|
560
449
|
|
|
561
450
|
return {
|
|
562
451
|
'pdf_surface': pdf_surface,
|
voly/core/rnd.py
CHANGED
|
@@ -165,7 +165,7 @@ def rookley(domain_params, s, r, o, t, return_domain):
|
|
|
165
165
|
|
|
166
166
|
|
|
167
167
|
@catch_exception
|
|
168
|
-
def get_all_moments(x, pdf):
|
|
168
|
+
def get_all_moments(x, pdf, model_params=None):
|
|
169
169
|
mean = np.trapz(x * pdf, x) # E[X]
|
|
170
170
|
median = x[np.searchsorted(np.cumsum(pdf * np.diff(x, prepend=x[0])), 0.5)] # Median (50th percentile)
|
|
171
171
|
mode = x[np.argmax(pdf)] # Mode (peak of PDF)
|
|
@@ -213,6 +213,11 @@ def get_all_moments(x, pdf):
|
|
|
213
213
|
'o3n': o3n,
|
|
214
214
|
'o4n': o4n
|
|
215
215
|
}
|
|
216
|
+
|
|
217
|
+
# Add model parameters if provided
|
|
218
|
+
if model_params is not None:
|
|
219
|
+
moments.update(model_params)
|
|
220
|
+
|
|
216
221
|
return moments
|
|
217
222
|
|
|
218
223
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
voly/__init__.py,sha256=8xyDk7rFCn_MOD5hxuv5cxxKZvBVRiSIM7TgaMPpwpw,211
|
|
2
|
-
voly/client.py,sha256=
|
|
2
|
+
voly/client.py,sha256=Lj3YY6P1VBQD5C_psPh2pSxCMVvFjRBybrMrs4e9qXI,14249
|
|
3
3
|
voly/exceptions.py,sha256=PBsbn1vNMvKcCJwwJ4lBO6glD85jo1h2qiEmD7ArAjs,92
|
|
4
4
|
voly/formulas.py,sha256=G_soRiPwQlHy6milOAj6TdmBWr-fNZpMvm0joXAMZ90,10767
|
|
5
5
|
voly/models.py,sha256=o-pHujGfr5Gn8ItckMzLI4Q8yaX9FQaV8UjCxv2zgTY,3364
|
|
@@ -7,13 +7,13 @@ voly/core/__init__.py,sha256=bu6fS2I1Pj9fPPnl-zY3L7NqrZSY5Zy6NY2uMUvdhKs,183
|
|
|
7
7
|
voly/core/charts.py,sha256=E21OZB5lTY4YL2flgaFJ6s5g3_ExtAQT2zryZZxLPyM,12735
|
|
8
8
|
voly/core/data.py,sha256=pDeuYhP0GX4RbtlqByvsE3rfHcIkix0BU5MLW8sKIeI,8935
|
|
9
9
|
voly/core/fit.py,sha256=Tb9eeG7e_2dQTcqt6aqEwFrZdy6jR9rSNqe6tzOdVhQ,9245
|
|
10
|
-
voly/core/hd.py,sha256=
|
|
10
|
+
voly/core/hd.py,sha256=K2X0isAchumuRPcc5RSEkMOR5sOeb_I3twwqAZYYL1A,16809
|
|
11
11
|
voly/core/interpolate.py,sha256=JkK172-FXyhesW3hY4pEeuJWG3Bugq7QZXbeKoRpLuo,5305
|
|
12
|
-
voly/core/rnd.py,sha256=
|
|
12
|
+
voly/core/rnd.py,sha256=GG4cZpWChy8ptIwanuullkx3Bai50rFjqa9E-D9q2_Q,10246
|
|
13
13
|
voly/utils/__init__.py,sha256=E05mWatyC-PDOsCxQV1p5Xi1IgpOomxrNURyCx_gB-w,200
|
|
14
14
|
voly/utils/logger.py,sha256=4-_2bVJmq17Q0d7Rd2mPg1AeR8gxv6EPvcmBDMFWcSM,1744
|
|
15
|
-
voly-0.0.
|
|
16
|
-
voly-0.0.
|
|
17
|
-
voly-0.0.
|
|
18
|
-
voly-0.0.
|
|
19
|
-
voly-0.0.
|
|
15
|
+
voly-0.0.140.dist-info/licenses/LICENSE,sha256=wcHIVbE12jfcBOai_wqBKY6xvNQU5E909xL1zZNq_2Q,1065
|
|
16
|
+
voly-0.0.140.dist-info/METADATA,sha256=6hSGujPj6Hbvl2sk8ru_YmxlQ28IpUNXZASJC-iaoLY,4115
|
|
17
|
+
voly-0.0.140.dist-info/WHEEL,sha256=DK49LOLCYiurdXXOXwGJm6U4DkHkg4lcxjhqwRa0CP4,91
|
|
18
|
+
voly-0.0.140.dist-info/top_level.txt,sha256=ZfLw2sSxF-LrKAkgGjOmeTcw6_gD-30zvtdEY5W4B7c,5
|
|
19
|
+
voly-0.0.140.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|