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.
@@ -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
+ }