alphaengine 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.
- alphaengine/__init__.py +47 -0
- alphaengine/_version.py +15 -0
- alphaengine/core/__init__.py +63 -0
- alphaengine/core/backtest.py +676 -0
- alphaengine/core/factors.py +136 -0
- alphaengine/core/pairs.py +514 -0
- alphaengine/core/performance.py +108 -0
- alphaengine/core/risk.py +127 -0
- alphaengine/core/technical.py +213 -0
- alphaengine/core/validation.py +361 -0
- alphaengine/py.typed +0 -0
- alphaengine/study/__init__.py +32 -0
- alphaengine/study/schema.py +146 -0
- alphaengine/sweep/__init__.py +18 -0
- alphaengine/sweep/runner.py +321 -0
- alphaengine-0.1.0.dist-info/METADATA +157 -0
- alphaengine-0.1.0.dist-info/RECORD +19 -0
- alphaengine-0.1.0.dist-info/WHEEL +4 -0
- alphaengine-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Risk, factor decomposition over supplied portfolio + factor return streams.
|
|
3
|
+
|
|
4
|
+
Lifted (math-identical) from backend/quant/factors.compute_multi_factor_loadings,
|
|
5
|
+
with the FRED rfr fetch and quant.limits import removed: the caller supplies the
|
|
6
|
+
risk-free rate (defaults to 0.04 annual) and the VIF threshold is inlined. OLS
|
|
7
|
+
with HAC standard errors (statsmodels) gives factor betas, alpha, t-stats, R²,
|
|
8
|
+
and a multicollinearity diagnostic (VIF).
|
|
9
|
+
|
|
10
|
+
Pure numpy/statsmodels. Deterministic given inputs on the pinned stack.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
try: # noqa: SIM105
|
|
16
|
+
import statsmodels # noqa: F401
|
|
17
|
+
except ModuleNotFoundError as exc: # pragma: no cover
|
|
18
|
+
raise ModuleNotFoundError(
|
|
19
|
+
"factors needs statsmodels, which is not a core dependency of "
|
|
20
|
+
"alphaengine. Install it with: pip install 'alphaengine[factors]' "
|
|
21
|
+
"The core (deflated Sharpe, PBO, CPCV, performance, risk) needs "
|
|
22
|
+
"only numpy and scipy and is unaffected."
|
|
23
|
+
) from exc
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
import statsmodels.api as sm
|
|
29
|
+
|
|
30
|
+
VIF_MAX_THRESHOLD = 10.0
|
|
31
|
+
_DEFAULT_RFR = 0.04
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _clean(val):
|
|
35
|
+
if isinstance(val, float) and (math.isnan(val) or math.isinf(val)):
|
|
36
|
+
return None
|
|
37
|
+
return val
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _compute_vif(X: np.ndarray, factor_names: list[str]) -> dict[str, float]:
|
|
41
|
+
"""VIF_j = 1/(1 - R_j²), R_j² from regressing factor j on the others.
|
|
42
|
+
|
|
43
|
+
Several proxies are constructed as `X - SPY`, so they can share variance
|
|
44
|
+
with "market"; VIF surfaces whether loadings are individually identified.
|
|
45
|
+
"""
|
|
46
|
+
n_features = X.shape[1]
|
|
47
|
+
vifs: dict[str, float] = {}
|
|
48
|
+
for j in range(n_features):
|
|
49
|
+
y_j = X[:, j]
|
|
50
|
+
X_others = np.delete(X, j, axis=1)
|
|
51
|
+
if X_others.shape[1] == 0:
|
|
52
|
+
vifs[factor_names[j]] = 1.0
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
X_const = np.column_stack([np.ones(len(y_j)), X_others])
|
|
56
|
+
betas, *_ = np.linalg.lstsq(X_const, y_j, rcond=None)
|
|
57
|
+
y_hat = X_const @ betas
|
|
58
|
+
ss_res = float(np.sum((y_j - y_hat) ** 2))
|
|
59
|
+
ss_tot = float(np.sum((y_j - y_j.mean()) ** 2))
|
|
60
|
+
r_sq = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
|
|
61
|
+
vif = 1.0 / max(1e-9, 1.0 - r_sq) if r_sq < 0.9999 else float("inf")
|
|
62
|
+
vifs[factor_names[j]] = vif if math.isfinite(vif) else float("inf")
|
|
63
|
+
except Exception:
|
|
64
|
+
vifs[factor_names[j]] = float("nan")
|
|
65
|
+
return vifs
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _model_label(factor_names: list[str]) -> str:
|
|
69
|
+
core_set = set(factor_names)
|
|
70
|
+
if {"market", "size", "value", "profitability", "low_vol", "momentum"}.issubset(core_set):
|
|
71
|
+
return "FF5-style + Low-Vol + Momentum"
|
|
72
|
+
if {"market", "size", "value", "momentum"}.issubset(core_set):
|
|
73
|
+
return "Carhart 4-factor"
|
|
74
|
+
return f"{len(factor_names)}-factor"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def decompose_factors(
|
|
78
|
+
portfolio_returns: list[float],
|
|
79
|
+
factor_returns: dict[str, list[float]],
|
|
80
|
+
*,
|
|
81
|
+
risk_free_rate: float | None = None,
|
|
82
|
+
) -> dict:
|
|
83
|
+
"""Multi-factor regression (FF5 + Momentum style) over supplied returns.
|
|
84
|
+
|
|
85
|
+
`factor_returns` = {"market": [...], "size": [...]...}. Returns alpha
|
|
86
|
+
(annualized %), factor betas + t-stats, R²/adj-R², residual vol, and a VIF
|
|
87
|
+
multicollinearity diagnostic. Excess returns use the supplied rfr (annual),
|
|
88
|
+
defaulting to 4%.
|
|
89
|
+
"""
|
|
90
|
+
factor_names = list(factor_returns.keys())
|
|
91
|
+
if not factor_names:
|
|
92
|
+
return {"error": "No factor data"}
|
|
93
|
+
|
|
94
|
+
min_len = min(len(portfolio_returns), *[len(v) for v in factor_returns.values()])
|
|
95
|
+
if min_len < 30:
|
|
96
|
+
return {"error": "Need 30+ observations"}
|
|
97
|
+
|
|
98
|
+
rfr = float(risk_free_rate) if risk_free_rate is not None else _DEFAULT_RFR
|
|
99
|
+
y = np.array(portfolio_returns[-min_len:], dtype=float)
|
|
100
|
+
rf_daily = rfr / 252
|
|
101
|
+
y_excess = y - rf_daily
|
|
102
|
+
|
|
103
|
+
X = np.column_stack([np.array(factor_returns[f][-min_len:], dtype=float) for f in factor_names])
|
|
104
|
+
|
|
105
|
+
vifs: dict[str, float] = {}
|
|
106
|
+
high_vif: list[str] = []
|
|
107
|
+
if X.shape[1] >= 2:
|
|
108
|
+
vifs = _compute_vif(X, factor_names)
|
|
109
|
+
high_vif = [f for f, v in vifs.items() if math.isfinite(v) and v > VIF_MAX_THRESHOLD]
|
|
110
|
+
|
|
111
|
+
X_const = sm.add_constant(X)
|
|
112
|
+
model = sm.OLS(y_excess, X_const).fit(cov_type="HAC", cov_kwds={"maxlags": 5})
|
|
113
|
+
|
|
114
|
+
betas = {}
|
|
115
|
+
tstats = {}
|
|
116
|
+
for i, name in enumerate(factor_names):
|
|
117
|
+
betas[name] = _clean(round(float(model.params[i + 1]), 4))
|
|
118
|
+
tstats[name] = _clean(round(float(model.tvalues[i + 1]), 2))
|
|
119
|
+
|
|
120
|
+
alpha_pvalue = float(model.pvalues[0])
|
|
121
|
+
return {
|
|
122
|
+
"alpha": _clean(round(float(model.params[0] * 252 * 100), 2)),
|
|
123
|
+
"alpha_tstat": _clean(round(float(model.tvalues[0]), 2)),
|
|
124
|
+
"alpha_pvalue": _clean(round(alpha_pvalue, 4)),
|
|
125
|
+
"alpha_significant_at_5pct": bool(alpha_pvalue < 0.05),
|
|
126
|
+
"factor_betas": betas,
|
|
127
|
+
"factor_tstats": tstats,
|
|
128
|
+
"r_squared": _clean(round(float(model.rsquared), 3)),
|
|
129
|
+
"adj_r_squared": _clean(round(float(model.rsquared_adj), 3)),
|
|
130
|
+
"residual_vol": _clean(round(float(np.std(model.resid) * np.sqrt(252) * 100), 2)),
|
|
131
|
+
"n_observations": int(min_len),
|
|
132
|
+
"model": _model_label(factor_names),
|
|
133
|
+
"vifs": {k: _clean(round(v, 2)) if math.isfinite(v) else None for k, v in vifs.items()},
|
|
134
|
+
"high_vif_factors": high_vif,
|
|
135
|
+
"multicollinearity_flag": bool(high_vif),
|
|
136
|
+
}
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Signals, pair-trade analytics over supplied price series.
|
|
3
|
+
|
|
4
|
+
Lifted (math-identical) from backend/quant/pairs.py, with the data-fetch removed:
|
|
5
|
+
the caller supplies aligned price arrays; nothing is fetched. The four
|
|
6
|
+
primitives a PM checks before sizing a pair, TLS hedge ratio, Engle-Granger
|
|
7
|
+
cointegration (ADF), Ornstein-Uhlenbeck half-life, rolling-correlation
|
|
8
|
+
stability, and a discrete trade signal.
|
|
9
|
+
|
|
10
|
+
Public (beta cut):
|
|
11
|
+
compute_spread_signal(a_closes, b_closes...) -> dict # one pair, end to end
|
|
12
|
+
find_cointegrated_pairs(prices...) -> dict # screen many series
|
|
13
|
+
|
|
14
|
+
Pure numpy/scipy/statsmodels. Deterministic given inputs on the pinned stack.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
try: # noqa: SIM105
|
|
20
|
+
import statsmodels # noqa: F401
|
|
21
|
+
except ModuleNotFoundError as exc: # pragma: no cover
|
|
22
|
+
raise ModuleNotFoundError(
|
|
23
|
+
"pairs needs statsmodels, which is not a core dependency of "
|
|
24
|
+
"alphaengine. Install it with: pip install 'alphaengine[factors]' "
|
|
25
|
+
"The core (deflated Sharpe, PBO, CPCV, performance, risk) needs "
|
|
26
|
+
"only numpy and scipy and is unaffected."
|
|
27
|
+
) from exc
|
|
28
|
+
|
|
29
|
+
import itertools
|
|
30
|
+
import math
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
from statsmodels.tsa.stattools import adfuller
|
|
35
|
+
|
|
36
|
+
# Pair-trading thresholds (documented so a caller can audit / override).
|
|
37
|
+
MIN_OBSERVATIONS = 126
|
|
38
|
+
COINTEGRATION_P_THRESHOLD = 0.05
|
|
39
|
+
MIN_HALF_LIFE_DAYS = 1.0
|
|
40
|
+
MAX_HALF_LIFE_DAYS = 60.0
|
|
41
|
+
MIN_STABILITY = 0.5
|
|
42
|
+
# Below this |rolling correlation| the legs barely co-move: a stationary spread
|
|
43
|
+
# here is reverting on idiosyncratic noise, not a structural relationship, the
|
|
44
|
+
# classic multiple-testing mirage. Cointegration still passes (ADF/half-life/
|
|
45
|
+
# stability), but the pair is flagged low structural quality.
|
|
46
|
+
MIN_COMOVEMENT_CORR = 0.5
|
|
47
|
+
ZSCORE_WINDOW = 60
|
|
48
|
+
STABILITY_WINDOW = 60
|
|
49
|
+
ENTRY_ZSCORE = 2.0
|
|
50
|
+
|
|
51
|
+
# ── Redundant-leg guard (share-class twins / fungible dual-listings) ─────────
|
|
52
|
+
# Two tickers for the SAME economic claim (GOOGL/GOOG, BRK.A/BRK.B) are ~1.0
|
|
53
|
+
# correlated, so they pass ADF + half-life + stability perfectly and top every
|
|
54
|
+
# cointegration screen, but their spread has no tradeable edge (it is the same
|
|
55
|
+
# bet minus a tiny structural basis). Exclude them: a pair needs two DISTINCT
|
|
56
|
+
# underlyings. Detected two ways, a curated same-issuer set, and a hard ceiling
|
|
57
|
+
# on mean rolling co-movement (distinct names rarely sustain |corr| >= 0.99).
|
|
58
|
+
MAX_COMOVEMENT_CORR = 0.99
|
|
59
|
+
|
|
60
|
+
_SAME_ISSUER_TWINS = frozenset(
|
|
61
|
+
{
|
|
62
|
+
frozenset({"GOOGL", "GOOG"}),
|
|
63
|
+
frozenset({"BRK.A", "BRK.B"}),
|
|
64
|
+
frozenset({"BRK-A", "BRK-B"}),
|
|
65
|
+
frozenset({"BRKA", "BRKB"}),
|
|
66
|
+
frozenset({"FOX", "FOXA"}),
|
|
67
|
+
frozenset({"NWS", "NWSA"}),
|
|
68
|
+
frozenset({"UA", "UAA"}),
|
|
69
|
+
frozenset({"PARA", "PARAA"}),
|
|
70
|
+
frozenset({"LEN", "LEN.B"}),
|
|
71
|
+
frozenset({"LEN", "LEN-B"}),
|
|
72
|
+
frozenset({"HEI", "HEI.A"}),
|
|
73
|
+
frozenset({"HEI", "HEI-A"}),
|
|
74
|
+
frozenset({"LBRDA", "LBRDK"}),
|
|
75
|
+
frozenset({"CWEN", "CWEN.A"}),
|
|
76
|
+
frozenset({"CWEN", "CWEN-A"}),
|
|
77
|
+
frozenset({"MOG.A", "MOG.B"}),
|
|
78
|
+
frozenset({"MOG-A", "MOG-B"}),
|
|
79
|
+
frozenset({"GEF", "GEF.B"}),
|
|
80
|
+
frozenset({"GEF", "GEF-B"}),
|
|
81
|
+
frozenset({"CRD.A", "CRD.B"}),
|
|
82
|
+
frozenset({"CRD-A", "CRD-B"}),
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _redundant_pair_reason(symbol_a, symbol_b, mean_corr) -> str | None:
|
|
88
|
+
"""Return WHY a pair is redundant (same economic claim), else None.
|
|
89
|
+
A redundant pair is not a tradeable spread and must be excluded from the
|
|
90
|
+
screen even though its cointegration stats look ~perfect."""
|
|
91
|
+
a = str(symbol_a or "").upper().strip()
|
|
92
|
+
b = str(symbol_b or "").upper().strip()
|
|
93
|
+
if a and b and frozenset({a, b}) in _SAME_ISSUER_TWINS:
|
|
94
|
+
return "Same issuer / share-class twin, not a tradeable spread (same economic claim)"
|
|
95
|
+
if mean_corr is not None and abs(mean_corr) >= MAX_COMOVEMENT_CORR:
|
|
96
|
+
return (
|
|
97
|
+
f"Near-identical co-movement (|corr|={abs(mean_corr):.3f} >= {MAX_COMOVEMENT_CORR}), "
|
|
98
|
+
f"legs are effectively the same instrument, no tradeable spread"
|
|
99
|
+
)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _clean(val: Any) -> Any:
|
|
104
|
+
if isinstance(val, float) and (math.isnan(val) or math.isinf(val)):
|
|
105
|
+
return None
|
|
106
|
+
return val
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _series_to_list(s) -> list[float]:
|
|
110
|
+
"""A series is either a list of closes or a {date: close} dict (sorted by date)."""
|
|
111
|
+
if isinstance(s, dict):
|
|
112
|
+
return [float(s[k]) for k in sorted(s.keys())]
|
|
113
|
+
return [float(x) for x in (s or []) if x is not None]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _align_pair(a, b) -> tuple[list[float], list[float]]:
|
|
117
|
+
"""Align two price series. If both are dated dicts, align on the intersection
|
|
118
|
+
of dates (the correct alignment for unaligned calendars). Otherwise treat as
|
|
119
|
+
index-aligned lists and truncate to the common length from the most recent end."""
|
|
120
|
+
if isinstance(a, dict) and isinstance(b, dict):
|
|
121
|
+
common = sorted(set(a.keys()) & set(b.keys()))
|
|
122
|
+
return [float(a[d]) for d in common], [float(b[d]) for d in common]
|
|
123
|
+
al, bl = _series_to_list(a), _series_to_list(b)
|
|
124
|
+
n = min(len(al), len(bl))
|
|
125
|
+
return al[-n:], bl[-n:]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _tls_hedge_ratio(a: np.ndarray, b: np.ndarray) -> float:
|
|
129
|
+
"""Total Least Squares slope of `a ~ β·b` via SVD of the centered design.
|
|
130
|
+
|
|
131
|
+
OLS biases β toward zero when b has noise (attenuation); TLS is the
|
|
132
|
+
unbiased estimator when both legs have measurement noise (always true for
|
|
133
|
+
two market prices).
|
|
134
|
+
"""
|
|
135
|
+
if len(a) != len(b) or len(a) < 2:
|
|
136
|
+
return float("nan")
|
|
137
|
+
a_c = a - float(np.mean(a))
|
|
138
|
+
b_c = b - float(np.mean(b))
|
|
139
|
+
if np.std(b_c) < 1e-12:
|
|
140
|
+
return float("nan")
|
|
141
|
+
M = np.column_stack([b_c, a_c])
|
|
142
|
+
try:
|
|
143
|
+
_, _, Vt = np.linalg.svd(M, full_matrices=False)
|
|
144
|
+
except np.linalg.LinAlgError:
|
|
145
|
+
return float("nan")
|
|
146
|
+
v = Vt[-1]
|
|
147
|
+
if abs(v[1]) < 1e-12:
|
|
148
|
+
return float("nan")
|
|
149
|
+
return float(-v[0] / v[1])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _ou_half_life(spread: np.ndarray) -> float | None:
|
|
153
|
+
"""Mean-reversion half-life via AR(1) on the differenced spread.
|
|
154
|
+
|
|
155
|
+
Δs_t = a + b·s_{t-1} + ε_t ; half_life = -ln(2)/b. None when b≥0.
|
|
156
|
+
"""
|
|
157
|
+
spread = np.asarray(spread, dtype=float)
|
|
158
|
+
spread = spread[np.isfinite(spread)]
|
|
159
|
+
n = len(spread)
|
|
160
|
+
if n < 30:
|
|
161
|
+
return None
|
|
162
|
+
s_lag = spread[:-1]
|
|
163
|
+
s_diff = np.diff(spread)
|
|
164
|
+
X = np.column_stack([np.ones(len(s_lag)), s_lag])
|
|
165
|
+
try:
|
|
166
|
+
coefs, *_ = np.linalg.lstsq(X, s_diff, rcond=None)
|
|
167
|
+
except np.linalg.LinAlgError:
|
|
168
|
+
return None
|
|
169
|
+
b = float(coefs[1])
|
|
170
|
+
if b >= 0 or not math.isfinite(b):
|
|
171
|
+
return None
|
|
172
|
+
half_life = -math.log(2.0) / b
|
|
173
|
+
if not math.isfinite(half_life) or half_life <= 0:
|
|
174
|
+
return None
|
|
175
|
+
return half_life
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _rolling_correlation_stability(
|
|
179
|
+
a_returns: np.ndarray, b_returns: np.ndarray, window: int = STABILITY_WINDOW
|
|
180
|
+
) -> dict:
|
|
181
|
+
"""Stability = 1 - std(rolling correlation). High = structurally consistent."""
|
|
182
|
+
n = min(len(a_returns), len(b_returns))
|
|
183
|
+
if n < window + 5:
|
|
184
|
+
return {"stability": None, "mean_corr": None, "std_corr": None, "n_windows": 0}
|
|
185
|
+
|
|
186
|
+
rolling: list[float] = []
|
|
187
|
+
for i in range(window, n):
|
|
188
|
+
chunk_a = a_returns[i - window : i]
|
|
189
|
+
chunk_b = b_returns[i - window : i]
|
|
190
|
+
if np.std(chunk_a) > 0 and np.std(chunk_b) > 0:
|
|
191
|
+
c = float(np.corrcoef(chunk_a, chunk_b)[0, 1])
|
|
192
|
+
if math.isfinite(c):
|
|
193
|
+
rolling.append(c)
|
|
194
|
+
|
|
195
|
+
if len(rolling) < 5:
|
|
196
|
+
return {"stability": None, "mean_corr": None, "std_corr": None, "n_windows": 0}
|
|
197
|
+
|
|
198
|
+
arr = np.array(rolling)
|
|
199
|
+
mean_c = float(np.mean(arr))
|
|
200
|
+
std_c = float(np.std(arr, ddof=1)) if len(arr) > 1 else 0.0
|
|
201
|
+
stability = max(0.0, min(1.0, 1.0 - std_c))
|
|
202
|
+
return {"stability": stability, "mean_corr": mean_c, "std_corr": std_c, "n_windows": len(rolling)}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def compute_spread(a_closes: np.ndarray, b_closes: np.ndarray, hedge_ratio: float) -> np.ndarray:
|
|
206
|
+
"""Log-price spread: s_t = log(P_a) - β·log(P_b)."""
|
|
207
|
+
a = np.asarray(a_closes, dtype=float)
|
|
208
|
+
b = np.asarray(b_closes, dtype=float)
|
|
209
|
+
return np.log(a) - hedge_ratio * np.log(b)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def engle_granger_test(spread: np.ndarray) -> dict:
|
|
213
|
+
"""ADF on the spread. Null: unit root (NOT cointegrated). Reject at p<0.05."""
|
|
214
|
+
spread_clean = np.asarray(spread, dtype=float)
|
|
215
|
+
spread_clean = spread_clean[np.isfinite(spread_clean)]
|
|
216
|
+
if len(spread_clean) < 30:
|
|
217
|
+
return {
|
|
218
|
+
"p_value": None,
|
|
219
|
+
"test_statistic": None,
|
|
220
|
+
"method": "engle_granger",
|
|
221
|
+
"error": "insufficient_data",
|
|
222
|
+
}
|
|
223
|
+
try:
|
|
224
|
+
result = adfuller(spread_clean, regression="c", autolag="AIC")
|
|
225
|
+
return {
|
|
226
|
+
"p_value": float(result[1]),
|
|
227
|
+
"test_statistic": float(result[0]),
|
|
228
|
+
"critical_values": {k: float(v) for k, v in result[4].items()},
|
|
229
|
+
"n_lags": int(result[2]),
|
|
230
|
+
"method": "engle_granger",
|
|
231
|
+
}
|
|
232
|
+
except Exception as e: # noqa: BLE001
|
|
233
|
+
return {"p_value": None, "test_statistic": None, "method": "engle_granger", "error": str(e)}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def compute_spread_signal(
|
|
237
|
+
a_closes,
|
|
238
|
+
b_closes,
|
|
239
|
+
*,
|
|
240
|
+
symbol_a: str = "A",
|
|
241
|
+
symbol_b: str = "B",
|
|
242
|
+
zscore_window: int = ZSCORE_WINDOW,
|
|
243
|
+
stability_window: int = STABILITY_WINDOW,
|
|
244
|
+
significance: float = COINTEGRATION_P_THRESHOLD,
|
|
245
|
+
max_half_life: float = MAX_HALF_LIFE_DAYS,
|
|
246
|
+
) -> dict:
|
|
247
|
+
"""End-to-end pair analysis over two supplied, index-aligned close series.
|
|
248
|
+
|
|
249
|
+
Identical math to backend analyze_pair, minus the fetch: hedge ratio (TLS),
|
|
250
|
+
cointegration p-value (Engle-Granger ADF), spread z-score, OU half-life,
|
|
251
|
+
rolling-correlation stability, and a discrete trade signal. A pair is
|
|
252
|
+
`cointegrated=True` only when ADF, half-life, and stability all pass.
|
|
253
|
+
"""
|
|
254
|
+
# Accept lists or dated {date: close} dicts; align on common dates if dated.
|
|
255
|
+
a_list, b_list = _align_pair(a_closes, b_closes)
|
|
256
|
+
n = len(a_list)
|
|
257
|
+
|
|
258
|
+
if symbol_a == symbol_b:
|
|
259
|
+
return {
|
|
260
|
+
"ticker_a": symbol_a,
|
|
261
|
+
"ticker_b": symbol_b,
|
|
262
|
+
"error": "Same ticker for both legs",
|
|
263
|
+
"cointegrated": False,
|
|
264
|
+
}
|
|
265
|
+
if n < MIN_OBSERVATIONS:
|
|
266
|
+
return {
|
|
267
|
+
"ticker_a": symbol_a,
|
|
268
|
+
"ticker_b": symbol_b,
|
|
269
|
+
"n_observations": n,
|
|
270
|
+
"error": f"Insufficient overlap: {n} obs (need {MIN_OBSERVATIONS}+)",
|
|
271
|
+
"cointegrated": False,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
a = np.array(a_list, dtype=float)
|
|
275
|
+
b = np.array(b_list, dtype=float)
|
|
276
|
+
if (a <= 0).any() or (b <= 0).any():
|
|
277
|
+
return {
|
|
278
|
+
"ticker_a": symbol_a,
|
|
279
|
+
"ticker_b": symbol_b,
|
|
280
|
+
"n_observations": n,
|
|
281
|
+
"error": "Non-positive prices found (cannot take log)",
|
|
282
|
+
"cointegrated": False,
|
|
283
|
+
}
|
|
284
|
+
if np.std(a) < 1e-9 or np.std(b) < 1e-9:
|
|
285
|
+
return {
|
|
286
|
+
"ticker_a": symbol_a,
|
|
287
|
+
"ticker_b": symbol_b,
|
|
288
|
+
"n_observations": n,
|
|
289
|
+
"error": "Degenerate: constant price series",
|
|
290
|
+
"cointegrated": False,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
log_a = np.log(a)
|
|
294
|
+
log_b = np.log(b)
|
|
295
|
+
hedge_ratio = _tls_hedge_ratio(log_a, log_b)
|
|
296
|
+
if not math.isfinite(hedge_ratio) or abs(hedge_ratio) < 1e-9:
|
|
297
|
+
return {
|
|
298
|
+
"ticker_a": symbol_a,
|
|
299
|
+
"ticker_b": symbol_b,
|
|
300
|
+
"n_observations": n,
|
|
301
|
+
"error": "Hedge ratio degenerate",
|
|
302
|
+
"cointegrated": False,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
spread = compute_spread(a, b, hedge_ratio)
|
|
306
|
+
if len(spread) < max(zscore_window, 30):
|
|
307
|
+
return {
|
|
308
|
+
"ticker_a": symbol_a,
|
|
309
|
+
"ticker_b": symbol_b,
|
|
310
|
+
"n_observations": n,
|
|
311
|
+
"error": "Insufficient spread observations after alignment",
|
|
312
|
+
"cointegrated": False,
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
eg = engle_granger_test(spread)
|
|
316
|
+
p_value = eg.get("p_value")
|
|
317
|
+
|
|
318
|
+
recent_spread = spread[-zscore_window:]
|
|
319
|
+
mu = float(np.mean(recent_spread))
|
|
320
|
+
sigma = float(np.std(recent_spread, ddof=1)) if len(recent_spread) > 1 else 0.0
|
|
321
|
+
current_z: float | None
|
|
322
|
+
if sigma > 1e-9 and math.isfinite(spread[-1]):
|
|
323
|
+
current_z = float((spread[-1] - mu) / sigma)
|
|
324
|
+
else:
|
|
325
|
+
current_z = None
|
|
326
|
+
|
|
327
|
+
half_life = _ou_half_life(spread)
|
|
328
|
+
a_returns = np.diff(log_a)
|
|
329
|
+
b_returns = np.diff(log_b)
|
|
330
|
+
stability_info = _rolling_correlation_stability(a_returns, b_returns, window=stability_window)
|
|
331
|
+
|
|
332
|
+
reasons: list[str] = []
|
|
333
|
+
p_ok = p_value is not None and p_value < significance
|
|
334
|
+
hl_ok = half_life is not None and MIN_HALF_LIFE_DAYS < half_life < max_half_life
|
|
335
|
+
stab = stability_info.get("stability")
|
|
336
|
+
stab_ok = stab is not None and stab > MIN_STABILITY
|
|
337
|
+
|
|
338
|
+
if not p_ok:
|
|
339
|
+
reasons.append(
|
|
340
|
+
"ADF test unavailable"
|
|
341
|
+
if p_value is None
|
|
342
|
+
else f"Failed cointegration (ADF p={p_value:.3f} ≥ {significance})"
|
|
343
|
+
)
|
|
344
|
+
if not hl_ok:
|
|
345
|
+
if half_life is None:
|
|
346
|
+
reasons.append("Spread not mean-reverting (AR(1) coefficient ≥ 0)")
|
|
347
|
+
elif half_life <= MIN_HALF_LIFE_DAYS:
|
|
348
|
+
reasons.append(f"Half-life too short ({half_life:.1f}d)")
|
|
349
|
+
else:
|
|
350
|
+
reasons.append(f"Half-life too long ({half_life:.1f}d > {max_half_life:.0f}d)")
|
|
351
|
+
if not stab_ok:
|
|
352
|
+
reasons.append(
|
|
353
|
+
"Stability test insufficient data"
|
|
354
|
+
if stab is None
|
|
355
|
+
else f"Unstable correlation (stability={stab:.2f} ≤ {MIN_STABILITY:.2f})"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
cointegrated = bool(p_ok and hl_ok and stab_ok)
|
|
359
|
+
if cointegrated and not reasons:
|
|
360
|
+
reasons.append("Cointegrated, mean-reverting in tradable window, stable correlation")
|
|
361
|
+
|
|
362
|
+
# Co-movement quality. Cointegration measures a STATIONARY spread; it can pass
|
|
363
|
+
# when the legs barely move together (a stable-but-near-zero rolling
|
|
364
|
+
# correlation), in which case the reversion is idiosyncratic noise, the
|
|
365
|
+
# multiple-testing mirage. Flag it so a cointegrated hit isn't mistaken for a
|
|
366
|
+
# structural pair. `cointegrated` is unchanged (ADF/half-life/stability); this
|
|
367
|
+
# is an honesty signal layered on top.
|
|
368
|
+
mean_corr = stability_info.get("mean_corr")
|
|
369
|
+
low_comovement = mean_corr is not None and abs(mean_corr) < MIN_COMOVEMENT_CORR
|
|
370
|
+
structural_quality = "unknown" if mean_corr is None else ("low" if low_comovement else "high")
|
|
371
|
+
if cointegrated and low_comovement:
|
|
372
|
+
reasons.append(
|
|
373
|
+
f"Low leg co-movement (|corr|={abs(mean_corr):.2f} < {MIN_COMOVEMENT_CORR}), "
|
|
374
|
+
f"likely spurious / reversion on idiosyncratic noise"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Redundant legs (share-class twins / near-1.0 co-movement): the same economic
|
|
378
|
+
# claim, not a spread. Overrides the cointegration verdict, which for a twin is
|
|
379
|
+
# ~perfect and would otherwise top the screen. Exclude it from tradeable pairs.
|
|
380
|
+
redundant_reason = _redundant_pair_reason(symbol_a, symbol_b, mean_corr)
|
|
381
|
+
redundant = redundant_reason is not None
|
|
382
|
+
if redundant:
|
|
383
|
+
structural_quality = "redundant"
|
|
384
|
+
reasons.append(redundant_reason)
|
|
385
|
+
cointegrated = False
|
|
386
|
+
|
|
387
|
+
trade_signal = "hold"
|
|
388
|
+
if cointegrated and current_z is not None:
|
|
389
|
+
if current_z > ENTRY_ZSCORE:
|
|
390
|
+
trade_signal = "short_spread"
|
|
391
|
+
elif current_z < -ENTRY_ZSCORE:
|
|
392
|
+
trade_signal = "long_spread"
|
|
393
|
+
|
|
394
|
+
share_ratio_at_close: float | None = None
|
|
395
|
+
if math.isfinite(hedge_ratio) and b[-1] > 0:
|
|
396
|
+
share_ratio_at_close = float(hedge_ratio * a[-1] / b[-1])
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
"ticker_a": symbol_a,
|
|
400
|
+
"ticker_b": symbol_b,
|
|
401
|
+
"n_observations": n,
|
|
402
|
+
"hedge_ratio": round(float(hedge_ratio), 4),
|
|
403
|
+
"hedge_ratio_method": "total_least_squares_log_prices",
|
|
404
|
+
"share_ratio_at_close": _clean(round(share_ratio_at_close, 4))
|
|
405
|
+
if share_ratio_at_close is not None
|
|
406
|
+
else None,
|
|
407
|
+
"cointegration": {
|
|
408
|
+
"p_value": _clean(round(p_value, 4)) if p_value is not None else None,
|
|
409
|
+
"test_statistic": (
|
|
410
|
+
_clean(round(eg.get("test_statistic", float("nan")), 3))
|
|
411
|
+
if eg.get("test_statistic") is not None
|
|
412
|
+
else None
|
|
413
|
+
),
|
|
414
|
+
"critical_values": eg.get("critical_values"),
|
|
415
|
+
"n_lags": eg.get("n_lags"),
|
|
416
|
+
"method": eg.get("method"),
|
|
417
|
+
"significant_at_5pct": bool(p_ok),
|
|
418
|
+
},
|
|
419
|
+
"half_life_days": _clean(round(half_life, 2)) if half_life is not None else None,
|
|
420
|
+
"spread": {
|
|
421
|
+
"current_value": _clean(round(float(spread[-1]), 6)),
|
|
422
|
+
"rolling_mean": round(mu, 6),
|
|
423
|
+
"rolling_std": round(sigma, 6),
|
|
424
|
+
"current_zscore": _clean(round(current_z, 3)) if current_z is not None else None,
|
|
425
|
+
"window": zscore_window,
|
|
426
|
+
},
|
|
427
|
+
"stability": {
|
|
428
|
+
"rolling_correlation_mean": (
|
|
429
|
+
_clean(round(stability_info["mean_corr"], 3))
|
|
430
|
+
if stability_info["mean_corr"] is not None
|
|
431
|
+
else None
|
|
432
|
+
),
|
|
433
|
+
"rolling_correlation_std": (
|
|
434
|
+
_clean(round(stability_info["std_corr"], 3))
|
|
435
|
+
if stability_info["std_corr"] is not None
|
|
436
|
+
else None
|
|
437
|
+
),
|
|
438
|
+
"stability_score": (_clean(round(stab, 3)) if stab is not None else None),
|
|
439
|
+
"n_windows": stability_info["n_windows"],
|
|
440
|
+
},
|
|
441
|
+
"cointegrated": cointegrated,
|
|
442
|
+
"structural_quality": structural_quality, # high | low | unknown | redundant
|
|
443
|
+
"low_comovement": low_comovement, # True => likely spurious
|
|
444
|
+
"redundant": redundant, # True => share-class twin / same instrument
|
|
445
|
+
"trade_signal": trade_signal,
|
|
446
|
+
"reasons": reasons,
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def find_cointegrated_pairs(
|
|
451
|
+
prices: dict,
|
|
452
|
+
*,
|
|
453
|
+
candidates: list[tuple[str, str]] | None = None,
|
|
454
|
+
zscore_window: int = ZSCORE_WINDOW,
|
|
455
|
+
stability_window: int = STABILITY_WINDOW,
|
|
456
|
+
cointegrated_only: bool = True,
|
|
457
|
+
significance: float = COINTEGRATION_P_THRESHOLD,
|
|
458
|
+
max_half_life: float = MAX_HALF_LIFE_DAYS,
|
|
459
|
+
) -> dict:
|
|
460
|
+
"""Screen a universe of supplied price series for cointegrated pairs.
|
|
461
|
+
|
|
462
|
+
`prices`: {symbol: [close...]}, series are assumed to share a trading
|
|
463
|
+
calendar; each pair is aligned to its common length from the most recent
|
|
464
|
+
end. `candidates`: optional explicit pair list; default is all unique
|
|
465
|
+
unordered pairs. Returns pairs sorted by ADF p-value ascending.
|
|
466
|
+
"""
|
|
467
|
+
symbols = list(prices.keys())
|
|
468
|
+
pairs = candidates if candidates is not None else list(itertools.combinations(symbols, 2))
|
|
469
|
+
|
|
470
|
+
results: list[dict] = []
|
|
471
|
+
for sa, sb in pairs:
|
|
472
|
+
if sa not in prices or sb not in prices:
|
|
473
|
+
continue
|
|
474
|
+
res = compute_spread_signal(
|
|
475
|
+
prices[sa],
|
|
476
|
+
prices[sb],
|
|
477
|
+
symbol_a=sa,
|
|
478
|
+
symbol_b=sb,
|
|
479
|
+
zscore_window=zscore_window,
|
|
480
|
+
stability_window=stability_window,
|
|
481
|
+
significance=significance,
|
|
482
|
+
max_half_life=max_half_life,
|
|
483
|
+
)
|
|
484
|
+
results.append(res)
|
|
485
|
+
|
|
486
|
+
def _p(r: dict):
|
|
487
|
+
p = (r.get("cointegration") or {}).get("p_value")
|
|
488
|
+
return p if p is not None else 1.0
|
|
489
|
+
|
|
490
|
+
results.sort(key=_p)
|
|
491
|
+
coint = [r for r in results if r.get("cointegrated")]
|
|
492
|
+
selected = coint if cointegrated_only else results
|
|
493
|
+
n_low = sum(1 for r in coint if r.get("low_comovement"))
|
|
494
|
+
n_eval = len(results)
|
|
495
|
+
exp_fp = round(significance * n_eval, 1)
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
"n_evaluated": n_eval,
|
|
499
|
+
"n_cointegrated": len(coint),
|
|
500
|
+
# Honesty layer (the multiple-testing tax made explicit). Screening N pairs
|
|
501
|
+
# at p<sig yields ~sig*N cointegrated hits by chance alone; low-comovement
|
|
502
|
+
# hits are the likely-spurious ones. The host MUST deflate survivors by the
|
|
503
|
+
# number of pairs searched, not 1.
|
|
504
|
+
"n_low_comovement": n_low,
|
|
505
|
+
"expected_false_positives": exp_fp,
|
|
506
|
+
"n_trials_recommended": n_eval,
|
|
507
|
+
"multiple_testing_note": (
|
|
508
|
+
f"Screened {n_eval} pairs at p<{significance}; ~{exp_fp} cointegrated hits are expected "
|
|
509
|
+
f"by chance alone. {n_low} of {len(coint)} cointegrated pairs have near-zero rolling "
|
|
510
|
+
f"correlation (low_comovement=true, likely spurious). Validate survivors with "
|
|
511
|
+
f"deflated_sharpe at n_trials={n_eval} and pbo_cscv before trading; do not deflate at n_trials=1."
|
|
512
|
+
),
|
|
513
|
+
"pairs": selected,
|
|
514
|
+
}
|