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.
- {voly-0.0.142/src/voly.egg-info → voly-0.0.144}/PKG-INFO +1 -1
- {voly-0.0.142 → voly-0.0.144}/pyproject.toml +2 -2
- {voly-0.0.142 → voly-0.0.144}/src/voly/client.py +5 -22
- voly-0.0.144/src/voly/core/hd.py +635 -0
- {voly-0.0.142 → voly-0.0.144/src/voly.egg-info}/PKG-INFO +1 -1
- voly-0.0.142/src/voly/core/hd.py +0 -455
- {voly-0.0.142 → voly-0.0.144}/LICENSE +0 -0
- {voly-0.0.142 → voly-0.0.144}/README.md +0 -0
- {voly-0.0.142 → voly-0.0.144}/setup.cfg +0 -0
- {voly-0.0.142 → voly-0.0.144}/setup.py +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly/__init__.py +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly/core/__init__.py +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly/core/charts.py +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly/core/data.py +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly/core/fit.py +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly/core/interpolate.py +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly/core/rnd.py +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly/exceptions.py +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly/formulas.py +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly/models.py +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly/utils/__init__.py +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly/utils/logger.py +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly.egg-info/SOURCES.txt +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly.egg-info/dependency_links.txt +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly.egg-info/requires.txt +0 -0
- {voly-0.0.142 → voly-0.0.144}/src/voly.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "voly"
|
|
7
|
-
version = "0.0.
|
|
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.
|
|
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 = '
|
|
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
|
+
}
|
voly-0.0.142/src/voly/core/hd.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|