9612560479
Nuova harness condivisa xslib.py (panel HL certificato, score per-asset causale, book long-k/short-k vol-targeted leak-free) + 43 script in runs/ su 11 famiglie (MOM/REV/VOL/ DIST/LIQ/VAL/STRUCT/UNIV). Scoring = earns_slot (full>0 AND hold-out>0 AND marginal ADDS al portafoglio live AND corr XS01<0.6, con jackknife drop-one-month). Find: 42/257 config earns_slot=True, ma TUTTE con corr TP01 -0.2..-0.4 e PnL ~solo 2025. Verify (verify_survivors.py, 3 scettici deterministici): - S1 redundancy: cluster low-vol = UNA scommessa (XV01=XU02=1.00, XV02/XV03 r 0.44-0.67); XM09/XL02/XS06b/XR02 distinti (corr media off-diag +0.20). - S2 short-beta: cluster low-vol carica 0.44-0.70 su short-market -> NON market-neutral, e' un tilt short-alt-beta di regime. XM09(0.08)/XR02(-0.21) NON short-beta. - S3 per-anno: cluster low-vol decade (XV01/XU02 2026 -0.09); XL02 morto (2025 -0.14, 2026 -0.43); XM09 (0.82/0.50/0.74) e XR02 (0.84/0.40/2.68) positivi in tutti e 3 gli anni. Esito: nessuna sleeve nuova. Cluster low-vol RIGETTATO (regime-bet), XL02 RIGETTATO (overfit). 2 LEAD genuini (XM09 trend-gated x-sec momentum, XR02 reversal vol-gated) -> forward-monitor, non deployabili (panel 2.5y regime unico + STAT-MODE esecuzione). Portafoglio live invariato. Incluso anche options_vrp_managed.py (A/B VRP01 hold-to-expiry vs gestione attiva del doc credit-spread): la gestione attiva DISTRUGGE l'edge (combo FULL managed Sh -1.29 vs HtE +0.96, il delta-exit taglia i vincenti) -> scartata, VRP01 resta hold-to-expiry. Diari: 2026-06-20-xsec-strategies-sweep.md, 2026-06-20-vrp-active-management.md. gitignore: data/paper_portfolio/ (stato runtime paper) + scripts/research/xsec/runs/out/ (output rigenerabile). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
359 lines
15 KiB
Python
359 lines
15 KiB
Python
"""xslib — SHARED CROSS-SECTIONAL research harness over the certified Hyperliquid alt panel.
|
||
|
||
Built for the "cerca altre strategie" wave (2026-06-20, follow-up to the 104-hypothesis BTC/ETH
|
||
sweep that exhausted the single-asset directional space). The frontier the prior synthesis pointed
|
||
to: CROSS-SECTIONAL / multi-asset mechanisms on the 51 certified Hyperliquid alts (1d, 2024-2026),
|
||
where the ~1.3 BTC/ETH-directional ceiling does NOT bind, and DISTINCT from XS01 (plain x-sec momentum).
|
||
|
||
Why a new harness: the panel is N assets × ~900 days. A strategy = a per-asset SCORE computed
|
||
causally (data <= close[i]); the harness ranks it cross-sectionally each rebalance, goes long the
|
||
top-k / short the bottom-k (market-neutral) or long-only top-k, vol-targets, charges fee on turnover,
|
||
and — crucially — the weight decided at bar i is applied to the return of bar i+1, so look-ahead is
|
||
structurally impossible (same convention as src.portfolio xs_book / sleeves._xsec_returns).
|
||
|
||
A candidate only matters if it (a) is robust (positive FULL + hold-out 2025+ + jackknife), AND
|
||
(b) is DISTINCT from XS01 (low correlation), AND (c) ADDS to the live TP01+XS01+VRP01 portfolio.
|
||
CAVEAT baked in: the panel is ~2.5 years — every result is SUGGESTIVE, not robust like 6y BTC/ETH.
|
||
|
||
Quick start (agent script):
|
||
import sys; sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/xsec")
|
||
import xslib as xs, numpy as np
|
||
p = xs.load_panel("all") # or "majors", a list, or an int N (top-N liquidity)
|
||
score = xs.past_return(p.close, 30) # momentum: higher = long
|
||
rep = xs.study_xs("MOM30", lambda P: xs.past_return(P.close, 30), H=10, k=5)
|
||
print(xs.fmt(rep)); print("JSON:", xs.as_json(rep))
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import glob
|
||
import json
|
||
import sys
|
||
import warnings
|
||
from dataclasses import dataclass
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
# panel research has many all-NaN edge windows (rolling beta/vol on first rows) -> benign
|
||
warnings.filterwarnings("ignore", category=RuntimeWarning)
|
||
|
||
_ROOT = Path(__file__).resolve().parents[3]
|
||
if str(_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(_ROOT))
|
||
sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt"))
|
||
import altlib as al # noqa: E402 (reuse _sh, _dd_ret, _to_daily, HOLDOUT, metric helpers)
|
||
|
||
RAW = _ROOT / "data" / "raw"
|
||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||
FEE = 0.001 # round-trip; charged /2 per side on turnover
|
||
|
||
MAJORS = ["BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "AVAX", "LINK", "LTC", "ADA",
|
||
"ARB", "OP", "SUI", "APT", "INJ", "TIA", "SEI", "NEAR", "AAVE"]
|
||
|
||
|
||
# ===========================================================================
|
||
# PANEL
|
||
# ===========================================================================
|
||
@dataclass
|
||
class Panel:
|
||
syms: list
|
||
index: pd.DatetimeIndex
|
||
close: np.ndarray
|
||
open: np.ndarray
|
||
high: np.ndarray
|
||
low: np.ndarray
|
||
vol: np.ndarray
|
||
ret: np.ndarray # daily simple returns, ret[0]=0
|
||
|
||
|
||
@lru_cache(maxsize=16)
|
||
def load_panel(universe="all", min_rows: int = 700) -> Panel:
|
||
"""Common-date OHLCV panel of the certified HL alts (1d). `universe`:
|
||
'all' -> every alt with >= min_rows of history (drops short ones e.g. ALGO/SAND),
|
||
'majors' -> the 19 XS01 majors, a list of symbols, or an int N (top-N by median $-volume)."""
|
||
close, vol, high, low, opn = {}, {}, {}, {}, {}
|
||
for f in sorted(glob.glob(str(RAW / "hl_*_1d.parquet"))):
|
||
sym = Path(f).stem.replace("hl_", "").replace("_1d", "").upper()
|
||
d = pd.read_parquet(f)
|
||
if len(d) < min_rows:
|
||
continue
|
||
idx = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||
close[sym] = pd.Series(d["close"].values.astype(float), index=idx)
|
||
vol[sym] = pd.Series(d["volume"].values.astype(float), index=idx)
|
||
high[sym] = pd.Series(d["high"].values.astype(float), index=idx)
|
||
low[sym] = pd.Series(d["low"].values.astype(float), index=idx)
|
||
opn[sym] = pd.Series(d["open"].values.astype(float), index=idx)
|
||
C = pd.concat(close, axis=1, join="inner").sort_index().dropna()
|
||
syms = list(C.columns)
|
||
if universe == "majors":
|
||
syms = [s for s in MAJORS if s in syms]
|
||
elif isinstance(universe, (list, tuple)):
|
||
syms = [s for s in universe if s in syms]
|
||
elif isinstance(universe, int):
|
||
dollar = {s: float(np.nanmedian(C[s].values * pd.concat(vol, axis=1)[s].reindex(C.index).values))
|
||
for s in syms}
|
||
syms = sorted(syms, key=lambda s: -dollar[s])[:universe]
|
||
C = C[syms]
|
||
idx = C.index
|
||
|
||
def stack(dd):
|
||
return pd.concat(dd, axis=1).reindex(index=idx)[syms].values.astype(float)
|
||
cl = C.values
|
||
ret = np.zeros_like(cl)
|
||
ret[1:] = cl[1:] / cl[:-1] - 1.0
|
||
return Panel(syms, idx, cl, stack(opn), stack(high), stack(low), stack(vol), ret)
|
||
|
||
|
||
# ===========================================================================
|
||
# CAUSAL CROSS-SECTIONAL HELPERS (value at row i uses data <= i)
|
||
# ===========================================================================
|
||
def past_return(close, L):
|
||
out = np.full_like(close, np.nan)
|
||
out[L:] = close[L:] / close[:-L] - 1.0
|
||
return out
|
||
|
||
|
||
def roll_std(mat, win):
|
||
return pd.DataFrame(mat).rolling(win, min_periods=max(2, win // 2)).std().values
|
||
|
||
|
||
def roll_mean(mat, win):
|
||
return pd.DataFrame(mat).rolling(win, min_periods=max(2, win // 2)).mean().values
|
||
|
||
|
||
def roll_skew(mat, win):
|
||
return pd.DataFrame(mat).rolling(win, min_periods=max(3, win // 2)).skew().values
|
||
|
||
|
||
def ewm_mean(mat, span):
|
||
return pd.DataFrame(mat).ewm(span=span, adjust=False).mean().values
|
||
|
||
|
||
def xs_zscore(mat):
|
||
"""Cross-sectional z-score per row (across assets). NaN-safe."""
|
||
m = np.nanmean(mat, axis=1, keepdims=True)
|
||
s = np.nanstd(mat, axis=1, keepdims=True)
|
||
return (mat - m) / np.where(s > 0, s, np.nan)
|
||
|
||
|
||
def xs_rank(mat):
|
||
"""Cross-sectional rank in [0,1] per row (0=lowest)."""
|
||
out = np.full_like(mat, np.nan, dtype=float)
|
||
for i in range(mat.shape[0]):
|
||
row = mat[i]
|
||
ok = np.isfinite(row)
|
||
if ok.sum() >= 2:
|
||
r = pd.Series(row[ok]).rank().values
|
||
out[i, ok] = (r - 1) / (ok.sum() - 1)
|
||
return out
|
||
|
||
|
||
def market_ret(ret):
|
||
"""Equal-weight market return per day (n,)."""
|
||
return np.nanmean(ret, axis=1)
|
||
|
||
|
||
def roll_beta(ret, win):
|
||
"""Rolling beta of each asset to the equal-weight market (n,A), causal."""
|
||
mkt = market_ret(ret)
|
||
ms = pd.Series(mkt)
|
||
var = ms.rolling(win, min_periods=max(5, win // 2)).var()
|
||
out = np.full_like(ret, np.nan)
|
||
for a in range(ret.shape[1]):
|
||
cov = pd.Series(ret[:, a]).rolling(win, min_periods=max(5, win // 2)).cov(ms)
|
||
out[:, a] = (cov / var.replace(0, np.nan)).values
|
||
return out
|
||
|
||
|
||
def residual_return(ret, win):
|
||
"""Idiosyncratic daily return = ret - beta*market (beta rolling, causal)."""
|
||
beta = roll_beta(ret, win)
|
||
mkt = market_ret(ret)[:, None]
|
||
return ret - beta * mkt
|
||
|
||
|
||
def volume_z(vol, win):
|
||
m = roll_mean(vol, win)
|
||
s = roll_std(vol, win)
|
||
return (vol - m) / np.where(s > 0, s, np.nan)
|
||
|
||
|
||
# ===========================================================================
|
||
# BACKTEST — generic cross-sectional book from a per-asset SCORE matrix.
|
||
# score[i] (data <= i) -> rank assets -> long top-k / short bottom-k; W[i] earns dret[i+1].
|
||
# ===========================================================================
|
||
def xs_backtest(panel: Panel, score, H=10, k=5, long_short=True, target_vol=0.20,
|
||
fee=FEE, vt_cap=3.0):
|
||
px = panel.close
|
||
n, A = px.shape
|
||
dret = panel.ret
|
||
score = np.asarray(score, float)
|
||
if score.shape != (n, A):
|
||
raise ValueError(f"score shape {score.shape} != panel {(n, A)}")
|
||
W = np.zeros((n, A))
|
||
w = np.zeros(A)
|
||
for i in range(n):
|
||
if i % H == 0:
|
||
row = score[i]
|
||
fin = np.isfinite(row)
|
||
if fin.sum() >= 2 * k:
|
||
ranked = np.where(fin, row, -np.inf)
|
||
order = np.argsort(ranked)
|
||
order = order[np.isfinite(ranked[order])]
|
||
lo, hi = order[:k], order[-k:]
|
||
w = np.zeros(A)
|
||
if long_short:
|
||
w[hi] = 0.5 / k
|
||
w[lo] = -0.5 / k
|
||
else:
|
||
w[hi] = 1.0 / k
|
||
W[i] = w
|
||
gross = np.zeros(n)
|
||
gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
|
||
turn = np.zeros(n)
|
||
turn[0] = np.abs(W[0]).sum()
|
||
turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
|
||
net = gross - turn * (fee / 2.0)
|
||
s = pd.Series(net, index=panel.index)
|
||
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
|
||
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, vt_cap)
|
||
return pd.Series(s.values * scale, index=panel.index)
|
||
|
||
|
||
# ===========================================================================
|
||
# BASELINES (live stack) + MARGINAL scoring
|
||
# ===========================================================================
|
||
@lru_cache(maxsize=1)
|
||
def baselines():
|
||
"""Daily returns of the LIVE stack: TP01, XS01, and the combined active portfolio."""
|
||
from src.portfolio.portfolio import StrategyPortfolio, to_daily
|
||
from src.portfolio.sleeves import _tp01_returns, _xsec_returns, active_sleeves
|
||
tp = to_daily(_tp01_returns())
|
||
xs01 = to_daily(_xsec_returns())
|
||
active = StrategyPortfolio(active_sleeves()).combined_daily()
|
||
return dict(tp01=tp, xs01=xs01, active=active)
|
||
|
||
|
||
def _corr(a, b):
|
||
J = pd.concat({"a": a, "b": b}, axis=1, join="inner").dropna()
|
||
return round(float(J["a"].corr(J["b"])), 3) if len(J) > 5 else None
|
||
|
||
|
||
def marginal_vs(cand, base, weights=(0.2, 0.35)):
|
||
"""Does `cand` improve `base`? blend uplift (full & hold-out), + OOS jackknife robustness."""
|
||
J = pd.concat({"B": base, "C": cand}, axis=1, join="inner").dropna()
|
||
if len(J) < 30:
|
||
return dict(verdict="N/A", reason="overlap < 30d")
|
||
JH = J[J.index >= HOLDOUT]
|
||
has_h = len(JH) > 20
|
||
out = dict(corr=_corr(J["B"], J["C"]), base_full=round(al._sh(J["B"]), 3),
|
||
base_hold=round(al._sh(JH["B"]), 3) if has_h else None,
|
||
cand_full=round(al._sh(J["C"]), 3), cand_hold=round(al._sh(JH["C"]), 3) if has_h else None,
|
||
blends={})
|
||
for w in weights:
|
||
bf, bh = (1 - w) * J["B"] + w * J["C"], (1 - w) * JH["B"] + w * JH["C"]
|
||
out["blends"][f"w{int(w * 100)}"] = dict(
|
||
uplift_full=round(al._sh(bf) - al._sh(J["B"]), 3),
|
||
uplift_hold=round(al._sh(bh) - al._sh(JH["B"]), 3) if has_h else None,
|
||
dd=round(al._dd_ret(bf), 4))
|
||
# OOS jackknife at w=0.2
|
||
robust = False
|
||
cu = jk = None
|
||
if has_h:
|
||
def _u(sub):
|
||
return al._sh(0.8 * sub["B"] + 0.2 * sub["C"]) - al._sh(sub["B"])
|
||
months = sorted(set(zip(JH.index.year, JH.index.month)))
|
||
cu = round(_u(JH), 3)
|
||
jk = round(min(_u(JH[~((JH.index.year == y) & (JH.index.month == m))]) for y, m in months), 3) \
|
||
if len(months) > 1 else cu
|
||
robust = bool(cu > 0.02 and jk > 0.0)
|
||
out["holdout_uplift_w20"] = cu
|
||
out["jackknife_min_uplift"] = jk
|
||
out["robust_oos"] = robust
|
||
up = out["blends"][f"w{int(weights[0] * 100)}"]["uplift_hold"]
|
||
cc = out["corr"] if out["corr"] is not None else 0.0
|
||
if cc is not None and cc > 0.85 and (up is None or abs(up) < 0.05):
|
||
out["verdict"] = "REDUNDANT"
|
||
elif up is not None and up >= 0.05 and robust:
|
||
out["verdict"] = "ADDS"
|
||
elif up is not None and up <= -0.05:
|
||
out["verdict"] = "DILUTES"
|
||
else:
|
||
out["verdict"] = "NEUTRAL"
|
||
return out
|
||
|
||
|
||
# ===========================================================================
|
||
# DRIVER
|
||
# ===========================================================================
|
||
def study_xs(name, score_fn, universe="all", H=10, k=5, long_short=True,
|
||
target_vol=0.20, min_rows=700) -> dict:
|
||
"""Backtest one cross-sectional hypothesis and score it honestly:
|
||
FULL + hold-out 2025+ + yearly, correlation to TP01 & XS01 (distinctness),
|
||
and marginal contribution to the LIVE active portfolio. `score_fn(panel) -> (n,A)`
|
||
per-asset score (higher = long), computed CAUSALLY (data <= close[i])."""
|
||
p = load_panel(universe, min_rows=min_rows)
|
||
score = score_fn(p)
|
||
daily = al._to_daily(xs_backtest(p, score, H=H, k=k, long_short=long_short, target_vol=target_vol))
|
||
net = daily.values
|
||
idx = daily.index
|
||
full = al._metrics_from_net(net, idx)
|
||
hmask = idx >= HOLDOUT
|
||
hold = al._metrics_from_net(net[hmask], idx[hmask]) if hmask.sum() > 20 else dict(sharpe=0.0, n=int(hmask.sum()))
|
||
bl = baselines()
|
||
marg = marginal_vs(daily, bl["active"])
|
||
earns_slot = (full["sharpe"] > 0 and hold.get("sharpe", 0) > 0
|
||
and marg.get("verdict") == "ADDS"
|
||
and (_corr(daily, bl["xs01"]) or 0) < 0.6) # distinct from existing x-sec
|
||
return dict(
|
||
name=name, universe=str(universe), H=H, k=k, long_short=long_short,
|
||
n_assets=len(p.syms), n_days=int(len(idx)),
|
||
full=full, holdout=hold, yearly=al._yearly(net, idx),
|
||
corr_tp01=_corr(daily, bl["tp01"]), corr_xs01=_corr(daily, bl["xs01"]),
|
||
corr_active=_corr(daily, bl["active"]),
|
||
marginal=marg, earns_slot=earns_slot,
|
||
caveat="panel ~2.5y (2024-26): suggestive, not robust",
|
||
)
|
||
|
||
|
||
def _clean(o):
|
||
if isinstance(o, dict):
|
||
return {k: _clean(v) for k, v in o.items()}
|
||
if isinstance(o, (list, tuple)):
|
||
return [_clean(x) for x in o]
|
||
if isinstance(o, (np.floating,)):
|
||
return round(float(o), 4)
|
||
if isinstance(o, (np.integer,)):
|
||
return int(o)
|
||
if isinstance(o, (np.bool_,)):
|
||
return bool(o)
|
||
return o
|
||
|
||
|
||
def as_json(rep):
|
||
return json.dumps(_clean(rep), default=str)
|
||
|
||
|
||
def fmt(rep):
|
||
m = rep["marginal"]
|
||
yr = " ".join(f"{y}:{d['ret'] * 100:+.0f}%" for y, d in rep["yearly"].items())
|
||
return (f"=== {rep['name']} [{rep['universe']} H{rep['H']} k{rep['k']} "
|
||
f"{'LS' if rep['long_short'] else 'LO'}] EARNS_SLOT={rep['earns_slot']}\n"
|
||
f" FULL Sh {rep['full']['sharpe']:+.2f} DD {rep['full']['maxdd'] * 100:.0f}% "
|
||
f"ret {rep['full']['ret'] * 100:+.0f}% | HOLD Sh {rep['holdout'].get('sharpe', 0):+.2f} "
|
||
f"| corr TP01 {rep['corr_tp01']} XS01 {rep['corr_xs01']}\n"
|
||
f" marginal vs active: {m.get('verdict')} (corr {m.get('corr')}, "
|
||
f"holdUplift_w20 {m.get('holdout_uplift_w20')}, jackknife {m.get('jackknife_min_uplift')}, "
|
||
f"robust_oos {m.get('robust_oos')}) | {yr}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
print("--- SMOKE TEST xslib ---")
|
||
# 1) x-sec momentum (should resemble XS01 ballpark) ; 2) short-term reversal ; 3) low-vol
|
||
print(fmt(study_xs("MOM30-90", lambda P: xs_zscore(past_return(P.close, 30)) + xs_zscore(past_return(P.close, 90)), H=10, k=5)))
|
||
print(fmt(study_xs("REV5", lambda P: -past_return(P.close, 5), H=5, k=5)))
|
||
print(fmt(study_xs("LOWVOL", lambda P: -roll_std(P.ret, 30), H=10, k=5)))
|
||
print("\nJSON sample:", as_json(study_xs("MOM30", lambda P: past_return(P.close, 30)))[:240])
|