hfvol 0.1.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.
- hfvol/__init__.py +85 -0
- hfvol/forecasting.py +153 -0
- hfvol/har.py +114 -0
- hfvol/hawkes.py +323 -0
- hfvol/py.typed +0 -0
- hfvol/realized.py +161 -0
- hfvol/transformers.py +416 -0
- hfvol-0.1.0.dist-info/METADATA +119 -0
- hfvol-0.1.0.dist-info/RECORD +12 -0
- hfvol-0.1.0.dist-info/WHEEL +4 -0
- hfvol-0.1.0.dist-info/licenses/LICENSE +21 -0
- hfvol-0.1.0.dist-info/licenses/NOTICE +12 -0
hfvol/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""hfvol: realized variance, bipower and jump variation, Hawkes intensity and HAR-RV.
|
|
2
|
+
|
|
3
|
+
numpy functions live in :mod:`hfvol.realized`, :mod:`hfvol.har` and
|
|
4
|
+
:mod:`hfvol.hawkes`. The sktime estimators (``RealizedVariance``,
|
|
5
|
+
``BipowerVariation``, ``JumpVariation``, ``HawkesIntensity``, ``HARForecaster``)
|
|
6
|
+
are loaded on first access and need ``pip install hfvol[sktime]``.
|
|
7
|
+
|
|
8
|
+
Carved out of srfm-lab (https://github.com/Mattbusel/srfm-lab), MIT.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from hfvol.har import HARParams, fit_har, har_features, har_forecast, har_forecast_path
|
|
14
|
+
from hfvol.hawkes import (
|
|
15
|
+
HawkesParams,
|
|
16
|
+
branching_ratio,
|
|
17
|
+
fit_hawkes_mle,
|
|
18
|
+
hawkes_compensator,
|
|
19
|
+
hawkes_intensity_path,
|
|
20
|
+
hawkes_log_likelihood,
|
|
21
|
+
hawkes_residual_analysis,
|
|
22
|
+
hawkes_simulate,
|
|
23
|
+
)
|
|
24
|
+
from hfvol.realized import (
|
|
25
|
+
bipower_variation,
|
|
26
|
+
bipower_variation_series,
|
|
27
|
+
jump_variation,
|
|
28
|
+
jump_variation_series,
|
|
29
|
+
realized_variance,
|
|
30
|
+
realized_variance_series,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
|
|
35
|
+
_SKTIME = {
|
|
36
|
+
"RealizedVariance": "hfvol.transformers",
|
|
37
|
+
"BipowerVariation": "hfvol.transformers",
|
|
38
|
+
"JumpVariation": "hfvol.transformers",
|
|
39
|
+
"HawkesIntensity": "hfvol.transformers",
|
|
40
|
+
"HARForecaster": "hfvol.forecasting",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"__version__",
|
|
45
|
+
"HARParams",
|
|
46
|
+
"HawkesParams",
|
|
47
|
+
"bipower_variation",
|
|
48
|
+
"bipower_variation_series",
|
|
49
|
+
"branching_ratio",
|
|
50
|
+
"fit_har",
|
|
51
|
+
"fit_hawkes_mle",
|
|
52
|
+
"har_features",
|
|
53
|
+
"har_forecast",
|
|
54
|
+
"har_forecast_path",
|
|
55
|
+
"hawkes_compensator",
|
|
56
|
+
"hawkes_intensity_path",
|
|
57
|
+
"hawkes_log_likelihood",
|
|
58
|
+
"hawkes_residual_analysis",
|
|
59
|
+
"hawkes_simulate",
|
|
60
|
+
"jump_variation",
|
|
61
|
+
"jump_variation_series",
|
|
62
|
+
"realized_variance",
|
|
63
|
+
"realized_variance_series",
|
|
64
|
+
*_SKTIME,
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def __getattr__(name):
|
|
69
|
+
module = _SKTIME.get(name)
|
|
70
|
+
if module is None:
|
|
71
|
+
raise AttributeError(f"module 'hfvol' has no attribute {name!r}")
|
|
72
|
+
try:
|
|
73
|
+
import importlib
|
|
74
|
+
|
|
75
|
+
return getattr(importlib.import_module(module), name)
|
|
76
|
+
except ImportError as exc:
|
|
77
|
+
if "sktime" in str(exc):
|
|
78
|
+
raise ImportError(
|
|
79
|
+
f"hfvol.{name} is an sktime estimator; install it with pip install 'hfvol[sktime]'"
|
|
80
|
+
) from exc
|
|
81
|
+
raise
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def __dir__():
|
|
85
|
+
return sorted(__all__)
|
hfvol/forecasting.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""sktime forecaster for the HAR-RV model (Corsi 2009).
|
|
2
|
+
|
|
3
|
+
Built from sktime's ``extension_templates/forecasting.py``. Requires sktime
|
|
4
|
+
(``pip install hfvol[sktime]``); :mod:`hfvol.har` does the work.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from sktime.forecasting.base import BaseForecaster
|
|
12
|
+
|
|
13
|
+
from hfvol.har import fit_har, har_forecast_path
|
|
14
|
+
|
|
15
|
+
__all__ = ["HARForecaster"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HARForecaster(BaseForecaster):
|
|
19
|
+
"""Heterogeneous autoregressive (HAR-RV) forecaster for realized variance.
|
|
20
|
+
|
|
21
|
+
``RV_{t+1} = c + b_d RV_t + b_w mean(RV_{t-w+1..t}) + b_m mean(RV_{t-m+1..t})``
|
|
22
|
+
|
|
23
|
+
fitted by OLS. Multi-step forecasts iterate the recursion, feeding each
|
|
24
|
+
forecast back in as the newest observation. Forecasts are clipped at zero.
|
|
25
|
+
|
|
26
|
+
Feed it a daily (or other per-period) realized measure, for example the
|
|
27
|
+
output of :class:`hfvol.RealizedVariance`.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
window_w : int, default 5
|
|
32
|
+
Length of the "weekly" mean.
|
|
33
|
+
window_m : int, default 22
|
|
34
|
+
Length of the "monthly" mean. ``fit`` needs at least ``window_m + 4``
|
|
35
|
+
observations.
|
|
36
|
+
|
|
37
|
+
Attributes
|
|
38
|
+
----------
|
|
39
|
+
params_ : hfvol.har.HARParams
|
|
40
|
+
Fitted coefficients.
|
|
41
|
+
coef_ : pd.Series
|
|
42
|
+
The same coefficients indexed by ``["const", "rv_d", "rv_w", "rv_m"]``.
|
|
43
|
+
|
|
44
|
+
References
|
|
45
|
+
----------
|
|
46
|
+
Corsi, F. (2009). A simple approximate long-memory model of realized
|
|
47
|
+
volatility. Journal of Financial Econometrics 7(2), 174-196.
|
|
48
|
+
|
|
49
|
+
Examples
|
|
50
|
+
--------
|
|
51
|
+
>>> import numpy as np, pandas as pd
|
|
52
|
+
>>> from hfvol import HARForecaster
|
|
53
|
+
>>> rv = pd.Series(np.random.default_rng(0).gamma(2.0, 1e-4, 300))
|
|
54
|
+
>>> HARForecaster().fit(rv).predict(fh=[1, 2, 3]).shape
|
|
55
|
+
(3,)
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
_tags = {
|
|
59
|
+
"authors": ["Mattbusel"],
|
|
60
|
+
"maintainers": ["Mattbusel"],
|
|
61
|
+
"python_dependencies": None,
|
|
62
|
+
"y_inner_mtype": "pd.Series",
|
|
63
|
+
"X_inner_mtype": "pd.DataFrame",
|
|
64
|
+
"capability:multivariate": False,
|
|
65
|
+
"capability:exogenous": False,
|
|
66
|
+
"capability:insample": False,
|
|
67
|
+
"capability:pred_int": False,
|
|
68
|
+
"capability:missing_values": False,
|
|
69
|
+
"requires-fh-in-fit": False,
|
|
70
|
+
"X-y-must-have-same-index": True,
|
|
71
|
+
"enforce_index_type": None,
|
|
72
|
+
"fit_is_empty": False,
|
|
73
|
+
"capability:update": True,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def __init__(self, window_w=5, window_m=22):
|
|
77
|
+
self.window_w = window_w
|
|
78
|
+
self.window_m = window_m
|
|
79
|
+
super().__init__()
|
|
80
|
+
|
|
81
|
+
def _fit(self, y, X=None, fh=None):
|
|
82
|
+
"""Fit the HAR coefficients by OLS.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
y : pd.Series
|
|
87
|
+
Realized variance (or another positive realized measure).
|
|
88
|
+
X : ignored
|
|
89
|
+
fh : ignored
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
self
|
|
94
|
+
"""
|
|
95
|
+
self._cur_y = y.copy()
|
|
96
|
+
self._refit()
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def _refit(self):
|
|
100
|
+
y = self._cur_y.to_numpy(dtype=float)
|
|
101
|
+
self.params_ = fit_har(y, self.window_w, self.window_m)
|
|
102
|
+
self.coef_ = pd.Series(self.params_.as_array(), index=["const", "rv_d", "rv_w", "rv_m"])
|
|
103
|
+
|
|
104
|
+
def _update(self, y, X=None, update_params=True):
|
|
105
|
+
"""Append new observations; refit the coefficients if ``update_params``.
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
y : pd.Series
|
|
110
|
+
New observations after the current cutoff.
|
|
111
|
+
X : ignored
|
|
112
|
+
update_params : bool, default True
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
self
|
|
117
|
+
"""
|
|
118
|
+
combined = pd.concat([self._cur_y, y])
|
|
119
|
+
self._cur_y = combined[~combined.index.duplicated(keep="last")].sort_index()
|
|
120
|
+
if update_params:
|
|
121
|
+
self._refit()
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def _predict(self, fh, X=None):
|
|
125
|
+
"""Forecast the realized measure at ``fh``.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
fh : ForecastingHorizon
|
|
130
|
+
Out-of-sample horizon (steps after the cutoff).
|
|
131
|
+
X : ignored
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
pd.Series
|
|
136
|
+
"""
|
|
137
|
+
steps = np.asarray(fh.to_relative(self.cutoff), dtype=int)
|
|
138
|
+
if np.any(steps < 1):
|
|
139
|
+
raise NotImplementedError("HARForecaster does not make in-sample predictions")
|
|
140
|
+
path = har_forecast_path(
|
|
141
|
+
self.params_,
|
|
142
|
+
self._cur_y.to_numpy(dtype=float),
|
|
143
|
+
horizon=int(steps.max()),
|
|
144
|
+
window_w=self.window_w,
|
|
145
|
+
window_m=self.window_m,
|
|
146
|
+
)
|
|
147
|
+
index = fh.to_absolute_index(self.cutoff)
|
|
148
|
+
return pd.Series(path[steps - 1], index=index, name=self._cur_y.name)
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def get_test_params(cls, parameter_set="default"):
|
|
152
|
+
"""Return testing parameter settings for the estimator."""
|
|
153
|
+
return [{"window_w": 2, "window_m": 4}, {"window_w": 3, "window_m": 6}]
|
hfvol/har.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""HAR-RV: the heterogeneous autoregressive model of realized volatility.
|
|
2
|
+
|
|
3
|
+
``RV_{t+1} = c + b_d RV_t + b_w RV_t^{(w)} + b_m RV_t^{(m)} + e_{t+1}``
|
|
4
|
+
|
|
5
|
+
where ``RV^{(w)}`` and ``RV^{(m)}`` are the means of the last ``window_w`` (5)
|
|
6
|
+
and ``window_m`` (22) daily values, estimated by OLS.
|
|
7
|
+
|
|
8
|
+
Reference: Corsi, F. (2009). A simple approximate long-memory model of realized
|
|
9
|
+
volatility. Journal of Financial Econometrics 7(2), 174-196.
|
|
10
|
+
|
|
11
|
+
Derived from srfm-lab ``lib/math/volatility_models.py`` (MIT). See docs/provenance.md.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
__all__ = ["HARParams", "har_features", "fit_har", "har_forecast", "har_forecast_path"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class HARParams:
|
|
25
|
+
"""Fitted HAR-RV coefficients."""
|
|
26
|
+
|
|
27
|
+
mu: float #: intercept
|
|
28
|
+
beta_d: float #: daily coefficient
|
|
29
|
+
beta_w: float #: weekly (``window_w`` mean) coefficient
|
|
30
|
+
beta_m: float #: monthly (``window_m`` mean) coefficient
|
|
31
|
+
|
|
32
|
+
def as_array(self) -> np.ndarray:
|
|
33
|
+
return np.array([self.mu, self.beta_d, self.beta_w, self.beta_m])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _check_windows(window_w: int, window_m: int) -> None:
|
|
37
|
+
if not (1 <= int(window_w) <= int(window_m)):
|
|
38
|
+
raise ValueError(f"need 1 <= window_w <= window_m, got {window_w}, {window_m}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def har_features(rv_series, window_w: int = 5, window_m: int = 22) -> np.ndarray:
|
|
42
|
+
"""HAR design matrix ``[1, RV_d, RV_w, RV_m]``, shape ``(n - window_m, 4)``.
|
|
43
|
+
|
|
44
|
+
Row ``k`` holds the regressors known at the end of day ``window_m + k - 1``,
|
|
45
|
+
i.e. it is used to predict ``rv_series[window_m + k]``.
|
|
46
|
+
"""
|
|
47
|
+
_check_windows(window_w, window_m)
|
|
48
|
+
rv = np.asarray(rv_series, dtype=float)
|
|
49
|
+
n = len(rv)
|
|
50
|
+
if n <= window_m:
|
|
51
|
+
return np.empty((0, 4))
|
|
52
|
+
c = np.concatenate([[0.0], np.cumsum(rv)])
|
|
53
|
+
i = np.arange(window_m, n)
|
|
54
|
+
rv_d = rv[i - 1]
|
|
55
|
+
rv_w = (c[i] - c[i - window_w]) / window_w
|
|
56
|
+
rv_m = (c[i] - c[i - window_m]) / window_m
|
|
57
|
+
return np.column_stack([np.ones(len(i)), rv_d, rv_w, rv_m])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def fit_har(rv_series, window_w: int = 5, window_m: int = 22) -> HARParams:
|
|
61
|
+
"""Estimate HAR-RV coefficients by ordinary least squares."""
|
|
62
|
+
rv = np.asarray(rv_series, dtype=float)
|
|
63
|
+
X = har_features(rv, window_w, window_m)
|
|
64
|
+
if X.shape[0] < 4:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"HAR needs at least window_m + 4 = {window_m + 4} observations, got {len(rv)}"
|
|
67
|
+
)
|
|
68
|
+
y = rv[window_m:]
|
|
69
|
+
coef, *_ = np.linalg.lstsq(X, y, rcond=None)
|
|
70
|
+
return HARParams(*(float(v) for v in coef))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def har_forecast_path(
|
|
74
|
+
params: HARParams,
|
|
75
|
+
rv_series,
|
|
76
|
+
horizon: int = 1,
|
|
77
|
+
window_w: int = 5,
|
|
78
|
+
window_m: int = 22,
|
|
79
|
+
) -> np.ndarray:
|
|
80
|
+
"""Forecasts for steps ``1..horizon``, iterating the HAR recursion.
|
|
81
|
+
|
|
82
|
+
Each step feeds the previous (unclipped) forecast back in as the newest
|
|
83
|
+
observation. Returned values are clipped at zero.
|
|
84
|
+
"""
|
|
85
|
+
_check_windows(window_w, window_m)
|
|
86
|
+
horizon = int(horizon)
|
|
87
|
+
if horizon < 1:
|
|
88
|
+
raise ValueError(f"horizon must be >= 1, got {horizon}")
|
|
89
|
+
hist = list(np.asarray(rv_series, dtype=float)[-window_m:])
|
|
90
|
+
if len(hist) < window_m:
|
|
91
|
+
raise ValueError(f"need at least window_m = {window_m} observations")
|
|
92
|
+
out = np.empty(horizon)
|
|
93
|
+
for h in range(horizon):
|
|
94
|
+
arr = np.asarray(hist[-window_m:])
|
|
95
|
+
fc = (
|
|
96
|
+
params.mu
|
|
97
|
+
+ params.beta_d * arr[-1]
|
|
98
|
+
+ params.beta_w * arr[-window_w:].mean()
|
|
99
|
+
+ params.beta_m * arr.mean()
|
|
100
|
+
)
|
|
101
|
+
out[h] = fc
|
|
102
|
+
hist.append(fc)
|
|
103
|
+
return np.maximum(out, 0.0)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def har_forecast(
|
|
107
|
+
params: HARParams,
|
|
108
|
+
rv_series,
|
|
109
|
+
horizon: int = 1,
|
|
110
|
+
window_w: int = 5,
|
|
111
|
+
window_m: int = 22,
|
|
112
|
+
) -> float:
|
|
113
|
+
"""Forecast ``horizon`` steps ahead (the last value of :func:`har_forecast_path`)."""
|
|
114
|
+
return float(har_forecast_path(params, rv_series, horizon, window_w, window_m)[-1])
|
hfvol/hawkes.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Univariate Hawkes process with an exponential kernel.
|
|
2
|
+
|
|
3
|
+
Intensity::
|
|
4
|
+
|
|
5
|
+
lambda(t) = mu + sum_{t_i < t} alpha * exp(-beta * (t - t_i))
|
|
6
|
+
|
|
7
|
+
``alpha`` is the jump in intensity per event and ``n = alpha / beta`` is the
|
|
8
|
+
branching ratio (the expected number of direct offspring per event). The process
|
|
9
|
+
is stationary when ``n < 1`` and then has mean rate ``mu / (1 - n)``.
|
|
10
|
+
|
|
11
|
+
Everything here runs in O(n) using the standard recursion for exponential
|
|
12
|
+
kernels (Ozaki 1979), so no C++ build is needed.
|
|
13
|
+
|
|
14
|
+
References
|
|
15
|
+
----------
|
|
16
|
+
Hawkes, A. G. (1971). Spectra of some self-exciting and mutually exciting point
|
|
17
|
+
processes. Biometrika 58(1), 83-90.
|
|
18
|
+
|
|
19
|
+
Ozaki, T. (1979). Maximum likelihood estimation of Hawkes' self-exciting point
|
|
20
|
+
processes. Annals of the Institute of Statistical Mathematics 31, 145-155.
|
|
21
|
+
|
|
22
|
+
Ogata, Y. (1981). On Lewis' simulation method for point processes. IEEE
|
|
23
|
+
Transactions on Information Theory 27(1), 23-31.
|
|
24
|
+
|
|
25
|
+
Derived from srfm-lab ``lib/math/hawkes_process.py`` (MIT). See docs/provenance.md.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import math
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from typing import Tuple, Union
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"HawkesParams",
|
|
38
|
+
"hawkes_simulate",
|
|
39
|
+
"hawkes_intensity_path",
|
|
40
|
+
"hawkes_compensator",
|
|
41
|
+
"hawkes_log_likelihood",
|
|
42
|
+
"fit_hawkes_mle",
|
|
43
|
+
"branching_ratio",
|
|
44
|
+
"hawkes_residual_analysis",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
RandomState = Union[None, int, np.random.Generator]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _rng(random_state: RandomState) -> np.random.Generator:
|
|
51
|
+
if isinstance(random_state, np.random.Generator):
|
|
52
|
+
return random_state
|
|
53
|
+
return np.random.default_rng(random_state)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class HawkesParams:
|
|
58
|
+
"""Parameters of a univariate exponential-kernel Hawkes process."""
|
|
59
|
+
|
|
60
|
+
mu: float = 0.5 #: baseline intensity
|
|
61
|
+
alpha: float = 0.6 #: jump in intensity per event
|
|
62
|
+
beta: float = 1.0 #: decay rate
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def branching_ratio(self) -> float:
|
|
66
|
+
"""``alpha / beta``; must be < 1 for stationarity."""
|
|
67
|
+
return self.alpha / self.beta if self.beta > 0 else float("inf")
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_stationary(self) -> bool:
|
|
71
|
+
return self.branching_ratio < 1.0
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def unconditional_intensity(self) -> float:
|
|
75
|
+
"""Mean event rate ``mu / (1 - n)`` (``inf`` if not stationary)."""
|
|
76
|
+
n = self.branching_ratio
|
|
77
|
+
return self.mu / (1.0 - n) if n < 1 else float("inf")
|
|
78
|
+
|
|
79
|
+
def _validate(self) -> None:
|
|
80
|
+
if not (self.mu > 0 and self.alpha >= 0 and self.beta > 0):
|
|
81
|
+
raise ValueError(f"need mu > 0, alpha >= 0, beta > 0; got {self}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _as_events(events) -> np.ndarray:
|
|
85
|
+
ev = np.asarray(events, dtype=float).ravel()
|
|
86
|
+
if ev.size > 1 and np.any(np.diff(ev) < 0):
|
|
87
|
+
raise ValueError("event times must be sorted in non-decreasing order")
|
|
88
|
+
return ev
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _excitation_at_events(events: np.ndarray, beta: float) -> np.ndarray:
|
|
92
|
+
"""``R_i = sum_{t_j < t_i} exp(-beta (t_i - t_j))`` for every event ``i``.
|
|
93
|
+
|
|
94
|
+
Only strictly earlier events count, so events sharing a time stamp do not
|
|
95
|
+
excite each other. With tied stamps (binned data) counting them would make
|
|
96
|
+
the likelihood unbounded: ``alpha, beta -> inf`` with ``alpha / beta`` fixed
|
|
97
|
+
sends ``log(mu + alpha)`` to infinity at every tie.
|
|
98
|
+
"""
|
|
99
|
+
n = len(events)
|
|
100
|
+
R = np.zeros(n)
|
|
101
|
+
if n > 1:
|
|
102
|
+
prev_t = events[0]
|
|
103
|
+
m = 1 # events at prev_t
|
|
104
|
+
for i in range(1, n):
|
|
105
|
+
t = events[i]
|
|
106
|
+
if t == prev_t:
|
|
107
|
+
R[i] = R[i - 1]
|
|
108
|
+
m += 1
|
|
109
|
+
else:
|
|
110
|
+
R[i] = math.exp(-beta * (t - prev_t)) * (R[i - 1] + m)
|
|
111
|
+
prev_t = t
|
|
112
|
+
m = 1
|
|
113
|
+
return R
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _excitation_after_events(events: np.ndarray, beta: float) -> np.ndarray:
|
|
117
|
+
"""``A_k = sum_{j <= k} exp(-beta (t_k - t_j))``: excitation just after event ``k``.
|
|
118
|
+
|
|
119
|
+
Only meaningful at the last event of each tie group, which is where
|
|
120
|
+
``searchsorted`` lands.
|
|
121
|
+
"""
|
|
122
|
+
R = _excitation_at_events(events, beta)
|
|
123
|
+
first = np.searchsorted(events, events, side="left")
|
|
124
|
+
return R + (np.arange(len(events)) - first + 1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def hawkes_simulate(
|
|
128
|
+
params: HawkesParams,
|
|
129
|
+
T: float,
|
|
130
|
+
rng: RandomState = None,
|
|
131
|
+
) -> np.ndarray:
|
|
132
|
+
"""Simulate event times on ``[0, T]`` with Ogata's thinning algorithm.
|
|
133
|
+
|
|
134
|
+
``rng`` may be a seed or a ``numpy.random.Generator``.
|
|
135
|
+
"""
|
|
136
|
+
params._validate()
|
|
137
|
+
if not params.is_stationary:
|
|
138
|
+
raise ValueError("branching ratio alpha / beta must be < 1 for simulation")
|
|
139
|
+
gen = _rng(rng)
|
|
140
|
+
mu, alpha, beta = params.mu, params.alpha, params.beta
|
|
141
|
+
|
|
142
|
+
events = []
|
|
143
|
+
t = 0.0
|
|
144
|
+
t_last = 0.0
|
|
145
|
+
excite = 0.0 # sum_j alpha * exp(-beta (t_last - t_j)), evaluated just after t_last
|
|
146
|
+
lam_bar = mu
|
|
147
|
+
while True:
|
|
148
|
+
t += gen.exponential(1.0 / lam_bar)
|
|
149
|
+
if t > T:
|
|
150
|
+
break
|
|
151
|
+
lam_t = mu + excite * math.exp(-beta * (t - t_last))
|
|
152
|
+
if gen.uniform(0.0, lam_bar) <= lam_t:
|
|
153
|
+
events.append(t)
|
|
154
|
+
excite = lam_t - mu + alpha
|
|
155
|
+
t_last = t
|
|
156
|
+
lam_bar = lam_t + alpha
|
|
157
|
+
else:
|
|
158
|
+
lam_bar = lam_t
|
|
159
|
+
return np.asarray(events)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def hawkes_intensity_path(
|
|
163
|
+
events,
|
|
164
|
+
params: HawkesParams,
|
|
165
|
+
times,
|
|
166
|
+
include_current: bool = True,
|
|
167
|
+
) -> np.ndarray:
|
|
168
|
+
"""Intensity ``lambda(t)`` on an arbitrary time grid.
|
|
169
|
+
|
|
170
|
+
With ``include_current=True`` (the srfm-lab behaviour) an event at exactly
|
|
171
|
+
``t`` is counted, which gives the right-continuous intensity. Use
|
|
172
|
+
``include_current=False`` for the predictable (left-continuous) intensity,
|
|
173
|
+
the one to use as a forecasting feature since it has no look-ahead.
|
|
174
|
+
"""
|
|
175
|
+
ev = _as_events(events)
|
|
176
|
+
t = np.asarray(times, dtype=float)
|
|
177
|
+
out = np.full(t.shape, float(params.mu))
|
|
178
|
+
if ev.size == 0:
|
|
179
|
+
return out
|
|
180
|
+
A = _excitation_after_events(ev, params.beta)
|
|
181
|
+
side = "right" if include_current else "left"
|
|
182
|
+
k = np.searchsorted(ev, t, side=side) - 1
|
|
183
|
+
has = k >= 0
|
|
184
|
+
kk = k[has]
|
|
185
|
+
out[has] += params.alpha * A[kk] * np.exp(-params.beta * (t[has] - ev[kk]))
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def hawkes_compensator(events, params: HawkesParams, times) -> np.ndarray:
|
|
190
|
+
"""Integrated intensity ``Lambda(t) = int_0^t lambda(s) ds`` on a time grid."""
|
|
191
|
+
ev = _as_events(events)
|
|
192
|
+
t = np.asarray(times, dtype=float)
|
|
193
|
+
out = params.mu * t
|
|
194
|
+
if ev.size == 0:
|
|
195
|
+
return out
|
|
196
|
+
A = _excitation_after_events(ev, params.beta)
|
|
197
|
+
k = np.searchsorted(ev, t, side="right") - 1
|
|
198
|
+
has = k >= 0
|
|
199
|
+
kk = k[has]
|
|
200
|
+
# sum_{t_i <= t} (1 - exp(-beta (t - t_i))) = (k + 1) - A_k exp(-beta (t - t_k))
|
|
201
|
+
remaining = A[kk] * np.exp(-params.beta * (t[has] - ev[kk]))
|
|
202
|
+
out[has] += (params.alpha / params.beta) * ((kk + 1) - remaining)
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def hawkes_log_likelihood(events, T: float, params: HawkesParams) -> float:
|
|
207
|
+
"""Exact log-likelihood on ``[0, T]``.
|
|
208
|
+
|
|
209
|
+
``LL = sum_i log lambda(t_i) - mu T - (alpha / beta) sum_i (1 - exp(-beta (T - t_i)))``
|
|
210
|
+
|
|
211
|
+
``lambda(t_i)`` counts only events strictly before ``t_i``, so events that
|
|
212
|
+
share a time stamp (binned data) do not excite each other.
|
|
213
|
+
|
|
214
|
+
Returns ``-1e12`` for invalid or non-stationary parameters (convenient for
|
|
215
|
+
unconstrained optimisers), as in srfm-lab.
|
|
216
|
+
"""
|
|
217
|
+
p = params
|
|
218
|
+
if not (p.mu > 0 and p.alpha >= 0 and p.beta > 0) or not p.is_stationary:
|
|
219
|
+
return -1e12
|
|
220
|
+
ev = _as_events(events)
|
|
221
|
+
if ev.size == 0:
|
|
222
|
+
return float(-p.mu * T)
|
|
223
|
+
lam = p.mu + p.alpha * _excitation_at_events(ev, p.beta)
|
|
224
|
+
log_lk = np.sum(np.log(np.maximum(lam, 1e-300)))
|
|
225
|
+
integral = p.mu * T + (p.alpha / p.beta) * np.sum(1.0 - np.exp(-p.beta * (T - ev)))
|
|
226
|
+
return float(log_lk - integral)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _unpack(x) -> HawkesParams:
|
|
230
|
+
log_mu, log_beta, logit_n = x
|
|
231
|
+
beta = math.exp(log_beta)
|
|
232
|
+
n = 1.0 / (1.0 + math.exp(-logit_n))
|
|
233
|
+
return HawkesParams(mu=math.exp(log_mu), alpha=n * beta, beta=beta)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def fit_hawkes_mle(
|
|
237
|
+
events,
|
|
238
|
+
T: float,
|
|
239
|
+
n_restarts: int = 10,
|
|
240
|
+
rng: RandomState = None,
|
|
241
|
+
) -> Tuple[HawkesParams, float]:
|
|
242
|
+
"""Maximum-likelihood fit of ``(mu, alpha, beta)``.
|
|
243
|
+
|
|
244
|
+
Nelder-Mead over ``(log mu, log beta, logit n)``, which keeps every candidate
|
|
245
|
+
stationary, from ``n_restarts`` random starts scaled to the observed event
|
|
246
|
+
rate. Returns ``(params, log_likelihood)``. Requires scipy.
|
|
247
|
+
"""
|
|
248
|
+
try:
|
|
249
|
+
from scipy.optimize import minimize
|
|
250
|
+
except ImportError as exc: # pragma: no cover
|
|
251
|
+
raise ImportError("fit_hawkes_mle needs scipy: pip install hfvol[scipy]") from exc
|
|
252
|
+
|
|
253
|
+
ev = _as_events(events)
|
|
254
|
+
if T <= 0:
|
|
255
|
+
raise ValueError(f"T must be positive, got {T}")
|
|
256
|
+
gen = _rng(rng)
|
|
257
|
+
rate = max(len(ev), 1) / T
|
|
258
|
+
|
|
259
|
+
def neg_ll(x):
|
|
260
|
+
if not np.all(np.isfinite(x)) or np.any(np.abs(x) > 50):
|
|
261
|
+
return 1e12
|
|
262
|
+
return -hawkes_log_likelihood(ev, T, _unpack(x))
|
|
263
|
+
|
|
264
|
+
best_params, best_ll = HawkesParams(mu=rate, alpha=0.0, beta=1.0), -np.inf
|
|
265
|
+
for k in range(max(int(n_restarts), 1)):
|
|
266
|
+
if k == 0:
|
|
267
|
+
n0, beta0 = 0.5, 2.0 * rate
|
|
268
|
+
else:
|
|
269
|
+
n0 = gen.uniform(0.05, 0.9)
|
|
270
|
+
beta0 = rate * math.exp(gen.uniform(-2.0, 3.0))
|
|
271
|
+
mu0 = max(rate * (1.0 - n0), 1e-12)
|
|
272
|
+
x0 = [math.log(mu0), math.log(beta0), math.log(n0 / (1.0 - n0))]
|
|
273
|
+
res = minimize(
|
|
274
|
+
neg_ll,
|
|
275
|
+
x0=x0,
|
|
276
|
+
method="Nelder-Mead",
|
|
277
|
+
options={"maxiter": 4000, "xatol": 1e-8, "fatol": 1e-10},
|
|
278
|
+
)
|
|
279
|
+
ll = -float(res.fun)
|
|
280
|
+
if np.isfinite(ll) and ll > best_ll:
|
|
281
|
+
best_ll, best_params = ll, _unpack(res.x)
|
|
282
|
+
return best_params, float(best_ll)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def branching_ratio(events, T: float, n_restarts: int = 10, rng: RandomState = None) -> float:
|
|
286
|
+
"""MLE branching ratio ``alpha / beta`` clipped to ``[0, 1]``.
|
|
287
|
+
|
|
288
|
+
Values near 1 mean most events are triggered by earlier events rather than
|
|
289
|
+
by the baseline (high reflexivity).
|
|
290
|
+
"""
|
|
291
|
+
params, _ = fit_hawkes_mle(events, T, n_restarts=n_restarts, rng=rng)
|
|
292
|
+
return float(np.clip(params.branching_ratio, 0.0, 1.0))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def hawkes_residual_analysis(events, T: float, params: HawkesParams) -> dict:
|
|
296
|
+
"""Time-rescaling goodness of fit.
|
|
297
|
+
|
|
298
|
+
If the model is right, the compensator increments
|
|
299
|
+
``Lambda(t_i) - Lambda(t_{i-1})`` (with ``Lambda(t_0) = 0``) are iid Exp(1).
|
|
300
|
+
Returns the Kolmogorov-Smirnov statistic and p-value against Exp(1) plus the
|
|
301
|
+
residual mean and standard deviation (both 1 under the model). Requires scipy.
|
|
302
|
+
"""
|
|
303
|
+
ev = _as_events(events)
|
|
304
|
+
if ev.size < 2:
|
|
305
|
+
return {
|
|
306
|
+
"ks_statistic": float("nan"),
|
|
307
|
+
"ks_pvalue": float("nan"),
|
|
308
|
+
"mean_residual": float("nan"),
|
|
309
|
+
"std_residual": float("nan"),
|
|
310
|
+
"well_fit": False,
|
|
311
|
+
}
|
|
312
|
+
from scipy.stats import kstest
|
|
313
|
+
|
|
314
|
+
lam_cum = hawkes_compensator(ev, params, ev)
|
|
315
|
+
residuals = np.diff(np.concatenate([[0.0], lam_cum]))
|
|
316
|
+
ks_stat, ks_pvalue = kstest(residuals, "expon")
|
|
317
|
+
return {
|
|
318
|
+
"ks_statistic": float(ks_stat),
|
|
319
|
+
"ks_pvalue": float(ks_pvalue),
|
|
320
|
+
"mean_residual": float(residuals.mean()),
|
|
321
|
+
"std_residual": float(residuals.std()),
|
|
322
|
+
"well_fit": bool(ks_pvalue > 0.05),
|
|
323
|
+
}
|
hfvol/py.typed
ADDED
|
File without changes
|