voly 0.0.129__tar.gz → 0.0.130__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.129/src/voly.egg-info → voly-0.0.130}/PKG-INFO +1 -1
  2. {voly-0.0.129 → voly-0.0.130}/pyproject.toml +2 -2
  3. {voly-0.0.129 → voly-0.0.130}/src/voly/client.py +46 -0
  4. voly-0.0.130/src/voly/core/hd.py +554 -0
  5. {voly-0.0.129 → voly-0.0.130/src/voly.egg-info}/PKG-INFO +1 -1
  6. voly-0.0.129/src/voly/core/hd.py +0 -207
  7. {voly-0.0.129 → voly-0.0.130}/LICENSE +0 -0
  8. {voly-0.0.129 → voly-0.0.130}/README.md +0 -0
  9. {voly-0.0.129 → voly-0.0.130}/setup.cfg +0 -0
  10. {voly-0.0.129 → voly-0.0.130}/setup.py +0 -0
  11. {voly-0.0.129 → voly-0.0.130}/src/voly/__init__.py +0 -0
  12. {voly-0.0.129 → voly-0.0.130}/src/voly/core/__init__.py +0 -0
  13. {voly-0.0.129 → voly-0.0.130}/src/voly/core/charts.py +0 -0
  14. {voly-0.0.129 → voly-0.0.130}/src/voly/core/data.py +0 -0
  15. {voly-0.0.129 → voly-0.0.130}/src/voly/core/fit.py +0 -0
  16. {voly-0.0.129 → voly-0.0.130}/src/voly/core/interpolate.py +0 -0
  17. {voly-0.0.129 → voly-0.0.130}/src/voly/core/rnd.py +0 -0
  18. {voly-0.0.129 → voly-0.0.130}/src/voly/exceptions.py +0 -0
  19. {voly-0.0.129 → voly-0.0.130}/src/voly/formulas.py +0 -0
  20. {voly-0.0.129 → voly-0.0.130}/src/voly/models.py +0 -0
  21. {voly-0.0.129 → voly-0.0.130}/src/voly/utils/__init__.py +0 -0
  22. {voly-0.0.129 → voly-0.0.130}/src/voly/utils/logger.py +0 -0
  23. {voly-0.0.129 → voly-0.0.130}/src/voly.egg-info/SOURCES.txt +0 -0
  24. {voly-0.0.129 → voly-0.0.130}/src/voly.egg-info/dependency_links.txt +0 -0
  25. {voly-0.0.129 → voly-0.0.130}/src/voly.egg-info/requires.txt +0 -0
  26. {voly-0.0.129 → voly-0.0.130}/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.129
3
+ Version: 0.0.130
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.129"
7
+ version = "0.0.130"
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.129"
63
+ python_version = "0.0.130"
64
64
  warn_return_any = true
65
65
  warn_unused_configs = true
66
66
  disallow_untyped_defs = true
@@ -368,3 +368,49 @@ class VolyClient:
368
368
  'x_surface': x_surface,
369
369
  'moments': moments
370
370
  }
371
+
372
+ @staticmethod
373
+ def get_garch_hd_surface(model_results: pd.DataFrame,
374
+ df_hist: pd.DataFrame,
375
+ domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000),
376
+ return_domain: str = 'log_moneyness',
377
+ n_fits: int = 400,
378
+ simulations: int = 5000,
379
+ window_length: int = 365,
380
+ variate_parameters: bool = True,
381
+ bandwidth: float = 0.15) -> Dict[str, Any]:
382
+ """
383
+ Generate historical density using GARCH(1,1) model and Monte Carlo simulation.
384
+
385
+ This method implements the approach from SPD Trading, using:
386
+ 1. GARCH(1,1) model fit with sliding windows
387
+ 2. Monte Carlo simulation with innovation resampling
388
+ 3. Kernel density estimation of terminal prices
389
+
390
+ Parameters:
391
+ model_results: DataFrame with model parameters and maturities
392
+ df_hist: DataFrame with historical price data
393
+ domain_params: Tuple of (min, max, num_points) for x-domain
394
+ return_domain: Domain for x-axis values ('log_moneyness', 'moneyness', 'returns', 'strikes')
395
+ n_fits: Number of sliding windows for GARCH parameter estimation
396
+ simulations: Number of Monte Carlo simulations
397
+ window_length: Length of each sliding window for GARCH estimation
398
+ variate_parameters: Whether to vary GARCH parameters between simulations
399
+ bandwidth: Bandwidth for KDE of final density
400
+
401
+ Returns:
402
+ Dictionary containing pdf_surface, cdf_surface, x_surface, and moments
403
+ """
404
+ logger.info("Calculating GARCH historical density surface")
405
+
406
+ return get_garch_hd_surface(
407
+ model_results=model_results,
408
+ df_hist=df_hist,
409
+ domain_params=domain_params,
410
+ return_domain=return_domain,
411
+ n_fits=n_fits,
412
+ simulations=simulations,
413
+ window_length=window_length,
414
+ variate_parameters=variate_parameters,
415
+ bandwidth=bandwidth
416
+ )
@@ -0,0 +1,554 @@
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
+
19
+
20
+ @catch_exception
21
+ def get_historical_data(currency, lookback_days, granularity, exchange_name):
22
+ """
23
+ Fetch historical OHLCV data for a cryptocurrency.
24
+
25
+ Parameters:
26
+ ----------
27
+ currency : str
28
+ The cryptocurrency to fetch data for (e.g., 'BTC', 'ETH').
29
+ lookback_days : str
30
+ The lookback period in days, formatted as '90d', '30d', etc.
31
+ granularity : str
32
+ The time interval for data points (e.g., '15m', '1h', '1d').
33
+ exchange_name : str
34
+ The exchange to fetch data from (default: 'binance').
35
+
36
+ Returns:
37
+ -------
38
+ df_hist : pandas.DataFrame containing the historical price data with OHLCV columns.
39
+ """
40
+
41
+ try:
42
+ # Get the exchange class from ccxt
43
+ exchange_class = getattr(ccxt, exchange_name.lower())
44
+ exchange = exchange_class({'enableRateLimit': True})
45
+ except (AttributeError, TypeError):
46
+ raise VolyError(f"Exchange '{exchange_name}' not found in ccxt. Please check the exchange name.")
47
+
48
+ # Form the trading pair symbol
49
+ symbol = currency + '/USDT'
50
+
51
+ # Convert lookback_days to timestamp
52
+ if lookback_days.endswith('d'):
53
+ days_ago = int(lookback_days[:-1])
54
+ date_start = (dt.datetime.now() - dt.timedelta(days=days_ago)).strftime('%Y-%m-%d %H:%M:%S')
55
+ else:
56
+ raise VolyError("lookback_days should be in format '90d', '30d', etc.")
57
+
58
+ from_ts = exchange.parse8601(date_start)
59
+ ohlcv_list = []
60
+ ohlcv = exchange.fetch_ohlcv(symbol, granularity, since=from_ts, limit=1000)
61
+ ohlcv_list.append(ohlcv)
62
+ while True:
63
+ from_ts = ohlcv[-1][0]
64
+ new_ohlcv = exchange.fetch_ohlcv(symbol, granularity, since=from_ts, limit=1000)
65
+ ohlcv.extend(new_ohlcv)
66
+ if len(new_ohlcv) != 1000:
67
+ break
68
+
69
+ # Convert to DataFrame
70
+ df_hist = pd.DataFrame(ohlcv, columns=['date', 'open', 'high', 'low', 'close', 'volume'])
71
+ df_hist['date'] = pd.to_datetime(df_hist['date'], unit='ms')
72
+ df_hist.set_index('date', inplace=True)
73
+ df_hist = df_hist.sort_index(ascending=True)
74
+
75
+ print(f"Data fetched successfully: {len(df_hist)} rows from {df_hist.index[0]} to {df_hist.index[-1]}")
76
+
77
+ return df_hist
78
+
79
+
80
+ def generate_lm_points(min_lm, max_lm):
81
+ if min_lm >= max_lm:
82
+ raise ValueError("min_lm must be less than max_lm")
83
+
84
+ max_transformed = np.sqrt(max_lm) if max_lm > 0 else 0
85
+ min_transformed = -np.sqrt(-min_lm) if min_lm < 0 else 0
86
+
87
+ transformed_points = np.arange(min_transformed, max_transformed + 0.05, 0.05)
88
+ lm_points = np.sign(transformed_points) * transformed_points ** 2
89
+
90
+ lm_points = np.unique(np.round(lm_points, decimals=2))
91
+ lm_points = sorted(lm_points)
92
+
93
+ return lm_points
94
+
95
+
96
+ @catch_exception
97
+ def get_hd_surface(model_results: pd.DataFrame,
98
+ df_hist: pd.DataFrame,
99
+ domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000),
100
+ return_domain: str = 'log_moneyness') -> Tuple[
101
+ Dict[str, np.ndarray], Dict[str, np.ndarray], Dict[str, np.ndarray], pd.DataFrame]:
102
+
103
+ # Check if required columns are present
104
+ required_columns = ['s', 't', 'r']
105
+ missing_columns = [col for col in required_columns if col not in model_results.columns]
106
+ if missing_columns:
107
+ raise VolyError(f"Required columns missing in model_results: {missing_columns}")
108
+
109
+ # Determine granularity from df_hist
110
+ if len(df_hist) > 1:
111
+ # Calculate minutes between consecutive timestamps
112
+ minutes_diff = (df_hist.index[1] - df_hist.index[0]).total_seconds() / 60
113
+ minutes_per_period = int(minutes_diff)
114
+ else:
115
+ VolyError("Cannot determine granularity from df_hist.")
116
+ return
117
+
118
+ pdf_surface = {}
119
+ cdf_surface = {}
120
+ x_surface = {}
121
+ all_moments = {}
122
+
123
+ # Process each maturity
124
+ for i in model_results.index:
125
+ # Get parameters for this maturity
126
+ s = model_results.loc[i, 's']
127
+ r = model_results.loc[i, 'r']
128
+ t = model_results.loc[i, 't']
129
+
130
+ LM = get_domain(domain_params, s, r, None, t, 'log_moneyness')
131
+ M = get_domain(domain_params, s, r, None, t, 'moneyness')
132
+ R = get_domain(domain_params, s, r, None, t, 'returns')
133
+ K = get_domain(domain_params, s, r, None, t, 'log_moneyness')
134
+
135
+ # Filter historical data for this maturity's lookback period
136
+ start_date = dt.datetime.now() - dt.timedelta(days=int(t * 365.25))
137
+ maturity_hist = df_hist[df_hist.index >= start_date].copy()
138
+
139
+ if len(maturity_hist) < 10:
140
+ logger.warning(f"Not enough historical data for maturity {i}, skipping.")
141
+ continue
142
+
143
+ # Calculate the number of periods that match the time to expiry
144
+ n_periods = int(t * 365.25 * 24 * 60 / minutes_per_period)
145
+
146
+ # Compute returns and weights
147
+ maturity_hist['returns'] = np.log(maturity_hist['close'] / maturity_hist['close'].shift(1)) * np.sqrt(n_periods)
148
+ maturity_hist = maturity_hist.dropna()
149
+
150
+ returns = maturity_hist['returns'].values
151
+
152
+ if len(returns) < 10:
153
+ logger.warning(f"Not enough valid returns for maturity {i}, skipping.")
154
+ continue
155
+
156
+ mu_scaled = returns.mean()
157
+ sigma_scaled = returns.std()
158
+
159
+ # Correct Girsanov adjustment to match the risk-neutral mean
160
+ expected_risk_neutral_mean = (r - 0.5 * sigma_scaled ** 2) * np.sqrt(t)
161
+ adjustment = mu_scaled - expected_risk_neutral_mean
162
+ adj_returns = returns - adjustment # Shift the mean to risk-neutral
163
+
164
+ # Create HD and Normalize
165
+ f = stats.gaussian_kde(adj_returns, bw_method='silverman')
166
+ hd_lm = f(LM)
167
+ hd_lm = np.maximum(hd_lm, 0)
168
+ total_area = np.trapz(hd_lm, LM)
169
+ if total_area > 0:
170
+ pdf_lm = hd_lm / total_area
171
+ else:
172
+ logger.warning(f"Total area is zero for maturity {i}, skipping.")
173
+ continue
174
+
175
+ pdf_k = pdf_lm / K
176
+ pdf_m = pdf_k * s
177
+ pdf_r = pdf_lm / (1 + R)
178
+
179
+ cdf = np.concatenate(([0], np.cumsum(pdf_lm[:-1] * np.diff(LM))))
180
+
181
+ if return_domain == 'log_moneyness':
182
+ x = LM
183
+ pdf = pdf_lm
184
+ moments = get_all_moments(x, pdf)
185
+ elif return_domain == 'moneyness':
186
+ x = M
187
+ pdf = pdf_m
188
+ moments = get_all_moments(x, pdf)
189
+ elif return_domain == 'returns':
190
+ x = R
191
+ pdf = pdf_r
192
+ moments = get_all_moments(x, pdf)
193
+ elif return_domain == 'strikes':
194
+ x = K
195
+ pdf = pdf_k
196
+ moments = get_all_moments(x, pdf)
197
+
198
+ # Store results
199
+ pdf_surface[i] = pdf
200
+ cdf_surface[i] = cdf
201
+ x_surface[i] = x
202
+ all_moments[i] = moments
203
+
204
+ # Create a DataFrame with moments using the same index as model_results
205
+ moments = pd.DataFrame(all_moments).T
206
+
207
+ return pdf_surface, cdf_surface, x_surface, moments
208
+
209
+
210
+
211
+ import numpy as np
212
+ import pandas as pd
213
+ from scipy import stats
214
+ from typing import Dict, List, Tuple, Optional, Union, Any
215
+ from voly.utils.logger import logger, catch_exception
216
+ from voly.exceptions import VolyError
217
+
218
+
219
+ class GARCHModel:
220
+ """
221
+ GARCH(1,1) model for volatility modeling and simulation.
222
+
223
+ Fits a GARCH(1,1) model to historical returns and simulates future paths
224
+ for historical density estimation.
225
+ """
226
+
227
+ def __init__(self,
228
+ data: np.ndarray,
229
+ data_name: str,
230
+ n_fits: int = 400,
231
+ window_length: int = 365,
232
+ z_h: float = 0.1):
233
+ """
234
+ Initialize the GARCH model.
235
+
236
+ Args:
237
+ data: Array of log returns
238
+ data_name: Identifier for the dataset
239
+ n_fits: Number of sliding windows to use for parameter estimation
240
+ window_length: Length of each sliding window
241
+ z_h: Bandwidth factor for kernel density estimation of innovations
242
+ """
243
+ self.data = data
244
+ self.data_name = data_name
245
+ self.n_fits = n_fits
246
+ self.window_length = window_length
247
+ self.z_h = z_h
248
+
249
+ # Parameters to be created during fitting and simulation
250
+ self.parameters = None
251
+ self.e_process = None
252
+ self.z_process = None
253
+ self.sigma2_process = None
254
+ self.z_dens = None
255
+ self.simulated_log_returns = None
256
+ self.simulated_tau_mu = None
257
+
258
+ def fit(self):
259
+ """
260
+ Fit GARCH(1,1) model to historical data using sliding windows.
261
+
262
+ For each window, estimates parameters (ω, α, β) and extracts innovations.
263
+ """
264
+ from arch import arch_model
265
+
266
+ if len(self.data) < self.window_length + self.n_fits:
267
+ raise VolyError(
268
+ f"Not enough data points. Need at least {self.window_length + self.n_fits}, got {len(self.data)}")
269
+
270
+ start = self.window_length + self.n_fits
271
+ end = self.n_fits
272
+
273
+ parameters = np.zeros((self.n_fits, 4))
274
+ z_process = []
275
+ e_process = []
276
+ sigma2_process = []
277
+
278
+ logger.info(f"Fitting GARCH model with {self.n_fits} windows...")
279
+
280
+ for i in range(self.n_fits):
281
+ window = self.data[end - i - 1:start - i - 1]
282
+ data = window - np.mean(window)
283
+
284
+ model = arch_model(data, vol='GARCH', p=1, q=1)
285
+ GARCH_fit = model.fit(disp='off')
286
+
287
+ mu, omega, alpha, beta = [
288
+ GARCH_fit.params["mu"],
289
+ GARCH_fit.params["omega"],
290
+ GARCH_fit.params["alpha[1]"],
291
+ GARCH_fit.params["beta[1]"],
292
+ ]
293
+ parameters[i, :] = [mu, omega, alpha, beta]
294
+
295
+ if i == 0:
296
+ sigma2_tm1 = omega / (1 - alpha - beta)
297
+ else:
298
+ sigma2_tm1 = sigma2_process[-1]
299
+
300
+ e_t = data.tolist()[-1] # last observed log-return
301
+ e_tm1 = data.tolist()[-2] # previous observed log-return
302
+ sigma2_t = omega + alpha * e_tm1 ** 2 + beta * sigma2_tm1
303
+ z_t = e_t / np.sqrt(sigma2_t)
304
+
305
+ e_process.append(e_t)
306
+ z_process.append(z_t)
307
+ sigma2_process.append(sigma2_t)
308
+
309
+ self.parameters = parameters
310
+ self.e_process = e_process
311
+ self.z_process = z_process
312
+ self.sigma2_process = sigma2_process
313
+
314
+ # Kernel density estimation for innovations
315
+ z_dens_x = np.linspace(min(self.z_process), max(self.z_process), 500)
316
+ h_dyn = self.z_h * (np.max(z_process) - np.min(z_process))
317
+
318
+ # Use scipy's gaussian_kde for innovation distribution
319
+ kde = stats.gaussian_kde(np.array(z_process), bw_method=h_dyn)
320
+ z_dens_y = kde(z_dens_x)
321
+
322
+ self.z_dens = {"x": z_dens_x, "y": z_dens_y}
323
+
324
+ logger.info("GARCH model fitting complete")
325
+
326
+ def _GARCH_simulate(self, pars, horizon):
327
+ """
328
+ Simulate a single GARCH path to specified horizon.
329
+
330
+ Args:
331
+ pars: Tuple of (mu, omega, alpha, beta)
332
+ horizon: Number of steps to simulate
333
+
334
+ Returns:
335
+ Tuple of (sigma2_process, e_process) of simulated values
336
+ """
337
+ mu, omega, alpha, beta = pars
338
+ burnin = horizon * 2
339
+ sigma2 = [omega / (1 - alpha - beta)]
340
+ e = [self.data.tolist()[-1] - mu] # last observed log-return mean adjusted
341
+
342
+ # Convert density to probability weights
343
+ weights = self.z_dens["y"] / np.sum(self.z_dens["y"])
344
+
345
+ for _ in range(horizon + burnin):
346
+ sigma2_tp1 = omega + alpha * e[-1] ** 2 + beta * sigma2[-1]
347
+ # Sample from the estimated innovation distribution
348
+ z_tp1 = np.random.choice(self.z_dens["x"], 1, p=weights)[0]
349
+ e_tp1 = z_tp1 * np.sqrt(sigma2_tp1)
350
+ sigma2.append(sigma2_tp1)
351
+ e.append(e_tp1)
352
+
353
+ return sigma2[-horizon:], e[-horizon:]
354
+
355
+ def _variate_pars(self, pars, bounds):
356
+ """
357
+ Add variation to GARCH parameters for simulation uncertainty.
358
+
359
+ Args:
360
+ pars: Array of mean parameters [mu, omega, alpha, beta]
361
+ bounds: Standard deviation bounds for parameters
362
+
363
+ Returns:
364
+ Array of slightly varied parameters
365
+ """
366
+ new_pars = []
367
+ for i, (par, bound) in enumerate(zip(pars, bounds)):
368
+ var = bound ** 2 / self.n_fits
369
+ new_par = np.random.normal(par, var, 1)[0]
370
+ if (new_par <= 0) and (i >= 1):
371
+ new_par = 0.01
372
+ new_pars.append(new_par)
373
+ return new_pars
374
+
375
+ def simulate_paths(self, horizon, simulations=5000, variate_parameters=True):
376
+ """
377
+ Simulate multiple GARCH paths using Monte Carlo.
378
+
379
+ Args:
380
+ horizon: Number of steps to simulate (days)
381
+ simulations: Number of Monte Carlo simulations
382
+ variate_parameters: Whether to add variation to GARCH parameters
383
+
384
+ Returns:
385
+ Tuple of (simulated_log_returns, simulated_tau_mu)
386
+ """
387
+ if self.parameters is None:
388
+ self.fit()
389
+
390
+ pars = np.mean(self.parameters, axis=0).tolist() # [mu, omega, alpha, beta]
391
+ bounds = np.std(self.parameters, axis=0).tolist()
392
+
393
+ logger.info(f"Simulating {simulations} GARCH paths for {horizon} steps...")
394
+ logger.info(f"GARCH parameters: mu={pars[0]:.6f}, omega={pars[1]:.6f}, alpha={pars[2]:.6f}, beta={pars[3]:.6f}")
395
+
396
+ np.random.seed(42) # For reproducibility
397
+
398
+ new_pars = pars.copy() # start with unchanged parameters
399
+ simulated_log_returns = np.zeros(simulations)
400
+ simulated_tau_mu = np.zeros(simulations)
401
+
402
+ for i in range(simulations):
403
+ if ((i + 1) % (simulations // 10) == 0):
404
+ logger.info(f"Simulation progress: {i + 1}/{simulations}")
405
+
406
+ if ((i + 1) % (simulations // 20) == 0) and variate_parameters:
407
+ new_pars = self._variate_pars(pars, bounds)
408
+
409
+ sigma2, e = self._GARCH_simulate(new_pars, horizon)
410
+ simulated_log_returns[i] = np.sum(e) # Sum log returns over horizon
411
+ simulated_tau_mu[i] = horizon * pars[0] # Total drift
412
+
413
+ self.simulated_log_returns = simulated_log_returns
414
+ self.simulated_tau_mu = simulated_tau_mu
415
+
416
+ return simulated_log_returns, simulated_tau_mu
417
+
418
+
419
+ @catch_exception
420
+ def get_garch_hd_surface(model_results: pd.DataFrame,
421
+ df_hist: pd.DataFrame,
422
+ domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000),
423
+ return_domain: str = 'log_moneyness',
424
+ n_fits: int = 400,
425
+ simulations: int = 5000,
426
+ window_length: int = 365,
427
+ variate_parameters: bool = True,
428
+ bandwidth: float = 0.15) -> Dict[str, Any]:
429
+ """
430
+ Generate historical density surface using GARCH(1,1) model and Monte Carlo simulation.
431
+
432
+ Parameters:
433
+ model_results: DataFrame with model parameters and maturities
434
+ df_hist: DataFrame with historical price data (must have 'close' column)
435
+ domain_params: Tuple of (min, max, num_points) for x-domain
436
+ return_domain: Domain for x-axis values ('log_moneyness', 'moneyness', 'returns', 'strikes')
437
+ n_fits: Number of sliding windows for GARCH parameter estimation
438
+ simulations: Number of Monte Carlo simulations
439
+ window_length: Length of each sliding window for GARCH estimation
440
+ variate_parameters: Whether to vary GARCH parameters between simulations
441
+ bandwidth: Bandwidth for kernel density estimation of final density
442
+
443
+ Returns:
444
+ Dictionary containing pdf_surface, cdf_surface, x_surface, and moments
445
+ """
446
+ # Check if required columns are present
447
+ required_columns = ['s', 't', 'r']
448
+ missing_columns = [col for col in required_columns if col not in model_results.columns]
449
+ if missing_columns:
450
+ raise VolyError(f"Required columns missing in model_results: {missing_columns}")
451
+
452
+ # Calculate log returns from price history
453
+ log_returns = np.log(df_hist['close'] / df_hist['close'].shift(1)) * 100
454
+ log_returns = log_returns.dropna().values
455
+
456
+ pdf_surface = {}
457
+ cdf_surface = {}
458
+ x_surface = {}
459
+ all_moments = {}
460
+
461
+ # Process each maturity
462
+ for i in model_results.index:
463
+ # Get parameters for this maturity
464
+ s = model_results.loc[i, 's'] # Current spot price
465
+ r = model_results.loc[i, 'r'] # Risk-free rate
466
+ t = model_results.loc[i, 't'] # Time to maturity in years
467
+
468
+ tau_day = int(t * 365.25) # Convert years to days
469
+
470
+ logger.info(f"Processing GARCH HD for maturity {i} (t={t:.4f} years, {tau_day} days)")
471
+
472
+ # Initialize GARCH model
473
+ garch_model = GARCHModel(
474
+ data=log_returns,
475
+ data_name=str(i),
476
+ n_fits=min(n_fits, len(log_returns) // 3), # Ensure we have enough data
477
+ window_length=min(window_length, len(log_returns) // 3),
478
+ z_h=0.1
479
+ )
480
+
481
+ # Simulate paths
482
+ simulated_log_returns, simulated_tau_mu = garch_model.simulate_paths(
483
+ horizon=tau_day,
484
+ simulations=simulations,
485
+ variate_parameters=variate_parameters
486
+ )
487
+
488
+ # Convert to terminal prices
489
+ simulated_prices = s * np.exp(simulated_log_returns / 100 + simulated_tau_mu / 100)
490
+
491
+ # Convert to moneyness domain
492
+ simulated_moneyness = s / simulated_prices
493
+
494
+ # Get x domain grid based on requested return_domain
495
+ LM = np.linspace(domain_params[0], domain_params[1], domain_params[2])
496
+ M = np.exp(LM) # Moneyness
497
+ R = M - 1 # Returns
498
+ K = s / M # Strike prices
499
+
500
+ # Perform kernel density estimation in moneyness domain
501
+ kde = stats.gaussian_kde(simulated_moneyness, bw_method=bandwidth)
502
+ pdf_m = kde(M)
503
+
504
+ # Ensure density integrates to 1
505
+ dx = LM[1] - LM[0]
506
+ total_area = np.sum(pdf_m * dx)
507
+ pdf_m = pdf_m / total_area
508
+
509
+ # Transform to other domains as needed
510
+ pdf_lm = pdf_m * M # Transform to log-moneyness domain
511
+ pdf_k = pdf_lm / K # Transform to strike domain
512
+ pdf_r = pdf_lm / (1 + R) # Transform to returns domain
513
+
514
+ # Calculate CDF
515
+ cdf = np.cumsum(pdf_lm) * dx
516
+ cdf = np.minimum(cdf / cdf[-1], 1.0) # Normalize and cap at 1.0
517
+
518
+ # Select appropriate domain for return
519
+ if return_domain == 'log_moneyness':
520
+ x = LM
521
+ pdf = pdf_lm
522
+ moments = get_all_moments(x, pdf)
523
+ elif return_domain == 'moneyness':
524
+ x = M
525
+ pdf = pdf_m
526
+ moments = get_all_moments(x, pdf)
527
+ elif return_domain == 'returns':
528
+ x = R
529
+ pdf = pdf_r
530
+ moments = get_all_moments(x, pdf)
531
+ elif return_domain == 'strikes':
532
+ x = K
533
+ pdf = pdf_k
534
+ moments = get_all_moments(x, pdf)
535
+ else:
536
+ raise VolyError(f"Unsupported return_domain: {return_domain}")
537
+
538
+ # Store results
539
+ pdf_surface[i] = pdf
540
+ cdf_surface[i] = cdf
541
+ x_surface[i] = x
542
+ all_moments[i] = moments
543
+
544
+ # Create DataFrame with moments
545
+ moments = pd.DataFrame(all_moments).T
546
+
547
+ logger.info("GARCH historical density calculation complete")
548
+
549
+ return {
550
+ 'pdf_surface': pdf_surface,
551
+ 'cdf_surface': cdf_surface,
552
+ 'x_surface': x_surface,
553
+ 'moments': moments
554
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voly
3
- Version: 0.0.129
3
+ Version: 0.0.130
4
4
  Summary: Options & volatility research package
5
5
  Author-email: Manu de Cara <manu.de.cara@gmail.com>
6
6
  License: MIT
@@ -1,207 +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
-
19
-
20
- @catch_exception
21
- def get_historical_data(currency, lookback_days, granularity, exchange_name):
22
- """
23
- Fetch historical OHLCV data for a cryptocurrency.
24
-
25
- Parameters:
26
- ----------
27
- currency : str
28
- The cryptocurrency to fetch data for (e.g., 'BTC', 'ETH').
29
- lookback_days : str
30
- The lookback period in days, formatted as '90d', '30d', etc.
31
- granularity : str
32
- The time interval for data points (e.g., '15m', '1h', '1d').
33
- exchange_name : str
34
- The exchange to fetch data from (default: 'binance').
35
-
36
- Returns:
37
- -------
38
- df_hist : pandas.DataFrame containing the historical price data with OHLCV columns.
39
- """
40
-
41
- try:
42
- # Get the exchange class from ccxt
43
- exchange_class = getattr(ccxt, exchange_name.lower())
44
- exchange = exchange_class({'enableRateLimit': True})
45
- except (AttributeError, TypeError):
46
- raise VolyError(f"Exchange '{exchange_name}' not found in ccxt. Please check the exchange name.")
47
-
48
- # Form the trading pair symbol
49
- symbol = currency + '/USDT'
50
-
51
- # Convert lookback_days to timestamp
52
- if lookback_days.endswith('d'):
53
- days_ago = int(lookback_days[:-1])
54
- date_start = (dt.datetime.now() - dt.timedelta(days=days_ago)).strftime('%Y-%m-%d %H:%M:%S')
55
- else:
56
- raise VolyError("lookback_days should be in format '90d', '30d', etc.")
57
-
58
- from_ts = exchange.parse8601(date_start)
59
- ohlcv_list = []
60
- ohlcv = exchange.fetch_ohlcv(symbol, granularity, since=from_ts, limit=1000)
61
- ohlcv_list.append(ohlcv)
62
- while True:
63
- from_ts = ohlcv[-1][0]
64
- new_ohlcv = exchange.fetch_ohlcv(symbol, granularity, since=from_ts, limit=1000)
65
- ohlcv.extend(new_ohlcv)
66
- if len(new_ohlcv) != 1000:
67
- break
68
-
69
- # Convert to DataFrame
70
- df_hist = pd.DataFrame(ohlcv, columns=['date', 'open', 'high', 'low', 'close', 'volume'])
71
- df_hist['date'] = pd.to_datetime(df_hist['date'], unit='ms')
72
- df_hist.set_index('date', inplace=True)
73
- df_hist = df_hist.sort_index(ascending=True)
74
-
75
- print(f"Data fetched successfully: {len(df_hist)} rows from {df_hist.index[0]} to {df_hist.index[-1]}")
76
-
77
- return df_hist
78
-
79
-
80
- def generate_lm_points(min_lm, max_lm):
81
- if min_lm >= max_lm:
82
- raise ValueError("min_lm must be less than max_lm")
83
-
84
- max_transformed = np.sqrt(max_lm) if max_lm > 0 else 0
85
- min_transformed = -np.sqrt(-min_lm) if min_lm < 0 else 0
86
-
87
- transformed_points = np.arange(min_transformed, max_transformed + 0.05, 0.05)
88
- lm_points = np.sign(transformed_points) * transformed_points ** 2
89
-
90
- lm_points = np.unique(np.round(lm_points, decimals=2))
91
- lm_points = sorted(lm_points)
92
-
93
- return lm_points
94
-
95
-
96
- @catch_exception
97
- def get_hd_surface(model_results: pd.DataFrame,
98
- df_hist: pd.DataFrame,
99
- domain_params: Tuple[float, float, int] = (-1.5, 1.5, 1000),
100
- return_domain: str = 'log_moneyness') -> Tuple[
101
- Dict[str, np.ndarray], Dict[str, np.ndarray], Dict[str, np.ndarray], pd.DataFrame]:
102
-
103
- # Check if required columns are present
104
- required_columns = ['s', 't', 'r']
105
- missing_columns = [col for col in required_columns if col not in model_results.columns]
106
- if missing_columns:
107
- raise VolyError(f"Required columns missing in model_results: {missing_columns}")
108
-
109
- # Determine granularity from df_hist
110
- if len(df_hist) > 1:
111
- # Calculate minutes between consecutive timestamps
112
- minutes_diff = (df_hist.index[1] - df_hist.index[0]).total_seconds() / 60
113
- minutes_per_period = int(minutes_diff)
114
- else:
115
- VolyError("Cannot determine granularity from df_hist.")
116
- return
117
-
118
- pdf_surface = {}
119
- cdf_surface = {}
120
- x_surface = {}
121
- all_moments = {}
122
-
123
- # Process each maturity
124
- for i in model_results.index:
125
- # Get parameters for this maturity
126
- s = model_results.loc[i, 's']
127
- r = model_results.loc[i, 'r']
128
- t = model_results.loc[i, 't']
129
-
130
- LM = get_domain(domain_params, s, r, None, t, 'log_moneyness')
131
- M = get_domain(domain_params, s, r, None, t, 'moneyness')
132
- R = get_domain(domain_params, s, r, None, t, 'returns')
133
- K = get_domain(domain_params, s, r, None, t, 'log_moneyness')
134
-
135
- # Filter historical data for this maturity's lookback period
136
- start_date = dt.datetime.now() - dt.timedelta(days=int(t * 365.25))
137
- maturity_hist = df_hist[df_hist.index >= start_date].copy()
138
-
139
- if len(maturity_hist) < 10:
140
- logger.warning(f"Not enough historical data for maturity {i}, skipping.")
141
- continue
142
-
143
- # Calculate the number of periods that match the time to expiry
144
- n_periods = int(t * 365.25 * 24 * 60 / minutes_per_period)
145
-
146
- # Compute returns and weights
147
- maturity_hist['returns'] = np.log(maturity_hist['close'] / maturity_hist['close'].shift(1)) * np.sqrt(n_periods)
148
- maturity_hist = maturity_hist.dropna()
149
-
150
- returns = maturity_hist['returns'].values
151
-
152
- if len(returns) < 10:
153
- logger.warning(f"Not enough valid returns for maturity {i}, skipping.")
154
- continue
155
-
156
- mu_scaled = returns.mean()
157
- sigma_scaled = returns.std()
158
-
159
- # Correct Girsanov adjustment to match the risk-neutral mean
160
- expected_risk_neutral_mean = (r - 0.5 * sigma_scaled ** 2) * np.sqrt(t)
161
- adjustment = mu_scaled - expected_risk_neutral_mean
162
- adj_returns = returns - adjustment # Shift the mean to risk-neutral
163
-
164
- # Create HD and Normalize
165
- f = stats.gaussian_kde(adj_returns, bw_method='silverman')
166
- hd_lm = f(LM)
167
- hd_lm = np.maximum(hd_lm, 0)
168
- total_area = np.trapz(hd_lm, LM)
169
- if total_area > 0:
170
- pdf_lm = hd_lm / total_area
171
- else:
172
- logger.warning(f"Total area is zero for maturity {i}, skipping.")
173
- continue
174
-
175
- pdf_k = pdf_lm / K
176
- pdf_m = pdf_k * s
177
- pdf_r = pdf_lm / (1 + R)
178
-
179
- cdf = np.concatenate(([0], np.cumsum(pdf_lm[:-1] * np.diff(LM))))
180
-
181
- if return_domain == 'log_moneyness':
182
- x = LM
183
- pdf = pdf_lm
184
- moments = get_all_moments(x, pdf)
185
- elif return_domain == 'moneyness':
186
- x = M
187
- pdf = pdf_m
188
- moments = get_all_moments(x, pdf)
189
- elif return_domain == 'returns':
190
- x = R
191
- pdf = pdf_r
192
- moments = get_all_moments(x, pdf)
193
- elif return_domain == 'strikes':
194
- x = K
195
- pdf = pdf_k
196
- moments = get_all_moments(x, pdf)
197
-
198
- # Store results
199
- pdf_surface[i] = pdf
200
- cdf_surface[i] = cdf
201
- x_surface[i] = x
202
- all_moments[i] = moments
203
-
204
- # Create a DataFrame with moments using the same index as model_results
205
- moments = pd.DataFrame(all_moments).T
206
-
207
- return pdf_surface, cdf_surface, x_surface, moments
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