voly 0.0.142__tar.gz → 0.0.144__tar.gz

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.
Files changed (26) hide show
  1. {voly-0.0.142/src/voly.egg-info → voly-0.0.144}/PKG-INFO +1 -1
  2. {voly-0.0.142 → voly-0.0.144}/pyproject.toml +2 -2
  3. {voly-0.0.142 → voly-0.0.144}/src/voly/client.py +5 -22
  4. voly-0.0.144/src/voly/core/hd.py +635 -0
  5. {voly-0.0.142 → voly-0.0.144/src/voly.egg-info}/PKG-INFO +1 -1
  6. voly-0.0.142/src/voly/core/hd.py +0 -455
  7. {voly-0.0.142 → voly-0.0.144}/LICENSE +0 -0
  8. {voly-0.0.142 → voly-0.0.144}/README.md +0 -0
  9. {voly-0.0.142 → voly-0.0.144}/setup.cfg +0 -0
  10. {voly-0.0.142 → voly-0.0.144}/setup.py +0 -0
  11. {voly-0.0.142 → voly-0.0.144}/src/voly/__init__.py +0 -0
  12. {voly-0.0.142 → voly-0.0.144}/src/voly/core/__init__.py +0 -0
  13. {voly-0.0.142 → voly-0.0.144}/src/voly/core/charts.py +0 -0
  14. {voly-0.0.142 → voly-0.0.144}/src/voly/core/data.py +0 -0
  15. {voly-0.0.142 → voly-0.0.144}/src/voly/core/fit.py +0 -0
  16. {voly-0.0.142 → voly-0.0.144}/src/voly/core/interpolate.py +0 -0
  17. {voly-0.0.142 → voly-0.0.144}/src/voly/core/rnd.py +0 -0
  18. {voly-0.0.142 → voly-0.0.144}/src/voly/exceptions.py +0 -0
  19. {voly-0.0.142 → voly-0.0.144}/src/voly/formulas.py +0 -0
  20. {voly-0.0.142 → voly-0.0.144}/src/voly/models.py +0 -0
  21. {voly-0.0.142 → voly-0.0.144}/src/voly/utils/__init__.py +0 -0
  22. {voly-0.0.142 → voly-0.0.144}/src/voly/utils/logger.py +0 -0
  23. {voly-0.0.142 → voly-0.0.144}/src/voly.egg-info/SOURCES.txt +0 -0
  24. {voly-0.0.142 → voly-0.0.144}/src/voly.egg-info/dependency_links.txt +0 -0
  25. {voly-0.0.142 → voly-0.0.144}/src/voly.egg-info/requires.txt +0 -0
  26. {voly-0.0.142 → voly-0.0.144}/src/voly.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voly
3
- Version: 0.0.142
3
+ Version: 0.0.144
4
4
  Summary: Options & volatility research package
5
5
  Author-email: Manu de Cara <manu.de.cara@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "voly"
7
- version = "0.0.142"
7
+ version = "0.0.144"
8
8
  description = "Options & volatility research package"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -60,7 +60,7 @@ line_length = 100
60
60
  multi_line_output = 3
61
61
 
62
62
  [tool.mypy]
63
- python_version = "0.0.142"
63
+ python_version = "0.0.144"
64
64
  warn_return_any = true
65
65
  warn_unused_configs = true
66
66
  disallow_untyped_defs = true
@@ -343,30 +343,11 @@ class VolyClient:
343
343
  df_hist: pd.DataFrame,
344
344
  domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000),
345
345
  return_domain: str = 'log_moneyness',
346
- method: str = 'garch',
346
+ method: str = 'arch_returns',
347
+ model_type: str = 'garch',
348
+ distribution: str = 'normal',
347
349
  **kwargs) -> Dict[str, Any]:
348
- """
349
- Generate historical density surface from historical price data.
350
-
351
- Parameters:
352
- model_results: DataFrame with model parameters and maturities
353
- df_hist: DataFrame with historical price data
354
- domain_params: Tuple of (min, max, num_points) for x-domain
355
- return_domain: Domain for x-axis values ('log_moneyness', 'moneyness', 'returns', 'strikes')
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')
366
350
 
367
- Returns:
368
- Dictionary containing pdf_surface, cdf_surface, x_surface, and moments
369
- """
370
351
  logger.info(f"Calculating historical density surface using {method} method")
371
352
 
372
353
  return get_hd_surface(
@@ -375,5 +356,7 @@ class VolyClient:
375
356
  domain_params=domain_params,
376
357
  return_domain=return_domain,
377
358
  method=method,
359
+ model_type=model_type,
360
+ distribution=distribution,
378
361
  **kwargs
379
362
  )
@@ -0,0 +1,635 @@
1
+ """
2
+ This module handles calculating historical densities from
3
+ time series of prices and converting them to implied volatility smiles.
4
+ """
5
+
6
+ import ccxt
7
+ import pandas as pd
8
+ import numpy as np
9
+ import datetime as dt
10
+ from scipy import stats
11
+ from typing import Dict, List, Tuple, Optional, Union, Any
12
+ from voly.utils.logger import logger, catch_exception
13
+ from voly.exceptions import VolyError
14
+ from voly.core.rnd import get_all_moments
15
+ from voly.formulas import iv, get_domain
16
+ from voly.models import SVIModel
17
+ from voly.core.fit import fit_model
18
+ from arch import arch_model
19
+ from arch.univariate import GARCH, EGARCH
20
+
21
+
22
+ @catch_exception
23
+ def get_historical_data(currency, lookback_days, granularity, exchange_name):
24
+ """
25
+ Fetch historical OHLCV data for a cryptocurrency.
26
+
27
+ Parameters:
28
+ ----------
29
+ currency : str
30
+ The cryptocurrency to fetch data for (e.g., 'BTC', 'ETH').
31
+ lookback_days : str
32
+ The lookback period in days, formatted as '90d', '30d', etc.
33
+ granularity : str
34
+ The time interval for data points (e.g., '15m', '1h', '1d').
35
+ exchange_name : str
36
+ The exchange to fetch data from (default: 'binance').
37
+
38
+ Returns:
39
+ -------
40
+ df_hist : pandas.DataFrame containing the historical price data with OHLCV columns.
41
+ """
42
+
43
+ try:
44
+ # Get the exchange class from ccxt
45
+ exchange_class = getattr(ccxt, exchange_name.lower())
46
+ exchange = exchange_class({'enableRateLimit': True})
47
+ except (AttributeError, TypeError):
48
+ raise VolyError(f"Exchange '{exchange_name}' not found in ccxt. Please check the exchange name.")
49
+
50
+ # Form the trading pair symbol
51
+ symbol = currency + '/USDT'
52
+
53
+ # Convert lookback_days to timestamp
54
+ if lookback_days.endswith('d'):
55
+ days_ago = int(lookback_days[:-1])
56
+ date_start = (dt.datetime.now() - dt.timedelta(days=days_ago)).strftime('%Y-%m-%d %H:%M:%S')
57
+ else:
58
+ raise VolyError("lookback_days should be in format '90d', '30d', etc.")
59
+
60
+ from_ts = exchange.parse8601(date_start)
61
+ ohlcv_list = []
62
+ ohlcv = exchange.fetch_ohlcv(symbol, granularity, since=from_ts, limit=1000)
63
+ ohlcv_list.append(ohlcv)
64
+ while True:
65
+ from_ts = ohlcv[-1][0]
66
+ new_ohlcv = exchange.fetch_ohlcv(symbol, granularity, since=from_ts, limit=1000)
67
+ ohlcv.extend(new_ohlcv)
68
+ if len(new_ohlcv) != 1000:
69
+ break
70
+
71
+ # Convert to DataFrame
72
+ df_hist = pd.DataFrame(ohlcv, columns=['date', 'open', 'high', 'low', 'close', 'volume'])
73
+ df_hist['date'] = pd.to_datetime(df_hist['date'], unit='ms')
74
+ df_hist.set_index('date', inplace=True)
75
+ df_hist = df_hist.sort_index(ascending=True)
76
+
77
+ print(f"Data fetched successfully: {len(df_hist)} rows from {df_hist.index[0]} to {df_hist.index[-1]}")
78
+
79
+ return df_hist
80
+
81
+
82
+ @catch_exception
83
+ def parse_window_length(window_length, df_hist):
84
+ """
85
+ Parse window length from string format (e.g., '7d', '30d') to number of data points.
86
+
87
+ Parameters:
88
+ -----------
89
+ window_length : str
90
+ Window length in days, formatted as '7d', '30d', etc.
91
+ df_hist : pd.DataFrame
92
+ Historical data DataFrame with datetime index.
93
+
94
+ Returns:
95
+ --------
96
+ int
97
+ Number of data points corresponding to the window length.
98
+ """
99
+ if not window_length.endswith('d'):
100
+ raise VolyError("window_length should be in format '7d', '30d', etc.")
101
+
102
+ # Extract number of days
103
+ days = int(window_length[:-1])
104
+
105
+ # Calculate time delta between consecutive data points
106
+ if len(df_hist) > 1:
107
+ avg_delta = (df_hist.index[-1] - df_hist.index[0]) / (len(df_hist) - 1)
108
+ # Convert to days and get points per day
109
+ days_per_point = avg_delta.total_seconds() / (24 * 60 * 60)
110
+ # Calculate number of points for the window
111
+ n_points = int(days / days_per_point)
112
+ return max(n_points, 10) # Ensure at least 10 points
113
+ else:
114
+ raise VolyError("Not enough data points in df_hist to calculate granularity.")
115
+
116
+
117
+ @catch_exception
118
+ def fit_volatility_model(log_returns, df_hist, model_type='garch', distribution='normal', window_length='30d',
119
+ n_fits=400):
120
+ """
121
+ Fit a volatility model (GARCH or EGARCH) to log returns.
122
+
123
+ Args:
124
+ log_returns: Array of log returns
125
+ df_hist: DataFrame with historical price data
126
+ model_type: Type of volatility model ('garch' or 'egarch')
127
+ distribution: Distribution type ('normal', 'studentst', or 'skewstudent')
128
+ window_length: Length of each window as a string (e.g., '30d')
129
+ n_fits: Number of sliding windows
130
+
131
+ Returns:
132
+ Dict with model parameters and processes
133
+ """
134
+ # Parse window length
135
+ window_points = parse_window_length(window_length, df_hist)
136
+
137
+ if len(log_returns) < window_points + n_fits:
138
+ raise VolyError(f"Not enough data points. Need at least {window_points + n_fits}, got {len(log_returns)}")
139
+
140
+ # Adjust window sizes if necessary
141
+ n_fits = min(n_fits, len(log_returns) // 3)
142
+ window_points = min(window_points, len(log_returns) // 3)
143
+
144
+ start = window_points + n_fits
145
+ end = n_fits
146
+
147
+ # Different number of parameters based on model type and distribution
148
+ if model_type.lower() == 'garch':
149
+ if distribution.lower() == 'normal':
150
+ n_params = 4 # mu, omega, alpha, beta
151
+ elif distribution.lower() == 'studentst':
152
+ n_params = 5 # mu, omega, alpha, beta, nu
153
+ else: # skewstudent
154
+ n_params = 6 # mu, omega, alpha, beta, nu, lambda (skew)
155
+ else: # egarch
156
+ if distribution.lower() == 'normal':
157
+ n_params = 5 # mu, omega, alpha, gamma, beta
158
+ elif distribution.lower() == 'studentst':
159
+ n_params = 6 # mu, omega, alpha, gamma, beta, nu
160
+ else: # skewstudent
161
+ n_params = 7 # mu, omega, alpha, gamma, beta, nu, lambda (skew)
162
+
163
+ parameters = np.zeros((n_fits, n_params))
164
+ z_process = []
165
+
166
+ logger.info(f"Fitting {model_type.upper()} model with {distribution} distribution using {n_fits} windows...")
167
+
168
+ for i in range(n_fits):
169
+ window = log_returns[end - i - 1:start - i - 1]
170
+ data = window - np.mean(window)
171
+
172
+ try:
173
+ # Configure model based on type and distribution
174
+ if model_type.lower() == 'garch':
175
+ model = arch_model(data, vol='GARCH', p=1, q=1, dist=distribution.lower())
176
+ else: # egarch
177
+ model = arch_model(data, vol='EGARCH', p=1, o=1, q=1, dist=distribution.lower())
178
+
179
+ fit_result = model.fit(disp='off')
180
+
181
+ # Extract parameters based on model type and distribution
182
+ params_dict = fit_result.params.to_dict()
183
+
184
+ if model_type.lower() == 'garch':
185
+ mu = params_dict.get("mu", 0)
186
+ omega = params_dict.get("omega", 0)
187
+ alpha = params_dict.get("alpha[1]", 0)
188
+ beta = params_dict.get("beta[1]", 0)
189
+
190
+ if distribution.lower() == 'normal':
191
+ parameters[i, :] = [mu, omega, alpha, beta]
192
+ elif distribution.lower() == 'studentst':
193
+ nu = params_dict.get("nu", 0)
194
+ parameters[i, :] = [mu, omega, alpha, beta, nu]
195
+ else: # skewstudent
196
+ nu = params_dict.get("nu", 0)
197
+ lam = params_dict.get("lambda", 0)
198
+ parameters[i, :] = [mu, omega, alpha, beta, nu, lam]
199
+ else: # egarch
200
+ mu = params_dict.get("mu", 0)
201
+ omega = params_dict.get("omega", 0)
202
+ alpha = params_dict.get("alpha[1]", 0)
203
+ gamma = params_dict.get("gamma[1]", 0)
204
+ beta = params_dict.get("beta[1]", 0)
205
+
206
+ if distribution.lower() == 'normal':
207
+ parameters[i, :] = [mu, omega, alpha, gamma, beta]
208
+ elif distribution.lower() == 'studentst':
209
+ nu = params_dict.get("nu", 0)
210
+ parameters[i, :] = [mu, omega, alpha, gamma, beta, nu]
211
+ else: # skewstudent
212
+ nu = params_dict.get("nu", 0)
213
+ lam = params_dict.get("lambda", 0)
214
+ parameters[i, :] = [mu, omega, alpha, gamma, beta, nu, lam]
215
+
216
+ # Get last innovation
217
+ residuals = fit_result.resid
218
+ conditional_vol = fit_result.conditional_volatility
219
+ z_t = residuals[-1] / conditional_vol[-1]
220
+ z_process.append(z_t)
221
+
222
+ except Exception as e:
223
+ logger.warning(f"Model fit failed for window {i}: {str(e)}")
224
+
225
+ # Clean up any failed fits
226
+ if len(z_process) < n_fits / 2:
227
+ raise VolyError("Too many model fits failed. Check your data.")
228
+
229
+ avg_params = np.mean(parameters, axis=0)
230
+ std_params = np.std(parameters, axis=0)
231
+
232
+ return {
233
+ 'parameters': parameters,
234
+ 'avg_params': avg_params,
235
+ 'std_params': std_params,
236
+ 'z_process': np.array(z_process),
237
+ 'model_type': model_type,
238
+ 'distribution': distribution,
239
+ 'param_names': get_param_names(model_type, distribution)
240
+ }
241
+
242
+
243
+ def get_param_names(model_type, distribution):
244
+ """Get parameter names based on model type and distribution."""
245
+ if model_type.lower() == 'garch':
246
+ if distribution.lower() == 'normal':
247
+ return ['mu', 'omega', 'alpha', 'beta']
248
+ elif distribution.lower() == 'studentst':
249
+ return ['mu', 'omega', 'alpha', 'beta', 'nu']
250
+ else: # skewstudent
251
+ return ['mu', 'omega', 'alpha', 'beta', 'nu', 'lambda']
252
+ else: # egarch
253
+ if distribution.lower() == 'normal':
254
+ return ['mu', 'omega', 'alpha', 'gamma', 'beta']
255
+ elif distribution.lower() == 'studentst':
256
+ return ['mu', 'omega', 'alpha', 'gamma', 'beta', 'nu']
257
+ else: # skewstudent
258
+ return ['mu', 'omega', 'alpha', 'gamma', 'beta', 'nu', 'lambda']
259
+
260
+
261
+ @catch_exception
262
+ def simulate_volatility_paths(vol_model, horizon, simulations=5000, variate_parameters=True):
263
+ """
264
+ Simulate future paths using a fitted volatility model.
265
+
266
+ Args:
267
+ vol_model: Dict with volatility model parameters
268
+ horizon: Number of steps to simulate
269
+ simulations: Number of paths to simulate
270
+ variate_parameters: Whether to vary parameters between simulations
271
+
272
+ Returns:
273
+ Array of simulated log returns
274
+ """
275
+ parameters = vol_model['parameters']
276
+ z_process = vol_model['z_process']
277
+ model_type = vol_model['model_type']
278
+ distribution = vol_model['distribution']
279
+ param_names = vol_model['param_names']
280
+
281
+ # Use mean parameters as starting point
282
+ pars = vol_model['avg_params'].copy()
283
+ bounds = vol_model['std_params'].copy()
284
+
285
+ # Log parameters
286
+ param_str = ", ".join([f"{name}={par:.6f}" for name, par in zip(param_names, pars)])
287
+ logger.info(f"{model_type.upper()} parameters: {param_str}")
288
+
289
+ # Create KDE for innovations based on distribution
290
+ if distribution.lower() == 'normal':
291
+ # Use standard normal for normal distribution
292
+ def sample_innovation(size=1):
293
+ return np.random.normal(0, 1, size=size)
294
+ else:
295
+ # Use KDE for non-normal distributions to capture empirical distribution
296
+ kde = stats.gaussian_kde(z_process, bw_method='silverman') # original code doesn't include bw_method
297
+ z_range = np.linspace(min(z_process), max(z_process), 1000)
298
+ z_prob = kde(z_range)
299
+ z_prob = z_prob / np.sum(z_prob)
300
+
301
+ def sample_innovation(size=1):
302
+ return np.random.choice(z_range, size=size, p=z_prob)
303
+
304
+ # Simulate paths
305
+ simulated_returns = np.zeros(simulations)
306
+
307
+ for i in range(simulations):
308
+ if (i + 1) % (simulations // 10) == 0:
309
+ logger.info(f"Simulation progress: {i + 1}/{simulations}")
310
+
311
+ # Optionally vary parameters
312
+ if variate_parameters and (i + 1) % (simulations // 20) == 0:
313
+ new_pars = []
314
+ for j, (par, bound) in enumerate(zip(pars, bounds)):
315
+ var = bound ** 2 / len(parameters)
316
+ new_par = np.random.normal(par, var)
317
+ # Ensure omega is positive, betas are between 0 and 1, etc.
318
+ if j >= 1 and new_par <= 0:
319
+ new_par = 0.01
320
+ new_pars.append(new_par)
321
+ sim_pars = new_pars
322
+ else:
323
+ sim_pars = pars.copy()
324
+
325
+ # Initialize variables based on model type
326
+ if model_type.lower() == 'garch':
327
+ if distribution.lower() == 'normal':
328
+ mu, omega, alpha, beta = sim_pars
329
+ sigma2 = omega / (1 - alpha - beta)
330
+ elif distribution.lower() == 'studentst':
331
+ mu, omega, alpha, beta, nu = sim_pars
332
+ sigma2 = omega / (1 - alpha - beta)
333
+ else: # skewstudent
334
+ mu, omega, alpha, beta, nu, lam = sim_pars
335
+ sigma2 = omega / (1 - alpha - beta)
336
+ else: # egarch
337
+ if distribution.lower() == 'normal':
338
+ mu, omega, alpha, gamma, beta = sim_pars
339
+ log_sigma2 = omega / (1 - beta)
340
+ sigma2 = np.exp(log_sigma2)
341
+ elif distribution.lower() == 'studentst':
342
+ mu, omega, alpha, gamma, beta, nu = sim_pars
343
+ log_sigma2 = omega / (1 - beta)
344
+ sigma2 = np.exp(log_sigma2)
345
+ else: # skewstudent
346
+ mu, omega, alpha, gamma, beta, nu, lam = sim_pars
347
+ log_sigma2 = omega / (1 - beta)
348
+ sigma2 = np.exp(log_sigma2)
349
+
350
+ returns_sum = 0
351
+
352
+ # Simulate path
353
+ for _ in range(horizon):
354
+ # Sample innovation
355
+ z = sample_innovation()
356
+
357
+ # Update volatility and returns based on model type
358
+ if model_type.lower() == 'garch':
359
+ # Calculate return
360
+ e = z * np.sqrt(sigma2)
361
+ returns_sum += e + mu
362
+
363
+ # Update GARCH volatility
364
+ sigma2 = omega + alpha * e ** 2 + beta * sigma2
365
+ else: # egarch
366
+ # Calculate return
367
+ e = z * np.sqrt(sigma2)
368
+ returns_sum += e + mu
369
+
370
+ # Update EGARCH volatility
371
+ abs_z = abs(z)
372
+ log_sigma2 = omega + beta * log_sigma2 + alpha * (abs_z - np.sqrt(2 / np.pi)) + gamma * z
373
+ sigma2 = np.exp(log_sigma2)
374
+
375
+ simulated_returns[i] = returns_sum
376
+
377
+ return simulated_returns, mu * horizon
378
+
379
+
380
+ def get_hd_surface(model_results: pd.DataFrame,
381
+ df_hist: pd.DataFrame,
382
+ domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000),
383
+ return_domain: str = 'log_moneyness',
384
+ method: str = 'arch_returns',
385
+ model_type: str = 'garch',
386
+ distribution: str = 'normal',
387
+ **kwargs) -> Dict[str, Any]:
388
+ """
389
+ Generate historical density surface from historical price data.
390
+
391
+ Parameters:
392
+ model_results: DataFrame with model parameters and maturities
393
+ df_hist: DataFrame with historical price data
394
+ domain_params: Tuple of (min, max, num_points) for x-domain
395
+ return_domain: Domain for x-axis values ('log_moneyness', 'moneyness', 'returns', 'strikes')
396
+ method: Method to use for HD estimation ('hist_returns' or 'arch_returns')
397
+ model_type: Type of volatility model to use ('garch' or 'egarch')
398
+ distribution: Distribution to use ('normal', 'studentst', or 'skewstudent')
399
+ **kwargs: Additional parameters for specific methods:
400
+ For volatility models ('garch'/'egarch' method):
401
+ n_fits: Number of sliding windows (default: 400)
402
+ simulations: Number of Monte Carlo simulations (default: 5000)
403
+ window_length: Length of sliding windows as string (default: '30d')
404
+ variate_parameters: Whether to vary parameters (default: True)
405
+ bandwidth: KDE bandwidth (default: 'silverman')
406
+ For 'hist_returns' method:
407
+ bandwidth: KDE bandwidth (default: 'silverman')
408
+
409
+ Returns:
410
+ Dictionary containing pdf_surface, cdf_surface, x_surface, and moments
411
+ """
412
+ # Check if required columns are present
413
+ required_columns = ['s', 't', 'r']
414
+ missing_columns = [col for col in required_columns if col not in model_results.columns]
415
+ if missing_columns:
416
+ raise VolyError(f"Required columns missing in model_results: {missing_columns}")
417
+
418
+ # Determine granularity from df_hist
419
+ if len(df_hist) > 1:
420
+ # Calculate minutes between consecutive timestamps
421
+ minutes_diff = (df_hist.index[1] - df_hist.index[0]).total_seconds() / 60
422
+ minutes_per_period = int(minutes_diff)
423
+ else:
424
+ raise VolyError("Cannot determine granularity from df_hist.")
425
+
426
+ # Validate model_type and distribution
427
+ valid_model_types = ['garch', 'egarch']
428
+ valid_distributions = ['normal', 'studentst', 'skewstudent']
429
+
430
+ if model_type.lower() not in valid_model_types:
431
+ raise VolyError(f"Invalid model_type: {model_type}. Must be one of {valid_model_types}")
432
+
433
+ if distribution.lower() not in valid_distributions:
434
+ raise VolyError(f"Invalid distribution: {distribution}. Must be one of {valid_distributions}")
435
+
436
+ # Get method-specific parameters
437
+ if method == 'arch_returns':
438
+ n_fits = kwargs.get('n_fits', 400)
439
+ simulations = kwargs.get('simulations', 5000)
440
+ window_length = kwargs.get('window_length', '30d')
441
+ variate_parameters = kwargs.get('variate_parameters', True)
442
+ bandwidth = kwargs.get('bandwidth', 'silverman')
443
+ logger.info(
444
+ f"Using {model_type.upper()} method with {distribution} distribution, {n_fits} fits, {simulations} simulations")
445
+ elif method == 'hist_returns':
446
+ bandwidth = kwargs.get('bandwidth', 'silverman')
447
+ logger.info(f"Using returns-based KDE method with bandwidth {bandwidth}")
448
+ else:
449
+ raise VolyError(f"Unknown method: {method}. Use 'hist_returns', 'arch_returns'.")
450
+
451
+ # Calculate log returns from price history
452
+ log_returns = np.log(df_hist['close'] / df_hist['close'].shift(1)) * 100
453
+ log_returns = log_returns.dropna().values
454
+
455
+ # Fit volatility model once if using garch/egarch method
456
+ vol_model = None
457
+ if method == 'arch_returns':
458
+ vol_model = fit_volatility_model(
459
+ log_returns,
460
+ df_hist,
461
+ model_type=model_type,
462
+ distribution=distribution,
463
+ window_length=window_length,
464
+ n_fits=n_fits
465
+ )
466
+
467
+ pdf_surface = {}
468
+ cdf_surface = {}
469
+ x_surface = {}
470
+ all_moments = {}
471
+
472
+ # Process each maturity
473
+ for i in model_results.index:
474
+ # Get parameters for this maturity
475
+ s = model_results.loc[i, 's'] # Current spot price
476
+ r = model_results.loc[i, 'r'] # Risk-free rate
477
+ t = model_results.loc[i, 't'] # Time to maturity in years
478
+
479
+ # Get domain grids
480
+ LM = np.linspace(domain_params[0], domain_params[1], domain_params[2])
481
+ M = np.exp(LM) # Moneyness
482
+ R = M - 1 # Returns
483
+ K = s / M # Strike prices
484
+
485
+ # For time scaling calculations
486
+ tau_days_float = t * 365.25 # Exact number of days
487
+ n_periods = max(1, int(t * 365.25 * 24 * 60 / minutes_per_period))
488
+
489
+ logger.info(f"Processing HD for maturity {i} (t={t:.4f} years, {tau_days_float:.2f} days)")
490
+
491
+ if method == 'hist_returns':
492
+ # Standard returns-based method
493
+ # Filter historical data for this maturity's lookback period
494
+ start_date = pd.Timestamp.now() - pd.Timedelta(days=int(t * 365.25))
495
+ maturity_hist = df_hist[df_hist.index >= start_date].copy()
496
+
497
+ if len(maturity_hist) < 10:
498
+ logger.warning(f"Not enough historical data for maturity {i}, skipping.")
499
+ continue
500
+
501
+ # Calculate scaled returns
502
+ maturity_hist['log_returns'] = np.log(maturity_hist['close'] / maturity_hist['close'].shift(1)) * np.sqrt(
503
+ n_periods)
504
+ maturity_hist = maturity_hist.dropna()
505
+
506
+ returns = maturity_hist['log_returns'].values
507
+ if len(returns) < 2:
508
+ logger.warning(f"Not enough valid returns for maturity {i}, skipping.")
509
+ continue
510
+
511
+ # Girsanov adjustment to shift to risk-neutral measure
512
+ mu_scaled = returns.mean()
513
+ sigma_scaled = returns.std()
514
+ expected_risk_neutral_mean = (r - 0.5 * sigma_scaled ** 2) * np.sqrt(t)
515
+ adjustment = mu_scaled - expected_risk_neutral_mean
516
+ adj_returns = returns - adjustment
517
+
518
+ # Create HD and normalize
519
+ f = stats.gaussian_kde(adj_returns, bw_method=bandwidth)
520
+ pdf_values = f(LM)
521
+
522
+ elif method == 'arch_returns':
523
+ # Volatility model-based method
524
+ if vol_model is None:
525
+ logger.warning(f"Volatility model fitting failed, skipping maturity {i}")
526
+ continue
527
+
528
+ # Simulate paths with the volatility model
529
+ horizon = max(1, int(tau_days_float))
530
+ simulated_returns, simulated_mu = simulate_volatility_paths(
531
+ vol_model,
532
+ horizon,
533
+ simulations,
534
+ variate_parameters
535
+ )
536
+
537
+ # Scale the simulated returns to match target time horizon
538
+ scaling_factor = np.sqrt(n_periods / tau_days_float)
539
+ scaled_returns = simulated_returns * scaling_factor
540
+
541
+ # Risk-neutral adjustment
542
+ mu_scaled = scaled_returns.mean()
543
+ sigma_scaled = scaled_returns.std()
544
+ expected_risk_neutral_mean = (r - 0.5 * (sigma_scaled / 100) ** 2) * 100 * np.sqrt(t)
545
+ adjustment = mu_scaled - expected_risk_neutral_mean
546
+ risk_neutral_returns = scaled_returns - adjustment
547
+
548
+ # Convert to terminal prices
549
+ simulated_prices = s * np.exp(risk_neutral_returns / 100)
550
+
551
+ # Convert to moneyness domain
552
+ simulated_moneyness = s / simulated_prices
553
+
554
+ # Perform KDE to get PDF
555
+ kde = stats.gaussian_kde(simulated_moneyness, bw_method=bandwidth)
556
+ pdf_values = kde(M)
557
+
558
+ # Include volatility model params in moments
559
+ avg_params = vol_model['avg_params']
560
+ param_names = vol_model['param_names']
561
+ model_params = {name: value for name, value in zip(param_names, avg_params)}
562
+ model_params['model_type'] = model_type
563
+ model_params['distribution'] = distribution
564
+
565
+ # Add persistence for GARCH-type models
566
+ if model_type.lower() == 'garch':
567
+ model_params['persistence'] = model_params.get('alpha', 0) + model_params.get('beta', 0)
568
+ else:
569
+ continue # Skip this maturity if method is invalid
570
+
571
+ # Ensure density integrates to 1
572
+ dx = LM[1] - LM[0]
573
+ total_area = np.sum(pdf_values * dx)
574
+ if total_area <= 0:
575
+ logger.warning(f"Invalid density (area <= 0) for maturity {i}, skipping.")
576
+ continue
577
+
578
+ pdf_values = pdf_values / total_area
579
+
580
+ # Common processing for both methods
581
+
582
+ # Transform densities to various domains
583
+ if method == 'hist_returns':
584
+ pdf_lm = pdf_values
585
+ pdf_m = pdf_lm / M
586
+ pdf_k = pdf_lm / K
587
+ pdf_r = pdf_lm / (1 + R)
588
+ else: # volatility models
589
+ pdf_m = pdf_values
590
+ pdf_lm = pdf_m * M
591
+ pdf_k = pdf_lm / K
592
+ pdf_r = pdf_lm / (1 + R)
593
+
594
+ # Calculate CDF
595
+ cdf = np.cumsum(pdf_lm * dx)
596
+ cdf = np.minimum(cdf / cdf[-1], 1.0)
597
+
598
+ # Select appropriate domain and calculate moments
599
+ if return_domain == 'log_moneyness':
600
+ x = LM
601
+ pdf = pdf_lm
602
+ moments = get_all_moments(x, pdf, model_params if method in ['garch', 'egarch'] else None)
603
+ elif return_domain == 'moneyness':
604
+ x = M
605
+ pdf = pdf_m
606
+ moments = get_all_moments(x, pdf, model_params if method in ['garch', 'egarch'] else None)
607
+ elif return_domain == 'returns':
608
+ x = R
609
+ pdf = pdf_r
610
+ moments = get_all_moments(x, pdf, model_params if method in ['garch', 'egarch'] else None)
611
+ elif return_domain == 'strikes':
612
+ x = K
613
+ pdf = pdf_k
614
+ moments = get_all_moments(x, pdf, model_params if method in ['garch', 'egarch'] else None)
615
+ else:
616
+ raise VolyError(f"Unsupported return_domain: {return_domain}")
617
+
618
+ # Store results
619
+ pdf_surface[i] = pdf
620
+ cdf_surface[i] = cdf
621
+ x_surface[i] = x
622
+ all_moments[i] = moments
623
+
624
+ # Create DataFrame with moments
625
+ moments = pd.DataFrame(all_moments).T
626
+
627
+ logger.info(
628
+ f"Historical density calculation complete using {method} method with {model_type} model and {distribution} distribution")
629
+
630
+ return {
631
+ 'pdf_surface': pdf_surface,
632
+ 'cdf_surface': cdf_surface,
633
+ 'x_surface': x_surface,
634
+ 'moments': moments
635
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voly
3
- Version: 0.0.142
3
+ Version: 0.0.144
4
4
  Summary: Options & volatility research package
5
5
  Author-email: Manu de Cara <manu.de.cara@gmail.com>
6
6
  License: MIT
@@ -1,455 +0,0 @@
1
- """
2
- This module handles calculating historical densities from
3
- time series of prices and converting them to implied volatility smiles.
4
- """
5
-
6
- import ccxt
7
- import pandas as pd
8
- import numpy as np
9
- import datetime as dt
10
- from scipy import stats
11
- from typing import Dict, List, Tuple, Optional, Union, Any
12
- from voly.utils.logger import logger, catch_exception
13
- from voly.exceptions import VolyError
14
- from voly.core.rnd import get_all_moments
15
- from voly.formulas import iv, get_domain
16
- from voly.models import SVIModel
17
- from voly.core.fit import fit_model
18
- from arch import arch_model
19
-
20
-
21
- @catch_exception
22
- def get_historical_data(currency, lookback_days, granularity, exchange_name):
23
- """
24
- Fetch historical OHLCV data for a cryptocurrency.
25
-
26
- Parameters:
27
- ----------
28
- currency : str
29
- The cryptocurrency to fetch data for (e.g., 'BTC', 'ETH').
30
- lookback_days : str
31
- The lookback period in days, formatted as '90d', '30d', etc.
32
- granularity : str
33
- The time interval for data points (e.g., '15m', '1h', '1d').
34
- exchange_name : str
35
- The exchange to fetch data from (default: 'binance').
36
-
37
- Returns:
38
- -------
39
- df_hist : pandas.DataFrame containing the historical price data with OHLCV columns.
40
- """
41
-
42
- try:
43
- # Get the exchange class from ccxt
44
- exchange_class = getattr(ccxt, exchange_name.lower())
45
- exchange = exchange_class({'enableRateLimit': True})
46
- except (AttributeError, TypeError):
47
- raise VolyError(f"Exchange '{exchange_name}' not found in ccxt. Please check the exchange name.")
48
-
49
- # Form the trading pair symbol
50
- symbol = currency + '/USDT'
51
-
52
- # Convert lookback_days to timestamp
53
- if lookback_days.endswith('d'):
54
- days_ago = int(lookback_days[:-1])
55
- date_start = (dt.datetime.now() - dt.timedelta(days=days_ago)).strftime('%Y-%m-%d %H:%M:%S')
56
- else:
57
- raise VolyError("lookback_days should be in format '90d', '30d', etc.")
58
-
59
- from_ts = exchange.parse8601(date_start)
60
- ohlcv_list = []
61
- ohlcv = exchange.fetch_ohlcv(symbol, granularity, since=from_ts, limit=1000)
62
- ohlcv_list.append(ohlcv)
63
- while True:
64
- from_ts = ohlcv[-1][0]
65
- new_ohlcv = exchange.fetch_ohlcv(symbol, granularity, since=from_ts, limit=1000)
66
- ohlcv.extend(new_ohlcv)
67
- if len(new_ohlcv) != 1000:
68
- break
69
-
70
- # Convert to DataFrame
71
- df_hist = pd.DataFrame(ohlcv, columns=['date', 'open', 'high', 'low', 'close', 'volume'])
72
- df_hist['date'] = pd.to_datetime(df_hist['date'], unit='ms')
73
- df_hist.set_index('date', inplace=True)
74
- df_hist = df_hist.sort_index(ascending=True)
75
-
76
- print(f"Data fetched successfully: {len(df_hist)} rows from {df_hist.index[0]} to {df_hist.index[-1]}")
77
-
78
- return df_hist
79
-
80
-
81
- @catch_exception
82
- def fit_garch_model(log_returns, n_fits=400, window_length=365):
83
- """
84
- Fit a GARCH(1,1) model to log returns.
85
-
86
- Args:
87
- log_returns: Array of log returns
88
- n_fits: Number of sliding windows
89
- window_length: Length of each window
90
-
91
- Returns:
92
- Dict with GARCH parameters and processes
93
- """
94
-
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)}")
97
-
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)
101
-
102
- start = window_length + n_fits
103
- end = n_fits
104
-
105
- parameters = np.zeros((n_fits, 4)) # [mu, omega, alpha, beta]
106
- z_process = []
107
-
108
- logger.info(f"Fitting GARCH model with {n_fits} windows...")
109
-
110
- for i in range(n_fits):
111
- window = log_returns[end - i - 1:start - i - 1]
112
- data = window - np.mean(window)
113
-
114
- model = arch_model(data, vol='GARCH', p=1, q=1)
115
- try:
116
- GARCH_fit = model.fit(disp='off')
117
-
118
- mu, omega, alpha, beta = [
119
- GARCH_fit.params["mu"],
120
- GARCH_fit.params["omega"],
121
- GARCH_fit.params["alpha[1]"],
122
- GARCH_fit.params["beta[1]"],
123
- ]
124
- parameters[i, :] = [mu, omega, alpha, beta]
125
-
126
- # Calculate sigma2 and innovations for last observation
127
- if i == 0:
128
- sigma2_tm1 = omega / (1 - alpha - beta)
129
- else:
130
- e_tm1 = data.tolist()[-2]
131
- sigma2_tm1 = omega + alpha * e_tm1 ** 2 + beta * sigma2_tm1
132
-
133
- e_t = data.tolist()[-1]
134
- sigma2_t = omega + alpha * data.tolist()[-2] ** 2 + beta * sigma2_tm1
135
- z_t = e_t / np.sqrt(sigma2_t)
136
- z_process.append(z_t)
137
-
138
- except Exception as e:
139
- logger.warning(f"GARCH fit failed for window {i}: {str(e)}")
140
-
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.")
144
-
145
- avg_params = np.mean(parameters, axis=0)
146
- std_params = np.std(parameters, axis=0)
147
-
148
- return {
149
- 'parameters': parameters,
150
- 'avg_params': avg_params,
151
- 'std_params': std_params,
152
- 'z_process': np.array(z_process)
153
- }
154
-
155
-
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.
160
-
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
166
-
167
- Returns:
168
- Array of simulated log returns
169
- """
170
- parameters = garch_model['parameters']
171
- z_process = garch_model['z_process']
172
-
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()
176
-
177
- mu, omega, alpha, beta = pars
178
- logger.info(f"GARCH parameters: mu={mu:.6f}, omega={omega:.6f}, alpha={alpha:.6f}, beta={beta:.6f}")
179
-
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)
185
-
186
- # Simulate paths
187
- simulated_returns = np.zeros(simulations)
188
-
189
- for i in range(simulations):
190
- if (i + 1) % (simulations // 10) == 0:
191
- logger.info(f"Simulation progress: {i + 1}/{simulations}")
192
-
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
203
-
204
- # Initial values
205
- sigma2 = omega / (1 - alpha - beta)
206
- returns_sum = 0
207
-
208
- # Simulate path
209
- for _ in range(horizon):
210
- # Sample from innovation distribution
211
- z = np.random.choice(z_range, p=z_prob)
212
-
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
217
-
218
- simulated_returns[i] = returns_sum
219
-
220
- return simulated_returns, mu * horizon
221
-
222
-
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]:
229
- """
230
- Generate historical density surface from historical price data.
231
-
232
- Parameters:
233
- model_results: DataFrame with model parameters and maturities
234
- df_hist: DataFrame with historical price data
235
- domain_params: Tuple of (min, max, num_points) for x-domain
236
- return_domain: Domain for x-axis values ('log_moneyness', 'moneyness', 'returns', 'strikes')
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')
247
-
248
- Returns:
249
- Dictionary containing pdf_surface, cdf_surface, x_surface, and moments
250
- """
251
-
252
- # Check if required columns are present
253
- required_columns = ['s', 't', 'r']
254
- missing_columns = [col for col in required_columns if col not in model_results.columns]
255
- if missing_columns:
256
- raise VolyError(f"Required columns missing in model_results: {missing_columns}")
257
-
258
- # Determine granularity from df_hist
259
- if len(df_hist) > 1:
260
- # Calculate minutes between consecutive timestamps
261
- minutes_diff = (df_hist.index[1] - df_hist.index[0]).total_seconds() / 60
262
- minutes_per_period = int(minutes_diff)
263
- else:
264
- raise VolyError("Cannot determine granularity from df_hist.")
265
-
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
281
- log_returns = np.log(df_hist['close'] / df_hist['close'].shift(1)) * 100
282
- log_returns = log_returns.dropna().values
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
-
289
- pdf_surface = {}
290
- cdf_surface = {}
291
- x_surface = {}
292
- all_moments = {}
293
-
294
- # Process each maturity
295
- for i in model_results.index:
296
- # Get parameters for this maturity
297
- s = model_results.loc[i, 's'] # Current spot price
298
- r = model_results.loc[i, 'r'] # Risk-free rate
299
- t = model_results.loc[i, 't'] # Time to maturity in years
300
-
301
- # Get domain grids
302
- LM = np.linspace(domain_params[0], domain_params[1], domain_params[2])
303
- M = np.exp(LM) # Moneyness
304
- R = M - 1 # Returns
305
- K = s / M # Strike prices
306
-
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
391
-
392
- # Ensure density integrates to 1
393
- dx = LM[1] - LM[0]
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
402
-
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)
414
-
415
- # Calculate CDF
416
- cdf = np.cumsum(pdf_lm * dx)
417
- cdf = np.minimum(cdf / cdf[-1], 1.0)
418
-
419
- # Select appropriate domain and calculate moments
420
- if return_domain == 'log_moneyness':
421
- x = LM
422
- pdf = pdf_lm
423
- moments = get_all_moments(x, pdf, model_params if method == 'garch' else None)
424
- elif return_domain == 'moneyness':
425
- x = M
426
- pdf = pdf_m
427
- moments = get_all_moments(x, pdf, model_params if method == 'garch' else None)
428
- elif return_domain == 'returns':
429
- x = R
430
- pdf = pdf_r
431
- moments = get_all_moments(x, pdf, model_params if method == 'garch' else None)
432
- elif return_domain == 'strikes':
433
- x = K
434
- pdf = pdf_k
435
- moments = get_all_moments(x, pdf, model_params if method == 'garch' else None)
436
- else:
437
- raise VolyError(f"Unsupported return_domain: {return_domain}")
438
-
439
- # Store results
440
- pdf_surface[i] = pdf
441
- cdf_surface[i] = cdf
442
- x_surface[i] = x
443
- all_moments[i] = moments
444
-
445
- # Create DataFrame with moments
446
- moments = pd.DataFrame(all_moments).T
447
-
448
- logger.info(f"Historical density calculation complete using {method} method")
449
-
450
- return {
451
- 'pdf_surface': pdf_surface,
452
- 'cdf_surface': cdf_surface,
453
- 'x_surface': x_surface,
454
- 'moments': moments
455
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes