jump-diffusion-estimation 0.2.0__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.
- jump_diffusion/__init__.py +40 -0
- jump_diffusion/distributions/__init__.py +22 -0
- jump_diffusion/distributions/base.py +152 -0
- jump_diffusion/distributions/kou.py +104 -0
- jump_diffusion/distributions/normal.py +73 -0
- jump_diffusion/distributions/sged.py +113 -0
- jump_diffusion/distributions/skew_normal.py +97 -0
- jump_diffusion/distributions/student_t.py +86 -0
- jump_diffusion/estimation/__init__.py +19 -0
- jump_diffusion/estimation/base_estimator.py +62 -0
- jump_diffusion/estimation/maximum_likelihood.py +1239 -0
- jump_diffusion/models/__init__.py +14 -0
- jump_diffusion/models/base_model.py +93 -0
- jump_diffusion/models/jump_diffusion.py +168 -0
- jump_diffusion/scripts/__init__.py +3 -0
- jump_diffusion/scripts/benchmark.py +10 -0
- jump_diffusion/scripts/validate.py +74 -0
- jump_diffusion/simulation/__init__.py +14 -0
- jump_diffusion/simulation/base_simulator.py +51 -0
- jump_diffusion/simulation/jump_diffusion_simulator.py +264 -0
- jump_diffusion/validation/__init__.py +14 -0
- jump_diffusion/validation/distribution_comparison.py +270 -0
- jump_diffusion/validation/monte_carlo.py +349 -0
- jump_diffusion_estimation-0.2.0.dist-info/METADATA +241 -0
- jump_diffusion_estimation-0.2.0.dist-info/RECORD +29 -0
- jump_diffusion_estimation-0.2.0.dist-info/WHEEL +5 -0
- jump_diffusion_estimation-0.2.0.dist-info/entry_points.txt +3 -0
- jump_diffusion_estimation-0.2.0.dist-info/licenses/LICENSE +25 -0
- jump_diffusion_estimation-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1239 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Maximum Likelihood Estimator for Jump-Diffusion Models
|
|
3
|
+
|
|
4
|
+
This module estimates JumpDiffusionModel parameters by maximizing the
|
|
5
|
+
mixture likelihood implemented on the model itself.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import warnings
|
|
11
|
+
from scipy import stats
|
|
12
|
+
from scipy.optimize import Bounds, differential_evolution, minimize
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
|
+
from .base_estimator import BaseEstimator
|
|
15
|
+
from ..models.jump_diffusion import JumpDiffusionModel
|
|
16
|
+
from ..distributions import JumpDistribution
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JumpDiffusionEstimator(BaseEstimator):
|
|
20
|
+
"""
|
|
21
|
+
Maximum likelihood estimator for jump-diffusion models.
|
|
22
|
+
|
|
23
|
+
This estimator handles jump-diffusion processes with a pluggable
|
|
24
|
+
jump-size distribution (skew-normal by default) using mixture
|
|
25
|
+
likelihood functions. The input ``data`` must be a one-dimensional
|
|
26
|
+
array of increments.
|
|
27
|
+
|
|
28
|
+
References:
|
|
29
|
+
- Ardia, D., Ospina, J. D., & Giraldo, N. D. (2011). Jump-diffusion
|
|
30
|
+
calibration using differential evolution. Wilmott, 2011(55), 76-79.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
data: np.ndarray,
|
|
36
|
+
dt: float,
|
|
37
|
+
jump_distribution: Optional[JumpDistribution] = None,
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
Initialize the estimator.
|
|
41
|
+
|
|
42
|
+
Parameters:
|
|
43
|
+
-----------
|
|
44
|
+
data : np.ndarray
|
|
45
|
+
One-dimensional array of observed increments. If you have
|
|
46
|
+
a path, compute ``np.diff(path)`` first.
|
|
47
|
+
dt : float
|
|
48
|
+
Time step size between consecutive increments.
|
|
49
|
+
jump_distribution : JumpDistribution, optional
|
|
50
|
+
Distribution assumed for the jump sizes. Defaults to
|
|
51
|
+
:class:`SkewNormalJump`.
|
|
52
|
+
"""
|
|
53
|
+
# Accept only 1D arrays of increments
|
|
54
|
+
if data.ndim == 1:
|
|
55
|
+
self.increments = data
|
|
56
|
+
else:
|
|
57
|
+
msg = "data must be a one-dimensional array of increments"
|
|
58
|
+
raise ValueError(msg)
|
|
59
|
+
|
|
60
|
+
super().__init__(self.increments, dt)
|
|
61
|
+
|
|
62
|
+
# Model used to evaluate the mixture likelihood at trial parameters
|
|
63
|
+
self._model = JumpDiffusionModel(jump_distribution=jump_distribution)
|
|
64
|
+
self._param_names = (
|
|
65
|
+
"mu",
|
|
66
|
+
"sigma",
|
|
67
|
+
"jump_prob",
|
|
68
|
+
) + self._model.jump_distribution.param_names
|
|
69
|
+
|
|
70
|
+
# Calculate basic statistics
|
|
71
|
+
self.n_obs = len(self.increments)
|
|
72
|
+
self.mean_increment = float(np.mean(self.increments))
|
|
73
|
+
self.std_increment = float(np.std(self.increments))
|
|
74
|
+
self.skewness = float(stats.skew(self.increments))
|
|
75
|
+
self.kurtosis = float(stats.kurtosis(self.increments))
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def param_names(self) -> Tuple[str, ...]:
|
|
79
|
+
"""
|
|
80
|
+
Ordered parameter names for this estimator.
|
|
81
|
+
|
|
82
|
+
Always ``("mu", "sigma", "jump_prob", *jump_distribution.param_names)``.
|
|
83
|
+
This is the order used by every parameter vector, standard-error and
|
|
84
|
+
confidence-interval dictionary the estimator produces, so it is the
|
|
85
|
+
natural key for iterating over results.
|
|
86
|
+
"""
|
|
87
|
+
return self._param_names
|
|
88
|
+
|
|
89
|
+
def log_likelihood(self, params: np.ndarray) -> float:
|
|
90
|
+
"""
|
|
91
|
+
Calculate negative log-likelihood for optimization.
|
|
92
|
+
|
|
93
|
+
Parameters:
|
|
94
|
+
-----------
|
|
95
|
+
params : np.ndarray
|
|
96
|
+
Parameter vector, ordered as ``self._param_names``:
|
|
97
|
+
``[mu, sigma, jump_prob, *jump_distribution.param_names]``.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
--------
|
|
101
|
+
float
|
|
102
|
+
Negative log-likelihood value
|
|
103
|
+
"""
|
|
104
|
+
sigma, jump_prob = params[1], params[2]
|
|
105
|
+
|
|
106
|
+
# Parameter constraints shared by every jump distribution
|
|
107
|
+
if sigma <= 0:
|
|
108
|
+
return np.inf
|
|
109
|
+
if jump_prob < 0 or jump_prob > 1:
|
|
110
|
+
return np.inf
|
|
111
|
+
|
|
112
|
+
self._model.update_parameters(**dict(zip(self._param_names, params)))
|
|
113
|
+
value = self._model.log_likelihood(self.increments, self.dt)
|
|
114
|
+
# Penalize non-numeric likelihood values so that population-based
|
|
115
|
+
# optimizers (differential evolution) simply discard the offending
|
|
116
|
+
# candidates instead of corrupting the ranking -- same device as in
|
|
117
|
+
# Ospina Arango (2009), where NaN evaluations are mapped to a large
|
|
118
|
+
# positive constant.
|
|
119
|
+
if not np.isfinite(value):
|
|
120
|
+
return np.inf
|
|
121
|
+
return -value
|
|
122
|
+
|
|
123
|
+
def _get_initial_guess(self) -> np.ndarray:
|
|
124
|
+
"""Generate intelligent initial parameter guess."""
|
|
125
|
+
initial_mu = self.mean_increment / self.dt
|
|
126
|
+
initial_sigma = self.std_increment / np.sqrt(self.dt)
|
|
127
|
+
initial_jump_prob = 0.1
|
|
128
|
+
|
|
129
|
+
jump_guess = self._model.jump_distribution.initial_guess(
|
|
130
|
+
self.mean_increment, self.std_increment, self.skewness
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
values = [initial_mu, initial_sigma, initial_jump_prob]
|
|
134
|
+
values += [
|
|
135
|
+
jump_guess[name] for name in self._model.jump_distribution.param_names
|
|
136
|
+
]
|
|
137
|
+
return np.array(values)
|
|
138
|
+
|
|
139
|
+
def _finite_bounds(self) -> List[Tuple[float, float]]:
|
|
140
|
+
"""
|
|
141
|
+
Data-driven finite search box for differential evolution.
|
|
142
|
+
|
|
143
|
+
Differential evolution samples its initial population uniformly
|
|
144
|
+
over a bounded region, so the open-ended bounds used by L-BFGS-B
|
|
145
|
+
(e.g. ``mu`` unbounded, ``sigma < inf``) must be replaced by finite
|
|
146
|
+
ones. This mirrors the :math:`\\theta_L`/:math:`\\theta_U` limits
|
|
147
|
+
in Ospina Arango (2009), whose applied result is that even *very*
|
|
148
|
+
generous boxes (little prior knowledge of the solution) still lead
|
|
149
|
+
differential evolution to the optimum where gradient methods fail.
|
|
150
|
+
Bounds that are already finite are kept as-is; only ``None`` ends
|
|
151
|
+
are replaced with wide data-driven limits.
|
|
152
|
+
"""
|
|
153
|
+
initial_mu = self.mean_increment / self.dt
|
|
154
|
+
initial_sigma = self.std_increment / np.sqrt(self.dt)
|
|
155
|
+
mu_halfwidth = max(1.0, 10 * abs(initial_mu), 10 * initial_sigma)
|
|
156
|
+
# Jump parameters live on the scale of individual increments; a
|
|
157
|
+
# single jump 20x the typical increment size is a generous cap
|
|
158
|
+
# (and std_increment is itself inflated by any jumps in the data).
|
|
159
|
+
jump_cap = max(20 * self.std_increment, 1e-3)
|
|
160
|
+
|
|
161
|
+
finite = [
|
|
162
|
+
(initial_mu - mu_halfwidth, initial_mu + mu_halfwidth), # mu
|
|
163
|
+
(1e-6, 10 * initial_sigma), # sigma
|
|
164
|
+
(1e-6, 1 - 1e-6), # jump_prob
|
|
165
|
+
]
|
|
166
|
+
jump_bounds = self._model.jump_distribution.param_bounds()
|
|
167
|
+
for name in self._model.jump_distribution.param_names:
|
|
168
|
+
low, high = jump_bounds[name]
|
|
169
|
+
finite.append(
|
|
170
|
+
(
|
|
171
|
+
-jump_cap if low is None else low,
|
|
172
|
+
jump_cap if high is None else high,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return finite
|
|
176
|
+
|
|
177
|
+
def estimate(
|
|
178
|
+
self,
|
|
179
|
+
initial_guess: Optional[np.ndarray] = None,
|
|
180
|
+
method: str = "L-BFGS-B",
|
|
181
|
+
bounds: Optional[List[Tuple[float, float]]] = None,
|
|
182
|
+
**kwargs,
|
|
183
|
+
) -> Dict[str, Any]:
|
|
184
|
+
r"""
|
|
185
|
+
Estimate parameters using maximum likelihood.
|
|
186
|
+
|
|
187
|
+
Parameters:
|
|
188
|
+
-----------
|
|
189
|
+
initial_guess : np.array, optional
|
|
190
|
+
Initial parameter values. Optional for every method: gradient
|
|
191
|
+
methods fall back to a moment-based heuristic guess, while
|
|
192
|
+
``"differential_evolution"`` does not need one at all (its
|
|
193
|
+
initial population is sampled over ``bounds``; when a guess
|
|
194
|
+
*is* supplied it merely seeds one population member).
|
|
195
|
+
method : str
|
|
196
|
+
Optimization method. Any ``scipy.optimize.minimize`` method
|
|
197
|
+
name (default ``"L-BFGS-B"``), or ``"differential_evolution"``
|
|
198
|
+
for the global, population-based optimizer from
|
|
199
|
+
``scipy.optimize.differential_evolution``. Differential
|
|
200
|
+
evolution is markedly more robust to poor prior knowledge on
|
|
201
|
+
this mixture likelihood -- the applied finding of Ospina
|
|
202
|
+
Arango (2009), where L-BFGS-B stalled at box boundaries and
|
|
203
|
+
simulated annealing diverged, while DE (rand/1, population 70,
|
|
204
|
+
400 generations in R's ``DEoptim``) recovered the true
|
|
205
|
+
parameters even under very wide bounds. The trade-off is
|
|
206
|
+
computational cost (thousands of likelihood evaluations).
|
|
207
|
+
For the differential evolution algorithm, see Storn & Price (1997).
|
|
208
|
+
bounds : list of (low, high) tuples, optional
|
|
209
|
+
Optimization box, one pair per parameter in the order
|
|
210
|
+
``[mu, sigma, jump_prob, *jump_distribution.param_names]``.
|
|
211
|
+
Defaults to the model's own bounds for gradient methods, or a
|
|
212
|
+
wide data-driven finite box (see ``_finite_bounds``) for
|
|
213
|
+
differential evolution, which requires every bound finite.
|
|
214
|
+
\*\*kwargs : dict
|
|
215
|
+
Additional arguments for the optimizer: merged into
|
|
216
|
+
``options`` for ``scipy.optimize.minimize``, passed directly
|
|
217
|
+
to ``scipy.optimize.differential_evolution`` (e.g. ``seed=42``
|
|
218
|
+
for reproducibility, ``maxiter``, ``popsize``, ``workers``).
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
--------
|
|
222
|
+
dict
|
|
223
|
+
Estimation results
|
|
224
|
+
"""
|
|
225
|
+
if method.lower() in ("differential_evolution", "de"):
|
|
226
|
+
result = self._optimize_differential_evolution(
|
|
227
|
+
initial_guess, bounds, **kwargs
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
result = self._optimize_minimize(initial_guess, method, bounds, **kwargs)
|
|
231
|
+
|
|
232
|
+
# Process results
|
|
233
|
+
parameters = dict(zip(self._param_names, result.x))
|
|
234
|
+
|
|
235
|
+
results: Dict[str, Any] = {
|
|
236
|
+
"parameters": parameters,
|
|
237
|
+
"log_likelihood": -result.fun,
|
|
238
|
+
"aic": 2 * len(result.x) + 2 * result.fun,
|
|
239
|
+
"bic": len(result.x) * np.log(self.n_obs) + 2 * result.fun,
|
|
240
|
+
"optimization_result": result,
|
|
241
|
+
"convergence": result.success,
|
|
242
|
+
"data_stats": {
|
|
243
|
+
"n_obs": self.n_obs,
|
|
244
|
+
"mean_increment": self.mean_increment,
|
|
245
|
+
"std_increment": self.std_increment,
|
|
246
|
+
"skewness": self.skewness,
|
|
247
|
+
"kurtosis": self.kurtosis,
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
self.results = results
|
|
252
|
+
self.fitted = True
|
|
253
|
+
return results
|
|
254
|
+
|
|
255
|
+
def _optimize_minimize(
|
|
256
|
+
self,
|
|
257
|
+
initial_guess: Optional[np.ndarray],
|
|
258
|
+
method: str,
|
|
259
|
+
bounds: Optional[List[Tuple[float, float]]],
|
|
260
|
+
**kwargs,
|
|
261
|
+
) -> Any:
|
|
262
|
+
"""Local optimization via ``scipy.optimize.minimize``."""
|
|
263
|
+
if initial_guess is None:
|
|
264
|
+
initial_guess = self._get_initial_guess()
|
|
265
|
+
initial_guess = np.asarray(initial_guess, dtype=float)
|
|
266
|
+
|
|
267
|
+
if bounds is None:
|
|
268
|
+
bounds = self._model.get_parameter_bounds()
|
|
269
|
+
|
|
270
|
+
with warnings.catch_warnings():
|
|
271
|
+
warnings.simplefilter("ignore")
|
|
272
|
+
return minimize(
|
|
273
|
+
fun=self.log_likelihood, # type: ignore
|
|
274
|
+
x0=initial_guess,
|
|
275
|
+
method=method,
|
|
276
|
+
bounds=bounds,
|
|
277
|
+
options={"maxiter": 1000, "ftol": 1e-9, **kwargs},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def _optimize_differential_evolution(
|
|
281
|
+
self,
|
|
282
|
+
initial_guess: Optional[np.ndarray],
|
|
283
|
+
bounds: Optional[List[Tuple[float, float]]],
|
|
284
|
+
**kwargs,
|
|
285
|
+
) -> Any:
|
|
286
|
+
"""
|
|
287
|
+
Global optimization via ``scipy.optimize.differential_evolution``.
|
|
288
|
+
|
|
289
|
+
Defaults port the configuration from Ospina Arango (2009), which
|
|
290
|
+
used R's ``DEoptim``: strategy rand/1 with binomial crossover
|
|
291
|
+
(scipy's ``"rand1bin"``) and 400 generations. ``DEoptim`` used a
|
|
292
|
+
fixed population of 70 individuals for the 7-parameter SGED model;
|
|
293
|
+
scipy sizes the population as ``popsize * n_params``, so
|
|
294
|
+
``popsize=10`` reproduces exactly that for the SGED case and
|
|
295
|
+
scales proportionally for other jump distributions. The thesis
|
|
296
|
+
also observes convergence well before the generation cap and
|
|
297
|
+
suggests a convergence-based stopping criterion -- scipy's ``tol``
|
|
298
|
+
(default 0.01) provides exactly that, so runs typically stop
|
|
299
|
+
early. Any of these can be overridden via ``**kwargs``.
|
|
300
|
+
"""
|
|
301
|
+
if bounds is None:
|
|
302
|
+
bounds = self._finite_bounds()
|
|
303
|
+
lows, highs = zip(*bounds)
|
|
304
|
+
de_bounds = Bounds(np.asarray(lows, float), np.asarray(highs, float))
|
|
305
|
+
|
|
306
|
+
de_kwargs: Dict[str, Any] = {
|
|
307
|
+
"strategy": "rand1bin",
|
|
308
|
+
"maxiter": 400,
|
|
309
|
+
"popsize": 10,
|
|
310
|
+
}
|
|
311
|
+
de_kwargs.update(kwargs)
|
|
312
|
+
if initial_guess is not None:
|
|
313
|
+
de_kwargs["x0"] = np.asarray(initial_guess, dtype=float)
|
|
314
|
+
|
|
315
|
+
with warnings.catch_warnings():
|
|
316
|
+
warnings.simplefilter("ignore")
|
|
317
|
+
return differential_evolution(
|
|
318
|
+
func=self.log_likelihood,
|
|
319
|
+
bounds=de_bounds,
|
|
320
|
+
**de_kwargs,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def estimate_standard_errors(
|
|
324
|
+
self,
|
|
325
|
+
n_points: int = 7,
|
|
326
|
+
confidence_level: float = 0.95,
|
|
327
|
+
grid_width_factor: float = 3.0,
|
|
328
|
+
) -> Dict[str, Any]:
|
|
329
|
+
"""
|
|
330
|
+
Estimate parameter standard errors and confidence intervals using
|
|
331
|
+
likelihood profiling based on Wilks' theorem (Wilks, 1938).
|
|
332
|
+
|
|
333
|
+
Parameters:
|
|
334
|
+
-----------
|
|
335
|
+
n_points : int
|
|
336
|
+
Number of points to evaluate in the grid for profiling.
|
|
337
|
+
confidence_level : float
|
|
338
|
+
Confidence level for the intervals (e.g. 0.95 for 95% confidence).
|
|
339
|
+
grid_width_factor : float
|
|
340
|
+
Factor determining the width of the grid around the MLE.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
--------
|
|
344
|
+
dict
|
|
345
|
+
Dictionary containing standard errors and confidence intervals.
|
|
346
|
+
"""
|
|
347
|
+
if not self.fitted or self.results is None:
|
|
348
|
+
raise ValueError("Model must be fitted first.")
|
|
349
|
+
|
|
350
|
+
opt_params = self.results["parameters"]
|
|
351
|
+
opt_param_vals = np.array([opt_params[name] for name in self._param_names])
|
|
352
|
+
opt_log_lik = self.results["log_likelihood"]
|
|
353
|
+
|
|
354
|
+
standard_errors = {}
|
|
355
|
+
confidence_intervals = {}
|
|
356
|
+
profile_data = {}
|
|
357
|
+
|
|
358
|
+
# Critical chi-squared value for the profile likelihood threshold
|
|
359
|
+
# (Wilks' theorem)
|
|
360
|
+
critical_val = stats.chi2.ppf(confidence_level, df=1)
|
|
361
|
+
threshold = opt_log_lik - 0.5 * critical_val
|
|
362
|
+
|
|
363
|
+
# Get optimization bounds
|
|
364
|
+
bounds = self._model.get_parameter_bounds()
|
|
365
|
+
|
|
366
|
+
for idx, param_name in enumerate(self._param_names):
|
|
367
|
+
mle_val = opt_param_vals[idx]
|
|
368
|
+
|
|
369
|
+
# Initialize profile dictionary with MLE
|
|
370
|
+
profile_dict = {mle_val: opt_log_lik}
|
|
371
|
+
low_bound, high_bound = bounds[idx]
|
|
372
|
+
|
|
373
|
+
# Optimization closure
|
|
374
|
+
def optimize_profile(val):
|
|
375
|
+
def neg_log_lik_profile(free_params):
|
|
376
|
+
full_params = np.zeros(len(self._param_names))
|
|
377
|
+
full_params[idx] = val
|
|
378
|
+
free_idx = 0
|
|
379
|
+
for j in range(len(self._param_names)):
|
|
380
|
+
if j != idx:
|
|
381
|
+
full_params[j] = free_params[free_idx]
|
|
382
|
+
free_idx += 1
|
|
383
|
+
return self.log_likelihood(full_params)
|
|
384
|
+
|
|
385
|
+
free_init = [
|
|
386
|
+
opt_param_vals[j] for j in range(len(self._param_names)) if j != idx
|
|
387
|
+
]
|
|
388
|
+
free_bounds = [
|
|
389
|
+
bounds[j] for j in range(len(self._param_names)) if j != idx
|
|
390
|
+
]
|
|
391
|
+
|
|
392
|
+
with warnings.catch_warnings():
|
|
393
|
+
warnings.simplefilter("ignore")
|
|
394
|
+
res = minimize(
|
|
395
|
+
fun=neg_log_lik_profile,
|
|
396
|
+
x0=free_init,
|
|
397
|
+
method="L-BFGS-B",
|
|
398
|
+
bounds=free_bounds,
|
|
399
|
+
options={"maxiter": 100, "ftol": 1e-6},
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if res.success and np.isfinite(res.fun):
|
|
403
|
+
return -res.fun
|
|
404
|
+
return -np.inf
|
|
405
|
+
|
|
406
|
+
# Adaptive search parameters
|
|
407
|
+
initial_step = max(0.01 * abs(mle_val), 1e-4)
|
|
408
|
+
max_step_factor = 2.0
|
|
409
|
+
max_search_steps = 15
|
|
410
|
+
|
|
411
|
+
# Outward search right
|
|
412
|
+
curr_val = mle_val
|
|
413
|
+
step = initial_step
|
|
414
|
+
for _ in range(max_search_steps):
|
|
415
|
+
next_val = curr_val + step
|
|
416
|
+
if high_bound is not None and next_val >= high_bound:
|
|
417
|
+
next_val = high_bound - 1e-5
|
|
418
|
+
val_lik = optimize_profile(next_val)
|
|
419
|
+
profile_dict[next_val] = val_lik
|
|
420
|
+
break
|
|
421
|
+
val_lik = optimize_profile(next_val)
|
|
422
|
+
profile_dict[next_val] = val_lik
|
|
423
|
+
|
|
424
|
+
if val_lik < threshold:
|
|
425
|
+
break
|
|
426
|
+
|
|
427
|
+
curr_val = next_val
|
|
428
|
+
step *= max_step_factor
|
|
429
|
+
|
|
430
|
+
# Outward search left
|
|
431
|
+
curr_val = mle_val
|
|
432
|
+
step = initial_step
|
|
433
|
+
for _ in range(max_search_steps):
|
|
434
|
+
next_val = curr_val - step
|
|
435
|
+
if low_bound is not None and next_val <= low_bound:
|
|
436
|
+
next_val = low_bound + 1e-5
|
|
437
|
+
val_lik = optimize_profile(next_val)
|
|
438
|
+
profile_dict[next_val] = val_lik
|
|
439
|
+
break
|
|
440
|
+
val_lik = optimize_profile(next_val)
|
|
441
|
+
profile_dict[next_val] = val_lik
|
|
442
|
+
|
|
443
|
+
if val_lik < threshold:
|
|
444
|
+
break
|
|
445
|
+
|
|
446
|
+
curr_val = next_val
|
|
447
|
+
step *= max_step_factor
|
|
448
|
+
|
|
449
|
+
# Sort evaluated points
|
|
450
|
+
sorted_vals = sorted(list(profile_dict.keys()))
|
|
451
|
+
|
|
452
|
+
# Infill points if we have fewer than n_points
|
|
453
|
+
while len(sorted_vals) < n_points:
|
|
454
|
+
# Find the gap with the largest absolute distance
|
|
455
|
+
max_gap_idx = -1
|
|
456
|
+
max_gap_dist = -1
|
|
457
|
+
for i in range(len(sorted_vals) - 1):
|
|
458
|
+
v1, v2 = sorted_vals[i], sorted_vals[i + 1]
|
|
459
|
+
dist = v2 - v1
|
|
460
|
+
if dist > max_gap_dist:
|
|
461
|
+
max_gap_dist = dist
|
|
462
|
+
max_gap_idx = i
|
|
463
|
+
|
|
464
|
+
if max_gap_idx == -1:
|
|
465
|
+
break
|
|
466
|
+
|
|
467
|
+
v1 = sorted_vals[max_gap_idx]
|
|
468
|
+
v2 = sorted_vals[max_gap_idx + 1]
|
|
469
|
+
mid_val = (v1 + v2) / 2.0
|
|
470
|
+
val_lik = optimize_profile(mid_val)
|
|
471
|
+
profile_dict[mid_val] = val_lik
|
|
472
|
+
sorted_vals = sorted(list(profile_dict.keys()))
|
|
473
|
+
|
|
474
|
+
grid = np.array(sorted_vals)
|
|
475
|
+
profile_vals = np.array([profile_dict[v] for v in grid])
|
|
476
|
+
|
|
477
|
+
valid_mask = np.isfinite(profile_vals)
|
|
478
|
+
grid_valid = grid[valid_mask]
|
|
479
|
+
profile_valid = profile_vals[valid_mask]
|
|
480
|
+
|
|
481
|
+
if len(grid_valid) > 0:
|
|
482
|
+
min_val = grid_valid.min()
|
|
483
|
+
max_val = grid_valid.max()
|
|
484
|
+
else:
|
|
485
|
+
min_val = low_bound if low_bound is not None else mle_val - 1.0
|
|
486
|
+
max_val = high_bound if high_bound is not None else mle_val + 1.0
|
|
487
|
+
|
|
488
|
+
se_val = np.nan
|
|
489
|
+
ci_low, ci_high = np.nan, np.nan
|
|
490
|
+
|
|
491
|
+
if len(profile_valid) >= 3:
|
|
492
|
+
# Fit parabola: y = a*x^2 + b*x + c
|
|
493
|
+
# Use up to 5 points with the highest log-likelihood to ensure
|
|
494
|
+
# we are near the peak and avoid arbitrary absolute
|
|
495
|
+
# log-likelihood thresholds.
|
|
496
|
+
n_fit_points = min(5, len(profile_valid))
|
|
497
|
+
top_indices = np.argsort(profile_valid)[-n_fit_points:]
|
|
498
|
+
p_coefs = np.polyfit(
|
|
499
|
+
grid_valid[top_indices], profile_valid[top_indices], 2
|
|
500
|
+
)
|
|
501
|
+
a = p_coefs[0]
|
|
502
|
+
if a < 0:
|
|
503
|
+
se_val = np.sqrt(-1.0 / (2.0 * a))
|
|
504
|
+
|
|
505
|
+
# Find roots where L_p(x) == threshold using linear interpolation
|
|
506
|
+
left_mask = grid_valid <= mle_val
|
|
507
|
+
grid_left = grid_valid[left_mask]
|
|
508
|
+
prof_left = profile_valid[left_mask]
|
|
509
|
+
if len(prof_left) >= 2 and prof_left[0] < threshold:
|
|
510
|
+
ci_low = float(np.interp(threshold, prof_left, grid_left))
|
|
511
|
+
else:
|
|
512
|
+
ci_low = float(min_val)
|
|
513
|
+
|
|
514
|
+
right_mask = grid_valid >= mle_val
|
|
515
|
+
grid_right = grid_valid[right_mask]
|
|
516
|
+
prof_right = profile_valid[right_mask]
|
|
517
|
+
if len(prof_right) >= 2 and prof_right[-1] < threshold:
|
|
518
|
+
ci_high = float(
|
|
519
|
+
np.interp(threshold, prof_right[::-1], grid_right[::-1])
|
|
520
|
+
)
|
|
521
|
+
else:
|
|
522
|
+
ci_high = float(max_val)
|
|
523
|
+
|
|
524
|
+
# Fallback: If parabolic fit failed (a >= 0, causing se_val=nan),
|
|
525
|
+
# estimate SE from the profile likelihood confidence interval width.
|
|
526
|
+
if not np.isfinite(se_val):
|
|
527
|
+
if (
|
|
528
|
+
np.isfinite(ci_low)
|
|
529
|
+
and np.isfinite(ci_high)
|
|
530
|
+
and ci_low < ci_high
|
|
531
|
+
):
|
|
532
|
+
# Ensure the bounds actually crossed the threshold
|
|
533
|
+
# (are inside the grid)
|
|
534
|
+
if ci_low > min_val + 1e-6 and ci_high < max_val - 1e-6:
|
|
535
|
+
z_val = np.sqrt(critical_val)
|
|
536
|
+
dist = (ci_high - ci_low) / 2.0
|
|
537
|
+
se_val = float(dist / z_val)
|
|
538
|
+
|
|
539
|
+
standard_errors[param_name] = se_val
|
|
540
|
+
confidence_intervals[param_name] = (ci_low, ci_high)
|
|
541
|
+
profile_data[param_name] = {
|
|
542
|
+
"grid": grid.tolist(),
|
|
543
|
+
"values": profile_vals.tolist(),
|
|
544
|
+
"threshold": threshold,
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
self.results["standard_errors"] = standard_errors
|
|
548
|
+
self.results["confidence_intervals"] = confidence_intervals
|
|
549
|
+
self.results["profile_data"] = profile_data
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
"standard_errors": standard_errors,
|
|
553
|
+
"confidence_intervals": confidence_intervals,
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
def _observed_information_matrix(
|
|
557
|
+
self,
|
|
558
|
+
param_vals: np.ndarray,
|
|
559
|
+
rel_step: float = 1e-4,
|
|
560
|
+
) -> np.ndarray:
|
|
561
|
+
"""
|
|
562
|
+
Numerically approximate the observed Fisher information matrix.
|
|
563
|
+
|
|
564
|
+
The observed information is the Hessian of the *negative*
|
|
565
|
+
log-likelihood evaluated at the estimate. Since
|
|
566
|
+
:meth:`log_likelihood` already returns the negative log-likelihood,
|
|
567
|
+
its Hessian is exactly the observed information. It is built here by
|
|
568
|
+
central finite differences: second partial derivatives on the
|
|
569
|
+
diagonal and mixed second partials off the diagonal.
|
|
570
|
+
|
|
571
|
+
Following Efron & Hinkley (1978), the *observed* information (the
|
|
572
|
+
Hessian at the estimate) is preferred over the *expected* Fisher
|
|
573
|
+
information for constructing Wald standard errors, being both easier
|
|
574
|
+
to compute for this mixture likelihood and, in their analysis, a
|
|
575
|
+
more relevant conditioning quantity.
|
|
576
|
+
|
|
577
|
+
Parameters:
|
|
578
|
+
-----------
|
|
579
|
+
param_vals : np.ndarray
|
|
580
|
+
Parameter vector at which to evaluate the Hessian, ordered as
|
|
581
|
+
``self._param_names``. Normally the MLE.
|
|
582
|
+
rel_step : float
|
|
583
|
+
Relative step size for the finite differences. The per-parameter
|
|
584
|
+
absolute step is ``rel_step * max(|value|, 1e-2)``, further
|
|
585
|
+
shrunk so that every perturbed point stays strictly inside the
|
|
586
|
+
parameter bounds (``log_likelihood`` returns ``+inf`` outside
|
|
587
|
+
them, which would corrupt the Hessian).
|
|
588
|
+
|
|
589
|
+
Returns:
|
|
590
|
+
--------
|
|
591
|
+
np.ndarray
|
|
592
|
+
The ``(k, k)`` symmetric observed information matrix, where
|
|
593
|
+
``k = len(self._param_names)``. Entries that could not be
|
|
594
|
+
evaluated (e.g. a parameter pinned to a bound) are ``nan``.
|
|
595
|
+
"""
|
|
596
|
+
n = len(param_vals)
|
|
597
|
+
bounds = self._model.get_parameter_bounds()
|
|
598
|
+
|
|
599
|
+
# Per-parameter finite-difference steps, kept inside the bounds.
|
|
600
|
+
steps = np.empty(n)
|
|
601
|
+
for i in range(n):
|
|
602
|
+
scale = max(abs(param_vals[i]), 1e-2)
|
|
603
|
+
h = rel_step * scale
|
|
604
|
+
low, high = bounds[i]
|
|
605
|
+
if low is not None:
|
|
606
|
+
h = min(h, 0.25 * (param_vals[i] - low))
|
|
607
|
+
if high is not None:
|
|
608
|
+
h = min(h, 0.25 * (high - param_vals[i]))
|
|
609
|
+
steps[i] = h if h > 0 else np.nan
|
|
610
|
+
|
|
611
|
+
f0 = self.log_likelihood(param_vals)
|
|
612
|
+
hessian = np.full((n, n), np.nan)
|
|
613
|
+
|
|
614
|
+
def f(shift: np.ndarray) -> float:
|
|
615
|
+
return self.log_likelihood(param_vals + shift)
|
|
616
|
+
|
|
617
|
+
# Diagonal: second partial derivatives
|
|
618
|
+
for i in range(n):
|
|
619
|
+
hi = steps[i]
|
|
620
|
+
if not np.isfinite(hi):
|
|
621
|
+
continue
|
|
622
|
+
ei = np.zeros(n)
|
|
623
|
+
ei[i] = hi
|
|
624
|
+
f_plus, f_minus = f(ei), f(-ei)
|
|
625
|
+
if np.isfinite(f_plus) and np.isfinite(f_minus) and np.isfinite(f0):
|
|
626
|
+
hessian[i, i] = (f_plus - 2.0 * f0 + f_minus) / (hi * hi)
|
|
627
|
+
|
|
628
|
+
# Off-diagonal: mixed second partial derivatives
|
|
629
|
+
for i in range(n):
|
|
630
|
+
hi = steps[i]
|
|
631
|
+
if not np.isfinite(hi):
|
|
632
|
+
continue
|
|
633
|
+
for j in range(i + 1, n):
|
|
634
|
+
hj = steps[j]
|
|
635
|
+
if not np.isfinite(hj):
|
|
636
|
+
continue
|
|
637
|
+
ei = np.zeros(n)
|
|
638
|
+
ei[i] = hi
|
|
639
|
+
ej = np.zeros(n)
|
|
640
|
+
ej[j] = hj
|
|
641
|
+
f_pp, f_pm = f(ei + ej), f(ei - ej)
|
|
642
|
+
f_mp, f_mm = f(-ei + ej), f(-ei - ej)
|
|
643
|
+
if all(np.isfinite(v) for v in (f_pp, f_pm, f_mp, f_mm)):
|
|
644
|
+
val = (f_pp - f_pm - f_mp + f_mm) / (4.0 * hi * hj)
|
|
645
|
+
hessian[i, j] = val
|
|
646
|
+
hessian[j, i] = val
|
|
647
|
+
|
|
648
|
+
return hessian
|
|
649
|
+
|
|
650
|
+
def estimate_wald_standard_errors(
|
|
651
|
+
self,
|
|
652
|
+
confidence_level: float = 0.95,
|
|
653
|
+
) -> Dict[str, Any]:
|
|
654
|
+
"""
|
|
655
|
+
Estimate standard errors and confidence intervals from the observed
|
|
656
|
+
Fisher information (the Wald method).
|
|
657
|
+
|
|
658
|
+
This is the classical large-sample alternative to the profile
|
|
659
|
+
likelihood intervals of :meth:`estimate_standard_errors`. It inverts
|
|
660
|
+
the observed information matrix (see
|
|
661
|
+
:meth:`_observed_information_matrix`) to obtain the asymptotic
|
|
662
|
+
covariance of the maximum likelihood estimator, takes the square
|
|
663
|
+
root of its diagonal as the standard errors, and forms symmetric
|
|
664
|
+
intervals ``estimate +/- z * SE`` with ``z`` the standard normal
|
|
665
|
+
quantile.
|
|
666
|
+
|
|
667
|
+
Wald intervals are cheap (a single Hessian, no re-optimization) but
|
|
668
|
+
are symmetric by construction and are known to under-cover in finite
|
|
669
|
+
samples for parameters near a boundary -- notably the jump
|
|
670
|
+
probability as it approaches zero. Providing both Wald and profile
|
|
671
|
+
intervals is deliberate: comparing their coverage across jump
|
|
672
|
+
distributions is one of the intended research uses of this library.
|
|
673
|
+
|
|
674
|
+
Parameters:
|
|
675
|
+
-----------
|
|
676
|
+
confidence_level : float
|
|
677
|
+
Confidence level for the intervals (e.g. 0.95 for 95%).
|
|
678
|
+
|
|
679
|
+
Returns:
|
|
680
|
+
--------
|
|
681
|
+
dict
|
|
682
|
+
Dictionary with ``standard_errors`` and
|
|
683
|
+
``confidence_intervals``, each keyed by parameter name. A
|
|
684
|
+
parameter's standard error is ``nan`` when the observed
|
|
685
|
+
information could not be evaluated or inverted to a positive
|
|
686
|
+
variance (e.g. an estimate pinned to a bound, or a singular
|
|
687
|
+
Hessian); its interval is then ``(nan, nan)``. The results are
|
|
688
|
+
also stored on ``self.results`` under ``wald_standard_errors``,
|
|
689
|
+
``wald_confidence_intervals`` and ``observed_information``.
|
|
690
|
+
|
|
691
|
+
Raises:
|
|
692
|
+
-------
|
|
693
|
+
ValueError
|
|
694
|
+
If the model has not been fitted yet.
|
|
695
|
+
"""
|
|
696
|
+
if not self.fitted or self.results is None:
|
|
697
|
+
raise ValueError("Model must be fitted first.")
|
|
698
|
+
results = self.results
|
|
699
|
+
|
|
700
|
+
opt_params = results["parameters"]
|
|
701
|
+
param_vals = np.array([opt_params[name] for name in self._param_names])
|
|
702
|
+
|
|
703
|
+
information = self._observed_information_matrix(param_vals)
|
|
704
|
+
|
|
705
|
+
alpha = 1.0 - confidence_level
|
|
706
|
+
z = float(stats.norm.ppf(1.0 - alpha / 2.0))
|
|
707
|
+
|
|
708
|
+
# Invert the observed information to get the asymptotic covariance
|
|
709
|
+
# matrix, guarding against a non-finite or singular Hessian.
|
|
710
|
+
covariance: Optional[np.ndarray]
|
|
711
|
+
if np.all(np.isfinite(information)):
|
|
712
|
+
try:
|
|
713
|
+
covariance = np.linalg.inv(information)
|
|
714
|
+
except np.linalg.LinAlgError:
|
|
715
|
+
covariance = None
|
|
716
|
+
else:
|
|
717
|
+
covariance = None
|
|
718
|
+
|
|
719
|
+
standard_errors: Dict[str, float] = {}
|
|
720
|
+
confidence_intervals: Dict[str, Tuple[float, float]] = {}
|
|
721
|
+
|
|
722
|
+
for idx, name in enumerate(self._param_names):
|
|
723
|
+
se_val = np.nan
|
|
724
|
+
if covariance is not None:
|
|
725
|
+
var = covariance[idx, idx]
|
|
726
|
+
if np.isfinite(var) and var > 0:
|
|
727
|
+
se_val = float(np.sqrt(var))
|
|
728
|
+
|
|
729
|
+
if np.isfinite(se_val):
|
|
730
|
+
est = float(param_vals[idx])
|
|
731
|
+
ci_low, ci_high = est - z * se_val, est + z * se_val
|
|
732
|
+
else:
|
|
733
|
+
ci_low, ci_high = np.nan, np.nan
|
|
734
|
+
|
|
735
|
+
standard_errors[name] = se_val
|
|
736
|
+
confidence_intervals[name] = (ci_low, ci_high)
|
|
737
|
+
|
|
738
|
+
results["wald_standard_errors"] = standard_errors
|
|
739
|
+
results["wald_confidence_intervals"] = confidence_intervals
|
|
740
|
+
results["observed_information"] = information
|
|
741
|
+
|
|
742
|
+
return {
|
|
743
|
+
"standard_errors": standard_errors,
|
|
744
|
+
"confidence_intervals": confidence_intervals,
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
def estimate_bootstrap_standard_errors(
|
|
748
|
+
self,
|
|
749
|
+
n_bootstrap: int = 200,
|
|
750
|
+
confidence_level: float = 0.95,
|
|
751
|
+
seed: Optional[int] = None,
|
|
752
|
+
**estimate_kwargs: Any,
|
|
753
|
+
) -> Dict[str, Any]:
|
|
754
|
+
"""
|
|
755
|
+
Estimate standard errors and confidence intervals by parametric
|
|
756
|
+
bootstrap.
|
|
757
|
+
|
|
758
|
+
This is the third inference route in the library, alongside the
|
|
759
|
+
profile likelihood intervals of :meth:`estimate_standard_errors` and
|
|
760
|
+
the Wald intervals of :meth:`estimate_wald_standard_errors`. It makes
|
|
761
|
+
no asymptotic-normality or regularity assumption, so it is the most
|
|
762
|
+
trustworthy of the three near a boundary (e.g. the jump probability
|
|
763
|
+
approaching zero) -- at the cost of re-fitting the model
|
|
764
|
+
``n_bootstrap`` times.
|
|
765
|
+
|
|
766
|
+
The procedure is the textbook parametric bootstrap: treat the fitted
|
|
767
|
+
parameters as if they were the truth, simulate ``n_bootstrap`` fresh
|
|
768
|
+
datasets of the same length from that model, re-estimate the
|
|
769
|
+
parameters on each, and read the sampling variability directly off
|
|
770
|
+
the resulting replicate estimates. Standard errors are their
|
|
771
|
+
(ddof=1) standard deviation and confidence intervals are percentile
|
|
772
|
+
intervals.
|
|
773
|
+
|
|
774
|
+
Parameters:
|
|
775
|
+
-----------
|
|
776
|
+
n_bootstrap : int
|
|
777
|
+
Number of bootstrap replicates (model re-fits). More replicates
|
|
778
|
+
reduce Monte Carlo error in the standard errors and intervals
|
|
779
|
+
but scale the cost linearly.
|
|
780
|
+
confidence_level : float
|
|
781
|
+
Confidence level for the percentile intervals (e.g. 0.95).
|
|
782
|
+
seed : int, optional
|
|
783
|
+
Base seed for reproducibility. When given, replicate ``b`` is
|
|
784
|
+
simulated with seed ``seed + b``; when ``None`` the replicates
|
|
785
|
+
draw from NumPy's global random state and are not reproducible.
|
|
786
|
+
\\*\\*estimate_kwargs : dict
|
|
787
|
+
Forwarded to :meth:`estimate` for each replicate re-fit (e.g.
|
|
788
|
+
``method="differential_evolution"``). Defaults to the same
|
|
789
|
+
gradient-based fit used elsewhere.
|
|
790
|
+
|
|
791
|
+
Returns:
|
|
792
|
+
--------
|
|
793
|
+
dict
|
|
794
|
+
Dictionary with ``standard_errors`` and
|
|
795
|
+
``confidence_intervals`` (each keyed by parameter name), plus
|
|
796
|
+
``n_successful`` -- the number of replicates whose re-fit
|
|
797
|
+
converged and contributed to the estimates. Non-converged
|
|
798
|
+
replicates are discarded. If fewer than two replicates succeed,
|
|
799
|
+
all standard errors and interval endpoints are ``nan``. The
|
|
800
|
+
results are also stored on ``self.results`` under
|
|
801
|
+
``bootstrap_standard_errors``, ``bootstrap_confidence_intervals``
|
|
802
|
+
and ``bootstrap_estimates`` (the raw replicate matrix).
|
|
803
|
+
|
|
804
|
+
Raises:
|
|
805
|
+
-------
|
|
806
|
+
ValueError
|
|
807
|
+
If the model has not been fitted yet.
|
|
808
|
+
"""
|
|
809
|
+
if not self.fitted or self.results is None:
|
|
810
|
+
raise ValueError("Model must be fitted first.")
|
|
811
|
+
results = self.results
|
|
812
|
+
|
|
813
|
+
fitted_params = results["parameters"]
|
|
814
|
+
jump_dist = self._model.jump_distribution
|
|
815
|
+
|
|
816
|
+
# Data-generating model: the fitted parameters treated as truth.
|
|
817
|
+
model = JumpDiffusionModel(jump_distribution=jump_dist, **fitted_params)
|
|
818
|
+
n_steps = self.n_obs
|
|
819
|
+
horizon = n_steps * self.dt
|
|
820
|
+
|
|
821
|
+
replicates: List[List[float]] = []
|
|
822
|
+
for b in range(n_bootstrap):
|
|
823
|
+
rep_seed = None if seed is None else seed + b
|
|
824
|
+
_, path, _ = model.simulate(
|
|
825
|
+
T=horizon, n_steps=n_steps, x0=0.0, seed=rep_seed
|
|
826
|
+
)
|
|
827
|
+
increments = np.diff(path)
|
|
828
|
+
|
|
829
|
+
estimator = JumpDiffusionEstimator(
|
|
830
|
+
increments, self.dt, jump_distribution=jump_dist
|
|
831
|
+
)
|
|
832
|
+
rep_result = estimator.estimate(**estimate_kwargs)
|
|
833
|
+
if rep_result["convergence"]:
|
|
834
|
+
rep_params = rep_result["parameters"]
|
|
835
|
+
replicates.append([rep_params[name] for name in self._param_names])
|
|
836
|
+
|
|
837
|
+
n_successful = len(replicates)
|
|
838
|
+
|
|
839
|
+
standard_errors: Dict[str, float] = {}
|
|
840
|
+
confidence_intervals: Dict[str, Tuple[float, float]] = {}
|
|
841
|
+
|
|
842
|
+
alpha = 1.0 - confidence_level
|
|
843
|
+
lower_pct, upper_pct = 100.0 * alpha / 2.0, 100.0 * (1.0 - alpha / 2.0)
|
|
844
|
+
|
|
845
|
+
if n_successful >= 2:
|
|
846
|
+
estimates = np.asarray(replicates, dtype=float)
|
|
847
|
+
ses = np.std(estimates, axis=0, ddof=1)
|
|
848
|
+
ci_lows = np.percentile(estimates, lower_pct, axis=0)
|
|
849
|
+
ci_highs = np.percentile(estimates, upper_pct, axis=0)
|
|
850
|
+
for idx, name in enumerate(self._param_names):
|
|
851
|
+
standard_errors[name] = float(ses[idx])
|
|
852
|
+
confidence_intervals[name] = (
|
|
853
|
+
float(ci_lows[idx]),
|
|
854
|
+
float(ci_highs[idx]),
|
|
855
|
+
)
|
|
856
|
+
else:
|
|
857
|
+
estimates = np.asarray(replicates, dtype=float)
|
|
858
|
+
for name in self._param_names:
|
|
859
|
+
standard_errors[name] = np.nan
|
|
860
|
+
confidence_intervals[name] = (np.nan, np.nan)
|
|
861
|
+
|
|
862
|
+
results["bootstrap_standard_errors"] = standard_errors
|
|
863
|
+
results["bootstrap_confidence_intervals"] = confidence_intervals
|
|
864
|
+
results["bootstrap_estimates"] = estimates
|
|
865
|
+
|
|
866
|
+
return {
|
|
867
|
+
"standard_errors": standard_errors,
|
|
868
|
+
"confidence_intervals": confidence_intervals,
|
|
869
|
+
"n_successful": n_successful,
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
def _pure_diffusion_log_likelihood(
|
|
873
|
+
self, increments: np.ndarray
|
|
874
|
+
) -> Tuple[float, float, float]:
|
|
875
|
+
"""
|
|
876
|
+
Maximized log-likelihood under the no-jump null (pure diffusion).
|
|
877
|
+
|
|
878
|
+
Under ``H0: jump_prob = 0`` the increments are i.i.d. Gaussian, so
|
|
879
|
+
the maximum likelihood fit is available in closed form: the mean and
|
|
880
|
+
(population, MLE) standard deviation of the increments. This returns
|
|
881
|
+
that maximized log-likelihood together with the two fitted moments,
|
|
882
|
+
which also parameterize the data-generating process for the
|
|
883
|
+
bootstrap in :meth:`test_for_jumps`.
|
|
884
|
+
|
|
885
|
+
Parameters:
|
|
886
|
+
-----------
|
|
887
|
+
increments : np.ndarray
|
|
888
|
+
One-dimensional array of increments.
|
|
889
|
+
|
|
890
|
+
Returns:
|
|
891
|
+
--------
|
|
892
|
+
tuple
|
|
893
|
+
``(log_likelihood, mean, std)``. ``log_likelihood`` is
|
|
894
|
+
``-inf`` when the increments are constant (``std == 0``).
|
|
895
|
+
"""
|
|
896
|
+
mean = float(np.mean(increments))
|
|
897
|
+
std = float(np.std(increments)) # ddof=0 -> the Gaussian MLE
|
|
898
|
+
if std <= 0:
|
|
899
|
+
return -np.inf, mean, std
|
|
900
|
+
log_lik = float(np.sum(stats.norm.logpdf(increments, loc=mean, scale=std)))
|
|
901
|
+
return log_lik, mean, std
|
|
902
|
+
|
|
903
|
+
def test_for_jumps(
|
|
904
|
+
self,
|
|
905
|
+
n_bootstrap: int = 200,
|
|
906
|
+
seed: Optional[int] = None,
|
|
907
|
+
**estimate_kwargs: Any,
|
|
908
|
+
) -> Dict[str, Any]:
|
|
909
|
+
"""
|
|
910
|
+
Test for the presence of jumps via a parametric bootstrap likelihood
|
|
911
|
+
ratio test of ``H0: jump_prob = 0`` (pure diffusion) against
|
|
912
|
+
``H1: jump_prob > 0``.
|
|
913
|
+
|
|
914
|
+
The statistic is the usual likelihood ratio
|
|
915
|
+
``LR = 2 * (loglik_full - loglik_null)``, where ``loglik_full`` is
|
|
916
|
+
the maximized jump-diffusion log-likelihood (already available from
|
|
917
|
+
the fit) and ``loglik_null`` is the closed-form maximized Gaussian
|
|
918
|
+
log-likelihood (see :meth:`_pure_diffusion_log_likelihood`).
|
|
919
|
+
|
|
920
|
+
Its asymptotic null distribution is **non-standard**: under ``H0``
|
|
921
|
+
the jump probability sits on the boundary of the parameter space
|
|
922
|
+
(Chernoff, 1954; Self & Liang, 1987) *and* the jump-size parameters
|
|
923
|
+
become unidentified (Davies, 1977, 1987) -- so the usual chi-squared
|
|
924
|
+
calibration does not apply. We therefore obtain the p-value by
|
|
925
|
+
parametric bootstrap (a Monte Carlo test): simulate ``n_bootstrap``
|
|
926
|
+
pure-diffusion datasets from the fitted null model, recompute the LR
|
|
927
|
+
statistic on each, and compare the observed statistic against that
|
|
928
|
+
simulated null distribution. This sidesteps both pathologies by
|
|
929
|
+
construction.
|
|
930
|
+
|
|
931
|
+
Parameters:
|
|
932
|
+
-----------
|
|
933
|
+
n_bootstrap : int
|
|
934
|
+
Number of bootstrap datasets drawn under the null. Each requires
|
|
935
|
+
re-fitting the full jump-diffusion model, so cost scales
|
|
936
|
+
linearly.
|
|
937
|
+
seed : int, optional
|
|
938
|
+
Seed for the bootstrap data generation, for reproducibility.
|
|
939
|
+
\\*\\*estimate_kwargs : dict
|
|
940
|
+
Forwarded to :meth:`estimate` for each bootstrap re-fit.
|
|
941
|
+
|
|
942
|
+
Returns:
|
|
943
|
+
--------
|
|
944
|
+
dict
|
|
945
|
+
Dictionary with:
|
|
946
|
+
|
|
947
|
+
* ``lr_statistic`` -- observed likelihood ratio statistic.
|
|
948
|
+
* ``p_value`` -- bootstrap p-value
|
|
949
|
+
``(1 + #{LR* >= LR_obs}) / (n_successful + 1)``; ``nan`` if no
|
|
950
|
+
bootstrap re-fit converged.
|
|
951
|
+
* ``log_likelihood_full`` / ``log_likelihood_null`` -- the two
|
|
952
|
+
maximized log-likelihoods on the observed data.
|
|
953
|
+
* ``n_bootstrap`` / ``n_successful`` -- requested and converged
|
|
954
|
+
replicate counts.
|
|
955
|
+
* ``bootstrap_statistics`` -- the simulated null LR values.
|
|
956
|
+
|
|
957
|
+
The result is also stored on ``self.results`` under
|
|
958
|
+
``jump_test``.
|
|
959
|
+
|
|
960
|
+
Raises:
|
|
961
|
+
-------
|
|
962
|
+
ValueError
|
|
963
|
+
If the model has not been fitted yet.
|
|
964
|
+
"""
|
|
965
|
+
if not self.fitted or self.results is None:
|
|
966
|
+
raise ValueError("Model must be fitted first.")
|
|
967
|
+
results = self.results
|
|
968
|
+
|
|
969
|
+
log_lik_full = results["log_likelihood"]
|
|
970
|
+
log_lik_null, mean0, std0 = self._pure_diffusion_log_likelihood(self.increments)
|
|
971
|
+
# The full model nests the null, so LR is non-negative in theory;
|
|
972
|
+
# clamp to guard against the optimizer stopping just short of it.
|
|
973
|
+
lr_observed = max(0.0, 2.0 * (log_lik_full - log_lik_null))
|
|
974
|
+
|
|
975
|
+
jump_dist = self._model.jump_distribution
|
|
976
|
+
rng = np.random.default_rng(seed)
|
|
977
|
+
|
|
978
|
+
bootstrap_stats: List[float] = []
|
|
979
|
+
for _ in range(n_bootstrap):
|
|
980
|
+
increments_b = rng.normal(mean0, std0, size=self.n_obs)
|
|
981
|
+
estimator = JumpDiffusionEstimator(
|
|
982
|
+
increments_b, self.dt, jump_distribution=jump_dist
|
|
983
|
+
)
|
|
984
|
+
rep_result = estimator.estimate(**estimate_kwargs)
|
|
985
|
+
if not rep_result["convergence"]:
|
|
986
|
+
continue
|
|
987
|
+
log_lik_full_b = rep_result["log_likelihood"]
|
|
988
|
+
log_lik_null_b, _, _ = self._pure_diffusion_log_likelihood(increments_b)
|
|
989
|
+
bootstrap_stats.append(max(0.0, 2.0 * (log_lik_full_b - log_lik_null_b)))
|
|
990
|
+
|
|
991
|
+
n_successful = len(bootstrap_stats)
|
|
992
|
+
stats_array = np.asarray(bootstrap_stats, dtype=float)
|
|
993
|
+
if n_successful >= 1:
|
|
994
|
+
exceed = int(np.sum(stats_array >= lr_observed))
|
|
995
|
+
p_value = (1.0 + exceed) / (n_successful + 1.0)
|
|
996
|
+
else:
|
|
997
|
+
p_value = np.nan
|
|
998
|
+
|
|
999
|
+
jump_test = {
|
|
1000
|
+
"lr_statistic": lr_observed,
|
|
1001
|
+
"p_value": p_value,
|
|
1002
|
+
"log_likelihood_full": log_lik_full,
|
|
1003
|
+
"log_likelihood_null": log_lik_null,
|
|
1004
|
+
"n_bootstrap": n_bootstrap,
|
|
1005
|
+
"n_successful": n_successful,
|
|
1006
|
+
"bootstrap_statistics": stats_array,
|
|
1007
|
+
}
|
|
1008
|
+
results["jump_test"] = jump_test
|
|
1009
|
+
return jump_test
|
|
1010
|
+
|
|
1011
|
+
def summary(self) -> pd.DataFrame:
|
|
1012
|
+
"""
|
|
1013
|
+
Tidy table of the estimates and every inference route computed so far.
|
|
1014
|
+
|
|
1015
|
+
Returns one row per parameter with its point ``estimate`` plus, for
|
|
1016
|
+
each inference method that has been run, the standard error and
|
|
1017
|
+
confidence interval bounds. Columns are only added for methods that
|
|
1018
|
+
were actually computed:
|
|
1019
|
+
|
|
1020
|
+
* ``estimate_standard_errors`` -> ``profile_se``, ``profile_ci_low``,
|
|
1021
|
+
``profile_ci_high``
|
|
1022
|
+
* ``estimate_wald_standard_errors`` -> ``wald_se``, ``wald_ci_low``,
|
|
1023
|
+
``wald_ci_high``
|
|
1024
|
+
* ``estimate_bootstrap_standard_errors`` -> ``bootstrap_se``,
|
|
1025
|
+
``bootstrap_ci_low``, ``bootstrap_ci_high``
|
|
1026
|
+
|
|
1027
|
+
This is the structured counterpart to :meth:`diagnostics` (which
|
|
1028
|
+
prints a single-method table): it puts the profile, Wald and
|
|
1029
|
+
bootstrap results side by side, which is exactly what a coverage
|
|
1030
|
+
comparison or a results table for a paper needs.
|
|
1031
|
+
|
|
1032
|
+
Returns:
|
|
1033
|
+
--------
|
|
1034
|
+
pandas.DataFrame
|
|
1035
|
+
One row per parameter, indexed by insertion order, with an
|
|
1036
|
+
``estimate`` column and the SE/CI columns of whichever methods
|
|
1037
|
+
have been run.
|
|
1038
|
+
|
|
1039
|
+
Raises:
|
|
1040
|
+
-------
|
|
1041
|
+
ValueError
|
|
1042
|
+
If the model has not been fitted yet.
|
|
1043
|
+
"""
|
|
1044
|
+
if not self.fitted or self.results is None:
|
|
1045
|
+
raise ValueError("Model must be fitted first.")
|
|
1046
|
+
results = self.results
|
|
1047
|
+
params = results["parameters"]
|
|
1048
|
+
|
|
1049
|
+
# (label, standard-errors key, confidence-intervals key)
|
|
1050
|
+
method_keys = [
|
|
1051
|
+
("profile", "standard_errors", "confidence_intervals"),
|
|
1052
|
+
("wald", "wald_standard_errors", "wald_confidence_intervals"),
|
|
1053
|
+
(
|
|
1054
|
+
"bootstrap",
|
|
1055
|
+
"bootstrap_standard_errors",
|
|
1056
|
+
"bootstrap_confidence_intervals",
|
|
1057
|
+
),
|
|
1058
|
+
]
|
|
1059
|
+
|
|
1060
|
+
rows = []
|
|
1061
|
+
for name in self._param_names:
|
|
1062
|
+
row: Dict[str, Any] = {
|
|
1063
|
+
"parameter": name,
|
|
1064
|
+
"estimate": params[name],
|
|
1065
|
+
}
|
|
1066
|
+
for label, se_key, ci_key in method_keys:
|
|
1067
|
+
se_dict = results.get(se_key)
|
|
1068
|
+
if se_dict is None:
|
|
1069
|
+
continue
|
|
1070
|
+
ci_dict = results.get(ci_key) or {}
|
|
1071
|
+
ci_low, ci_high = ci_dict.get(name, (np.nan, np.nan))
|
|
1072
|
+
row[f"{label}_se"] = se_dict.get(name, np.nan)
|
|
1073
|
+
row[f"{label}_ci_low"] = ci_low
|
|
1074
|
+
row[f"{label}_ci_high"] = ci_high
|
|
1075
|
+
rows.append(row)
|
|
1076
|
+
|
|
1077
|
+
return pd.DataFrame(rows)
|
|
1078
|
+
|
|
1079
|
+
def plot_profiles(self, figsize: Tuple[float, float] = (15, 10)) -> None:
|
|
1080
|
+
"""
|
|
1081
|
+
Plot the profile log-likelihood curves for each parameter.
|
|
1082
|
+
"""
|
|
1083
|
+
if (
|
|
1084
|
+
not self.fitted
|
|
1085
|
+
or self.results is None
|
|
1086
|
+
or "profile_data" not in self.results
|
|
1087
|
+
):
|
|
1088
|
+
print("Profile data not available. Run estimate_standard_errors() first.")
|
|
1089
|
+
return
|
|
1090
|
+
|
|
1091
|
+
import matplotlib.pyplot as plt
|
|
1092
|
+
|
|
1093
|
+
profile_data = self.results["profile_data"]
|
|
1094
|
+
standard_errors = self.results["standard_errors"]
|
|
1095
|
+
confidence_intervals = self.results["confidence_intervals"]
|
|
1096
|
+
opt_params = self.results["parameters"]
|
|
1097
|
+
|
|
1098
|
+
label_map = {
|
|
1099
|
+
"mu": "Drift (μ)",
|
|
1100
|
+
"sigma": "Volatility (σ)",
|
|
1101
|
+
"jump_prob": "Jump Prob (p)",
|
|
1102
|
+
"jump_scale": "Jump Scale",
|
|
1103
|
+
"jump_skew": "Skewness (α)",
|
|
1104
|
+
"jump_loc": "Jump Location",
|
|
1105
|
+
"jump_nu": "Kurtosis (ν)",
|
|
1106
|
+
"jump_xi": "Asymmetry (ξ)",
|
|
1107
|
+
"jump_df": "Degrees of Freedom (df)",
|
|
1108
|
+
"jump_prob_up": "Jump Prob Up (p_up)",
|
|
1109
|
+
"jump_scale_up": "Jump Scale Up (η_up)",
|
|
1110
|
+
"jump_scale_down": "Jump Scale Down (η_down)",
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
n_plots = len(self._param_names)
|
|
1114
|
+
n_cols = min(3, n_plots)
|
|
1115
|
+
n_rows = int(np.ceil(n_plots / n_cols))
|
|
1116
|
+
|
|
1117
|
+
fig, axes = plt.subplots(n_rows, n_cols, figsize=figsize)
|
|
1118
|
+
if n_plots == 1:
|
|
1119
|
+
axes = np.array([axes])
|
|
1120
|
+
else:
|
|
1121
|
+
axes = axes.flatten()
|
|
1122
|
+
|
|
1123
|
+
for idx, name in enumerate(self._param_names):
|
|
1124
|
+
ax = axes[idx]
|
|
1125
|
+
data = profile_data[name]
|
|
1126
|
+
grid = np.array(data["grid"])
|
|
1127
|
+
values = np.array(data["values"])
|
|
1128
|
+
threshold = data["threshold"]
|
|
1129
|
+
mle_val = opt_params[name]
|
|
1130
|
+
se = standard_errors[name]
|
|
1131
|
+
ci_low, ci_high = confidence_intervals[name]
|
|
1132
|
+
|
|
1133
|
+
# Filter finite values for plotting
|
|
1134
|
+
valid = np.isfinite(values)
|
|
1135
|
+
ax.plot(grid[valid], values[valid], "b-o", label="Profile Log-Lik")
|
|
1136
|
+
ax.axvline(
|
|
1137
|
+
x=mle_val, color="red", linestyle="--", label=f"MLE: {mle_val:.4f}"
|
|
1138
|
+
)
|
|
1139
|
+
ax.axhline(
|
|
1140
|
+
y=threshold, color="green", linestyle=":", label="95% CI Threshold"
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
if np.isfinite(ci_low) and ci_low > grid.min():
|
|
1144
|
+
ax.axvline(x=ci_low, color="green", linestyle="--", alpha=0.7)
|
|
1145
|
+
if np.isfinite(ci_high) and ci_high < grid.max():
|
|
1146
|
+
ax.axvline(x=ci_high, color="green", linestyle="--", alpha=0.7)
|
|
1147
|
+
|
|
1148
|
+
label = label_map.get(name, name.replace("_", " ").title())
|
|
1149
|
+
se_str = f", SE: {se:.4f}" if np.isfinite(se) else ""
|
|
1150
|
+
ax.set_title(f"{label}{se_str}")
|
|
1151
|
+
ax.set_xlabel("Value")
|
|
1152
|
+
ax.set_ylabel("Log-Likelihood")
|
|
1153
|
+
ax.grid(True, alpha=0.3)
|
|
1154
|
+
if idx == 0:
|
|
1155
|
+
ax.legend()
|
|
1156
|
+
|
|
1157
|
+
# Delete unused subplots
|
|
1158
|
+
for j in range(n_plots, len(axes)):
|
|
1159
|
+
fig.delaxes(axes[j])
|
|
1160
|
+
|
|
1161
|
+
plt.tight_layout()
|
|
1162
|
+
plt.show()
|
|
1163
|
+
|
|
1164
|
+
def diagnostics(self) -> None:
|
|
1165
|
+
"""Print diagnostic information about the estimation.
|
|
1166
|
+
|
|
1167
|
+
The method writes a summary of the fitted model to ``stdout`` and
|
|
1168
|
+
returns ``None``. The following metrics are reported:
|
|
1169
|
+
|
|
1170
|
+
* Estimated parameters (μ, σ, jump probability, and the jump
|
|
1171
|
+
distribution's own parameters)
|
|
1172
|
+
* Log-likelihood value
|
|
1173
|
+
* Information criteria (AIC and BIC)
|
|
1174
|
+
* Optimizer convergence flag
|
|
1175
|
+
* Comparison of empirical vs. theoretical mean, standard deviation
|
|
1176
|
+
and the expected number of jumps
|
|
1177
|
+
"""
|
|
1178
|
+
if not self.fitted or self.results is None:
|
|
1179
|
+
print("Model not fitted. Run estimate() first.")
|
|
1180
|
+
return
|
|
1181
|
+
|
|
1182
|
+
params = self.results["parameters"]
|
|
1183
|
+
se_dict = self.results.get("standard_errors", {})
|
|
1184
|
+
ci_dict = self.results.get("confidence_intervals", {})
|
|
1185
|
+
|
|
1186
|
+
print("=" * 60)
|
|
1187
|
+
print("JUMP-DIFFUSION ESTIMATION RESULTS")
|
|
1188
|
+
print("=" * 60)
|
|
1189
|
+
|
|
1190
|
+
if se_dict:
|
|
1191
|
+
print(
|
|
1192
|
+
f"{'Parameter':<18} | {'Estimate':<10} | "
|
|
1193
|
+
f"{'Std Error':<10} | {'95% Conf. Interval':<22}"
|
|
1194
|
+
)
|
|
1195
|
+
print("-" * 68)
|
|
1196
|
+
for name in self._param_names:
|
|
1197
|
+
val = params[name]
|
|
1198
|
+
se = se_dict.get(name, np.nan)
|
|
1199
|
+
se_str = f"{se:.6f}" if np.isfinite(se) else "N/A"
|
|
1200
|
+
ci = ci_dict.get(name, (np.nan, np.nan))
|
|
1201
|
+
ci_str = (
|
|
1202
|
+
f"[{ci[0]:.4f}, {ci[1]:.4f}]" if np.all(np.isfinite(ci)) else "N/A"
|
|
1203
|
+
)
|
|
1204
|
+
print(f"{name:<18} | {val:<10.6f} | {se_str:<10} | {ci_str:<22}")
|
|
1205
|
+
else:
|
|
1206
|
+
print(f"Drift (μ): {params['mu']:.6f}")
|
|
1207
|
+
print(f"Volatility (σ): {params['sigma']:.6f}")
|
|
1208
|
+
print(f"Jump probability (p): {params['jump_prob']:.6f}")
|
|
1209
|
+
for name in self._model.jump_distribution.param_names:
|
|
1210
|
+
print(f"{name:<24}{params[name]:.6f}")
|
|
1211
|
+
|
|
1212
|
+
print(
|
|
1213
|
+
f"\nLog-likelihood: {self.results['log_likelihood']:.2f}",
|
|
1214
|
+
)
|
|
1215
|
+
print(f"AIC: {self.results['aic']:.2f}")
|
|
1216
|
+
print(f"BIC: {self.results['bic']:.2f}")
|
|
1217
|
+
print(f"Convergence: {self.results['convergence']}")
|
|
1218
|
+
|
|
1219
|
+
# Model comparison with data
|
|
1220
|
+
print("\nDATA vs MODEL COMPARISON")
|
|
1221
|
+
print("-" * 30)
|
|
1222
|
+
theoretical_mean = params["mu"] * self.dt
|
|
1223
|
+
# Approximation: treats the distribution's characteristic scale as
|
|
1224
|
+
# the jump size's standard deviation, which holds exactly for the
|
|
1225
|
+
# Normal jump distribution and approximately for the others.
|
|
1226
|
+
jump_scale = self._model.jump_distribution.characteristic_scale(
|
|
1227
|
+
{k: params[k] for k in self._model.jump_distribution.param_names}
|
|
1228
|
+
)
|
|
1229
|
+
theoretical_var = (
|
|
1230
|
+
params["sigma"] ** 2 * self.dt + params["jump_prob"] * jump_scale**2
|
|
1231
|
+
)
|
|
1232
|
+
|
|
1233
|
+
print("Mean increment:")
|
|
1234
|
+
print(f" Empirical: {self.mean_increment:.6f}")
|
|
1235
|
+
print(f" Theoretical: {theoretical_mean:.6f}")
|
|
1236
|
+
print("Std deviation:")
|
|
1237
|
+
print(f" Empirical: {self.std_increment:.6f}")
|
|
1238
|
+
print(f" Theoretical: {np.sqrt(theoretical_var):.6f}")
|
|
1239
|
+
print(f"Expected jumps: {params['jump_prob'] * self.n_obs:.1f}")
|