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,40 @@
1
+ """
2
+ Jump-Diffusion Parameter Estimation Library
3
+
4
+ A comprehensive Python library for simulating and estimating parameters
5
+ of jump-diffusion processes with asymmetric jump distributions.
6
+ """
7
+
8
+ __version__ = "0.2.0"
9
+ __author__ = "Juan David OSPINA ARANGO"
10
+ __email__ = "jdospina@gmail.com"
11
+
12
+ # Core imports for easy access
13
+ from .simulation import JumpDiffusionSimulator
14
+ from .estimation import JumpDiffusionEstimator
15
+ from .models import JumpDiffusionModel
16
+ from .validation import ValidationExperiment
17
+
18
+ # Make key classes available at package level
19
+ __all__ = [
20
+ "JumpDiffusionSimulator",
21
+ "JumpDiffusionEstimator",
22
+ "JumpDiffusionModel",
23
+ "ValidationExperiment",
24
+ ]
25
+
26
+
27
+ # Version information
28
+ def get_version():
29
+ """Return the current version of the library."""
30
+ return __version__
31
+
32
+
33
+ def get_info():
34
+ """Return basic information about the library."""
35
+ return {
36
+ "name": "jump-diffusion-estimation",
37
+ "version": __version__,
38
+ "author": __author__,
39
+ "description": "Jump-Diffusion Parameter Estimation Library",
40
+ }
@@ -0,0 +1,22 @@
1
+ """
2
+ Pluggable Jump-Size Distributions
3
+
4
+ This module contains the jump-size distributions that can be plugged into
5
+ JumpDiffusionModel via its ``jump_distribution`` argument.
6
+ """
7
+
8
+ from .base import JumpDistribution
9
+ from .kou import KouJump
10
+ from .normal import NormalJump
11
+ from .sged import SGEDJump
12
+ from .skew_normal import SkewNormalJump
13
+ from .student_t import StudentTJump
14
+
15
+ __all__ = [
16
+ "JumpDistribution",
17
+ "SkewNormalJump",
18
+ "NormalJump",
19
+ "SGEDJump",
20
+ "KouJump",
21
+ "StudentTJump",
22
+ ]
@@ -0,0 +1,152 @@
1
+ """
2
+ Base interface for pluggable jump-size distributions.
3
+
4
+ This module defines the ``JumpDistribution`` abstract interface used by
5
+ :class:`~jump_diffusion.models.jump_diffusion.JumpDiffusionModel` to plug in
6
+ different jump-magnitude distributions. Concrete distributions only need to
7
+ implement the probability density (``pdf``) plus a few descriptive methods;
8
+ the diffusion-convolved mixture density and random sampling have generic
9
+ numerical fallbacks here, so any new distribution works out of the box.
10
+ Closed-form overrides remain available for speed where they exist (see
11
+ ``SkewNormalJump`` and ``NormalJump``).
12
+ """
13
+
14
+ from abc import ABC, abstractmethod
15
+ from typing import Dict, Optional, Tuple
16
+
17
+ import numpy as np
18
+ from scipy.stats import norm
19
+
20
+
21
+ class JumpDistribution(ABC):
22
+ """Abstract interface for a jump-size distribution."""
23
+
24
+ param_names: Tuple[str, ...] = ()
25
+
26
+ @abstractmethod
27
+ def default_params(self) -> Dict[str, float]:
28
+ """Return sensible default parameter values."""
29
+
30
+ @abstractmethod
31
+ def pdf(self, x: np.ndarray, params: Dict[str, float]) -> np.ndarray:
32
+ """Probability density of the jump size at ``x``."""
33
+
34
+ @abstractmethod
35
+ def param_bounds(self) -> Dict[str, Tuple[Optional[float], Optional[float]]]:
36
+ """Optimization bounds for each parameter in ``param_names``."""
37
+
38
+ @abstractmethod
39
+ def initial_guess(
40
+ self,
41
+ mean_increment: float,
42
+ std_increment: float,
43
+ skewness: float,
44
+ ) -> Dict[str, float]:
45
+ """Heuristic initial parameter guess derived from data moments."""
46
+
47
+ def characteristic_scale(self, params: Dict[str, float]) -> float:
48
+ """
49
+ Rough scale of the jump size, used to size the FFT/inverse-CDF grids
50
+ in :meth:`fft_convolved_pdf` and :meth:`rvs`.
51
+
52
+ Default reads ``jump_scale`` directly, the convention used by
53
+ ``NormalJump``/``SkewNormalJump``/``SGEDJump``. Override this for
54
+ distributions with a different parameterization (e.g. ``KouJump``,
55
+ which has separate up/down scales instead of a single one).
56
+ """
57
+ return params.get("jump_scale", 1.0)
58
+
59
+ def diffusion_convolved_pdf(
60
+ self,
61
+ x: np.ndarray,
62
+ params: Dict[str, float],
63
+ diffusion_mean: float,
64
+ diffusion_std: float,
65
+ ) -> Optional[np.ndarray]:
66
+ """
67
+ Closed-form density of (diffusion + jump), if one is known.
68
+
69
+ Returns ``None`` when no closed form is available, signalling
70
+ callers to fall back to :meth:`fft_convolved_pdf`.
71
+ """
72
+ return None
73
+
74
+ def fft_convolved_pdf(
75
+ self,
76
+ x: np.ndarray,
77
+ params: Dict[str, float],
78
+ diffusion_mean: float,
79
+ diffusion_std: float,
80
+ j: int = 15,
81
+ h: Optional[float] = None,
82
+ ) -> np.ndarray:
83
+ """
84
+ Numerically approximate the density of (diffusion + jump) via FFT
85
+ convolution.
86
+
87
+ Implements the discretization scheme from Ospina Arango (2009),
88
+ "Estimacion de un modelo de difusion con saltos con distribucion de
89
+ error generalizada asimetrica usando algoritmos evolutivos"
90
+ (Universidad Nacional de Colombia): both densities are discretized
91
+ on a symmetric grid of ``2 * 2**j`` half-integer-offset points
92
+ around zero, convolved via FFT, and linearly interpolated to
93
+ evaluate at the requested points.
94
+ """
95
+ x = np.asarray(x, dtype=float)
96
+ jump_std = self.characteristic_scale(params)
97
+ if jump_std is None or jump_std <= 0:
98
+ return np.zeros_like(x)
99
+
100
+ if h is None:
101
+ h = min(diffusion_std, jump_std) / 200.0
102
+
103
+ m = 2**j
104
+ k = np.arange(-m + 0.5, m, 1.0) # length 2*m, half-integer offsets
105
+ x_grid = k * h
106
+
107
+ f_diffusion = norm.pdf(x_grid, loc=diffusion_mean, scale=diffusion_std)
108
+ f_jump = self.pdf(x_grid, params)
109
+ if not (np.all(np.isfinite(f_diffusion)) and np.all(np.isfinite(f_jump))):
110
+ return np.zeros_like(x)
111
+
112
+ # numpy's ifft already divides by n, unlike R's fft(..., inverse=TRUE)
113
+ conv = np.real(np.fft.ifft(np.fft.fft(f_diffusion) * np.fft.fft(f_jump)))
114
+ conv *= h
115
+ conv = np.concatenate([conv[m:], conv[:m]]) # re-center around x_grid
116
+ conv = np.maximum(conv, 0.0)
117
+
118
+ if x.min() < x_grid[0] or x.max() > x_grid[-1]:
119
+ return np.zeros_like(x)
120
+
121
+ return np.interp(x, x_grid, conv)
122
+
123
+ def rvs(
124
+ self,
125
+ params: Dict[str, float],
126
+ size: int,
127
+ random_state: Optional[np.random.Generator] = None,
128
+ ) -> np.ndarray:
129
+ """
130
+ Draw random jump sizes via numerical inverse-CDF sampling.
131
+
132
+ Generic fallback for distributions without a native fast sampler:
133
+ discretizes ``pdf`` on a grid (in the same spirit as
134
+ :meth:`fft_convolved_pdf`) and inverts its cumulative sum.
135
+
136
+ When ``random_state`` is ``None`` (the default), draws from
137
+ NumPy's global legacy random state -- the same one seeded by
138
+ ``JumpDiffusionModel.simulate(seed=...)`` via ``np.random.seed``
139
+ -- so that simulations stay reproducible. Pass an explicit
140
+ ``np.random.Generator`` for an isolated, independent stream.
141
+ """
142
+ jump_std = self.characteristic_scale(params)
143
+ h = jump_std / 200.0
144
+ m = 2**12 # a coarser grid than fft_convolved_pdf suffices here
145
+ k = np.arange(-m + 0.5, m, 1.0)
146
+ x_grid = k * h
147
+ density = np.maximum(self.pdf(x_grid, params), 0.0)
148
+ cdf = np.cumsum(density) * h
149
+ cdf /= cdf[-1]
150
+ rand = random_state if random_state is not None else np.random
151
+ u = rand.uniform(0.0, 1.0, size=size)
152
+ return np.interp(u, cdf, x_grid)
@@ -0,0 +1,104 @@
1
+ """
2
+ Kou (2002) asymmetric double-exponential jump distribution.
3
+
4
+ Jump sizes are a mixture of one-sided exponentials: with probability
5
+ ``jump_prob_up`` the jump is positive with mean ``jump_scale_up``, otherwise
6
+ it is negative with mean magnitude ``jump_scale_down``. Unlike the
7
+ symmetric-around-zero ``NormalJump``/``SkewNormalJump`` parameterizations,
8
+ this distribution has no separate loc/skew parameter -- asymmetry comes
9
+ directly from ``jump_prob_up`` and the two scales differing.
10
+
11
+ The convolution of a one-sided exponential with an independent normal is the
12
+ well-known exponentially-modified-Gaussian ("ex-Gaussian") density, already
13
+ implemented (numerically stably, via ``erfcx``) as ``scipy.stats.exponnorm``.
14
+ Kou's double-exponential is just a ``jump_prob_up``-weighted mixture of two
15
+ such terms (one direct, one reflected for the downward branch), so the
16
+ closed-form diffusion-convolved density below reuses ``exponnorm`` rather
17
+ than re-deriving/re-implementing the stability tricks by hand. Verified
18
+ against direct numerical convolution in tests.
19
+ """
20
+
21
+ from typing import Dict, Optional, Tuple, cast
22
+
23
+ import numpy as np
24
+ from scipy.stats import expon, exponnorm
25
+
26
+ from .base import JumpDistribution
27
+
28
+
29
+ class KouJump(JumpDistribution):
30
+ """Jump sizes distributed as Kou's asymmetric double exponential."""
31
+
32
+ param_names: Tuple[str, ...] = ("jump_prob_up", "jump_scale_up", "jump_scale_down")
33
+
34
+ def default_params(self) -> Dict[str, float]:
35
+ return {"jump_prob_up": 0.5, "jump_scale_up": 0.1, "jump_scale_down": 0.1}
36
+
37
+ def pdf(self, x: np.ndarray, params: Dict[str, float]) -> np.ndarray:
38
+ p = params["jump_prob_up"]
39
+ x = np.asarray(x, dtype=float)
40
+ up = p * expon.pdf(x, scale=params["jump_scale_up"])
41
+ down = (1 - p) * expon.pdf(-x, scale=params["jump_scale_down"])
42
+ return cast(np.ndarray, up + down)
43
+
44
+ def param_bounds(self) -> Dict[str, Tuple[Optional[float], Optional[float]]]:
45
+ return {
46
+ "jump_prob_up": (1e-6, 1 - 1e-6),
47
+ "jump_scale_up": (1e-6, None),
48
+ "jump_scale_down": (1e-6, None),
49
+ }
50
+
51
+ def characteristic_scale(self, params: Dict[str, float]) -> float:
52
+ return max(params["jump_scale_up"], params["jump_scale_down"])
53
+
54
+ def initial_guess(
55
+ self,
56
+ mean_increment: float,
57
+ std_increment: float,
58
+ skewness: float,
59
+ ) -> Dict[str, float]:
60
+ return {
61
+ "jump_prob_up": float(np.clip(0.5 + 0.1 * np.sign(skewness), 0.1, 0.9)),
62
+ "jump_scale_up": std_increment * 0.5,
63
+ "jump_scale_down": std_increment * 0.5,
64
+ }
65
+
66
+ def diffusion_convolved_pdf(
67
+ self,
68
+ x: np.ndarray,
69
+ params: Dict[str, float],
70
+ diffusion_mean: float,
71
+ diffusion_std: float,
72
+ ) -> Optional[np.ndarray]:
73
+ p = params["jump_prob_up"]
74
+ x = np.asarray(x, dtype=float)
75
+
76
+ up = p * exponnorm.pdf(
77
+ x,
78
+ K=params["jump_scale_up"] / diffusion_std,
79
+ loc=diffusion_mean,
80
+ scale=diffusion_std,
81
+ )
82
+ down = (1 - p) * exponnorm.pdf(
83
+ -x,
84
+ K=params["jump_scale_down"] / diffusion_std,
85
+ loc=-diffusion_mean,
86
+ scale=diffusion_std,
87
+ )
88
+ return cast(np.ndarray, up + down)
89
+
90
+ def rvs(
91
+ self,
92
+ params: Dict[str, float],
93
+ size: int,
94
+ random_state: Optional[np.random.Generator] = None,
95
+ ) -> np.ndarray:
96
+ rand = random_state if random_state is not None else np.random
97
+ is_up = rand.uniform(0.0, 1.0, size=size) < params["jump_prob_up"]
98
+ up = expon.rvs(
99
+ scale=params["jump_scale_up"], size=size, random_state=random_state
100
+ )
101
+ down = expon.rvs(
102
+ scale=params["jump_scale_down"], size=size, random_state=random_state
103
+ )
104
+ return cast(np.ndarray, np.where(is_up, up, -down))
@@ -0,0 +1,73 @@
1
+ """
2
+ Normal jump distribution (Merton, 1976).
3
+
4
+ The classic symmetric jump-diffusion model. Nested inside
5
+ :class:`~jump_diffusion.distributions.skew_normal.SkewNormalJump` at
6
+ ``jump_skew=0``, which makes it a natural null model for a
7
+ likelihood-ratio test against the skew-normal or SGED jump models.
8
+ """
9
+
10
+ from typing import Dict, Optional, Tuple, cast
11
+
12
+ import numpy as np
13
+ from scipy.stats import norm
14
+
15
+ from .base import JumpDistribution
16
+
17
+
18
+ class NormalJump(JumpDistribution):
19
+ """
20
+ Jump sizes distributed as a normal centered at zero.
21
+
22
+ References:
23
+ - Merton, R. C. (1976). Option pricing when underlying stock returns
24
+ are discontinuous. Journal of financial economics, 3(1-2), 125-144.
25
+ """
26
+
27
+ param_names: Tuple[str, ...] = ("jump_scale",)
28
+
29
+ def default_params(self) -> Dict[str, float]:
30
+ return {"jump_scale": 0.15}
31
+
32
+ def pdf(self, x: np.ndarray, params: Dict[str, float]) -> np.ndarray:
33
+ return cast(np.ndarray, norm.pdf(x, loc=0, scale=params["jump_scale"]))
34
+
35
+ def param_bounds(self) -> Dict[str, Tuple[Optional[float], Optional[float]]]:
36
+ return {"jump_scale": (1e-6, None)}
37
+
38
+ def initial_guess(
39
+ self,
40
+ mean_increment: float,
41
+ std_increment: float,
42
+ skewness: float,
43
+ ) -> Dict[str, float]:
44
+ return {"jump_scale": std_increment * 0.5}
45
+
46
+ def diffusion_convolved_pdf(
47
+ self,
48
+ x: np.ndarray,
49
+ params: Dict[str, float],
50
+ diffusion_mean: float,
51
+ diffusion_std: float,
52
+ ) -> Optional[np.ndarray]:
53
+ combined_std = np.sqrt(diffusion_std**2 + params["jump_scale"] ** 2)
54
+ return cast(
55
+ np.ndarray,
56
+ norm.pdf(x, loc=diffusion_mean, scale=combined_std),
57
+ )
58
+
59
+ def rvs(
60
+ self,
61
+ params: Dict[str, float],
62
+ size: int,
63
+ random_state: Optional[np.random.Generator] = None,
64
+ ) -> np.ndarray:
65
+ return cast(
66
+ np.ndarray,
67
+ norm.rvs(
68
+ loc=0,
69
+ scale=params["jump_scale"],
70
+ size=size,
71
+ random_state=random_state,
72
+ ),
73
+ )
@@ -0,0 +1,113 @@
1
+ """
2
+ Skewed Generalized Error Distribution (SGED) jump distribution.
3
+
4
+ Ported from Ospina Arango (2009), "Estimacion de un modelo de difusion con
5
+ saltos con distribucion de error generalizada asimetrica usando algoritmos
6
+ evolutivos" (Universidad Nacional de Colombia, Escuela de Estadistica). This
7
+ is the Fernandez & Steel (1998) skewed extension of the Generalized Error
8
+ Distribution (GED), in the parameterization used by the ``fGarch`` R
9
+ package: X ~ SGED(loc, scale, nu, xi) has E[X] = loc and Var[X] = scale**2.
10
+
11
+ Special cases (verified against the closed forms in tests):
12
+ nu=2, xi=1 -> standard normal
13
+ nu=1, xi=1 -> standard Laplace
14
+
15
+ No closed form is known for the convolution of SGED with an independent
16
+ normal, so this distribution relies on the generic FFT-convolution and
17
+ inverse-CDF sampling fallbacks defined in ``JumpDistribution``.
18
+ """
19
+
20
+ from typing import Dict, Optional, Tuple
21
+
22
+ import numpy as np
23
+ from scipy.special import gamma
24
+
25
+ from .base import JumpDistribution
26
+
27
+
28
+ def _ged_lambda(nu: float) -> float:
29
+ return float(np.sqrt(2 ** (-2 / nu) * gamma(1 / nu) / gamma(3 / nu)))
30
+
31
+
32
+ def _ged_pdf(z: np.ndarray, nu: float) -> np.ndarray:
33
+ """Standardized (mean 0, var 1) Generalized Error Distribution pdf."""
34
+ lam = _ged_lambda(nu)
35
+ return (
36
+ nu
37
+ * np.exp(-0.5 * np.abs(z / lam) ** nu)
38
+ / (lam * 2 ** (1 + 1 / nu) * gamma(1 / nu))
39
+ )
40
+
41
+
42
+ def _fernandez_steel_pdf(z: np.ndarray, nu: float, xi: float) -> np.ndarray:
43
+ """Fernandez & Steel skewed GED, not yet re-centered/re-scaled."""
44
+ sign = np.sign(z)
45
+ return (2.0 / (xi + 1.0 / xi)) * _ged_pdf(np.power(xi, -sign) * z, nu)
46
+
47
+
48
+ def _skew_moments(nu: float, xi: float) -> Tuple[float, float]:
49
+ """Mean and std of the (not yet standardized) skewed GED."""
50
+ lam = _ged_lambda(nu)
51
+ m1 = gamma(2 / nu) * lam * 2 ** (1 / nu) / gamma(1 / nu)
52
+ m2 = gamma(3 / nu) * lam**2 * 2 ** (2 / nu) / gamma(1 / nu)
53
+ mu_xi = (xi - 1 / xi) * m1
54
+ sigma_xi2 = (xi**2 + 1 / xi**2) * (m2 - m1**2) + 2 * m1**2 - m2
55
+ return float(mu_xi), float(np.sqrt(sigma_xi2))
56
+
57
+
58
+ def _standardized_sged_pdf(z: np.ndarray, nu: float, xi: float) -> np.ndarray:
59
+ """Mean-0, var-1 SGED density (eq. densidadsged in the thesis)."""
60
+ mu_xi, sigma_xi = _skew_moments(nu, xi)
61
+ z_xi = sigma_xi * z + mu_xi
62
+ return sigma_xi * _fernandez_steel_pdf(z_xi, nu, xi)
63
+
64
+
65
+ class SGEDJump(JumpDistribution):
66
+ """
67
+ Jump sizes distributed as a Skewed Generalized Error Distribution.
68
+
69
+ References:
70
+ - Ospina Arango, J. D. (2009). Tesis de Maestría. Universidad Nacional de Colombia.
71
+ - Theodossiou, P. (2015). Skewed Generalized Error Distribution of
72
+ Financial Assets and Option Pricing. Multinational Finance Journal,
73
+ 19(4), 223-266.
74
+ """
75
+
76
+ param_names: Tuple[str, ...] = ("jump_loc", "jump_scale", "jump_nu", "jump_xi")
77
+
78
+ def default_params(self) -> Dict[str, float]:
79
+ return {
80
+ "jump_loc": 0.0,
81
+ "jump_scale": 0.15,
82
+ "jump_nu": 2.0,
83
+ "jump_xi": 1.0,
84
+ }
85
+
86
+ def pdf(self, x: np.ndarray, params: Dict[str, float]) -> np.ndarray:
87
+ loc = params["jump_loc"]
88
+ scale = params["jump_scale"]
89
+ nu = params["jump_nu"]
90
+ xi = params["jump_xi"]
91
+ z = (np.asarray(x, dtype=float) - loc) / scale
92
+ return _standardized_sged_pdf(z, nu, xi) / scale
93
+
94
+ def param_bounds(self) -> Dict[str, Tuple[Optional[float], Optional[float]]]:
95
+ return {
96
+ "jump_loc": (None, None),
97
+ "jump_scale": (1e-6, None),
98
+ "jump_nu": (0.1, 30.0),
99
+ "jump_xi": (0.05, 20.0),
100
+ }
101
+
102
+ def initial_guess(
103
+ self,
104
+ mean_increment: float,
105
+ std_increment: float,
106
+ skewness: float,
107
+ ) -> Dict[str, float]:
108
+ return {
109
+ "jump_loc": 0.0,
110
+ "jump_scale": std_increment * 0.5,
111
+ "jump_nu": 2.0,
112
+ "jump_xi": 1.0 + float(np.sign(skewness)) * 0.3,
113
+ }
@@ -0,0 +1,97 @@
1
+ """
2
+ Skew-normal jump distribution.
3
+
4
+ Migrated from the logic previously hardcoded in ``JumpDiffusionModel``. The
5
+ skew-normal family is closed under convolution with an independent normal
6
+ (Azzalini & Henze, 1986), which is why a closed-form diffusion-convolved
7
+ density is available here instead of falling back to FFT convolution.
8
+ """
9
+
10
+ from typing import Dict, Optional, Tuple, cast
11
+
12
+ import numpy as np
13
+ from scipy.stats import skewnorm
14
+
15
+ from .base import JumpDistribution
16
+
17
+
18
+ class SkewNormalJump(JumpDistribution):
19
+ """Jump sizes distributed as a skew-normal centered at zero."""
20
+
21
+ param_names: Tuple[str, ...] = ("jump_scale", "jump_skew")
22
+
23
+ def default_params(self) -> Dict[str, float]:
24
+ return {"jump_scale": 0.15, "jump_skew": 0.0}
25
+
26
+ def pdf(self, x: np.ndarray, params: Dict[str, float]) -> np.ndarray:
27
+ return cast(
28
+ np.ndarray,
29
+ skewnorm.pdf(
30
+ x,
31
+ a=params["jump_skew"],
32
+ loc=0,
33
+ scale=params["jump_scale"],
34
+ ),
35
+ )
36
+
37
+ def param_bounds(self) -> Dict[str, Tuple[Optional[float], Optional[float]]]:
38
+ return {
39
+ "jump_scale": (1e-6, None),
40
+ "jump_skew": (-10, 10),
41
+ }
42
+
43
+ def initial_guess(
44
+ self,
45
+ mean_increment: float,
46
+ std_increment: float,
47
+ skewness: float,
48
+ ) -> Dict[str, float]:
49
+ return {
50
+ "jump_scale": std_increment * 0.5,
51
+ "jump_skew": float(np.sign(skewness)) * 2.0,
52
+ }
53
+
54
+ def diffusion_convolved_pdf(
55
+ self,
56
+ x: np.ndarray,
57
+ params: Dict[str, float],
58
+ diffusion_mean: float,
59
+ diffusion_std: float,
60
+ ) -> Optional[np.ndarray]:
61
+ omega = params["jump_scale"]
62
+ alpha = params["jump_skew"]
63
+
64
+ combined_var = diffusion_std**2 + omega**2
65
+ combined_std = np.sqrt(combined_var)
66
+
67
+ # Azzalini & Henze (1986): SN(0,omega,alpha) + N(0,diffusion_std^2)
68
+ # is again skew-normal, but the new shape parameter is obtained by
69
+ # going through the "delta" parameterization (delta = alpha /
70
+ # sqrt(1+alpha^2)), not simply alpha*omega/combined_std -- that
71
+ # simpler-looking expression is off by ~0.2% at alpha=2, verified
72
+ # against direct numerical convolution.
73
+ delta = alpha / np.sqrt(1 + alpha**2)
74
+ adjusted_delta = omega * delta / combined_std
75
+ adjusted_skew = adjusted_delta / np.sqrt(1 - adjusted_delta**2)
76
+
77
+ return cast(
78
+ np.ndarray,
79
+ skewnorm.pdf(x, a=adjusted_skew, loc=diffusion_mean, scale=combined_std),
80
+ )
81
+
82
+ def rvs(
83
+ self,
84
+ params: Dict[str, float],
85
+ size: int,
86
+ random_state: Optional[np.random.Generator] = None,
87
+ ) -> np.ndarray:
88
+ return cast(
89
+ np.ndarray,
90
+ skewnorm.rvs(
91
+ a=params["jump_skew"],
92
+ loc=0,
93
+ scale=params["jump_scale"],
94
+ size=size,
95
+ random_state=random_state,
96
+ ),
97
+ )
@@ -0,0 +1,86 @@
1
+ """
2
+ Student-t jump distribution.
3
+
4
+ Location-scale Student-t, standardized so that ``jump_scale`` is the actual
5
+ standard deviation of the jump (matching the convention used elsewhere in
6
+ this package -- e.g. ``SGEDJump`` -- rather than scipy's raw ``t`` scale
7
+ parameter, whose relationship to the standard deviation depends on the
8
+ degrees of freedom). This is the same "standardized Student-t"
9
+ parameterization used for Student-t innovations in GARCH-t models
10
+ (Bollerslev, 1987): ``jump_df`` alone controls tail fatness, independent of
11
+ overall spread.
12
+
13
+ No closed form is known for the convolution of Student-t with an
14
+ independent normal, so this distribution relies on the generic
15
+ FFT-convolution and inverse-CDF fallbacks defined in ``JumpDistribution``.
16
+ """
17
+
18
+ from typing import Dict, Optional, Tuple, cast
19
+
20
+ import numpy as np
21
+ from scipy.stats import t
22
+
23
+ from .base import JumpDistribution
24
+
25
+
26
+ def _scipy_scale(scale: float, df: float) -> float:
27
+ """Convert the standardized (std-dev) scale to scipy's raw ``t`` scale."""
28
+ return scale * np.sqrt((df - 2) / df)
29
+
30
+
31
+ class StudentTJump(JumpDistribution):
32
+ """Jump sizes distributed as a (standardized) Student-t."""
33
+
34
+ param_names: Tuple[str, ...] = ("jump_loc", "jump_scale", "jump_df")
35
+
36
+ def default_params(self) -> Dict[str, float]:
37
+ return {"jump_loc": 0.0, "jump_scale": 0.15, "jump_df": 5.0}
38
+
39
+ def pdf(self, x: np.ndarray, params: Dict[str, float]) -> np.ndarray:
40
+ df = params["jump_df"]
41
+ return cast(
42
+ np.ndarray,
43
+ t.pdf(
44
+ x,
45
+ df=df,
46
+ loc=params["jump_loc"],
47
+ scale=_scipy_scale(params["jump_scale"], df),
48
+ ),
49
+ )
50
+
51
+ def param_bounds(self) -> Dict[str, Tuple[Optional[float], Optional[float]]]:
52
+ return {
53
+ "jump_loc": (None, None),
54
+ "jump_scale": (1e-6, None),
55
+ "jump_df": (2.05, 100.0),
56
+ }
57
+
58
+ def initial_guess(
59
+ self,
60
+ mean_increment: float,
61
+ std_increment: float,
62
+ skewness: float,
63
+ ) -> Dict[str, float]:
64
+ return {
65
+ "jump_loc": float(np.sign(skewness)) * std_increment * 0.1,
66
+ "jump_scale": std_increment * 0.5,
67
+ "jump_df": 5.0,
68
+ }
69
+
70
+ def rvs(
71
+ self,
72
+ params: Dict[str, float],
73
+ size: int,
74
+ random_state: Optional[np.random.Generator] = None,
75
+ ) -> np.ndarray:
76
+ df = params["jump_df"]
77
+ return cast(
78
+ np.ndarray,
79
+ t.rvs(
80
+ df=df,
81
+ loc=params["jump_loc"],
82
+ scale=_scipy_scale(params["jump_scale"], df),
83
+ size=size,
84
+ random_state=random_state,
85
+ ),
86
+ )