alphafade 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.
- alphafade/__init__.py +70 -0
- alphafade/_errors.py +56 -0
- alphafade/_stats.py +141 -0
- alphafade/_supwald_table.py +17 -0
- alphafade/_validate.py +324 -0
- alphafade/breaks.py +325 -0
- alphafade/compare.py +297 -0
- alphafade/crowding.py +326 -0
- alphafade/datasets.py +464 -0
- alphafade/decay.py +496 -0
- alphafade/horizon.py +284 -0
- alphafade/lifetime.py +268 -0
- alphafade/plotting.py +145 -0
- alphafade/publication.py +283 -0
- alphafade/py.typed +0 -0
- alphafade/report.py +542 -0
- alphafade/rolling.py +342 -0
- alphafade/walkforward.py +275 -0
- alphafade-0.1.0.dist-info/METADATA +262 -0
- alphafade-0.1.0.dist-info/RECORD +22 -0
- alphafade-0.1.0.dist-info/WHEEL +4 -0
- alphafade-0.1.0.dist-info/licenses/LICENSE +21 -0
alphafade/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""alphafade measures whether a trading signal's edge is dying, how fast, and why.
|
|
2
|
+
|
|
3
|
+
Measure how a signal's edge shrinks across calendar time (not forecast horizon), fit a
|
|
4
|
+
half-life, test for structural breaks and publication effects, and check whether the fade
|
|
5
|
+
lines up with crowding.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from . import datasets
|
|
11
|
+
from ._errors import (
|
|
12
|
+
AlignmentError,
|
|
13
|
+
AlphaFadeError,
|
|
14
|
+
AlphaFadeWarning,
|
|
15
|
+
DataDroppedWarning,
|
|
16
|
+
DownloadError,
|
|
17
|
+
FitWarning,
|
|
18
|
+
FrequencyError,
|
|
19
|
+
InputError,
|
|
20
|
+
InsufficientDataError,
|
|
21
|
+
)
|
|
22
|
+
from .breaks import BreakResult, chow_test, find_break
|
|
23
|
+
from .compare import SignalComparison, compare_signals
|
|
24
|
+
from .crowding import crowding_score
|
|
25
|
+
from .decay import DecayFit, fit_decay
|
|
26
|
+
from .horizon import HorizonResult, ic_by_horizon
|
|
27
|
+
from .lifetime import LifetimeResult, signal_lifetime
|
|
28
|
+
from .publication import GapResult, publication_gap
|
|
29
|
+
from .report import CrowdingLink, FadeReport, analyze
|
|
30
|
+
from .rolling import forward_returns, ic_series, rolling_ic, rolling_sharpe
|
|
31
|
+
from .walkforward import WalkForwardResult, walk_forward_decay
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AlignmentError",
|
|
37
|
+
"AlphaFadeError",
|
|
38
|
+
"AlphaFadeWarning",
|
|
39
|
+
"BreakResult",
|
|
40
|
+
"CrowdingLink",
|
|
41
|
+
"DataDroppedWarning",
|
|
42
|
+
"DecayFit",
|
|
43
|
+
"DownloadError",
|
|
44
|
+
"FadeReport",
|
|
45
|
+
"FitWarning",
|
|
46
|
+
"FrequencyError",
|
|
47
|
+
"GapResult",
|
|
48
|
+
"HorizonResult",
|
|
49
|
+
"InputError",
|
|
50
|
+
"InsufficientDataError",
|
|
51
|
+
"LifetimeResult",
|
|
52
|
+
"SignalComparison",
|
|
53
|
+
"WalkForwardResult",
|
|
54
|
+
"__version__",
|
|
55
|
+
"analyze",
|
|
56
|
+
"chow_test",
|
|
57
|
+
"compare_signals",
|
|
58
|
+
"crowding_score",
|
|
59
|
+
"datasets",
|
|
60
|
+
"find_break",
|
|
61
|
+
"fit_decay",
|
|
62
|
+
"forward_returns",
|
|
63
|
+
"ic_by_horizon",
|
|
64
|
+
"ic_series",
|
|
65
|
+
"publication_gap",
|
|
66
|
+
"rolling_ic",
|
|
67
|
+
"rolling_sharpe",
|
|
68
|
+
"signal_lifetime",
|
|
69
|
+
"walk_forward_decay",
|
|
70
|
+
]
|
alphafade/_errors.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Exception and warning classes.
|
|
2
|
+
|
|
3
|
+
Every error alphafade raises is an :class:`AlphaFadeError`. Input problems are also
|
|
4
|
+
:class:`ValueError`, so ``except ValueError`` keeps working for users who don't care about
|
|
5
|
+
the finer categories.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AlignmentError",
|
|
12
|
+
"AlphaFadeError",
|
|
13
|
+
"AlphaFadeWarning",
|
|
14
|
+
"DataDroppedWarning",
|
|
15
|
+
"DownloadError",
|
|
16
|
+
"FitWarning",
|
|
17
|
+
"FrequencyError",
|
|
18
|
+
"InputError",
|
|
19
|
+
"InsufficientDataError",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AlphaFadeError(Exception):
|
|
24
|
+
"""Base class for every error raised by alphafade."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InputError(AlphaFadeError, ValueError):
|
|
28
|
+
"""An input has the wrong type, shape, or values. The message says how to fix it."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AlignmentError(InputError):
|
|
32
|
+
"""Two inputs can't be lined up by date and/or asset."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FrequencyError(InputError):
|
|
36
|
+
"""The data frequency is ambiguous, mixed, or inconsistent between inputs."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InsufficientDataError(InputError):
|
|
40
|
+
"""There are too few usable observations to compute the requested statistic."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DownloadError(AlphaFadeError, OSError):
|
|
44
|
+
"""A dataset couldn't be downloaded or saved to the local cache."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AlphaFadeWarning(UserWarning):
|
|
48
|
+
"""Base class for every warning emitted by alphafade."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DataDroppedWarning(AlphaFadeWarning):
|
|
52
|
+
"""Some observations were dropped or couldn't be used (the message gives counts)."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FitWarning(AlphaFadeWarning):
|
|
56
|
+
"""A model fit was unreliable, hit a bound, or fell back to a simpler model."""
|
alphafade/_stats.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Small statistical building blocks: Newey-West OLS, lag rules, block bootstrap, RNG.
|
|
2
|
+
|
|
3
|
+
These are deliberately plain numpy so the core install stays small. The Newey-West code is
|
|
4
|
+
checked against statsmodels in the test suite.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from numpy.typing import NDArray
|
|
13
|
+
|
|
14
|
+
from ._errors import InputError, InsufficientDataError
|
|
15
|
+
|
|
16
|
+
FloatArray = NDArray[np.float64]
|
|
17
|
+
RngLike = int | np.random.Generator | None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_rng(rng: RngLike) -> np.random.Generator:
|
|
21
|
+
"""Turn a seed, Generator, or None into a Generator."""
|
|
22
|
+
if isinstance(rng, np.random.Generator):
|
|
23
|
+
return rng
|
|
24
|
+
if rng is None or (isinstance(rng, (int, np.integer)) and not isinstance(rng, bool)):
|
|
25
|
+
return np.random.default_rng(rng)
|
|
26
|
+
raise InputError(f"rng must be an int seed, a numpy Generator, or None; got {rng!r}.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def default_hac_lags(n: int) -> int:
|
|
30
|
+
"""Newey-West (1994) plug-in lag rule: floor(4 * (n / 100) ** (2 / 9))."""
|
|
31
|
+
return int(4.0 * (n / 100.0) ** (2.0 / 9.0)) # truncation == floor for n > 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_hac_lags(hac_lags: int | None, n: int, min_lags: int = 0) -> int:
|
|
35
|
+
"""Validate a user lag choice, or apply the default; both are at least ``min_lags``.
|
|
36
|
+
|
|
37
|
+
``min_lags`` is the fewest lags that keep overlapping windows honest (a series built from
|
|
38
|
+
rolling windows or multi-period returns has autocorrelation out to about ``window - 1``).
|
|
39
|
+
An explicit ``hac_lags`` below it is rejected: it would understate the standard errors and
|
|
40
|
+
make results look more significant than they are.
|
|
41
|
+
"""
|
|
42
|
+
if hac_lags is None:
|
|
43
|
+
return max(default_hac_lags(n), min_lags)
|
|
44
|
+
if isinstance(hac_lags, bool) or not isinstance(hac_lags, (int, np.integer)):
|
|
45
|
+
raise InputError(f"hac_lags must be a non-negative integer, got {hac_lags!r}.")
|
|
46
|
+
if hac_lags < 0:
|
|
47
|
+
raise InputError(f"hac_lags must be non-negative, got {hac_lags}.")
|
|
48
|
+
if hac_lags >= n:
|
|
49
|
+
raise InputError(f"hac_lags={hac_lags} must be smaller than the sample size ({n}).")
|
|
50
|
+
if hac_lags < min_lags:
|
|
51
|
+
raise InputError(
|
|
52
|
+
f"hac_lags={hac_lags} is too small: the data are overlapping (rolling windows or "
|
|
53
|
+
f"multi-period returns), which needs at least {min_lags} lags, otherwise the "
|
|
54
|
+
"t-statistics come out too confident. Use hac_lags=None for the default, or a "
|
|
55
|
+
f"value of {min_lags} or more."
|
|
56
|
+
)
|
|
57
|
+
return int(hac_lags)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class OLSResult:
|
|
62
|
+
"""Coefficients and Newey-West standard errors from :func:`ols_hac`."""
|
|
63
|
+
|
|
64
|
+
params: FloatArray
|
|
65
|
+
bse: FloatArray
|
|
66
|
+
cov: FloatArray
|
|
67
|
+
resid: FloatArray
|
|
68
|
+
nobs: int
|
|
69
|
+
lags: int
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def tvalues(self) -> FloatArray:
|
|
73
|
+
"""Coefficient / standard error (inf-safe: zero SE gives nan)."""
|
|
74
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
75
|
+
t: FloatArray = np.where(self.bse > 0, self.params / self.bse, np.nan)
|
|
76
|
+
return t
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ols_hac(y: FloatArray, x: FloatArray, lags: int) -> OLSResult:
|
|
80
|
+
"""OLS with Newey-West (Bartlett kernel) HAC covariance.
|
|
81
|
+
|
|
82
|
+
Matches ``statsmodels.OLS(y, x).fit(cov_type="HAC", cov_kwds={"maxlags": lags})``
|
|
83
|
+
(no small-sample degrees-of-freedom correction, as in Newey & West 1987).
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
y : ndarray, shape (n,)
|
|
88
|
+
x : ndarray, shape (n, k)
|
|
89
|
+
Include a column of ones for an intercept.
|
|
90
|
+
lags : int
|
|
91
|
+
Number of autocovariance lags; 0 gives White (heteroskedasticity-only) errors.
|
|
92
|
+
"""
|
|
93
|
+
y = np.asarray(y, dtype=np.float64)
|
|
94
|
+
x = np.asarray(x, dtype=np.float64)
|
|
95
|
+
n, k = x.shape
|
|
96
|
+
if n <= k:
|
|
97
|
+
raise InsufficientDataError(
|
|
98
|
+
f"Need more observations ({n}) than regression coefficients ({k})."
|
|
99
|
+
)
|
|
100
|
+
xtx = x.T @ x
|
|
101
|
+
if np.linalg.matrix_rank(xtx) < k:
|
|
102
|
+
raise InsufficientDataError(
|
|
103
|
+
"The regression is singular (a regressor is constant or duplicated). This "
|
|
104
|
+
"usually means one period or group has no observations."
|
|
105
|
+
)
|
|
106
|
+
xtx_inv = np.linalg.inv(xtx)
|
|
107
|
+
beta = xtx_inv @ (x.T @ y)
|
|
108
|
+
resid = y - x @ beta
|
|
109
|
+
scores = x * resid[:, None]
|
|
110
|
+
s = scores.T @ scores
|
|
111
|
+
for lag in range(1, lags + 1):
|
|
112
|
+
w = 1.0 - lag / (lags + 1.0)
|
|
113
|
+
gamma = scores[lag:].T @ scores[:-lag]
|
|
114
|
+
s += w * (gamma + gamma.T)
|
|
115
|
+
cov = xtx_inv @ s @ xtx_inv
|
|
116
|
+
bse = np.sqrt(np.clip(np.diag(cov), 0.0, None))
|
|
117
|
+
return OLSResult(params=beta, bse=bse, cov=cov, resid=resid, nobs=n, lags=lags)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def hac_mean(y: FloatArray, lags: int) -> tuple[float, float, float]:
|
|
121
|
+
"""Mean of ``y`` with its Newey-West standard error and t-stat."""
|
|
122
|
+
res = ols_hac(y, np.ones((len(y), 1)), lags)
|
|
123
|
+
return float(res.params[0]), float(res.bse[0]), float(res.tvalues[0])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def default_block_size(n: int, min_block: int = 1) -> int:
|
|
127
|
+
"""Moving-block bootstrap block length: about 1.75 * n^(1/3), at least ``min_block``."""
|
|
128
|
+
return int(min(max(round(1.75 * n ** (1.0 / 3.0)), min_block, 1), n))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def block_bootstrap_indices(n: int, block: int, rng: np.random.Generator) -> NDArray[np.intp]:
|
|
132
|
+
"""Draw indices for one moving-block bootstrap sample of length ``n``.
|
|
133
|
+
|
|
134
|
+
Blocks of ``block`` consecutive positions are drawn with replacement (starting points
|
|
135
|
+
uniform over all n - block + 1 positions) and concatenated, so short-range
|
|
136
|
+
autocorrelation inside each block is preserved.
|
|
137
|
+
"""
|
|
138
|
+
n_blocks = -(-n // block)
|
|
139
|
+
starts = rng.integers(0, n - block + 1, size=n_blocks)
|
|
140
|
+
idx = (starts[:, None] + np.arange(block)[None, :]).ravel()[:n]
|
|
141
|
+
return idx.astype(np.intp)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Quantiles of the sup-Wald null distribution (one parameter). GENERATED FILE.
|
|
2
|
+
|
|
3
|
+
Produced by scripts/make_supwald_table.py
|
|
4
|
+
(seed=20260927, reps=100000, steps=5000). Do not edit by hand.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
LEVELS: tuple[float, ...] = (0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.11, 0.12, 0.13, 0.14, 0.15, 0.16, 0.17, 0.18, 0.19, 0.2, 0.21, 0.22, 0.23, 0.24, 0.25, 0.26, 0.27, 0.28, 0.29, 0.3, 0.31, 0.32, 0.33, 0.34, 0.35, 0.36, 0.37, 0.38, 0.39, 0.4, 0.41, 0.42, 0.43, 0.44, 0.45, 0.46, 0.47, 0.48, 0.49, 0.5, 0.51, 0.52, 0.53, 0.54, 0.55, 0.56, 0.57, 0.58, 0.59, 0.6, 0.61, 0.62, 0.63, 0.64, 0.65, 0.66, 0.67, 0.68, 0.69, 0.7, 0.71, 0.72, 0.73, 0.74, 0.75, 0.76, 0.77, 0.78, 0.79, 0.8, 0.802, 0.804, 0.806, 0.808, 0.81, 0.812, 0.814, 0.816, 0.818, 0.82, 0.822, 0.824, 0.826, 0.828, 0.83, 0.832, 0.834, 0.836, 0.838, 0.84, 0.842, 0.844, 0.846, 0.848, 0.85, 0.852, 0.854, 0.856, 0.858, 0.86, 0.862, 0.864, 0.866, 0.868, 0.87, 0.872, 0.874, 0.876, 0.878, 0.88, 0.882, 0.884, 0.886, 0.888, 0.89, 0.892, 0.894, 0.896, 0.898, 0.9, 0.902, 0.904, 0.906, 0.908, 0.91, 0.912, 0.914, 0.916, 0.918, 0.92, 0.922, 0.924, 0.926, 0.928, 0.93, 0.932, 0.934, 0.936, 0.938, 0.94, 0.942, 0.944, 0.946, 0.948, 0.95, 0.952, 0.954, 0.956, 0.958, 0.96, 0.962, 0.964, 0.966, 0.968, 0.97, 0.972, 0.974, 0.976, 0.978, 0.98, 0.982, 0.984, 0.986, 0.988, 0.99, 0.9905, 0.991, 0.9915, 0.992, 0.9925, 0.993, 0.9935, 0.994, 0.9945, 0.995, 0.9955, 0.996, 0.9965, 0.997, 0.9975, 0.998, 0.9985, 0.999, 0.9995)
|
|
10
|
+
|
|
11
|
+
QUANTILES: dict[float, tuple[float, ...]] = {
|
|
12
|
+
0.05: (1.2567, 1.4284, 1.555, 1.6577, 1.7491, 1.8324, 1.9058, 1.9755, 2.0431, 2.1029, 2.164, 2.2241, 2.2819, 2.3409, 2.3996, 2.4541, 2.5075, 2.5581, 2.6108, 2.664, 2.7121, 2.7634, 2.8122, 2.8619, 2.9123, 2.9609, 3.0104, 3.0581, 3.1079, 3.1565, 3.2046, 3.2531, 3.3011, 3.3512, 3.4009, 3.4518, 3.5018, 3.5507, 3.6033, 3.6528, 3.7019, 3.7522, 3.8051, 3.8588, 3.9108, 3.9636, 4.0178, 4.0723, 4.1265, 4.1835, 4.2403, 4.2975, 4.3577, 4.4159, 4.4759, 4.5372, 4.5996, 4.6645, 4.7311, 4.7975, 4.8647, 4.9391, 5.0095, 5.0849, 5.1598, 5.2345, 5.309, 5.3841, 5.4648, 5.5523, 5.6364, 5.724, 5.8132, 5.9043, 5.997, 6.0968, 6.2011, 6.3074, 6.4288, 6.5518, 6.5777, 6.6003, 6.6233, 6.6469, 6.6732, 6.6986, 6.7252, 6.7494, 6.7752, 6.8025, 6.8294, 6.8544, 6.8837, 6.9104, 6.9363, 6.9687, 6.9987, 7.0276, 7.0577, 7.0839, 7.1146, 7.1442, 7.1745, 7.2029, 7.234, 7.2645, 7.2938, 7.3241, 7.3554, 7.3898, 7.4248, 7.4611, 7.49, 7.5248, 7.5578, 7.5978, 7.6354, 7.6732, 7.714, 7.7523, 7.7956, 7.8414, 7.8866, 7.9242, 7.9671, 8.0084, 8.0544, 8.0991, 8.1479, 8.1944, 8.2397, 8.2913, 8.3394, 8.3854, 8.438, 8.4896, 8.543, 8.5951, 8.6507, 8.7028, 8.7578, 8.8166, 8.8809, 8.9445, 9.0093, 9.0698, 9.133, 9.1976, 9.2695, 9.3505, 9.419, 9.497, 9.5751, 9.6615, 9.753, 9.8511, 9.9452, 10.0419, 10.1504, 10.2539, 10.3559, 10.4887, 10.6029, 10.7401, 10.8833, 11.0313, 11.2165, 11.3837, 11.5884, 11.7806, 11.9906, 12.2447, 12.5401, 12.8161, 13.2472, 13.3852, 13.5231, 13.6661, 13.8011, 13.9204, 14.0702, 14.2487, 14.4063, 14.6191, 14.7956, 15.022, 15.2404, 15.516, 15.8688, 16.2725, 16.6005, 17.2087, 18.0654, 19.3025),
|
|
13
|
+
0.1: (1.0093, 1.1631, 1.2741, 1.3678, 1.4463, 1.5182, 1.5842, 1.6466, 1.7048, 1.7629, 1.8184, 1.8713, 1.9238, 1.9736, 2.0258, 2.0736, 2.118, 2.1652, 2.2108, 2.2583, 2.3054, 2.3529, 2.4, 2.4475, 2.4928, 2.5376, 2.585, 2.6326, 2.6809, 2.7244, 2.7704, 2.8166, 2.8647, 2.9129, 2.9619, 3.0094, 3.0546, 3.1022, 3.1497, 3.1969, 3.2442, 3.2926, 3.3437, 3.394, 3.4474, 3.5001, 3.5527, 3.6051, 3.655, 3.7059, 3.7601, 3.8151, 3.8716, 3.9284, 3.9877, 4.047, 4.1096, 4.1682, 4.2312, 4.2948, 4.3633, 4.426, 4.4926, 4.5615, 4.6317, 4.7048, 4.7822, 4.8581, 4.9411, 5.0254, 5.1088, 5.1966, 5.2846, 5.3742, 5.4645, 5.5653, 5.6696, 5.7745, 5.888, 6.0003, 6.0238, 6.0483, 6.0726, 6.0987, 6.1256, 6.1502, 6.1738, 6.2001, 6.2255, 6.2498, 6.2785, 6.3019, 6.331, 6.3611, 6.3874, 6.4172, 6.4485, 6.4791, 6.5076, 6.538, 6.5694, 6.5993, 6.6286, 6.6609, 6.6917, 6.7202, 6.7527, 6.786, 6.8192, 6.852, 6.8853, 6.9182, 6.9543, 6.9916, 7.0259, 7.0626, 7.0974, 7.1339, 7.1699, 7.2054, 7.2444, 7.2815, 7.3191, 7.358, 7.3997, 7.445, 7.4866, 7.5306, 7.5764, 7.6247, 7.6682, 7.7141, 7.7575, 7.8056, 7.8582, 7.9084, 7.9591, 8.0195, 8.0822, 8.1373, 8.1938, 8.2578, 8.3215, 8.3762, 8.4465, 8.5121, 8.5792, 8.6445, 8.7071, 8.7776, 8.8578, 8.9322, 9.0085, 9.0832, 9.1701, 9.2662, 9.3677, 9.4575, 9.5658, 9.6837, 9.8003, 9.9259, 10.0625, 10.201, 10.3348, 10.5124, 10.6544, 10.8246, 11.0321, 11.2626, 11.4912, 11.7231, 11.9919, 12.3152, 12.6733, 12.7682, 12.8459, 12.9899, 13.1384, 13.302, 13.4802, 13.6435, 13.8201, 13.9812, 14.219, 14.507, 14.72, 15.022, 15.2708, 15.6517, 16.2365, 16.6585, 17.4934, 18.863),
|
|
14
|
+
0.15: (0.8478, 0.9778, 1.0763, 1.1581, 1.2293, 1.2911, 1.3518, 1.4116, 1.462, 1.5136, 1.5632, 1.6144, 1.6626, 1.7078, 1.7534, 1.7983, 1.8436, 1.8879, 1.9304, 1.9722, 2.0164, 2.0588, 2.0999, 2.1413, 2.1855, 2.227, 2.2698, 2.3141, 2.3574, 2.4023, 2.4461, 2.4895, 2.532, 2.5785, 2.6236, 2.6697, 2.7141, 2.7585, 2.8047, 2.8494, 2.8986, 2.9475, 2.9944, 3.042, 3.092, 3.1421, 3.1917, 3.2405, 3.2937, 3.3468, 3.4012, 3.4567, 3.5147, 3.5694, 3.6255, 3.6803, 3.7345, 3.7971, 3.8571, 3.9191, 3.9821, 4.046, 4.1116, 4.1779, 4.2465, 4.3209, 4.3923, 4.4642, 4.5428, 4.6228, 4.7048, 4.79, 4.8774, 4.971, 5.0647, 5.1647, 5.2651, 5.3707, 5.4778, 5.5918, 5.6156, 5.6428, 5.6661, 5.692, 5.7167, 5.7407, 5.7658, 5.7899, 5.817, 5.8424, 5.8688, 5.8941, 5.9186, 5.9446, 5.9731, 5.9985, 6.0256, 6.0542, 6.0804, 6.1102, 6.1398, 6.1694, 6.198, 6.2291, 6.2572, 6.2892, 6.3216, 6.3548, 6.3871, 6.4228, 6.4573, 6.4918, 6.5246, 6.5599, 6.5974, 6.6337, 6.6708, 6.7074, 6.7423, 6.7812, 6.8215, 6.8617, 6.9032, 6.9408, 6.9874, 7.0261, 7.0678, 7.1129, 7.1558, 7.1958, 7.2429, 7.2874, 7.3295, 7.3757, 7.4274, 7.4781, 7.5292, 7.5797, 7.6356, 7.6969, 7.753, 7.8152, 7.8783, 7.9341, 8.0038, 8.0794, 8.1421, 8.2156, 8.2898, 8.3593, 8.4379, 8.5229, 8.6079, 8.6832, 8.7616, 8.8579, 8.9566, 9.048, 9.1492, 9.2565, 9.3834, 9.499, 9.6261, 9.767, 9.9117, 10.0758, 10.2458, 10.4164, 10.5983, 10.8127, 11.0489, 11.3088, 11.606, 11.9121, 12.2828, 12.3909, 12.5201, 12.6022, 12.7114, 12.8045, 12.9793, 13.173, 13.3885, 13.576, 13.7863, 13.9862, 14.2695, 14.5587, 14.8454, 15.1535, 15.6332, 16.3415, 17.1102, 18.6404),
|
|
15
|
+
0.2: (0.712, 0.8288, 0.9149, 0.9836, 1.0482, 1.1058, 1.1641, 1.2152, 1.2635, 1.3097, 1.3573, 1.4014, 1.4447, 1.4878, 1.5294, 1.5694, 1.6109, 1.6513, 1.6917, 1.7298, 1.7697, 1.8113, 1.8497, 1.8896, 1.9281, 1.9664, 2.0081, 2.048, 2.0878, 2.1254, 2.166, 2.2085, 2.2491, 2.2885, 2.3327, 2.3748, 2.4175, 2.4607, 2.5048, 2.5478, 2.5945, 2.6406, 2.6883, 2.7336, 2.7794, 2.8296, 2.8774, 2.9262, 2.9768, 3.0276, 3.0793, 3.1307, 3.1847, 3.2351, 3.294, 3.3516, 3.4055, 3.466, 3.5267, 3.5874, 3.6474, 3.7069, 3.7723, 3.8373, 3.9067, 3.9753, 4.0461, 4.1192, 4.1915, 4.2715, 4.3507, 4.4303, 4.5132, 4.6014, 4.696, 4.7891, 4.8893, 4.9992, 5.1059, 5.2227, 5.2462, 5.2715, 5.2951, 5.3184, 5.3446, 5.3696, 5.3929, 5.4179, 5.4435, 5.468, 5.4917, 5.5161, 5.5462, 5.5704, 5.5968, 5.6228, 5.6533, 5.6829, 5.7113, 5.7388, 5.766, 5.7941, 5.8241, 5.8544, 5.8822, 5.9107, 5.938, 5.9722, 6.0029, 6.0367, 6.0677, 6.1034, 6.1362, 6.1711, 6.2059, 6.2419, 6.2792, 6.3159, 6.3556, 6.3927, 6.4346, 6.4736, 6.5137, 6.5526, 6.5949, 6.6354, 6.6781, 6.7177, 6.7624, 6.8112, 6.856, 6.9063, 6.9528, 7.0012, 7.0461, 7.0945, 7.1429, 7.1932, 7.2462, 7.2974, 7.3446, 7.4057, 7.4678, 7.5275, 7.5924, 7.6526, 7.7271, 7.7968, 7.8689, 7.9381, 8.0227, 8.1034, 8.19, 8.2773, 8.3611, 8.4526, 8.5485, 8.6459, 8.737, 8.8452, 8.9629, 9.0717, 9.2005, 9.349, 9.4912, 9.6481, 9.8201, 10.0042, 10.2028, 10.4111, 10.6381, 10.8998, 11.1907, 11.5499, 11.909, 12.01, 12.1121, 12.2241, 12.3552, 12.4826, 12.6, 12.7271, 12.8867, 13.098, 13.3676, 13.6217, 13.8559, 14.1816, 14.5291, 14.8454, 15.2433, 15.789, 16.6115, 18.1587),
|
|
16
|
+
0.25: (0.5897, 0.6892, 0.7654, 0.8271, 0.8849, 0.9357, 0.9821, 1.0273, 1.0735, 1.1163, 1.1612, 1.1998, 1.2401, 1.2779, 1.3152, 1.3547, 1.3916, 1.4284, 1.4658, 1.5041, 1.5407, 1.5784, 1.6173, 1.6556, 1.693, 1.7289, 1.7672, 1.8055, 1.8433, 1.8797, 1.917, 1.9547, 1.9919, 2.0306, 2.0685, 2.1065, 2.1453, 2.1855, 2.2257, 2.2666, 2.3103, 2.3537, 2.3996, 2.4428, 2.4869, 2.5311, 2.5788, 2.6251, 2.6733, 2.7213, 2.767, 2.82, 2.8714, 2.9237, 2.9799, 3.0323, 3.0876, 3.1429, 3.1991, 3.2555, 3.316, 3.3763, 3.4399, 3.5062, 3.5726, 3.6379, 3.706, 3.7786, 3.8507, 3.9291, 4.0081, 4.0899, 4.1736, 4.2647, 4.3556, 4.444, 4.5428, 4.6459, 4.7493, 4.8552, 4.8774, 4.904, 4.9261, 4.9507, 4.9758, 5.001, 5.0254, 5.0495, 5.0755, 5.101, 5.1289, 5.155, 5.1777, 5.204, 5.2333, 5.2612, 5.2883, 5.3167, 5.3449, 5.3733, 5.4005, 5.4292, 5.4556, 5.4806, 5.5077, 5.5408, 5.571, 5.602, 5.6356, 5.6698, 5.7008, 5.7307, 5.7647, 5.7977, 5.8335, 5.8663, 5.8997, 5.9332, 5.969, 6.0096, 6.0456, 6.0819, 6.1242, 6.1635, 6.203, 6.2432, 6.2876, 6.3293, 6.3719, 6.4193, 6.4696, 6.5117, 6.5593, 6.608, 6.6631, 6.7083, 6.7559, 6.8101, 6.8685, 6.9236, 6.9745, 7.0294, 7.0874, 7.1487, 7.2136, 7.2724, 7.3325, 7.4022, 7.4731, 7.5393, 7.6202, 7.7009, 7.7758, 7.8587, 7.9396, 8.0442, 8.1411, 8.2466, 8.3465, 8.4574, 8.5721, 8.6814, 8.8018, 8.9388, 9.0722, 9.2248, 9.3984, 9.5799, 9.7849, 10.0274, 10.2565, 10.5249, 10.7701, 11.0908, 11.4943, 11.6216, 11.7144, 11.8233, 11.9427, 12.0656, 12.2087, 12.3601, 12.5201, 12.6625, 12.8459, 13.0957, 13.4546, 13.7061, 13.9903, 14.4446, 14.8513, 15.3891, 16.3118, 17.769),
|
|
17
|
+
}
|
alphafade/_validate.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Input checks, alignment, and frequency inference.
|
|
2
|
+
|
|
3
|
+
Everything user-facing funnels through these helpers so the rules are the same everywhere:
|
|
4
|
+
inputs must have a sorted, unique DatetimeIndex, nothing is reindexed or resampled behind
|
|
5
|
+
the user's back, and anything lossy emits a :class:`DataDroppedWarning` with counts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import warnings
|
|
11
|
+
from typing import Final, Literal
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
from numpy.typing import NDArray
|
|
16
|
+
|
|
17
|
+
from ._errors import (
|
|
18
|
+
AlignmentError,
|
|
19
|
+
DataDroppedWarning,
|
|
20
|
+
FrequencyError,
|
|
21
|
+
InputError,
|
|
22
|
+
InsufficientDataError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
Freq = Literal["D", "W", "M", "Q", "A"]
|
|
26
|
+
|
|
27
|
+
PERIODS_PER_YEAR: Final[dict[str, int]] = {"D": 252, "W": 52, "M": 12, "Q": 4, "A": 1}
|
|
28
|
+
|
|
29
|
+
# Accepted spellings for ``freq=`` arguments.
|
|
30
|
+
_FREQ_ALIASES: Final[dict[str, Freq]] = {
|
|
31
|
+
"d": "D",
|
|
32
|
+
"b": "D",
|
|
33
|
+
"daily": "D",
|
|
34
|
+
"w": "W",
|
|
35
|
+
"weekly": "W",
|
|
36
|
+
"m": "M",
|
|
37
|
+
"me": "M",
|
|
38
|
+
"ms": "M",
|
|
39
|
+
"monthly": "M",
|
|
40
|
+
"q": "Q",
|
|
41
|
+
"qe": "Q",
|
|
42
|
+
"quarterly": "Q",
|
|
43
|
+
"a": "A",
|
|
44
|
+
"y": "A",
|
|
45
|
+
"ye": "A",
|
|
46
|
+
"annual": "A",
|
|
47
|
+
"yearly": "A",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Allowed gap (in calendar days) between consecutive observations for each frequency.
|
|
51
|
+
# Bands don't overlap. Longer holiday or closure gaps (e.g. the 6-day 2001 NYSE closure) fall
|
|
52
|
+
# outside the band but cover a tiny share of the time span, so they're tolerated below.
|
|
53
|
+
_GAP_BANDS: Final[dict[Freq, tuple[float, float]]] = {
|
|
54
|
+
"D": (1.0, 5.0),
|
|
55
|
+
"W": (5.5, 10.0),
|
|
56
|
+
"M": (25.0, 35.0),
|
|
57
|
+
"Q": (80.0, 100.0),
|
|
58
|
+
"A": (350.0, 380.0),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Share of the total time span that may be covered by out-of-band gaps before the index is
|
|
62
|
+
# called irregular or mixed-frequency.
|
|
63
|
+
_MAX_IRREGULAR_SHARE: Final = 0.05
|
|
64
|
+
|
|
65
|
+
DAYS_PER_YEAR: Final = 365.25
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_index(index: pd.Index, name: str) -> pd.DatetimeIndex:
|
|
69
|
+
if isinstance(index, pd.MultiIndex):
|
|
70
|
+
raise InputError(
|
|
71
|
+
f"{name} has a MultiIndex. alphafade expects wide data: one row per date and one "
|
|
72
|
+
f"column per asset. Try `{name}.unstack()` to reshape it."
|
|
73
|
+
)
|
|
74
|
+
if not isinstance(index, pd.DatetimeIndex):
|
|
75
|
+
raise InputError(
|
|
76
|
+
f"{name} must have a DatetimeIndex, got {type(index).__name__}. "
|
|
77
|
+
f"Convert it with `{name}.index = pd.to_datetime({name}.index)`."
|
|
78
|
+
)
|
|
79
|
+
if index.tz is not None:
|
|
80
|
+
raise InputError(
|
|
81
|
+
f"{name} has a timezone-aware index ({index.tz}). Remove the timezone with "
|
|
82
|
+
f"`{name}.index = {name}.index.tz_localize(None)`."
|
|
83
|
+
)
|
|
84
|
+
if index.has_duplicates:
|
|
85
|
+
dupes = index[index.duplicated()].unique()[:3]
|
|
86
|
+
raise InputError(
|
|
87
|
+
f"{name} has duplicate dates (e.g. {[str(d.date()) for d in dupes]}). "
|
|
88
|
+
"Each date must appear once; aggregate or drop the duplicates first."
|
|
89
|
+
)
|
|
90
|
+
if not index.is_monotonic_increasing:
|
|
91
|
+
raise InputError(f"{name} dates are not sorted. Sort them with `{name}.sort_index()`.")
|
|
92
|
+
return index
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Squaring values this big overflows float64 (max ~1.8e308), turning variances into inf.
|
|
96
|
+
_MAX_ABS_VALUE = 1e100
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _check_values(raw_dtypes: list[object], out: np.ndarray, name: str) -> None:
|
|
100
|
+
"""Reject data that would silently produce nonsense: booleans and absurd magnitudes."""
|
|
101
|
+
if any(pd.api.types.is_bool_dtype(dt) for dt in raw_dtypes): # type: ignore[arg-type]
|
|
102
|
+
raise InputError(
|
|
103
|
+
f"{name} holds True/False values, not returns. Convert it to numbers first "
|
|
104
|
+
"(e.g. a return series, not a yes/no flag)."
|
|
105
|
+
)
|
|
106
|
+
finite = out[np.isfinite(out)]
|
|
107
|
+
if finite.size and float(np.abs(finite).max()) > _MAX_ABS_VALUE:
|
|
108
|
+
raise InputError(
|
|
109
|
+
f"{name} contains values larger than {_MAX_ABS_VALUE:.0e}, which would overflow "
|
|
110
|
+
"the statistics. Check the units (returns are decimals like 0.05, not raw prices)."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def as_series(x: object, name: str) -> pd.Series[float]:
|
|
115
|
+
"""Coerce ``x`` to a float Series with a validated DatetimeIndex."""
|
|
116
|
+
if isinstance(x, pd.DataFrame):
|
|
117
|
+
if x.shape[1] != 1:
|
|
118
|
+
raise InputError(
|
|
119
|
+
f"{name} must be a Series (one return per date), got a DataFrame with "
|
|
120
|
+
f"{x.shape[1]} columns. Pick one column, e.g. `{name}['my_strategy']`."
|
|
121
|
+
)
|
|
122
|
+
x = x.iloc[:, 0]
|
|
123
|
+
if not isinstance(x, pd.Series):
|
|
124
|
+
raise InputError(f"{name} must be a pandas Series, got {type(x).__name__}.")
|
|
125
|
+
_check_index(x.index, name)
|
|
126
|
+
try:
|
|
127
|
+
out = x.astype("float64")
|
|
128
|
+
except (TypeError, ValueError) as exc:
|
|
129
|
+
raise InputError(f"{name} must contain numbers: {exc}") from None
|
|
130
|
+
if np.isinf(out.to_numpy()).any():
|
|
131
|
+
raise InputError(f"{name} contains infinite values. Replace or remove them first.")
|
|
132
|
+
_check_values([x.dtype], out.to_numpy(), name)
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def as_panel(x: object, name: str) -> pd.DataFrame:
|
|
137
|
+
"""Coerce ``x`` to a float DataFrame (dates x assets) with a validated index."""
|
|
138
|
+
if isinstance(x, pd.Series):
|
|
139
|
+
raise InputError(
|
|
140
|
+
f"{name} must be a DataFrame with one column per asset, got a Series. "
|
|
141
|
+
"If your data is in long format, reshape it with `.unstack()`."
|
|
142
|
+
)
|
|
143
|
+
if not isinstance(x, pd.DataFrame):
|
|
144
|
+
raise InputError(f"{name} must be a pandas DataFrame, got {type(x).__name__}.")
|
|
145
|
+
_check_index(x.index, name)
|
|
146
|
+
if x.columns.has_duplicates:
|
|
147
|
+
raise InputError(f"{name} has duplicate column (asset) labels.")
|
|
148
|
+
try:
|
|
149
|
+
out = x.astype("float64")
|
|
150
|
+
except (TypeError, ValueError) as exc:
|
|
151
|
+
raise InputError(f"{name} must contain only numbers: {exc}") from None
|
|
152
|
+
if np.isinf(out.to_numpy()).any():
|
|
153
|
+
raise InputError(f"{name} contains infinite values. Replace or remove them first.")
|
|
154
|
+
_check_values(list(x.dtypes), out.to_numpy(), name)
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def as_timestamp(date: object, name: str) -> pd.Timestamp:
|
|
159
|
+
"""Parse a user-supplied date."""
|
|
160
|
+
try:
|
|
161
|
+
ts = pd.Timestamp(date) # type: ignore[arg-type]
|
|
162
|
+
except (TypeError, ValueError):
|
|
163
|
+
raise InputError(f"{name}={date!r} is not a valid date. Use e.g. '1993-03-01'.") from None
|
|
164
|
+
if pd.isna(ts):
|
|
165
|
+
raise InputError(f"{name} must be a date, got NaT.")
|
|
166
|
+
return ts
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def check_window(window: int, n: int, name: str = "window") -> int:
|
|
170
|
+
"""Validate a rolling-window length against the number of observations."""
|
|
171
|
+
if isinstance(window, bool) or not isinstance(window, (int, np.integer)):
|
|
172
|
+
raise InputError(f"{name} must be an integer number of periods, got {window!r}.")
|
|
173
|
+
if window < 2:
|
|
174
|
+
raise InputError(f"{name} must be at least 2, got {window}.")
|
|
175
|
+
if window > n:
|
|
176
|
+
raise InsufficientDataError(
|
|
177
|
+
f"{name}={window} is longer than the data ({n} observations). "
|
|
178
|
+
"Use a shorter window or more data."
|
|
179
|
+
)
|
|
180
|
+
return int(window)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def parse_freq(freq: str) -> Freq:
|
|
184
|
+
"""Normalize a user-supplied frequency string."""
|
|
185
|
+
key = str(freq).strip().lower()
|
|
186
|
+
if key not in _FREQ_ALIASES:
|
|
187
|
+
raise FrequencyError(
|
|
188
|
+
f"Unknown freq {freq!r}. Use one of 'D' (daily), 'W' (weekly), 'M' (monthly), "
|
|
189
|
+
"'Q' (quarterly), or 'A' (annual)."
|
|
190
|
+
)
|
|
191
|
+
return _FREQ_ALIASES[key]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def infer_freq(index: pd.DatetimeIndex, name: str) -> Freq:
|
|
195
|
+
"""Infer D/W/M/Q/A from the spacing of dates, refusing to guess on mixed data.
|
|
196
|
+
|
|
197
|
+
The frequency whose allowed gap band contains the median gap wins. If gaps outside that
|
|
198
|
+
band cover more than 5% of the total time span, the data is called mixed or irregular
|
|
199
|
+
and a :class:`FrequencyError` is raised instead of guessing.
|
|
200
|
+
"""
|
|
201
|
+
if len(index) < 3:
|
|
202
|
+
raise FrequencyError(
|
|
203
|
+
f"Can't infer the frequency of {name} from {len(index)} dates. Pass freq= "
|
|
204
|
+
"explicitly ('D', 'W', 'M', 'Q' or 'A')."
|
|
205
|
+
)
|
|
206
|
+
gaps = np.diff(_as_ns(index)) / (86_400 * 1e9)
|
|
207
|
+
median = float(np.median(gaps))
|
|
208
|
+
match: Freq | None = None
|
|
209
|
+
for freq, (lo, hi) in _GAP_BANDS.items():
|
|
210
|
+
if lo <= median <= hi:
|
|
211
|
+
match = freq
|
|
212
|
+
break
|
|
213
|
+
if match is None:
|
|
214
|
+
raise FrequencyError(
|
|
215
|
+
f"Can't infer the frequency of {name}: the typical gap between dates is "
|
|
216
|
+
f"{median:.1f} days, which isn't daily, weekly, monthly, quarterly or annual. "
|
|
217
|
+
"Resample the data or pass freq= explicitly."
|
|
218
|
+
)
|
|
219
|
+
lo, hi = _GAP_BANDS[match]
|
|
220
|
+
off_band = (gaps < lo) | (gaps > hi)
|
|
221
|
+
share = float(gaps[off_band].sum() / gaps.sum())
|
|
222
|
+
if share > _MAX_IRREGULAR_SHARE:
|
|
223
|
+
raise FrequencyError(
|
|
224
|
+
f"{name} looks like mixed or irregular frequency: it is mostly {match!r} but "
|
|
225
|
+
f"{share:.0%} of its time span has gaps that don't fit. Resample it to one "
|
|
226
|
+
"frequency (e.g. `.resample('ME').sum()` for monthly) or pass freq= explicitly."
|
|
227
|
+
)
|
|
228
|
+
return match
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def resolve_freq(index: pd.DatetimeIndex, freq: str | None, name: str) -> Freq:
|
|
232
|
+
"""Return ``freq`` if the user gave one, otherwise infer it."""
|
|
233
|
+
if freq is not None:
|
|
234
|
+
return parse_freq(freq)
|
|
235
|
+
return infer_freq(index, name)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def same_freq(a: pd.DatetimeIndex, b: pd.DatetimeIndex, name_a: str, name_b: str) -> Freq:
|
|
239
|
+
"""Infer both frequencies and raise if they differ."""
|
|
240
|
+
fa = infer_freq(a, name_a)
|
|
241
|
+
fb = infer_freq(b, name_b)
|
|
242
|
+
if fa != fb:
|
|
243
|
+
raise FrequencyError(
|
|
244
|
+
f"{name_a} is {fa!r} but {name_b} is {fb!r}. alphafade never resamples for you: "
|
|
245
|
+
f"convert one of them first so both have the same frequency."
|
|
246
|
+
)
|
|
247
|
+
return fa
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def dropna_series(x: pd.Series[float], name: str) -> pd.Series[float]:
|
|
251
|
+
"""Drop NaNs, warning about interior gaps.
|
|
252
|
+
|
|
253
|
+
Leading and trailing NaNs (e.g. from rolling warm-up or forward-return shifts) carry no
|
|
254
|
+
information, so they're trimmed silently. NaNs in the middle are real missing data, so
|
|
255
|
+
dropping them emits a :class:`DataDroppedWarning`.
|
|
256
|
+
"""
|
|
257
|
+
valid = x.notna().to_numpy()
|
|
258
|
+
if not valid.any():
|
|
259
|
+
raise InsufficientDataError(f"{name} has no non-missing values.")
|
|
260
|
+
first = int(np.argmax(valid))
|
|
261
|
+
last = len(valid) - int(np.argmax(valid[::-1]))
|
|
262
|
+
trimmed = x.iloc[first:last]
|
|
263
|
+
interior = int(trimmed.isna().sum())
|
|
264
|
+
if interior:
|
|
265
|
+
warnings.warn(
|
|
266
|
+
f"Dropped {interior} missing value(s) from the middle of {name} "
|
|
267
|
+
f"({interior / len(trimmed):.1%} of observations).",
|
|
268
|
+
DataDroppedWarning,
|
|
269
|
+
stacklevel=3,
|
|
270
|
+
)
|
|
271
|
+
return trimmed.dropna()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def align_panels(
|
|
275
|
+
a: pd.DataFrame, b: pd.DataFrame, name_a: str, name_b: str
|
|
276
|
+
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
277
|
+
"""Restrict two panels to their shared dates and assets, warning about what's left out."""
|
|
278
|
+
dates = a.index.intersection(b.index)
|
|
279
|
+
assets = a.columns.intersection(b.columns)
|
|
280
|
+
if len(dates) == 0:
|
|
281
|
+
raise AlignmentError(
|
|
282
|
+
f"{name_a} and {name_b} share no dates. Check that both use the same date "
|
|
283
|
+
f"convention (e.g. both month-end). {name_a} runs {_span(a.index)}, {name_b} "
|
|
284
|
+
f"runs {_span(b.index)}."
|
|
285
|
+
)
|
|
286
|
+
if len(assets) == 0:
|
|
287
|
+
raise AlignmentError(
|
|
288
|
+
f"{name_a} and {name_b} share no asset (column) labels. Check that both use "
|
|
289
|
+
"the same identifiers (tickers, PERMNOs, ...)."
|
|
290
|
+
)
|
|
291
|
+
dropped_dates = len(a.index.union(b.index)) - len(dates)
|
|
292
|
+
dropped_assets = len(a.columns.union(b.columns)) - len(assets)
|
|
293
|
+
if dropped_dates or dropped_assets:
|
|
294
|
+
warnings.warn(
|
|
295
|
+
f"{name_a} and {name_b} only partly overlap: using {len(dates)} shared dates and "
|
|
296
|
+
f"{len(assets)} shared assets; ignoring {dropped_dates} date(s) and "
|
|
297
|
+
f"{dropped_assets} asset(s) that appear in only one of them.",
|
|
298
|
+
DataDroppedWarning,
|
|
299
|
+
stacklevel=3,
|
|
300
|
+
)
|
|
301
|
+
return a.loc[dates, assets], b.loc[dates, assets]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def to_array(x: pd.Series[float]) -> NDArray[np.float64]:
|
|
305
|
+
"""Values of a float Series as a float64 numpy array."""
|
|
306
|
+
return np.asarray(x.to_numpy(), dtype=np.float64)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def years_since_start(index: pd.DatetimeIndex) -> np.ndarray:
|
|
310
|
+
"""Elapsed calendar time in years from the first date (uses real dates, not row counts)."""
|
|
311
|
+
ns = _as_ns(index).astype("float64")
|
|
312
|
+
out: np.ndarray = (ns - ns[0]) / (86_400 * 1e9 * DAYS_PER_YEAR)
|
|
313
|
+
return out
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _as_ns(index: pd.DatetimeIndex) -> np.ndarray:
|
|
317
|
+
# pandas 3 may store datetimes in s/ms/us resolution; normalize to integer nanoseconds.
|
|
318
|
+
return index.as_unit("ns").to_numpy().astype(np.int64)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _span(index: pd.Index) -> str:
|
|
322
|
+
if len(index) == 0:
|
|
323
|
+
return "(empty)"
|
|
324
|
+
return f"{index[0]:%Y-%m-%d} to {index[-1]:%Y-%m-%d}"
|