research(wave-0701): 6 filoni multi-agente — 0 nuovi sleeve, pesi confermati, gate weights_tilt_null
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>
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user