"""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()