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
+ Jump-Diffusion Model Definitions
3
+
4
+ This module contains the mathematical models for various types of
5
+ jump-diffusion processes.
6
+ """
7
+
8
+ from .jump_diffusion import JumpDiffusionModel
9
+ from .base_model import BaseStochasticModel
10
+
11
+ __all__ = [
12
+ "JumpDiffusionModel",
13
+ "BaseStochasticModel",
14
+ ]
@@ -0,0 +1,93 @@
1
+ """
2
+ Base classes for stochastic process models.
3
+
4
+ This module provides abstract base classes that define the interface
5
+ for all stochastic process models in the library.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from typing import Dict, Any, Optional
10
+ import numpy as np
11
+
12
+
13
+ class BaseStochasticModel(ABC):
14
+ """
15
+ Abstract base class for all stochastic process models.
16
+
17
+ This class defines the common interface that all models must implement,
18
+ ensuring consistency and extensibility across different model types.
19
+ """
20
+
21
+ def __init__(self, **kwargs):
22
+ """Initialize the model with parameters."""
23
+ self.parameters = kwargs
24
+ self.fitted = False
25
+
26
+ @abstractmethod
27
+ def simulate(
28
+ self,
29
+ T: float,
30
+ n_steps: int,
31
+ x0: float = 1.0,
32
+ seed: Optional[int] = None,
33
+ ) -> tuple:
34
+ """
35
+ Simulate a path from the stochastic process.
36
+
37
+ Parameters:
38
+ -----------
39
+ T : float
40
+ Total time horizon
41
+ n_steps : int
42
+ Number of time steps
43
+ x0 : float
44
+ Initial value
45
+ seed : int, optional
46
+ Random seed for reproducibility
47
+
48
+ Returns:
49
+ --------
50
+ tuple
51
+ (times, path) or (times, path, additional_info)
52
+ """
53
+ pass
54
+
55
+ @abstractmethod
56
+ def log_likelihood(self, data: np.ndarray, dt: float) -> float:
57
+ """
58
+ Calculate the log-likelihood of observed data.
59
+
60
+ Parameters:
61
+ -----------
62
+ data : np.ndarray
63
+ Observed increments
64
+ dt : float
65
+ Time step size
66
+
67
+ Returns:
68
+ --------
69
+ float
70
+ Log-likelihood value
71
+ """
72
+ pass
73
+
74
+ @abstractmethod
75
+ def get_parameter_bounds(self) -> list:
76
+ """
77
+ Get parameter bounds for optimization.
78
+
79
+ Returns:
80
+ --------
81
+ list
82
+ List of (lower, upper) tuples for each parameter
83
+ """
84
+ pass
85
+
86
+ def update_parameters(self, **kwargs):
87
+ """Update model parameters."""
88
+ self.parameters.update(kwargs)
89
+ self.fitted = False
90
+
91
+ def get_parameters(self) -> Dict[str, Any]:
92
+ """Get current model parameters."""
93
+ return self.parameters.copy()
@@ -0,0 +1,168 @@
1
+ """
2
+ Jump-Diffusion Model Implementation
3
+
4
+ This module implements the jump-diffusion model with a pluggable jump-size
5
+ distribution.
6
+ """
7
+
8
+ import numpy as np
9
+ from scipy.stats import norm
10
+ from typing import Dict, Optional, Tuple
11
+ from .base_model import BaseStochasticModel
12
+ from ..distributions import JumpDistribution, SkewNormalJump
13
+
14
+
15
+ class JumpDiffusionModel(BaseStochasticModel):
16
+ """
17
+ Jump-diffusion model with a pluggable jump-size distribution.
18
+
19
+ The model follows the SDE:
20
+ dX_t = μ dt + σ dW_t + J_t dN_t
21
+
22
+ Where J_t follows the distribution given by ``jump_distribution``
23
+ (skew-normal by default) and N_t is a Poisson process approximated by
24
+ Bernoulli trials.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ mu: float = 0.05,
30
+ sigma: float = 0.2,
31
+ jump_prob: float = 0.1,
32
+ jump_distribution: Optional[JumpDistribution] = None,
33
+ **jump_params: float,
34
+ ):
35
+ r"""
36
+ Initialize the jump-diffusion model.
37
+
38
+ Parameters:
39
+ -----------
40
+ mu : float
41
+ Drift parameter
42
+ sigma : float
43
+ Diffusion volatility
44
+ jump_prob : float
45
+ Probability of jump per time step
46
+ jump_distribution : JumpDistribution, optional
47
+ Distribution used for the jump sizes. Defaults to
48
+ :class:`SkewNormalJump`.
49
+ \*\*jump_params : float
50
+ Values for ``jump_distribution.param_names``. Any name not
51
+ supplied falls back to ``jump_distribution.default_params()``.
52
+ """
53
+ self.jump_distribution = jump_distribution or SkewNormalJump()
54
+
55
+ resolved_jump_params = self.jump_distribution.default_params()
56
+ resolved_jump_params.update(jump_params)
57
+
58
+ super().__init__(
59
+ mu=mu,
60
+ sigma=sigma,
61
+ jump_prob=jump_prob,
62
+ **resolved_jump_params,
63
+ )
64
+
65
+ def _jump_params(self) -> Dict[str, float]:
66
+ """Extract this model's jump-distribution parameter values."""
67
+ return {
68
+ name: self.parameters[name] for name in self.jump_distribution.param_names
69
+ }
70
+
71
+ def simulate(
72
+ self,
73
+ T: float,
74
+ n_steps: int,
75
+ x0: float = 1.0,
76
+ seed: Optional[int] = None,
77
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
78
+ """
79
+ Simulate a jump-diffusion path.
80
+
81
+ Returns:
82
+ --------
83
+ tuple
84
+ (times, path, jumps) where jumps contains the jump component
85
+ """
86
+ if seed is not None:
87
+ np.random.seed(seed)
88
+
89
+ dt = T / n_steps
90
+ times = np.linspace(0, T, n_steps + 1)
91
+ path = np.zeros(n_steps + 1)
92
+ path[0] = x0
93
+
94
+ # Generate innovations
95
+ brownian_innovations = np.random.normal(0, 1, n_steps)
96
+ jump_indicators = np.random.binomial(
97
+ 1,
98
+ self.parameters["jump_prob"],
99
+ n_steps,
100
+ )
101
+ jump_sizes = self.jump_distribution.rvs(self._jump_params(), size=n_steps)
102
+
103
+ # Construct path
104
+ for i in range(n_steps):
105
+ drift = self.parameters["mu"] * dt
106
+ sigma_term = self.parameters["sigma"] * np.sqrt(dt)
107
+ diffusion = sigma_term * brownian_innovations[i]
108
+ jump = jump_indicators[i] * jump_sizes[i]
109
+
110
+ path[i + 1] = path[i] + drift + diffusion + jump
111
+
112
+ jump_components = jump_indicators * jump_sizes
113
+
114
+ return times, path, jump_components
115
+
116
+ def log_likelihood(self, data: np.ndarray, dt: float) -> float:
117
+ """
118
+ Calculate log-likelihood of observed increments.
119
+ """
120
+ increments = data if data.ndim == 1 else np.diff(data)
121
+
122
+ # Calculate mixture densities
123
+ densities = self._mixture_density(increments, dt)
124
+ densities = np.maximum(densities, 1e-300) # Numerical stability
125
+
126
+ return np.sum(np.log(densities))
127
+
128
+ def _mixture_density(self, x: np.ndarray, dt: float) -> np.ndarray:
129
+ """
130
+ Calculate mixture density for each increment.
131
+ """
132
+ mu = self.parameters["mu"]
133
+ sigma = self.parameters["sigma"]
134
+ p = self.parameters["jump_prob"]
135
+
136
+ # Pure diffusion component
137
+ diffusion_mean = mu * dt
138
+ diffusion_std = sigma * np.sqrt(dt)
139
+ diffusion_density = norm.pdf(
140
+ x,
141
+ loc=diffusion_mean,
142
+ scale=diffusion_std,
143
+ )
144
+
145
+ # Jump + diffusion component: use a closed form if the jump
146
+ # distribution has one, otherwise fall back to FFT convolution.
147
+ jump_params = self._jump_params()
148
+ jump_diffusion_density = self.jump_distribution.diffusion_convolved_pdf(
149
+ x, jump_params, diffusion_mean, diffusion_std
150
+ )
151
+ if jump_diffusion_density is None:
152
+ jump_diffusion_density = self.jump_distribution.fft_convolved_pdf(
153
+ x, jump_params, diffusion_mean, diffusion_std
154
+ )
155
+
156
+ # Mixture
157
+ return (1 - p) * diffusion_density + p * jump_diffusion_density
158
+
159
+ def get_parameter_bounds(self) -> list:
160
+ """Get parameter bounds for optimization."""
161
+ bounds = [
162
+ (None, None), # mu
163
+ (1e-6, None), # sigma > 0
164
+ (1e-6, 1 - 1e-6), # 0 < jump_prob < 1
165
+ ]
166
+ jump_bounds = self.jump_distribution.param_bounds()
167
+ bounds += [jump_bounds[name] for name in self.jump_distribution.param_names]
168
+ return bounds
@@ -0,0 +1,3 @@
1
+ """Console scripts for jump-diffusion-estimation."""
2
+
3
+ __all__ = ["validate", "benchmark"]
@@ -0,0 +1,10 @@
1
+ """Placeholder benchmark script."""
2
+
3
+
4
+ def main() -> None:
5
+ """Entry point for future benchmarking utilities."""
6
+ print("Benchmark functionality is not yet implemented.")
7
+
8
+
9
+ if __name__ == "__main__": # pragma: no cover
10
+ main()
@@ -0,0 +1,74 @@
1
+ """Run validation experiments from the command line."""
2
+
3
+ import argparse
4
+ import json
5
+
6
+ from ..validation import ValidationExperiment
7
+
8
+
9
+ def main() -> None:
10
+ parser = argparse.ArgumentParser(
11
+ description="Run jump-diffusion validation experiment"
12
+ )
13
+ parser.add_argument(
14
+ "--n_sims",
15
+ type=int,
16
+ default=10,
17
+ help="Number of simulations",
18
+ )
19
+ parser.add_argument("--T", type=float, default=1.0, help="Time horizon")
20
+ parser.add_argument(
21
+ "--n_steps",
22
+ type=int,
23
+ default=252,
24
+ help="Number of time steps",
25
+ )
26
+ parser.add_argument("--output", type=str, help="Output file for results")
27
+ parser.add_argument("--seed", type=int, default=42, help="Random seed")
28
+
29
+ args = parser.parse_args()
30
+
31
+ true_params = {
32
+ "mu": 0.05,
33
+ "sigma": 0.2,
34
+ "jump_prob": 0.1,
35
+ "jump_scale": 0.15,
36
+ "jump_skew": 2.0,
37
+ }
38
+
39
+ print(f"Running validation with {args.n_sims} simulations...")
40
+ experiment = ValidationExperiment(true_params)
41
+ results_df = experiment.run_experiment(
42
+ n_simulations=args.n_sims,
43
+ T=args.T,
44
+ n_steps=args.n_steps,
45
+ seed_base=args.seed,
46
+ )
47
+
48
+ if len(results_df) == 0:
49
+ print("Validation experiment failed.")
50
+ raise SystemExit(1)
51
+
52
+ analysis = experiment.analyze_results()
53
+
54
+ if args.output:
55
+ output_data = {
56
+ "true_parameters": true_params,
57
+ "experiment_settings": {
58
+ "n_simulations": args.n_sims,
59
+ "T": args.T,
60
+ "n_steps": args.n_steps,
61
+ "seed": args.seed,
62
+ },
63
+ "results": results_df.to_dict("records"),
64
+ "analysis": analysis,
65
+ }
66
+ with open(args.output, "w") as f:
67
+ json.dump(output_data, f, indent=2, default=str)
68
+ print(f"Results saved to {args.output}")
69
+
70
+ experiment.plot_results()
71
+
72
+
73
+ if __name__ == "__main__": # pragma: no cover
74
+ main()
@@ -0,0 +1,14 @@
1
+ """
2
+ Simulation Tools for Jump-Diffusion Processes
3
+
4
+ This module provides tools for simulating various jump-diffusion processes
5
+ with different jump distributions and arrival mechanisms.
6
+ """
7
+
8
+ from .jump_diffusion_simulator import JumpDiffusionSimulator
9
+ from .base_simulator import BaseSimulator
10
+
11
+ __all__ = [
12
+ "JumpDiffusionSimulator",
13
+ "BaseSimulator",
14
+ ]
@@ -0,0 +1,51 @@
1
+ """
2
+ Base simulator class for stochastic processes.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Optional, Tuple
7
+ import numpy as np
8
+
9
+
10
+ class BaseSimulator(ABC):
11
+ """
12
+ Abstract base class for all simulators.
13
+ """
14
+
15
+ def __init__(self, **kwargs):
16
+ """Initialize simulator with model parameters."""
17
+ self.parameters = kwargs
18
+
19
+ @abstractmethod
20
+ def simulate_path(
21
+ self,
22
+ T: float,
23
+ n_steps: int,
24
+ x0: float = 1.0,
25
+ seed: Optional[int] = None,
26
+ ) -> Tuple[np.ndarray, ...]:
27
+ """
28
+ Simulate a single path.
29
+
30
+ Parameters:
31
+ -----------
32
+ T : float
33
+ Time horizon
34
+ n_steps : int
35
+ Number of time steps
36
+ x0 : float
37
+ Initial value
38
+ seed : int, optional
39
+ Random seed
40
+
41
+ Returns:
42
+ --------
43
+ tuple
44
+ Simulation results
45
+ """
46
+ pass
47
+
48
+ @abstractmethod
49
+ def plot_simulation(self, *args, **kwargs):
50
+ """Plot simulation results."""
51
+ pass
@@ -0,0 +1,264 @@
1
+ """
2
+ Jump-Diffusion Simulator Implementation
3
+
4
+ This module contains JumpDiffusionSimulator, which builds on the path
5
+ and likelihood math implemented in :class:`JumpDiffusionModel` and adds
6
+ state-tracking and visualisation on top of it.
7
+ """
8
+
9
+ import numpy as np
10
+ import matplotlib.pyplot as plt
11
+ from typing import Optional, Tuple
12
+ from .base_simulator import BaseSimulator
13
+ from ..models.jump_diffusion import JumpDiffusionModel
14
+ from ..distributions import JumpDistribution
15
+
16
+
17
+ class JumpDiffusionSimulator(BaseSimulator, JumpDiffusionModel):
18
+ """
19
+ Simulator for jump-diffusion processes with asymmetric jumps.
20
+
21
+ Inherits path generation from :class:`JumpDiffusionModel` and adds
22
+ state-tracking (for repeated plotting) and visualisation on top.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ mu: float = 0.05,
28
+ sigma: float = 0.2,
29
+ jump_prob: float = 0.1,
30
+ jump_distribution: Optional[JumpDistribution] = None,
31
+ **jump_params: float,
32
+ ):
33
+ r"""
34
+ Initialize the jump-diffusion simulator.
35
+
36
+ Parameters:
37
+ -----------
38
+ mu : float
39
+ Drift parameter
40
+ sigma : float
41
+ Diffusion volatility
42
+ jump_prob : float
43
+ Probability of jump per period
44
+ jump_distribution : JumpDistribution, optional
45
+ Distribution used for the jump sizes. Defaults to
46
+ :class:`SkewNormalJump`.
47
+ \*\*jump_params : float
48
+ Values for ``jump_distribution.param_names``, e.g.
49
+ ``jump_scale``/``jump_skew`` for the default skew-normal
50
+ distribution. Any name not supplied falls back to
51
+ ``jump_distribution.default_params()``.
52
+ """
53
+ JumpDiffusionModel.__init__(
54
+ self,
55
+ mu=mu,
56
+ sigma=sigma,
57
+ jump_prob=jump_prob,
58
+ jump_distribution=jump_distribution,
59
+ **jump_params,
60
+ )
61
+
62
+ # Store last simulation results
63
+ self.last_path: Optional[np.ndarray] = None
64
+ self.last_jumps: Optional[np.ndarray] = None
65
+ self.last_jump_times: Optional[np.ndarray] = None
66
+
67
+ def simulate_path(
68
+ self,
69
+ T: float = 1.0,
70
+ n_steps: int = 252,
71
+ x0: float = 1.0,
72
+ seed: Optional[int] = None,
73
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
74
+ """
75
+ Simulate a complete jump-diffusion path.
76
+
77
+ Parameters:
78
+ -----------
79
+ T : float
80
+ Total simulation time
81
+ n_steps : int
82
+ Number of time steps
83
+ x0 : float
84
+ Initial value
85
+ seed : int, optional
86
+ Random seed for reproducibility
87
+
88
+ Returns:
89
+ --------
90
+ tuple
91
+ (times, path, jumps)
92
+ """
93
+ times, path, jump_components = self.simulate(
94
+ T=T, n_steps=n_steps, x0=x0, seed=seed
95
+ )
96
+ jump_times = np.where(jump_components != 0)[0]
97
+
98
+ # Store for plotting
99
+ self.last_path = path
100
+ self.last_jumps = jump_components
101
+ self.last_jump_times = jump_times
102
+
103
+ return times, path, jump_components
104
+
105
+ def plot_simulation(
106
+ self,
107
+ times=None,
108
+ path=None,
109
+ jumps=None,
110
+ figsize=(12, 8),
111
+ show_theoretical=True,
112
+ ):
113
+ """
114
+ Plot simulation results with comprehensive diagnostics.
115
+
116
+ Parameters
117
+ ----------
118
+ times : array-like, optional
119
+ Time grid for the plotted path. When ``None``, the time points from
120
+ the most recent call to :meth:`simulate_path` (uniformly spaced
121
+ between 0 and 1) are used.
122
+ path : array-like, optional
123
+ Simulated trajectory to visualise. Defaults to the last simulated
124
+ path stored by the simulator.
125
+ jumps : array-like, optional
126
+ Jump magnitudes for each step. Required to display jump-related
127
+ diagnostics. If ``None``, the jump component from the last
128
+ simulation is used.
129
+ figsize : tuple, optional
130
+ Figure size passed to :func:`matplotlib.pyplot.subplots`.
131
+ show_theoretical : bool, optional
132
+ When ``True`` (default) and at least one jump was realized,
133
+ overlay the jump distribution's theoretical ``pdf`` (evaluated at
134
+ ``self.jump_distribution`` / ``self._jump_params()``) on top of
135
+ the empirical jump-size histogram, so the plot doubles as a
136
+ visual check that simulated jumps match the selected
137
+ distribution's shape.
138
+
139
+ Returns
140
+ -------
141
+ matplotlib.figure.Figure
142
+ The created figure, e.g. for further customization or testing.
143
+
144
+ Notes
145
+ -----
146
+ The method creates a 2×2 grid of plots showing the trajectory with jump
147
+ markers, a stem plot of jump magnitudes, the distribution of increments,
148
+ and the distribution of jump sizes. If no arrays are provided and a
149
+ simulation has been run previously, these plots display the stored
150
+ results. Supplying ``times``, ``path`` and ``jumps`` overrides the stored
151
+ data and visualises the provided arrays instead.
152
+ """
153
+ if times is None or path is None:
154
+ if self.last_path is None:
155
+ raise ValueError(
156
+ "No simulation data available. Run simulate_path() first."
157
+ )
158
+ times = np.linspace(0, 1, len(self.last_path))
159
+ path = self.last_path
160
+ jumps = self.last_jumps
161
+ jump_times = self.last_jump_times
162
+ else:
163
+ jump_times = (
164
+ np.where(jumps != 0)[0]
165
+ if jumps is not None
166
+ else np.array([], dtype=int)
167
+ )
168
+
169
+ fig, axes = plt.subplots(2, 2, figsize=figsize)
170
+
171
+ # Main trajectory plot
172
+ axes[0, 0].plot(times, path, "b-", linewidth=1.5, alpha=0.8)
173
+ if len(jump_times) > 0:
174
+ axes[0, 0].scatter(
175
+ times[1:][jump_times],
176
+ path[1:][jump_times],
177
+ color="red",
178
+ s=30,
179
+ alpha=0.7,
180
+ zorder=5,
181
+ )
182
+ axes[0, 0].set_title("Jump-Diffusion Path")
183
+ axes[0, 0].set_xlabel("Time")
184
+ axes[0, 0].set_ylabel("X(t)")
185
+ axes[0, 0].grid(True, alpha=0.3)
186
+
187
+ # Jump magnitudes
188
+ if len(jump_times) > 0 and jumps is not None:
189
+ jump_magnitudes = jumps[jump_times]
190
+ axes[0, 1].stem(jump_times, jump_magnitudes, basefmt=" ")
191
+ axes[0, 1].set_title(
192
+ f"Detected Jumps (Total: {len(jump_times)})",
193
+ )
194
+ else:
195
+ axes[0, 1].text(
196
+ 0.5,
197
+ 0.5,
198
+ "No jumps detected",
199
+ ha="center",
200
+ va="center",
201
+ transform=axes[0, 1].transAxes,
202
+ )
203
+ axes[0, 1].set_title("Detected Jumps (Total: 0)")
204
+ axes[0, 1].set_xlabel("Time Index")
205
+ axes[0, 1].set_ylabel("Jump Magnitude")
206
+ axes[0, 1].grid(True, alpha=0.3)
207
+
208
+ # Increment distribution
209
+ increments = np.diff(path)
210
+ axes[1, 0].hist(
211
+ increments,
212
+ bins=50,
213
+ density=True,
214
+ alpha=0.7,
215
+ color="skyblue",
216
+ edgecolor="black",
217
+ )
218
+ axes[1, 0].set_title("Distribution of Increments")
219
+ axes[1, 0].set_xlabel("ΔX")
220
+ axes[1, 0].set_ylabel("Density")
221
+ axes[1, 0].grid(True, alpha=0.3)
222
+
223
+ # Jump size distribution
224
+ if len(jump_times) > 0 and jumps is not None:
225
+ actual_jumps = jumps[jumps != 0]
226
+ axes[1, 1].hist(
227
+ actual_jumps,
228
+ bins=20,
229
+ density=True,
230
+ alpha=0.7,
231
+ color="orange",
232
+ edgecolor="black",
233
+ label="Simulated",
234
+ )
235
+ if show_theoretical:
236
+ x_grid = np.linspace(actual_jumps.min(), actual_jumps.max(), 200)
237
+ theoretical_density = self.jump_distribution.pdf(
238
+ x_grid, self._jump_params()
239
+ )
240
+ axes[1, 1].plot(
241
+ x_grid,
242
+ theoretical_density,
243
+ color="darkred",
244
+ linewidth=2,
245
+ label="Theoretical pdf",
246
+ )
247
+ axes[1, 1].legend()
248
+ axes[1, 1].set_title("Jump Size Distribution")
249
+ else:
250
+ axes[1, 1].text(
251
+ 0.5,
252
+ 0.5,
253
+ "No jumps to analyze",
254
+ ha="center",
255
+ va="center",
256
+ transform=axes[1, 1].transAxes,
257
+ )
258
+ axes[1, 1].set_xlabel("Jump Size")
259
+ axes[1, 1].set_ylabel("Density")
260
+ axes[1, 1].grid(True, alpha=0.3)
261
+
262
+ plt.tight_layout()
263
+
264
+ return fig