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,14 @@
1
+ """
2
+ Validation and Testing Tools
3
+
4
+ This module provides tools for validating estimation methods
5
+ through Monte Carlo experiments and diagnostic tests.
6
+ """
7
+
8
+ from .monte_carlo import ValidationExperiment
9
+ from .distribution_comparison import JumpDistributionComparison
10
+
11
+ __all__ = [
12
+ "ValidationExperiment",
13
+ "JumpDistributionComparison",
14
+ ]
@@ -0,0 +1,270 @@
1
+ """
2
+ Jump-distribution goodness-of-fit comparison.
3
+
4
+ This module fits JumpDiffusionEstimator under several candidate jump-size
5
+ distributions on the same data and compares how well each one fits, via
6
+ information criteria (AIC/BIC) and a Kolmogorov-Smirnov goodness-of-fit
7
+ test whose p-value is obtained by parametric bootstrap, so it accounts for
8
+ the fact that the parameters were estimated from the same data. The KS
9
+ machinery only relies on ``JumpDistribution.rvs`` (through model
10
+ simulation), so it works the same way for any current or future jump
11
+ distribution.
12
+ """
13
+
14
+ from typing import Any, Dict, Optional, Tuple
15
+
16
+ import matplotlib.pyplot as plt
17
+ import numpy as np
18
+ import pandas as pd
19
+ from scipy.stats import ks_2samp
20
+
21
+ from ..distributions import JumpDistribution
22
+ from ..estimation import JumpDiffusionEstimator
23
+ from ..models.jump_diffusion import JumpDiffusionModel
24
+
25
+
26
+ class JumpDistributionComparison:
27
+ """
28
+ Fits several jump-size distributions to the same data and compares
29
+ their goodness of fit.
30
+ """
31
+
32
+ def __init__(self, data: np.ndarray, dt: float):
33
+ """
34
+ Parameters:
35
+ -----------
36
+ data : np.ndarray
37
+ One-dimensional array of observed increments.
38
+ dt : float
39
+ Time step size between consecutive increments.
40
+ """
41
+ self.data = data
42
+ self.dt = dt
43
+ self.results: Dict[str, Dict[str, Any]] = {}
44
+
45
+ def _simulate_increments(
46
+ self,
47
+ model: JumpDiffusionModel,
48
+ size: int,
49
+ seed: Optional[int],
50
+ ) -> np.ndarray:
51
+ """Simulate ``size`` increments from ``model`` at this object's dt."""
52
+ _, path, _ = model.simulate(T=size * self.dt, n_steps=size, x0=0.0, seed=seed)
53
+ return np.diff(path)
54
+
55
+ def _parametric_bootstrap_ks_pvalue(
56
+ self,
57
+ jump_distribution: JumpDistribution,
58
+ fitted_model: JumpDiffusionModel,
59
+ ks_observed: float,
60
+ n_obs: int,
61
+ reference_size: int,
62
+ n_bootstrap: int,
63
+ seed: Optional[int],
64
+ estimate_kwargs: Dict[str, Any],
65
+ ) -> float:
66
+ """
67
+ Monte Carlo p-value for the KS goodness-of-fit statistic.
68
+
69
+ A plain two-sample KS p-value is invalid here because the reference
70
+ distribution was estimated from the very data being tested, which
71
+ makes the naive p-value strongly anti-conservative. The parametric
72
+ bootstrap restores validity: under the fitted (null) model we
73
+ simulate ``n_bootstrap`` datasets of the observed size, **re-fit** the
74
+ model on each, and recompute the KS distance of each dataset to its
75
+ own re-fitted model. The fraction of these bootstrap distances that
76
+ equal or exceed the observed one estimates the p-value, with the
77
+ usual ``(1 + count) / (n_successful + 1)`` small-sample correction.
78
+ Re-fitting inside the loop is what makes the test account for
79
+ estimation error.
80
+
81
+ Returns ``nan`` if ``n_bootstrap <= 0`` or no replicate converged.
82
+ """
83
+ if n_bootstrap <= 0:
84
+ return float("nan")
85
+
86
+ exceed = 0
87
+ n_successful = 0
88
+ for b in range(n_bootstrap):
89
+ data_seed = None if seed is None else seed + 1000 + b
90
+ boot_data = self._simulate_increments(fitted_model, n_obs, data_seed)
91
+
92
+ estimator = JumpDiffusionEstimator(
93
+ boot_data, self.dt, jump_distribution=jump_distribution
94
+ )
95
+ rep_result = estimator.estimate(**estimate_kwargs)
96
+ if not rep_result["convergence"]:
97
+ continue
98
+
99
+ model_b = JumpDiffusionModel(
100
+ jump_distribution=jump_distribution, **rep_result["parameters"]
101
+ )
102
+ ref_seed = None if seed is None else seed + 100_000 + b
103
+ reference_b = self._simulate_increments(model_b, reference_size, ref_seed)
104
+ ks_b = float(ks_2samp(boot_data, reference_b).statistic)
105
+ if ks_b >= ks_observed:
106
+ exceed += 1
107
+ n_successful += 1
108
+
109
+ if n_successful == 0:
110
+ return float("nan")
111
+ return (1.0 + exceed) / (n_successful + 1.0)
112
+
113
+ def fit(
114
+ self,
115
+ name: str,
116
+ jump_distribution: JumpDistribution,
117
+ seed: Optional[int] = None,
118
+ n_bootstrap: int = 199,
119
+ ks_reference_size: Optional[int] = None,
120
+ **estimate_kwargs: Any,
121
+ ) -> Dict[str, Any]:
122
+ r"""
123
+ Fit ``jump_distribution`` to the data and record its goodness of fit.
124
+
125
+ The KS statistic is the distance between the data and the fitted
126
+ model's distribution, the latter approximated by a large simulated
127
+ reference sample so that simulation noise in the statistic itself is
128
+ negligible. Its p-value is obtained by parametric bootstrap (see
129
+ :meth:`_parametric_bootstrap_ks_pvalue`), which is the dominant cost
130
+ of this method.
131
+
132
+ Parameters:
133
+ -----------
134
+ name : str
135
+ Label used to identify this fit in ``results``/``compare()``.
136
+ jump_distribution : JumpDistribution
137
+ Jump-size distribution to fit.
138
+ seed : int, optional
139
+ Base random seed. When given, the reference sample, the plotting
140
+ sample and every bootstrap replicate use distinct derived seeds,
141
+ so the whole ``fit`` is reproducible.
142
+ n_bootstrap : int
143
+ Number of parametric bootstrap replicates for the KS p-value.
144
+ Each re-fits the model, so cost scales linearly; set to ``0`` to
145
+ skip the p-value (it is then reported as ``nan``).
146
+ ks_reference_size : int, optional
147
+ Size of the reference sample used to evaluate the KS statistic.
148
+ Defaults to ``max(20 * n_obs, 20000)``. Larger values give a
149
+ more stable statistic at higher simulation cost.
150
+ \*\*estimate_kwargs : dict
151
+ Additional arguments forwarded to
152
+ ``JumpDiffusionEstimator.estimate`` (also used for every
153
+ bootstrap re-fit).
154
+
155
+ Returns:
156
+ --------
157
+ dict
158
+ The estimation results, extended with ``ks_statistic``,
159
+ ``ks_pvalue`` (parametric bootstrap), ``ks_n_bootstrap`` and
160
+ ``simulated_increments`` (an observation-sized sample kept for
161
+ :meth:`plot_comparison`).
162
+ """
163
+ estimator = JumpDiffusionEstimator(
164
+ self.data, self.dt, jump_distribution=jump_distribution
165
+ )
166
+ result = dict(estimator.estimate(**estimate_kwargs))
167
+
168
+ fitted_model = JumpDiffusionModel(
169
+ jump_distribution=jump_distribution, **result["parameters"]
170
+ )
171
+ n_obs = len(self.data)
172
+ reference_size = (
173
+ ks_reference_size
174
+ if ks_reference_size is not None
175
+ else max(20 * n_obs, 20000)
176
+ )
177
+
178
+ # Stable KS distance to the fitted model, via a large reference
179
+ # sample so the statistic is not dominated by simulation noise.
180
+ ref_seed = None if seed is None else seed
181
+ reference_increments = self._simulate_increments(
182
+ fitted_model, reference_size, ref_seed
183
+ )
184
+ ks_statistic = float(ks_2samp(self.data, reference_increments).statistic)
185
+
186
+ # A same-size sample kept only for the histogram overlay in
187
+ # plot_comparison (not used by the statistic).
188
+ plot_seed = None if seed is None else seed + 1
189
+ simulated_increments = self._simulate_increments(fitted_model, n_obs, plot_seed)
190
+
191
+ ks_pvalue = self._parametric_bootstrap_ks_pvalue(
192
+ jump_distribution,
193
+ fitted_model,
194
+ ks_statistic,
195
+ n_obs,
196
+ reference_size,
197
+ n_bootstrap,
198
+ seed,
199
+ estimate_kwargs,
200
+ )
201
+
202
+ result["ks_statistic"] = ks_statistic
203
+ result["ks_pvalue"] = ks_pvalue
204
+ result["ks_n_bootstrap"] = n_bootstrap
205
+ result["simulated_increments"] = simulated_increments
206
+
207
+ self.results[name] = result
208
+ return result
209
+
210
+ def compare(self) -> pd.DataFrame:
211
+ """
212
+ Summarize all fitted distributions, ranked by AIC (best first).
213
+ """
214
+ if not self.results:
215
+ print("No fits yet. Call fit() first.")
216
+ return pd.DataFrame()
217
+
218
+ rows = [
219
+ {
220
+ "distribution": name,
221
+ "log_likelihood": result["log_likelihood"],
222
+ "aic": result["aic"],
223
+ "bic": result["bic"],
224
+ "ks_statistic": result["ks_statistic"],
225
+ "ks_pvalue": result["ks_pvalue"],
226
+ "convergence": result["convergence"],
227
+ }
228
+ for name, result in self.results.items()
229
+ ]
230
+ return pd.DataFrame(rows).sort_values("aic").reset_index(drop=True)
231
+
232
+ def plot_comparison(
233
+ self,
234
+ bins: int = 50,
235
+ figsize: Tuple[float, float] = (12, 6),
236
+ ) -> None:
237
+ """
238
+ Overlay a histogram of the real data with each fitted
239
+ distribution's simulated sample.
240
+ """
241
+ if not self.results:
242
+ print("No fits yet. Call fit() first.")
243
+ return
244
+
245
+ plt.figure(figsize=figsize)
246
+ plt.hist(
247
+ self.data,
248
+ bins=bins,
249
+ density=True,
250
+ alpha=0.35,
251
+ color="black",
252
+ label="Datos reales",
253
+ )
254
+ for name, result in self.results.items():
255
+ plt.hist(
256
+ result["simulated_increments"],
257
+ bins=bins,
258
+ density=True,
259
+ alpha=0.35,
260
+ histtype="step",
261
+ linewidth=2,
262
+ label=f"Simulado ({name})",
263
+ )
264
+
265
+ plt.title("Comparación de distribuciones de salto")
266
+ plt.xlabel("Incremento")
267
+ plt.ylabel("Densidad")
268
+ plt.legend()
269
+ plt.grid(True, alpha=0.3)
270
+ plt.show()
@@ -0,0 +1,349 @@
1
+ """
2
+ Monte Carlo validation experiments for jump-diffusion estimators.
3
+
4
+ This module provides tools to validate estimation methods through
5
+ controlled experiments with known parameters.
6
+ """
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ import matplotlib.pyplot as plt
11
+ from typing import Dict, Any, List, Optional
12
+ from ..simulation import JumpDiffusionSimulator
13
+ from ..estimation import JumpDiffusionEstimator
14
+ from ..distributions import JumpDistribution
15
+
16
+
17
+ class ValidationExperiment:
18
+ """
19
+ Monte Carlo validation experiment for jump-diffusion estimators.
20
+
21
+ This class orchestrates validation experiments where we simulate
22
+ data with known parameters and test how well our estimators
23
+ can recover those parameters.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ true_params: Dict[str, float],
29
+ jump_distribution: Optional[JumpDistribution] = None,
30
+ ):
31
+ """
32
+ Initialize validation experiment.
33
+
34
+ Parameters:
35
+ -----------
36
+ true_params : dict
37
+ True parameter values to use for simulation
38
+ jump_distribution : JumpDistribution, optional
39
+ Jump size distribution to use for simulation and estimation
40
+ """
41
+ self.true_params = true_params
42
+ self.jump_distribution = jump_distribution
43
+ self.results: pd.DataFrame = pd.DataFrame()
44
+ self.completed_experiments = 0
45
+
46
+ def run_experiment(
47
+ self,
48
+ n_simulations: int = 10,
49
+ T: float = 1.0,
50
+ n_steps: int = 252,
51
+ x0: float = 100.0,
52
+ seed_base: int = 42,
53
+ ) -> pd.DataFrame:
54
+ """
55
+ Run Monte Carlo validation experiment.
56
+
57
+ Parameters:
58
+ -----------
59
+ n_simulations : int
60
+ Number of simulation runs
61
+ T : float
62
+ Time horizon for each simulation
63
+ n_steps : int
64
+ Number of time steps per simulation
65
+ x0 : float
66
+ Initial value
67
+ seed_base : int
68
+ Base seed for reproducibility
69
+
70
+ Returns:
71
+ --------
72
+ pd.DataFrame
73
+ Results of all experiments
74
+ """
75
+ print(f"Running {n_simulations} validation experiments...")
76
+ print(f"True parameters: {self.true_params}")
77
+
78
+ # Create simulator.
79
+ simulator = JumpDiffusionSimulator(
80
+ jump_distribution=self.jump_distribution,
81
+ **self.true_params, # type: ignore[arg-type]
82
+ )
83
+
84
+ results: List[Dict[str, Any]] = []
85
+ successful_runs = 0
86
+
87
+ for i in range(n_simulations):
88
+ try:
89
+ # Simulate data
90
+ times, path, jumps = simulator.simulate_path(
91
+ T=T, n_steps=n_steps, x0=x0, seed=seed_base + i
92
+ )
93
+
94
+ # Estimate parameters
95
+ increments = np.diff(path)
96
+ dt = times[1] - times[0]
97
+
98
+ estimator = JumpDiffusionEstimator(
99
+ increments,
100
+ dt,
101
+ jump_distribution=self.jump_distribution,
102
+ )
103
+ est_results = estimator.estimate()
104
+
105
+ if est_results["convergence"]:
106
+ # Store results
107
+ row = {
108
+ "simulation_id": i + 1,
109
+ "seed": seed_base + i,
110
+ "convergence": True,
111
+ "log_likelihood": est_results["log_likelihood"],
112
+ "aic": est_results["aic"],
113
+ "bic": est_results["bic"],
114
+ }
115
+
116
+ # Add estimated parameters
117
+ for param, value in est_results["parameters"].items():
118
+ row[f"{param}_est"] = value
119
+ row[f"{param}_true"] = self.true_params[param]
120
+ row[f"{param}_error"] = value - self.true_params[param]
121
+ row[f"{param}_rel_error"] = (
122
+ (value - self.true_params[param])
123
+ / self.true_params[param]
124
+ * 100
125
+ )
126
+
127
+ results.append(row)
128
+ successful_runs += 1
129
+ print(
130
+ f"✓ Experiment {i+1}/{n_simulations} completed "
131
+ f"successfully",
132
+ )
133
+ else:
134
+ message = "✗ Experiment {}/{} failed to converge".format(
135
+ i + 1,
136
+ n_simulations,
137
+ )
138
+ print(message)
139
+
140
+ except Exception as e:
141
+ print(
142
+ f"✗ Experiment {i+1}/{n_simulations} failed with error: "
143
+ f"{str(e)}",
144
+ )
145
+
146
+ print(
147
+ f"\nCompleted: {successful_runs}/{n_simulations} successful "
148
+ f"experiments",
149
+ )
150
+
151
+ if successful_runs == 0:
152
+ print("No successful experiments. Cannot proceed with analysis.")
153
+ return pd.DataFrame()
154
+
155
+ self.results = pd.DataFrame(results)
156
+ self.completed_experiments = successful_runs
157
+
158
+ return self.results
159
+
160
+ def analyze_results(self) -> Dict[str, Any]:
161
+ """
162
+ Analyze validation results and compute statistics.
163
+
164
+ Returns:
165
+ --------
166
+ dict
167
+ Analysis results including bias, RMSE, etc.
168
+ """
169
+ if len(self.results) == 0:
170
+ print("No results to analyze. Run experiment first.")
171
+ return {}
172
+
173
+ param_names = [col[:-4] for col in self.results.columns if col.endswith("_est")]
174
+ analysis = {}
175
+
176
+ print("\n" + "=" * 60)
177
+ print("VALIDATION ANALYSIS RESULTS")
178
+ print("=" * 60)
179
+
180
+ for param in param_names:
181
+ true_val = self.true_params[param]
182
+ estimated_vals = self.results[f"{param}_est"]
183
+ errors = self.results[f"{param}_error"]
184
+ rel_errors = self.results[f"{param}_rel_error"]
185
+
186
+ stats = {
187
+ "true_value": true_val,
188
+ "mean_estimate": np.mean(estimated_vals),
189
+ "bias": np.mean(errors),
190
+ "rmse": np.sqrt(np.mean(errors**2)),
191
+ "mae": np.mean(np.abs(errors)),
192
+ "mean_rel_error": np.mean(rel_errors),
193
+ "std_estimate": np.std(estimated_vals),
194
+ "coverage_95": np.mean(
195
+ np.abs(rel_errors) <= 5
196
+ ), # Within 5% of true value
197
+ }
198
+
199
+ analysis[param] = stats
200
+
201
+ print(f"\n{param.upper()} (true: {true_val:.4f})")
202
+ print(f" Mean estimate: {stats['mean_estimate']:.6f}")
203
+ print(f" Bias: {stats['bias']:.6f}")
204
+ print(f" RMSE: {stats['rmse']:.6f}")
205
+ print(f" Mean rel. error: {stats['mean_rel_error']:.2f}%")
206
+ print(f" Std deviation: {stats['std_estimate']:.6f}")
207
+ print(f" 95% accuracy: {stats['coverage_95']:.1%}")
208
+
209
+ return analysis
210
+
211
+ def plot_results(self, figsize: tuple = (15, 10)):
212
+ """Create plots summarizing parameter estimation accuracy.
213
+
214
+ Parameters
215
+ ----------
216
+ figsize : tuple, optional
217
+ Width and height of the Matplotlib figure in inches.
218
+
219
+ Notes
220
+ -----
221
+ Generates a grid of subplots showing scatter plots for
222
+ each parameter and a summary bar chart of bias and RMSE. The
223
+ :meth:`run_experiment` method must be called beforehand to
224
+ populate ``self.results``. This function displays the plots
225
+ directly and returns ``None``.
226
+ """
227
+ if len(self.results) == 0:
228
+ print("No results to plot. Run experiment first.")
229
+ return
230
+
231
+ param_names = [col[:-4] for col in self.results.columns if col.endswith("_est")]
232
+ label_map = {
233
+ "mu": "Drift (μ)",
234
+ "sigma": "Volatility (σ)",
235
+ "jump_prob": "Jump Prob (p)",
236
+ "jump_scale": "Jump Scale",
237
+ "jump_skew": "Skewness (α)",
238
+ "jump_loc": "Jump Location",
239
+ "jump_nu": "Kurtosis (ν)",
240
+ "jump_xi": "Asymmetry (ξ)",
241
+ "jump_df": "Degrees of Freedom (df)",
242
+ "jump_prob_up": "Jump Prob Up (p_up)",
243
+ "jump_scale_up": "Jump Scale Up (η_up)",
244
+ "jump_scale_down": "Jump Scale Down (η_down)",
245
+ }
246
+ param_labels = [
247
+ label_map.get(p, p.replace("_", " ").title()) for p in param_names
248
+ ]
249
+
250
+ n_plots = len(param_names) + 1
251
+ n_cols = 3
252
+ n_rows = int(np.ceil(n_plots / n_cols))
253
+
254
+ fig, axes = plt.subplots(n_rows, n_cols, figsize=figsize)
255
+ axes = axes.flatten()
256
+
257
+ # Parameter accuracy plots
258
+ for i, (param, label) in enumerate(zip(param_names, param_labels)):
259
+ ax = axes[i]
260
+
261
+ true_val = self.true_params[param]
262
+ estimated_vals = self.results[f"{param}_est"]
263
+
264
+ # Scatter plot: estimated vs true
265
+ ax.scatter(
266
+ np.full_like(estimated_vals, true_val),
267
+ estimated_vals,
268
+ alpha=0.6,
269
+ s=50,
270
+ )
271
+
272
+ # Perfect estimation line
273
+ ax.axline(
274
+ (true_val, true_val),
275
+ slope=1,
276
+ color="red",
277
+ linestyle="--",
278
+ alpha=0.8,
279
+ label="Perfect Estimation",
280
+ )
281
+
282
+ # Confidence bands (±10% and ±20%)
283
+ ax.axhspan(
284
+ true_val * 0.9,
285
+ true_val * 1.1,
286
+ alpha=0.2,
287
+ color="green",
288
+ label="±10% band",
289
+ )
290
+ ax.axhspan(
291
+ true_val * 0.8,
292
+ true_val * 1.2,
293
+ alpha=0.1,
294
+ color="yellow",
295
+ label="±20% band",
296
+ )
297
+
298
+ ax.set_xlabel("True Value")
299
+ ax.set_ylabel("Estimated Value")
300
+ ax.set_title(f"{label}")
301
+ ax.grid(True, alpha=0.3)
302
+
303
+ if i == 0: # Add legend to first plot
304
+ ax.legend()
305
+
306
+ # Summary statistics plot
307
+ ax = axes[len(param_names)]
308
+ param_biases = []
309
+ for param in param_names:
310
+ param_biases.append(np.mean(self.results[f"{param}_rel_error"]))
311
+ param_rmses = [
312
+ np.sqrt(np.mean(self.results[f"{param}_error"] ** 2))
313
+ / self.true_params[param]
314
+ * 100
315
+ for param in param_names
316
+ ]
317
+
318
+ x = np.arange(len(param_names))
319
+ width = 0.35
320
+
321
+ ax.bar(
322
+ x - width / 2,
323
+ np.abs(param_biases),
324
+ width,
325
+ label="|Bias| (%)",
326
+ alpha=0.7,
327
+ )
328
+ ax.bar(
329
+ x + width / 2,
330
+ param_rmses,
331
+ width,
332
+ label="Rel. RMSE (%)",
333
+ alpha=0.7,
334
+ )
335
+
336
+ ax.set_xlabel("Parameters")
337
+ ax.set_ylabel("Error (%)")
338
+ ax.set_title("Estimation Accuracy Summary")
339
+ ax.set_xticks(x)
340
+ ax.set_xticklabels(param_labels, rotation=45)
341
+ ax.legend()
342
+ ax.grid(True, alpha=0.3)
343
+
344
+ # Delete unused subplots
345
+ for j in range(n_plots, len(axes)):
346
+ fig.delaxes(axes[j])
347
+
348
+ plt.tight_layout()
349
+ plt.show()