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 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