jump-diffusion-estimation 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jump_diffusion/__init__.py +40 -0
- jump_diffusion/distributions/__init__.py +22 -0
- jump_diffusion/distributions/base.py +152 -0
- jump_diffusion/distributions/kou.py +104 -0
- jump_diffusion/distributions/normal.py +73 -0
- jump_diffusion/distributions/sged.py +113 -0
- jump_diffusion/distributions/skew_normal.py +97 -0
- jump_diffusion/distributions/student_t.py +86 -0
- jump_diffusion/estimation/__init__.py +19 -0
- jump_diffusion/estimation/base_estimator.py +62 -0
- jump_diffusion/estimation/maximum_likelihood.py +1239 -0
- jump_diffusion/models/__init__.py +14 -0
- jump_diffusion/models/base_model.py +93 -0
- jump_diffusion/models/jump_diffusion.py +168 -0
- jump_diffusion/scripts/__init__.py +3 -0
- jump_diffusion/scripts/benchmark.py +10 -0
- jump_diffusion/scripts/validate.py +74 -0
- jump_diffusion/simulation/__init__.py +14 -0
- jump_diffusion/simulation/base_simulator.py +51 -0
- jump_diffusion/simulation/jump_diffusion_simulator.py +264 -0
- jump_diffusion/validation/__init__.py +14 -0
- jump_diffusion/validation/distribution_comparison.py +270 -0
- jump_diffusion/validation/monte_carlo.py +349 -0
- jump_diffusion_estimation-0.2.0.dist-info/METADATA +241 -0
- jump_diffusion_estimation-0.2.0.dist-info/RECORD +29 -0
- jump_diffusion_estimation-0.2.0.dist-info/WHEEL +5 -0
- jump_diffusion_estimation-0.2.0.dist-info/entry_points.txt +3 -0
- jump_diffusion_estimation-0.2.0.dist-info/licenses/LICENSE +25 -0
- jump_diffusion_estimation-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|