voly 0.0.138__py3-none-any.whl → 0.0.140__py3-none-any.whl

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