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.
@@ -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}")