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