491411ac77
Ondata onesta su angoli non coperti: funding-TS (chiude il filone funding su 3 lati), breadth alt (non-ridondante ma DSR 0.43, rivisitabile con storia), XS-residmom (REDUNDANT), pesi+guardia-DD (EW-STR refutato dallo scettico come selezione-sull'hold-out di 2° ordine, firma best-of-15), VRP-refine (filone esaurito), stagionalità-XS (morta allo step statistico). Lezione codificata: weights_tilt_null + combine_outer in src/portfolio (ogni cambio-pesi vs null di tilt casuali cap-respecting + delta in-sample>=0); 5 test nuovi, suite 165/165. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
296 lines
14 KiB
Python
296 lines
14 KiB
Python
"""r0701_xs_seasonal — STAGIONALITÀ CROSS-SECTIONAL sull'universo Hyperliquid (2026-07-01).
|
||
|
||
DOMANDA: esistono effetti calendario RELATIVI tra i 19 alt major HL (weekday tilt,
|
||
turn-of-month, pattern weekend->lunedì nel cross-section)? Essendo long/short
|
||
market-neutral (demeaned cross-section), il "buy&hold travestito" — che ha ucciso la
|
||
seasonality trackF su BTC/ETH — è strutturalmente escluso.
|
||
|
||
METODO (ordine obbligatorio, dal mandato):
|
||
1. TEST STATISTICO PRIMA DELLA STRATEGIA — persistenza split-half: per ogni giorno
|
||
della settimana, il tilt cross-sectional per-asset (media del ritorno relativo
|
||
demeaned in quel giorno, al netto del tilt incondizionato dell'asset) della PRIMA
|
||
metà del campione correla (Spearman rank) con quello della SECONDA metà?
|
||
Null: permutazione delle etichette-giorno (2000 draw) entro ciascuna metà →
|
||
distribuzione del max-su-7 rank-corr. Se il max reale non batte il 95° pctl del
|
||
null → SCARTATO senza backtest. Idem per weekend-bucket e turn-of-month.
|
||
La permutazione controlla automaticamente il confound "alpha persistente
|
||
dell'asset su entrambe le metà" (momentum), perché anche le etichette permutate
|
||
lo mostrerebbero.
|
||
2. (solo se persiste) strategia L/S market-neutral vol-target, fee 0.10% RT,
|
||
breakeven fee, selezione IN-SAMPLE (pre-2025), hold-out, deflated_sharpe.
|
||
3. day_boundary_robust OBBLIGATORIO per ogni effetto calendario — NB: i dati HL
|
||
locali sono SOLO 1d → il confine giorno NON è ri-tagliabile localmente sui 19
|
||
alt. Qualunque lead weekday su HL 1d resta NON-VERIFICABILE al boundary-shift
|
||
finché non esistono barre orarie HL: il verdetto massimo possibile qui è
|
||
LEAD-forward *condizionato*, mai sleeve. (Su BTC/ETH 1h il test esiste in
|
||
altlib, ma il cross-section a 2 asset non riproduce l'effetto a 19.)
|
||
|
||
DATI: data/raw/hl_*_1d.parquet — 19 major XS01, 913 giorni (2024-01-01 → 2026-07-01),
|
||
0 barre vol=0 (verificato). LIMITE DICHIARATO: ~2.5 anni → ~130 osservazioni per
|
||
weekday, ~65 per metà → alto rischio rumore; soglie severe (p<0.05 sul max-statistic
|
||
permutato, non per-weekday).
|
||
|
||
Esecuzione: cd /opt/docker/PythagorasGoal && uv run python scripts/research/r0701_xs_seasonal.py
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
_ROOT = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt"))
|
||
sys.path.insert(0, str(_ROOT))
|
||
|
||
import altlib as al # noqa: E402 (HOLDOUT, deflated_sharpe — riuso convenzioni)
|
||
|
||
RNG = np.random.default_rng(20260701)
|
||
N_PERM = 2000
|
||
UNIVERSE = ["BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "AVAX", "LINK", "LTC", "ADA",
|
||
"ARB", "OP", "SUI", "APT", "INJ", "TIA", "SEI", "NEAR", "AAVE"]
|
||
FEE_SIDE = 0.0005 # 0.10% RT
|
||
HOLDOUT = al.HOLDOUT # 2025-01-01 UTC (convenzione di progetto)
|
||
WD_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||
|
||
|
||
# ---------------------------------------------------------------- dati
|
||
def load_relative_returns():
|
||
"""Matrice (date × asset) dei ritorni GIORNALIERI RELATIVI (demeaned cross-section).
|
||
Esclude barre vol=0 (→ NaN). x[t,a] = r[t,a] − mean_a r[t,a] → market-neutral."""
|
||
cols = {}
|
||
for s in UNIVERSE:
|
||
d = pd.read_parquet(_ROOT / "data" / "raw" / f"hl_{s.lower()}_1d.parquet")
|
||
idx = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||
c = pd.Series(d["close"].values.astype(float), index=idx)
|
||
c[d["volume"].values <= 0] = np.nan # guardrail backfill sintetico
|
||
cols[s] = c
|
||
C = pd.concat(cols, axis=1).sort_index()
|
||
R = C.pct_change()
|
||
R = R.iloc[1:] # prima riga NaN
|
||
X = R.sub(R.mean(axis=1), axis=0) # demean cross-section per data
|
||
return R, X
|
||
|
||
|
||
# ---------------------------------------------------------------- statistica
|
||
def _rank(v):
|
||
return pd.Series(v).rank().values
|
||
|
||
|
||
def spearman(a, b):
|
||
ra, rb = _rank(a), _rank(b)
|
||
if np.std(ra) == 0 or np.std(rb) == 0:
|
||
return 0.0
|
||
return float(np.corrcoef(ra, rb)[0, 1])
|
||
|
||
|
||
def bucket_tilts(X: pd.DataFrame, labels: np.ndarray, n_buckets: int) -> np.ndarray:
|
||
"""tilt[b, a] = media di x nei giorni con label==b, MENO il tilt incondizionato
|
||
dell'asset (isola l'effetto calendario dal drift relativo generico)."""
|
||
base = np.nanmean(X.values, axis=0)
|
||
out = np.full((n_buckets, X.shape[1]), np.nan)
|
||
for b in range(n_buckets):
|
||
m = labels == b
|
||
if m.sum() >= 10:
|
||
out[b] = np.nanmean(X.values[m], axis=0) - base
|
||
return out
|
||
|
||
|
||
def split_half_persistence(X: pd.DataFrame, labels: np.ndarray, n_buckets: int,
|
||
n_perm: int = N_PERM):
|
||
"""Rank-corr H1 vs H2 dei tilt per bucket + null permutando le etichette entro
|
||
ciascuna metà. Ritorna (rho per bucket, max reale, p-value del max, null 95° pctl)."""
|
||
half = len(X) // 2
|
||
X1, X2 = X.iloc[:half], X.iloc[half:]
|
||
l1, l2 = labels[:half], labels[half:]
|
||
|
||
def rhos(la, lb):
|
||
t1, t2 = bucket_tilts(X1, la, n_buckets), bucket_tilts(X2, lb, n_buckets)
|
||
return np.array([spearman(t1[b], t2[b])
|
||
if np.isfinite(t1[b]).all() and np.isfinite(t2[b]).all() else np.nan
|
||
for b in range(n_buckets)])
|
||
|
||
real = rhos(l1, l2)
|
||
real_max = float(np.nanmax(real))
|
||
null_max = np.empty(n_perm)
|
||
for i in range(n_perm):
|
||
null_max[i] = np.nanmax(rhos(RNG.permutation(l1), RNG.permutation(l2)))
|
||
pval = float(np.mean(null_max >= real_max))
|
||
return real, real_max, pval, float(np.percentile(null_max, 95))
|
||
|
||
|
||
def weekend_monday_ic(X: pd.DataFrame):
|
||
"""Pattern pre/post weekend: il ritorno relativo cumulato Sab+Dom predice
|
||
(cross-sectionalmente) il ritorno relativo del lunedì? IC = Spearman giornaliero;
|
||
riporta mean IC e t-stat su ciascuna metà (persistenza del segno)."""
|
||
wd = X.index.dayofweek.values
|
||
ics, dates = [], []
|
||
for i in np.where(wd == 0)[0]: # lunedì
|
||
if i < 2 or wd[i - 1] != 6 or wd[i - 2] != 5:
|
||
continue
|
||
wkend = np.nansum(X.values[i - 2:i], axis=0) # Sab+Dom relativo
|
||
mon = X.values[i]
|
||
ok = np.isfinite(wkend) & np.isfinite(mon)
|
||
if ok.sum() >= 10:
|
||
ics.append(spearman(wkend[ok], mon[ok])); dates.append(X.index[i])
|
||
s = pd.Series(ics, index=pd.DatetimeIndex(dates))
|
||
half = len(s) // 2
|
||
out = {}
|
||
for name, seg in (("H1", s.iloc[:half]), ("H2", s.iloc[half:]), ("FULL", s)):
|
||
t = float(seg.mean() / seg.std() * np.sqrt(len(seg))) if len(seg) > 3 and seg.std() > 0 else 0.0
|
||
out[name] = dict(mean_ic=round(float(seg.mean()), 4), t=round(t, 2), n=len(seg))
|
||
return out
|
||
|
||
|
||
# ---------------------------------------------------------------- strategia (solo se persiste)
|
||
def sharpe(r):
|
||
r = np.asarray(pd.Series(r).dropna().values, float)
|
||
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) > 2 and np.std(r) > 0 else 0.0
|
||
|
||
|
||
def weekday_ls(R: pd.DataFrame, X: pd.DataFrame, k: int = 3, est_win: int = 0,
|
||
min_obs: int = 20, target_vol: float = 0.20):
|
||
"""L/S market-neutral: al close di t (dati ≤ t) stima il tilt per-asset del weekday
|
||
di DOMANI (expanding se est_win=0, altrimenti rolling est_win gg) e va long top-k /
|
||
short bottom-k per il giorno t+1. Fee su |Δw|. Ritorna (net Series, gross Series,
|
||
turnover medio/anno, breakeven fee %RT)."""
|
||
wd = X.index.dayofweek.values
|
||
xv = X.values
|
||
n, A = xv.shape
|
||
W = np.zeros((n, A))
|
||
# tilt causale: per ogni weekday d, media (expanding o rolling) dei soli giorni con wd==d fino a t
|
||
sums = np.zeros((7, A)); cnts = np.zeros((7, A))
|
||
hist: list[list[np.ndarray]] = [[] for _ in range(7)] # per rolling
|
||
for t in range(n - 1):
|
||
row = np.nan_to_num(xv[t], nan=0.0)
|
||
fin = np.isfinite(xv[t]).astype(float)
|
||
d = wd[t]
|
||
sums[d] += row; cnts[d] += fin
|
||
if est_win > 0:
|
||
hist[d].append(np.where(fin > 0, row, np.nan))
|
||
if len(hist[d]) > est_win:
|
||
hist[d].pop(0)
|
||
dn = wd[t + 1] # weekday di domani: noto (calendario)
|
||
if est_win > 0:
|
||
hh = np.array(hist[dn]) if hist[dn] else np.empty((0, A))
|
||
cnt = np.isfinite(hh).sum(axis=0) if len(hh) else np.zeros(A)
|
||
tilt = np.where(cnt >= min_obs, np.nanmean(hh, axis=0) if len(hh) else 0.0, np.nan)
|
||
else:
|
||
tilt = np.where(cnts[dn] >= min_obs, sums[dn] / np.maximum(cnts[dn], 1), np.nan)
|
||
ok = np.isfinite(tilt)
|
||
if ok.sum() >= 2 * k:
|
||
order = np.argsort(np.where(ok, tilt, -np.inf))
|
||
w = np.zeros(A)
|
||
w[order[-k:]] = 0.5 / k # long tilt positivi
|
||
lo = np.argsort(np.where(ok, tilt, np.inf))[:k]
|
||
w[lo] = -0.5 / k # short tilt negativi
|
||
W[t] = w
|
||
dret = np.nan_to_num(R.values, nan=0.0)
|
||
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
|
||
turn = np.abs(np.diff(W, axis=0, prepend=np.zeros((1, A)))).sum(axis=1)
|
||
net = gross - FEE_SIDE * turn
|
||
g, tn = pd.Series(gross, index=X.index), pd.Series(net, index=X.index)
|
||
rv = tn.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, 3.0)
|
||
net_vt = pd.Series(tn.values * scale, index=X.index)
|
||
mean_turn = float(turn.mean())
|
||
be_rt = 2 * float(gross.mean() / mean_turn) * 100 if mean_turn > 0 else np.nan # %RT a Sharpe 0
|
||
turn_yr = round(mean_turn * 365.25, 1)
|
||
return net_vt, g, turn_yr, be_rt
|
||
|
||
|
||
def run_strategy_branch(R, X):
|
||
"""Selezione IN-SAMPLE (pre-2025) su griglia piccola, hold-out, DSR su tutti i trial."""
|
||
grid = [dict(k=k, est_win=w) for k in (3, 5) for w in (0, 180)]
|
||
rows, all_full = [], []
|
||
for g in grid:
|
||
net, _, turn_yr, be = weekday_ls(R, X, **g)
|
||
ins, hold = net[net.index < HOLDOUT], net[net.index >= HOLDOUT]
|
||
rows.append(dict(params=g, ins=round(sharpe(ins), 2), hold=round(sharpe(hold), 2),
|
||
full=round(sharpe(net), 2), turn_yr=turn_yr,
|
||
be_rt=round(be, 3) if np.isfinite(be) else None, net=net))
|
||
all_full.append(sharpe(net))
|
||
chosen = max(rows, key=lambda r: r["ins"]) # selezione SOLO in-sample
|
||
dsr, sr0 = al.deflated_sharpe(chosen["full"], all_full, chosen["net"].dropna().values)
|
||
return rows, chosen, dsr, sr0
|
||
|
||
|
||
# ---------------------------------------------------------------- main
|
||
def main():
|
||
R, X = load_relative_returns()
|
||
print(f"Universo: {len(UNIVERSE)} major HL | {X.index[0].date()} -> {X.index[-1].date()} "
|
||
f"({len(X)} giorni, ~{len(X) / 365.25:.1f} anni) | barre vol=0 escluse: "
|
||
f"{int((~np.isfinite(X.values)).sum())} celle NaN")
|
||
print(f"LIMITE: ~{len(X) // 7 // 2} osservazioni per weekday per metà campione — "
|
||
f"soglia severa: max-statistic permutato, p<0.05\n")
|
||
|
||
wd = X.index.dayofweek.values
|
||
print("=" * 78)
|
||
print("STEP 1 — PERSISTENZA SPLIT-HALF (test statistico PRIMA della strategia)")
|
||
print("=" * 78)
|
||
|
||
# --- (a) weekday (7 bucket)
|
||
rho, mx, p, null95 = split_half_persistence(X, wd, 7)
|
||
print("\n[A] WEEKDAY TILT (tilt weekday-specifico, al netto del tilt incondizionato)")
|
||
for d in range(7):
|
||
print(f" {WD_NAMES[d]}: rank-corr H1 vs H2 = {rho[d]:+.3f}")
|
||
print(f" max reale = {mx:+.3f} | null 95° pctl (perm max-su-7) = {null95:+.3f} | p = {p:.3f}")
|
||
pass_wd = p < 0.05
|
||
|
||
# --- (b) weekend vs feriali (2 bucket)
|
||
wk_lab = (wd >= 5).astype(int)
|
||
rho_w, mx_w, p_w, null95_w = split_half_persistence(X, wk_lab, 2)
|
||
print(f"\n[B] WEEKEND-vs-FERIALI: rho weekday={rho_w[0]:+.3f} weekend={rho_w[1]:+.3f} "
|
||
f"| max={mx_w:+.3f} null95={null95_w:+.3f} p={p_w:.3f}")
|
||
pass_we = p_w < 0.05
|
||
|
||
# --- (c) turn-of-month (ultimi 2 + primi 2 gg del mese vs resto)
|
||
day = X.index.day.values
|
||
dim = X.index.days_in_month.values
|
||
tom_lab = ((day <= 2) | (day >= dim - 1)).astype(int)
|
||
rho_t, mx_t, p_t, null95_t = split_half_persistence(X, tom_lab, 2)
|
||
print(f"[C] TURN-OF-MONTH (±2gg): rho non-TOM={rho_t[0]:+.3f} TOM={rho_t[1]:+.3f} "
|
||
f"| max={mx_t:+.3f} null95={null95_t:+.3f} p={p_t:.3f}")
|
||
pass_tom = p_t < 0.05
|
||
|
||
# --- (d) pattern weekend->lunedì (IC cross-serial)
|
||
ic = weekend_monday_ic(X)
|
||
print(f"[D] WEEKEND->LUNEDÌ IC (Spearman x_weekend vs x_lunedì): "
|
||
f"H1 {ic['H1']['mean_ic']:+.3f} (t={ic['H1']['t']}) | "
|
||
f"H2 {ic['H2']['mean_ic']:+.3f} (t={ic['H2']['t']}) | "
|
||
f"FULL {ic['FULL']['mean_ic']:+.3f} (t={ic['FULL']['t']}, n={ic['FULL']['n']})")
|
||
pass_ic = (abs(ic["FULL"]["t"]) > 2.5
|
||
and np.sign(ic["H1"]["mean_ic"]) == np.sign(ic["H2"]["mean_ic"])
|
||
and abs(ic["H1"]["t"]) > 1.5 and abs(ic["H2"]["t"]) > 1.5)
|
||
print(f" persistenza segno + |t|>2.5 FULL + |t|>1.5 su entrambe le metà: {pass_ic}")
|
||
|
||
any_pass = pass_wd or pass_we or pass_tom or pass_ic
|
||
print("\n" + "=" * 78)
|
||
if not any_pass:
|
||
print("VERDETTO: SCARTATO — nessuna persistenza cross-sectional calendario.")
|
||
print("Nessun backtest eseguito (regola: test statistico prima della strategia).")
|
||
print("NB: il day_boundary_robust resta comunque NON eseguibile sui 19 alt (dati")
|
||
print("HL solo 1d) → anche un pass qui sarebbe stato al massimo LEAD condizionato.")
|
||
print("=" * 78)
|
||
return
|
||
|
||
# ---------------- branch strategia (si arriva qui solo con persistenza reale)
|
||
print("STEP 2 — persistenza rilevata: strategia L/S weekday-tilt (selezione IN-SAMPLE)")
|
||
print("=" * 78)
|
||
rows, chosen, dsr, sr0 = run_strategy_branch(R, X)
|
||
for r in rows:
|
||
print(f" k={r['params']['k']} est={'exp' if r['params']['est_win'] == 0 else r['params']['est_win']}: "
|
||
f"IS {r['ins']:+.2f} | HOLD {r['hold']:+.2f} | FULL {r['full']:+.2f} | "
|
||
f"turn/yr {r['turn_yr']} | breakeven fee {r['be_rt']}%RT")
|
||
print(f"\n CELLA IN-SAMPLE: {chosen['params']} -> FULL {chosen['full']:+.2f} "
|
||
f"HOLD {chosen['hold']:+.2f} | DSR={dsr:.3f} (null max {sr0:.2f}) "
|
||
f"{'PASS' if dsr >= 0.95 else 'FAIL'}")
|
||
print("\n ⚠ day_boundary_robust NON eseguibile su HL 1d (serve orario) → qualunque")
|
||
print(" esito qui è al massimo LEAD-forward CONDIZIONATO, mai sleeve.")
|
||
print("=" * 78)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|