research(crypto): 4 filoni 2026-06-29 — ERM lead sub-daily (forward), 3 scartati/deboli
Ricerca onesta su BTC/ETH + universo HL, branch separato (nessun impatto live).
Harness condiviso altlib (causale, fee 0.10% RT, marginal vs TP01, day-boundary,
haircut $600). Test 19/19 verdi.
- A DVOL direzionale -> LEAD hedge/DD-dampener, NON sleeve (buy-the-fear; is_hedge).
- B Intraday ERM 8h -> LEAD forte / forward-monitor: earns_slot=True, ADDS oltre
SKH01 (TP01+SKH+ERM 60/25/15 FULL 1.88/HOLD 1.46/DD 8.9%).
Caveat: plateau hold-out single-row, multiple-testing non
deflazionato, exec 8h. Controllo TOD = FAIL atteso.
- C Cross-sectional non-mom (low-vol HL) -> DEBOLE/forward-monitor (deflated-Sh 0.13,
storia 2.5a, non eseguibile $600) STAT-MODE.
- D Macro regime-gate -> RIDONDANTE col trend (corr->TP01 0.989), SCARTATO.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
"""DVOL-DIRECTIONAL — la vol IMPLICITA (Deribit DVOL) come SEGNALE DIREZIONALE/REGIME sul perp BTC/ETH.
|
||||
|
||||
Filone A. Diverso da TUTTO il lavoro DVOL precedente:
|
||||
* `tp01_dvol_overlay.py` (2026-06-26): DVOL come DENOMINATORE del vol-target -> solo de-levering, SCARTATO.
|
||||
* VOL03/04/10/11 (sweep alt): DVOL che GATA/SCALA un TSMOM (eredita lo Sharpe di trend di TP01 ->
|
||||
il marginal scorer li boccia NEUTRAL/REDUNDANT).
|
||||
* agent_14_dvol_spread (onda ortho): IV RELATIVA BTC-vs-ETH, market-NEUTRAL 2-leg (l'unico LEAD).
|
||||
Qui invece: usare DVOL/IV-RV come segnale DIREZIONALE STANDALONE sul LIVELLO di mercato (long-flat o
|
||||
L/S sul perp), per provare se il DVOL porta alpha DIREZIONALE ORTOGONALE a TP01 (non un overlay sul trend).
|
||||
|
||||
Angoli (tutti CAUSALI: decisi <= close[i], tenuti in i+1 dallo shift di eval_weights):
|
||||
VRP-Z : z-score causale del vol-risk-premium (IV-RV). VRP ricco (IV>>RV, paura sovra-prezzata)
|
||||
=> LONG underlying (fear-reversal, l'analogo direzionale dell'edge VRP); flip = falsifica.
|
||||
DVOL-LV : percentile espandente causale del DVOL. "Buy-the-fear" (rank alto => long) vs
|
||||
"buy-the-calm" (rank basso => long).
|
||||
DVOL-MOM : DVOL vs ema(DVOL,k). DVOL in calo (risk-on) => long; in salita (risk-off) => flat (o short, L/S).
|
||||
VRP-LS : variante long-short del VRP-Z (short quando VRP compresso).
|
||||
|
||||
DVOL parte 2021-03 -> pre-DVOL il segnale e' FLAT (0). Valuto sia FULL (con flat pre-periodo, deflaziona
|
||||
lo Sharpe) sia DVOL-ERA-ONLY (giusto per la tesi). Gate: study_weights (abs+fee sweep), marginal_vs_tp01
|
||||
(earns_slot), causality_ok, eval_weights_smallcap ($600), sign-falsification. Storia DVOL <5 anni -> caveat.
|
||||
|
||||
uv run python scripts/research/dvol_directional.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"))
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
# warmup: prima data DVOL (2021-03-24) + finestra max (z/rank) -> evita celle a campione corto
|
||||
DVOL_ERA = pd.Timestamp("2021-10-01", tz="UTC")
|
||||
TVOL = 0.20
|
||||
VOLWIN = 30
|
||||
LEVCAP = 2.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CONTESTO causale per-asset (IV, RV, VRP) — tutto <= close[i]
|
||||
# ===========================================================================
|
||||
def _ctx(df: pd.DataFrame, asset: str):
|
||||
c = df["close"].values.astype(float)
|
||||
r = al.simple_returns(c)
|
||||
bpd = al.bars_per_day(df)
|
||||
iv = al.dvol(df, asset) / 100.0 # IV implicita 30d annualizzata (causale)
|
||||
rv = al.realized_vol(r, max(2, 30 * bpd), bpd * 365.25) # RV trailing 30d annualizzata
|
||||
return c, r, bpd, iv, rv
|
||||
|
||||
|
||||
_RANK_CACHE: dict = {}
|
||||
|
||||
|
||||
def _expanding_rank_arr(x: np.ndarray, min_p: int = 120) -> np.ndarray:
|
||||
"""Percentile espandente causale: rank[i] = frazione di x[:i] < x[i].
|
||||
Usa SOLO barre STRETTAMENTE precedenti + il valore corrente (noto a close[i]) — niente
|
||||
future-peeking. Calcolato sull'array PASSATO (cosi' su un prefisso ridà i valori del prefisso,
|
||||
=> causality_ok lo verifica davvero). O(n^2), n~3k OK."""
|
||||
finite = np.isfinite(x)
|
||||
out = np.full(len(x), np.nan)
|
||||
for i in range(min_p, len(x)):
|
||||
if not finite[i]:
|
||||
continue
|
||||
prev = x[:i][finite[:i]]
|
||||
if len(prev) >= min_p // 2:
|
||||
out[i] = float(np.mean(prev < x[i]))
|
||||
return out
|
||||
|
||||
|
||||
def dvol_rank(df: pd.DataFrame, asset: str, min_p: int = 120) -> np.ndarray:
|
||||
x = np.asarray(al.dvol(df, asset), float)
|
||||
key = (asset, len(x), round(float(np.nan_to_num(x[-1])), 4))
|
||||
c = _RANK_CACHE.get(key)
|
||||
if c is None:
|
||||
c = _expanding_rank_arr(x, min_p)
|
||||
_RANK_CACHE[key] = c
|
||||
return c
|
||||
|
||||
|
||||
def _finalize(direction: np.ndarray, df: pd.DataFrame, long_only: bool) -> np.ndarray:
|
||||
d = np.nan_to_num(direction, nan=0.0)
|
||||
if long_only:
|
||||
d = np.clip(d, 0.0, 1.0)
|
||||
return al.vol_target(d, df, TVOL, VOLWIN, LEVCAP)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# FAMIGLIE DI SEGNALI (factory -> target_fn(df, asset))
|
||||
# ===========================================================================
|
||||
def make_vrp_z(win=90, thr=0.0, sign=+1, long_only=True):
|
||||
"""LONG quando z(IV-RV) * sign > thr. sign=+1 => long su VRP RICCO (fear-reversal)."""
|
||||
def fn(df, asset):
|
||||
c, r, bpd, iv, rv = _ctx(df, asset)
|
||||
vrp = iv - rv
|
||||
z = al.zscore(vrp, max(5, win * bpd))
|
||||
sig = sign * z
|
||||
if long_only:
|
||||
d = np.where(sig > thr, 1.0, 0.0)
|
||||
else:
|
||||
d = np.clip(np.tanh(sig), -1.0, 1.0)
|
||||
return _finalize(d, df, long_only)
|
||||
return fn
|
||||
|
||||
|
||||
def make_dvol_level(q=0.5, side="fear", long_only=True):
|
||||
"""side='fear': long quando rank DVOL > q (buy-the-fear). side='calm': long quando rank < q."""
|
||||
def fn(df, asset):
|
||||
rk = dvol_rank(df, asset)
|
||||
if side == "fear":
|
||||
base = (rk > q)
|
||||
else:
|
||||
base = (rk < q)
|
||||
if long_only:
|
||||
d = np.where(base, 1.0, 0.0)
|
||||
else:
|
||||
d = np.where(base, 1.0, -1.0)
|
||||
return _finalize(d, df, long_only)
|
||||
return fn
|
||||
|
||||
|
||||
def make_dvol_mom(k=10, long_only=True):
|
||||
"""DVOL in calo (dvol < ema(dvol,k)) => risk-on => long; in salita => flat (o short se L/S)."""
|
||||
def fn(df, asset):
|
||||
dv = al.dvol(df, asset)
|
||||
em = al.ema(np.nan_to_num(dv, nan=np.nan), k)
|
||||
falling = dv < em
|
||||
if long_only:
|
||||
d = np.where(falling, 1.0, 0.0)
|
||||
else:
|
||||
d = np.where(falling, 1.0, -1.0)
|
||||
d = np.where(np.isfinite(dv) & np.isfinite(em), d, 0.0)
|
||||
return _finalize(d, df, long_only)
|
||||
return fn
|
||||
|
||||
|
||||
def make_vrp_positive(long_only=True):
|
||||
"""Baseline 'quasi buy&hold': long quando IV>RV (VRP positivo, ~80% del tempo)."""
|
||||
def fn(df, asset):
|
||||
c, r, bpd, iv, rv = _ctx(df, asset)
|
||||
d = np.where((iv - rv) > 0, 1.0, 0.0)
|
||||
return _finalize(d, df, long_only)
|
||||
return fn
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DIAGNOSTICA: il DVOL ha CONTENUTO DIREZIONALE? (probe corr signal[i] vs r[i+1])
|
||||
# ===========================================================================
|
||||
def _raw_direction(fn_dir, df, asset):
|
||||
"""Estrae la DIREZIONE grezza (pre-vol-target) per la probe: rifa' il calcolo del segno."""
|
||||
return fn_dir(df, asset)
|
||||
|
||||
|
||||
def probe(name, dir_fn, era_only=True):
|
||||
"""corr(dir[i], r[i+1]) e media r[i+1] per dir>0 vs dir<=0, pooled BTC+ETH (1d)."""
|
||||
dd, rr = [], []
|
||||
for a in al.CERTIFIED:
|
||||
df = al.get(a, "1d")
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
|
||||
d = np.nan_to_num(dir_fn(df, a), nan=0.0)
|
||||
r = al.simple_returns(df["close"].values.astype(float))
|
||||
rnext = np.roll(r, -1); rnext[-1] = 0.0
|
||||
mask = np.ones(len(df), bool)
|
||||
if era_only:
|
||||
mask &= np.asarray(idx >= DVOL_ERA)
|
||||
mask[-1] = False
|
||||
dd.append(d[mask]); rr.append(rnext[mask])
|
||||
d = np.concatenate(dd); r = np.concatenate(rr)
|
||||
corr = float(np.corrcoef(d, r)[0, 1]) if np.std(d) > 0 and np.std(r) > 0 else 0.0
|
||||
up = float(np.mean(r[d > 0])) * 1e4 if (d > 0).any() else 0.0 # bps
|
||||
dn = float(np.mean(r[d <= 0])) * 1e4 if (d <= 0).any() else 0.0
|
||||
return dict(name=name, corr=round(corr, 4), n=int(len(d)),
|
||||
long_bps=round(up, 1), flat_bps=round(dn, 1), edge_bps=round(up - dn, 1),
|
||||
frac_long=round(float(np.mean(d > 0)), 3))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MARGINAL su finestra DVOL-ERA (piu' giusto della full che include il flat pre-2021)
|
||||
# ===========================================================================
|
||||
def cand_daily_era(target_fn, tf="1d", start=DVOL_ERA, fee_side=al.FEE_SIDE) -> pd.Series:
|
||||
series = {}
|
||||
for a in al.CERTIFIED:
|
||||
df = al.get(a, tf)
|
||||
ev = al.eval_weights(df, al._call_target(target_fn, df, a), fee_side=fee_side)
|
||||
series[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
d = al._to_daily(0.5 * J[al.CERTIFIED[0]] + 0.5 * J[al.CERTIFIED[1]])
|
||||
return d[d.index >= start]
|
||||
|
||||
|
||||
def era_full_sharpe(target_fn, tf="1d"):
|
||||
"""Sharpe/DD del candidato sulla SOLA era DVOL (no flat pre-2021), 50/50."""
|
||||
s = cand_daily_era(target_fn, tf)
|
||||
r = s.values
|
||||
sh = float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if np.std(r) > 0 else 0.0
|
||||
eq = np.cumprod(1 + r); pk = np.maximum.accumulate(eq)
|
||||
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
yrs = (s.index[-1] - s.index[0]).days / 365.25 if len(s) > 1 else 1.0
|
||||
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and eq[-1] > 0 else -1.0
|
||||
return dict(sharpe=round(sh, 3), dd=round(dd, 4), cagr=round(cagr, 4), n=len(s))
|
||||
|
||||
|
||||
def earns_slot_era(target_fn, tf="1d"):
|
||||
"""Replica del gate study_marginal MA sulla candidate-daily ristretta all'era DVOL (inner-join
|
||||
con TP01 baseline limita anche TP01 alla stessa finestra). Piu' equo per un segnale DVOL-only."""
|
||||
marg = al.marginal_vs_tp01(cand_daily_era(target_fn, tf))
|
||||
v = marg.get("marginal_verdict")
|
||||
earns = (v == "ADDS" and marg.get("robust_oos", False)
|
||||
and marg.get("beats_noise_null", False) and not marg.get("is_hedge", False))
|
||||
return marg, earns
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MAIN
|
||||
# ===========================================================================
|
||||
def hr(t=""):
|
||||
print("=" * 100)
|
||||
if t:
|
||||
print(t)
|
||||
print("=" * 100)
|
||||
|
||||
|
||||
def main():
|
||||
hr("DVOL-DIREZIONALE — DVOL/IV-RV come segnale DIREZIONALE standalone su BTC/ETH (perp). 1d, causale.")
|
||||
print(f" Era DVOL valutata da {DVOL_ERA.date()} (warmup z/rank). FULL include flat pre-2021 (deflaziona Sharpe).")
|
||||
print(f" vol-target {TVOL:.0%}, leva cap {LEVCAP}x, fee 0.10% RT. TP01 baseline come riferimento marginale.\n")
|
||||
|
||||
# ---- 1) PROBE: contenuto direzionale (corr signal[i] vs r[i+1]) ---------
|
||||
hr("1) PROBE DIREZIONALE — il DVOL predice il ritorno del giorno dopo? (pooled BTC+ETH, era DVOL)")
|
||||
probes = [
|
||||
("VRP-Z+ (long VRP ricco)", make_vrp_z(90, 0.0, +1)),
|
||||
("VRP-Z- (long VRP basso)", make_vrp_z(90, 0.0, -1)),
|
||||
("DVOL-LV fear (rank>0.5)", make_dvol_level(0.5, "fear")),
|
||||
("DVOL-LV calm (rank<0.5)", make_dvol_level(0.5, "calm")),
|
||||
("DVOL-MOM falling=long", make_dvol_mom(10)),
|
||||
("VRP>0 (quasi buy&hold)", make_vrp_positive()),
|
||||
]
|
||||
print(f" {'segnale':<28}{'corr':>9}{'long bps':>10}{'flat bps':>10}{'edge bps':>10}{'frac_long':>10}")
|
||||
for nm, fn in probes:
|
||||
p = probe(nm, fn)
|
||||
print(f" {nm:<28}{p['corr']:>+9.4f}{p['long_bps']:>+10.1f}{p['flat_bps']:>+10.1f}"
|
||||
f"{p['edge_bps']:>+10.1f}{p['frac_long']:>10.3f}")
|
||||
print(" (edge bps = ritorno medio giorno-dopo quando long MENO quando flat; >0 = il segnale separa)\n")
|
||||
|
||||
# ---- 2) ROBUSTEZZA ASSOLUTA (study_weights) + fee sweep + era-only ------
|
||||
hr("2) ROBUSTEZZA ASSOLUTA — study_weights su BTC+ETH (1d), fee sweep 0.00-0.20% RT")
|
||||
grid = {
|
||||
"VRP-Z90 long-flat": make_vrp_z(90, 0.0, +1, True),
|
||||
"VRP-Z60 long-flat": make_vrp_z(60, 0.0, +1, True),
|
||||
"VRP-Z120 long-flat": make_vrp_z(120, 0.0, +1, True),
|
||||
"VRP-Z90 L/S": make_vrp_z(90, 0.0, +1, False),
|
||||
"DVOL-fear q0.4 LF": make_dvol_level(0.4, "fear", True),
|
||||
"DVOL-fear q0.5 LF": make_dvol_level(0.5, "fear", True),
|
||||
"DVOL-calm q0.5 LF": make_dvol_level(0.5, "calm", True),
|
||||
"DVOL-MOM k10 LF": make_dvol_mom(10, True),
|
||||
"DVOL-MOM k20 LF": make_dvol_mom(20, True),
|
||||
"DVOL-MOM k10 L/S": make_dvol_mom(10, False),
|
||||
}
|
||||
reps = {}
|
||||
for nm, fn in grid.items():
|
||||
rep = al.study_weights(nm, fn, tfs=("1d",))
|
||||
reps[nm] = rep
|
||||
era = era_full_sharpe(fn)
|
||||
v = rep["verdict"]
|
||||
c = rep["cells"][0]
|
||||
print(f" {nm:<22} abs={v['grade']:<4} minFull={c['min_asset_full_sharpe']:+.2f} "
|
||||
f"minHold={c['min_asset_holdout_sharpe']:+.2f} feeOK={c['fee_survives']!s:<5} "
|
||||
f"|| ERA-only Sh={era['sharpe']:+.2f} DD={era['dd']*100:.0f}% CAGR={era['cagr']*100:+.0f}%")
|
||||
print(" (FULL include 2018-2021 flat => Sharpe basso e' atteso; ERA-only e' il giudizio equo del segnale)\n")
|
||||
|
||||
# candidati da portare al marginal: i migliori per ERA Sharpe + abs non-FAIL
|
||||
ranked = sorted(grid.items(), key=lambda kv: era_full_sharpe(kv[1])["sharpe"], reverse=True)
|
||||
top = ranked[:4]
|
||||
|
||||
# ---- 3) MARGINAL vs TP01 (era DVOL) — il gate vero -----------------------
|
||||
hr("3) MARGINAL vs TP01 (finestra DVOL-era) — earns_slot? (il gate decisivo)")
|
||||
for nm, fn in top:
|
||||
marg, earns = earns_slot_era(fn)
|
||||
bl = marg.get("blends", {}).get("w25", {})
|
||||
print(f"\n --- {nm} ---")
|
||||
print(f" verdetto={marg.get('marginal_verdict')} EARNS_SLOT(era)={earns}")
|
||||
print(f" corr->TP01 full {marg.get('corr_full')} hold {marg.get('corr_hold')} "
|
||||
f"beta {marg.get('beta_to_tp01')} resid Sh {marg.get('resid_sharpe_full')}")
|
||||
print(f" cand standalone full {marg.get('cand_full_sharpe')}/hold {marg.get('cand_hold_sharpe')} "
|
||||
f"in-sample Sh {marg.get('cand_insample_sharpe')} has_edge={marg.get('has_insample_edge')}")
|
||||
print(f" blend w25: full {bl.get('full')} (uplift {bl.get('uplift_full')}) "
|
||||
f"hold {bl.get('hold')} (uplift {bl.get('uplift_hold')}) DD {bl.get('dd')}")
|
||||
print(f" multi-cut {marg.get('multicut_uplift')} persistent={marg.get('multicut_persistent')} "
|
||||
f"robust_oos={marg.get('robust_oos')} is_hedge={marg.get('is_hedge')}")
|
||||
|
||||
# ---- 3b) study_marginal canonico (full history, gate di progetto) --------
|
||||
hr("3b) study_marginal CANONICO (full history, include flat pre-DVOL) — gate ufficiale di progetto")
|
||||
leader_nm, leader_fn = top[0]
|
||||
sm = al.study_marginal(leader_nm, leader_fn, tf="1d")
|
||||
print(al.fmt_marginal(sm))
|
||||
|
||||
# ---- 4) CAUSALITA' (look-ahead guard) -----------------------------------
|
||||
hr("4) CAUSALITA' — causality_ok (ricalcolo su prefisso, nessun future-peeking)")
|
||||
for nm, fn in top:
|
||||
co = al.causality_ok(fn, tf="1d")
|
||||
print(f" {nm:<22} ok={co['ok']!s:<5} max_tail_diff={co['max_tail_diff']} checked={co['checked']}")
|
||||
|
||||
# robustezza alignment DVOL: lag +1 giorno (extra-conservativo) sul leader
|
||||
hr("4b) ROBUSTEZZA ALIGNMENT — DVOL laggato +1g (extra-conservativo) sul leader: l'edge sopravvive?")
|
||||
|
||||
def _lag(fn):
|
||||
# ricostruisce il segnale con dvol shiftato di 1 barra (usa solo DVOL di IERI)
|
||||
def g(df, asset):
|
||||
# monkey: usa al.dvol ma shift -> approssimo rifacendo via wrapper sul ctx non e' banale;
|
||||
# piu' semplice: confronto Sharpe era con e senza lag costruendo direzione laggata generica.
|
||||
base = fn(df, asset)
|
||||
lagged = np.zeros_like(base); lagged[1:] = base[:-1]
|
||||
return lagged
|
||||
return g
|
||||
lag_era = era_full_sharpe(_lag(leader_fn))
|
||||
base_era = era_full_sharpe(leader_fn)
|
||||
print(f" {leader_nm}: ERA Sh base {base_era['sharpe']:+.2f} -> con segnale laggato +1g "
|
||||
f"{lag_era['sharpe']:+.2f} (calo grande = edge fragile all'alignment)")
|
||||
|
||||
# ---- 5) ESEGUIBILITA' $600 — eval_weights_smallcap ----------------------
|
||||
hr("5) ESEGUIBILITA' a $600 — eval_weights_smallcap (min_order $5, haircut reale vs modellato)")
|
||||
for nm, fn in top:
|
||||
for a in al.CERTIFIED:
|
||||
df = al.get(a, "1d")
|
||||
sc = al.eval_weights_smallcap(df, al._call_target(fn, df, a), capital=600, min_order=5)
|
||||
print(f" {nm:<22} {a}: modellato Sh {sc['modeled']['sharpe']:+.2f} -> reale $600 "
|
||||
f"Sh {sc['realistic']['sharpe']:+.2f} (haircut {sc['sharpe_haircut']:+.2f}) "
|
||||
f"trade eseguiti {sc['n_executed_trades']}")
|
||||
|
||||
# ---- 6) SIGN-FALSIFICATION sul leader -----------------------------------
|
||||
hr("6) SIGN-FALSIFICATION — invertire il segno del leader deve PEGGIORARE (altrimenti e' rumore)")
|
||||
# leader e' VRP-Z+ se nel top; provo l'inverso esplicito su VRP-Z e DVOL
|
||||
flips = [
|
||||
("VRP-Z90 sign +1 (tesi)", make_vrp_z(90, 0.0, +1, True)),
|
||||
("VRP-Z90 sign -1 (flip)", make_vrp_z(90, 0.0, -1, True)),
|
||||
("DVOL-fear q0.5 (tesi)", make_dvol_level(0.5, "fear", True)),
|
||||
("DVOL-calm q0.5 (flip)", make_dvol_level(0.5, "calm", True)),
|
||||
]
|
||||
for nm, fn in flips:
|
||||
e = era_full_sharpe(fn)
|
||||
print(f" {nm:<26} ERA Sh {e['sharpe']:+.2f} DD {e['dd']*100:.0f}% CAGR {e['cagr']*100:+.0f}%")
|
||||
|
||||
hr()
|
||||
print("FINE. Leggere il verdetto onesto nel diario docs/diary/2026-06-29-dvol-directional.md")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,376 @@
|
||||
"""intraday_regime.py — FILONE B: "INTRADAY REGIME BTC/ETH" (eseguibile) — 2026-06-29.
|
||||
|
||||
TESI. Cercare un meccanismo SUB-DAILY sui dati certificati 1h/4h/.../12h BTC/ETH che sia
|
||||
ORTOGONALE sia a TP01 (TSMOM trend daily, long-flat) sia a SKH01 (regime BuzVola/BuzVolume
|
||||
+ Donchian breakout a 230m). SKH01 prova che il sub-daily PUO' funzionare ed essere
|
||||
quasi-ortogonale: qui si esplora un MECCANISMO DIVERSO, basato sulla QUALITA' del moto
|
||||
intraday (efficiency-ratio / vol-expansion / thrust) come REGIME che condiziona una
|
||||
posizione direzionale tenuta ~1 giorno.
|
||||
|
||||
Il killer ricorrente del progetto sotto le 12h e' il MURO-FEE (0.10% RT) + overfitting.
|
||||
La ricetta che SKH01 usa per sopravvivere: DECISIONE sub-daily ma HOLD ~1 giorno -> pochi
|
||||
trade -> la fee non uccide. Qui ogni meccanismo e' costruito per essere a basso turnover
|
||||
(gate di regime che tiene flat la maggior parte del tempo, lookback non microscopici) e
|
||||
viene giudicato col fee-sweep ALLA SUA FREQUENZA REALE. Se muore appena si mette la fee ->
|
||||
SCARTATO e documentato (e' un risultato valido).
|
||||
|
||||
MECCANISMI (tutti come posizione CONTINUA decisa <= close[i], cosi' passano nativamente per
|
||||
eval_weights / study_marginal / day_boundary_robust / eval_weights_smallcap di altlib):
|
||||
|
||||
ERM Efficiency-Ratio regime momentum. ER = |moto netto su L barre| / |percorso| (Kaufman):
|
||||
alto = moto intraday "pulito"/direzionale, basso = chop. Prendi la direzione del moto
|
||||
netto SOLO quando ER >= soglia (regime trendy intraday), altrimenti flat. Vol-target.
|
||||
Storia economica: quando il prezzo intraday e' EFFICIENTE il momentum continua; quando
|
||||
e' choppy non c'e' edge. DIVERSO da SKH01 (regime vol/volume) e da TP01 (TSMOM 1-6 mesi).
|
||||
|
||||
VEM Vol-Expansion Momentum. Direzione = segno del moto su Lmom barre, ATTIVA solo quando la
|
||||
vol realizzata corta > vol realizzata lunga (espansione di volatilita'). Vol-target.
|
||||
|
||||
VBR Volatility/thrust breakout (Larry-Williams-style, ROLLING, no calendario). Segui solo i
|
||||
movimenti significativi: posizione = segno(c[i]-c[i-1]) quando |Δ| > k*ATR, altrimenti
|
||||
tieni la precedente. Momentum-continuation di thrust.
|
||||
|
||||
TOD Time-of-day seasonality (CONTROLLO calendario). Direzione per ora-del-giorno via media
|
||||
espandente causale. Incluso APPOSTA per passarlo a day_boundary_robust: e' il tipo di
|
||||
effetto che ha ucciso open_drive (artefatto di etichettatura del giorno UTC).
|
||||
|
||||
GATE (CLAUDE.md): causale/no-leak, NETTO fee 0.10% RT + sweep 0.00-0.20% a freq reale, OOS
|
||||
hold-out + griglia + plateau, day_boundary_robust per effetti calendario, MARGINAL vs TP01
|
||||
(earns_slot / has_insample_edge / multi-cut / non-hedge), corr con SKH01, haircut $600.
|
||||
|
||||
Esecuzione: uv run python scripts/research/intraday_regime.py
|
||||
Idempotente, niente scritture su disco (solo report a stdout).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
ASSETS = ("BTC", "ETH")
|
||||
SCREEN_TFS = ("1h", "4h", "6h", "8h", "12h")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TARGET FACTORIES (ogni fattoria ritorna un target_fn(df) causale, posizione continua)
|
||||
# ===========================================================================
|
||||
def make_erm(tf: str, L_days: float, thr: float, long_flat: bool,
|
||||
target_vol: float = 0.20):
|
||||
"""Efficiency-Ratio regime momentum. L_days = lunghezza finestra in GIORNI (-> barre via bpd)."""
|
||||
def fn(df):
|
||||
c = df["close"].values.astype(float)
|
||||
n = len(c)
|
||||
L = max(2, round(L_days * al.bars_per_day(df)))
|
||||
net = np.full(n, np.nan)
|
||||
net[L:] = c[L:] - c[:-L]
|
||||
step = np.abs(np.diff(c, prepend=c[0])) # |c[k]-c[k-1]|, causale
|
||||
path = pd.Series(step).rolling(L, min_periods=L).sum().values
|
||||
er = np.divide(np.abs(net), path, out=np.zeros(n), where=(path > 0))
|
||||
active = (er >= thr) & np.isfinite(net)
|
||||
raw = np.where(active, np.sign(net), 0.0)
|
||||
if long_flat:
|
||||
raw = np.clip(raw, 0.0, None)
|
||||
return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0)
|
||||
return fn
|
||||
|
||||
|
||||
def make_vem(tf: str, Lmom_days: float, Lshort_days: float, Llong_days: float,
|
||||
long_flat: bool, target_vol: float = 0.20):
|
||||
"""Vol-expansion momentum: momentum attivo solo quando rv_corta > rv_lunga (espansione)."""
|
||||
def fn(df):
|
||||
c = df["close"].values.astype(float)
|
||||
n = len(c)
|
||||
bpd = al.bars_per_day(df)
|
||||
Lmom = max(2, round(Lmom_days * bpd))
|
||||
ws, wl = max(2, round(Lshort_days * bpd)), max(3, round(Llong_days * bpd))
|
||||
r = al.simple_returns(c)
|
||||
rv_s = al.rolling_std(r, ws)
|
||||
rv_l = al.rolling_std(r, wl)
|
||||
expand = (rv_s > rv_l) & np.isfinite(rv_s) & np.isfinite(rv_l)
|
||||
net = np.full(n, np.nan)
|
||||
net[Lmom:] = c[Lmom:] - c[:-Lmom]
|
||||
raw = np.where(expand & np.isfinite(net), np.sign(net), 0.0)
|
||||
if long_flat:
|
||||
raw = np.clip(raw, 0.0, None)
|
||||
return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0)
|
||||
return fn
|
||||
|
||||
|
||||
def make_vbr(tf: str, k: float, atr_win: int, long_flat: bool, target_vol: float = 0.20):
|
||||
"""Thrust-breakout rolling: segui i moti significativi (|Δ| > k*ATR), altrimenti hold."""
|
||||
def fn(df):
|
||||
c = df["close"].values.astype(float)
|
||||
a = al.atr(df, atr_win)
|
||||
a_prev = np.roll(a, 1); a_prev[0] = a[0] # ATR noto a inizio barra (causale)
|
||||
delta = np.diff(c, prepend=c[0])
|
||||
sig = np.where(np.abs(delta) > k * a_prev, np.sign(delta), np.nan)
|
||||
raw = pd.Series(sig).ffill().fillna(0.0).values
|
||||
if long_flat:
|
||||
raw = np.clip(raw, 0.0, None)
|
||||
return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0)
|
||||
return fn
|
||||
|
||||
|
||||
def make_tod(tf: str, long_flat: bool, target_vol: float = 0.20, min_obs: int = 20):
|
||||
"""Time-of-day seasonality (controllo calendario). Direzione = segno della media espandente
|
||||
causale del rendimento della stessa ora-del-giorno. Da passare a day_boundary_robust."""
|
||||
def fn(df):
|
||||
c = df["close"].values.astype(float)
|
||||
r = al.simple_returns(c)
|
||||
hour = pd.to_datetime(df["datetime"], utc=True).dt.hour.values
|
||||
n = len(c)
|
||||
sums = {}; cnts = {}
|
||||
raw = np.zeros(n)
|
||||
for i in range(1, n):
|
||||
h_prev = int(hour[i - 1]) # aggiorna con la barra GIA' chiusa
|
||||
sums[h_prev] = sums.get(h_prev, 0.0) + r[i - 1]
|
||||
cnts[h_prev] = cnts.get(h_prev, 0) + 1
|
||||
h = int(hour[i])
|
||||
if cnts.get(h, 0) >= min_obs:
|
||||
raw[i] = 1.0 if sums[h] >= 0 else -1.0
|
||||
if long_flat:
|
||||
raw = np.clip(raw, 0.0, None)
|
||||
return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0)
|
||||
return fn
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SCREENING — griglia leggera per (asset,tf,params) via eval_weights (vettoriale).
|
||||
# ===========================================================================
|
||||
def _screen_cell(fn, tf):
|
||||
"""Min-asset full/hold Sharpe, fee@0.10 e @0.20 RT, turnover, time-in-market."""
|
||||
fulls, holds, f10, f20, turn, tim = [], [], [], [], [], []
|
||||
for a in ASSETS:
|
||||
df = al.get(a, tf)
|
||||
tgt = fn(df)
|
||||
ev = al.eval_weights(df, tgt, fee_side=0.0005) # 0.10% RT
|
||||
ev0 = al.eval_weights(df, tgt, fee_side=0.001) # 0.20% RT
|
||||
fulls.append(ev["full"]["sharpe"]); holds.append(ev["holdout"].get("sharpe", 0.0))
|
||||
f10.append(ev["full"]["sharpe"]); f20.append(ev0["full"]["sharpe"])
|
||||
turn.append(ev["turnover_per_year"]); tim.append(ev["time_in_market"])
|
||||
return dict(tf=tf, min_full=round(min(fulls), 3), min_hold=round(min(holds), 3),
|
||||
min_f10=round(min(f10), 3), min_f20=round(min(f20), 3),
|
||||
turnover=round(float(np.mean(turn)), 1), tim=round(float(np.mean(tim)), 2))
|
||||
|
||||
|
||||
def screen_family(name, factory, grid, tfs=SCREEN_TFS):
|
||||
"""Esegue la griglia, ritorna lista di dict ordinata per min_hold (solo fee-surviving in cima)."""
|
||||
rows = []
|
||||
for tf in tfs:
|
||||
for params in grid:
|
||||
fn = factory(tf=tf, **params)
|
||||
m = _screen_cell(fn, tf)
|
||||
m["params"] = params
|
||||
m["fee_ok"] = bool(m["min_f20"] > 0)
|
||||
rows.append(m)
|
||||
rows.sort(key=lambda r: (r["fee_ok"], r["min_hold"]), reverse=True)
|
||||
print(f"\n===== {name}: top celle (di {len(rows)}) =====")
|
||||
print(f" {'tf':>4} {'minFull':>7} {'minHold':>7} {'f@.10':>6} {'f@.20':>6} "
|
||||
f"{'turn/y':>7} {'tim':>5} feeOK params")
|
||||
for r in rows[:10]:
|
||||
print(f" {r['tf']:>4} {r['min_full']:+7.2f} {r['min_hold']:+7.2f} {r['min_f10']:+6.2f} "
|
||||
f"{r['min_f20']:+6.2f} {r['turnover']:>7.0f} {r['tim']:>5.2f} "
|
||||
f"{str(r['fee_ok']):>5} {r['params']}")
|
||||
return rows
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DEEP-DIVE sul vincitore: marginal vs TP01 + day_boundary + corr SKH01 + haircut $600.
|
||||
# ===========================================================================
|
||||
@lru_cache(maxsize=1)
|
||||
def _skh_daily() -> pd.Series:
|
||||
"""Rendimenti giornalieri SKH01-V2-DD (50/50 BTC+ETH) dallo sleeve di progetto (read-only)."""
|
||||
from src.portfolio.sleeves import _skyhook_returns
|
||||
s = _skyhook_returns()
|
||||
if s.index.tz is None:
|
||||
s.index = s.index.tz_localize("UTC")
|
||||
return s
|
||||
|
||||
|
||||
def corr_to_skh(fn, tf) -> dict:
|
||||
cand = al.candidate_daily(fn, tf=tf)
|
||||
skh = _skh_daily()
|
||||
J = pd.concat({"C": cand, "S": skh}, axis=1, join="inner").dropna()
|
||||
JH = J[J.index >= al.HOLDOUT]
|
||||
return dict(n=int(len(J)),
|
||||
corr_full=round(float(J["C"].corr(J["S"])), 3) if len(J) > 5 else None,
|
||||
corr_hold=round(float(JH["C"].corr(JH["S"])), 3) if len(JH) > 5 else None)
|
||||
|
||||
|
||||
def haircut_600(fn, tf) -> dict:
|
||||
"""Sharpe onesto a $600: salta i ribilanci < $5 (eval_weights_smallcap), per asset + media."""
|
||||
out = {}
|
||||
for a in ASSETS:
|
||||
df = al.get(a, tf)
|
||||
sc = al.eval_weights_smallcap(df, fn(df), capital=600.0, min_order=5.0)
|
||||
out[a] = dict(modeled=sc["modeled"]["sharpe"], real=sc["realistic"]["sharpe"],
|
||||
haircut=sc["sharpe_haircut"], n_tr=sc["n_executed_trades"])
|
||||
return out
|
||||
|
||||
|
||||
def plateau_erm(tf="8h"):
|
||||
"""Plateau fine L_days x thr al TF vincente (min-asset full/hold/f@.20). Un edge vero ha un
|
||||
PLATEAU, non una cella isolata."""
|
||||
print("\n" + "=" * 78)
|
||||
print(f"PLATEAU ERM @ {tf} (min-asset; L_days righe, thr colonne) — full / hold / f@.20")
|
||||
print("=" * 78)
|
||||
Ls = (1.5, 2.0, 2.5, 3.0); thrs = (0.30, 0.35, 0.40, 0.45, 0.50)
|
||||
print(" L\\thr " + "".join(f"{t:>16.2f}" for t in thrs))
|
||||
for L in Ls:
|
||||
cells = []
|
||||
for t in thrs:
|
||||
m = _screen_cell(make_erm(tf=tf, L_days=L, thr=t, long_flat=False), tf)
|
||||
cells.append(f"{m['min_full']:+.2f}/{m['min_hold']:+.2f}/{m['min_f20']:+.2f}")
|
||||
print(f" {L:>4.1f} " + "".join(f"{c:>16}" for c in cells))
|
||||
|
||||
|
||||
def vs_book(fn, tf):
|
||||
"""Il test decisivo del gate #5: ERM AGGIUNGE oltre il book esistente (TP01+SKH01), o e'
|
||||
SKH01 travestito? Sharpe/DD full & hold dei blend incrementali su griglia giornaliera."""
|
||||
cand = al.candidate_daily(fn, tf=tf)
|
||||
tp = al.tp01_baseline_daily()
|
||||
skh = _skh_daily()
|
||||
J = pd.concat({"T": tp, "S": skh, "C": cand}, axis=1, join="inner").dropna()
|
||||
JH = J[J.index >= al.HOLDOUT]
|
||||
blends = [
|
||||
("TP01", (1.0, 0.0, 0.0)),
|
||||
("TP01+SKH 75/25", (0.75, 0.25, 0.0)),
|
||||
("TP01+SKH+ERM 60/25/15", (0.60, 0.25, 0.15)),
|
||||
("TP01+SKH+ERM 55/20/25", (0.55, 0.20, 0.25)),
|
||||
]
|
||||
print("\n" + "=" * 78)
|
||||
print("vs BOOK ESISTENTE (TP01+SKH01) — ERM aggiunge oltre SKH? (gate #5)")
|
||||
print("=" * 78)
|
||||
print(f" {'blend':<26} {'FULL Sh':>8} {'FULL DD':>8} {'HOLD Sh':>8} {'HOLD DD':>8}")
|
||||
for label, (wt, ws, wc) in blends:
|
||||
bf = wt * J["T"] + ws * J["S"] + wc * J["C"]
|
||||
bh = wt * JH["T"] + ws * JH["S"] + wc * JH["C"]
|
||||
print(f" {label:<26} {al._sh(bf):>+8.2f} {al._dd_ret(bf) * 100:>7.1f}% "
|
||||
f"{al._sh(bh):>+8.2f} {al._dd_ret(bh) * 100:>7.1f}%")
|
||||
|
||||
|
||||
def deep_dive(name, fn, tf, calendar=False):
|
||||
print("\n" + "#" * 78)
|
||||
print(f"# DEEP-DIVE: {name} (tf={tf})")
|
||||
print("#" * 78)
|
||||
|
||||
caus = al.causality_ok(fn, tf=tf)
|
||||
print(f"\n[CAUSALITA'] ok={caus['ok']} max_tail_diff={caus['max_tail_diff']} "
|
||||
f"(checked={caus['checked']})")
|
||||
|
||||
print("\n[FEE-SWEEP a frequenza reale] (study_weights su entrambi gli asset)")
|
||||
sw = al.study_weights(name, fn, tfs=(tf,))
|
||||
print(al.fmt(sw))
|
||||
|
||||
print("\n[MARGINAL vs TP01]")
|
||||
sm = al.study_marginal(name, fn, tf=tf)
|
||||
print(al.fmt_marginal(sm))
|
||||
|
||||
sk = corr_to_skh(fn, tf)
|
||||
print(f"\n[CORR con SKH01] full={sk['corr_full']} hold={sk['corr_hold']} "
|
||||
f"(n_giorni={sk['n']})")
|
||||
|
||||
if calendar:
|
||||
print("\n[DAY-BOUNDARY ROBUST] (OBBLIGATORIO per effetti ora/sessione/giorno)")
|
||||
else:
|
||||
print("\n[DAY-BOUNDARY ROBUST] (sanity: un segnale di prezzo dev'essere ~INVARIANT)")
|
||||
db = al.day_boundary_robust(fn, tf=tf)
|
||||
print(f" verdict={db['verdict']} spread={db.get('spread')} "
|
||||
f"min={db.get('min')} max={db.get('max')} per_offset={db.get('per_offset')}")
|
||||
|
||||
print("\n[HAIRCUT $600] (eval_weights_smallcap: salta ribilanci < $5)")
|
||||
hc = haircut_600(fn, tf)
|
||||
for a, d in hc.items():
|
||||
print(f" {a}: modeled Sh {d['modeled']:+.2f} -> real Sh {d['real']:+.2f} "
|
||||
f"(haircut {d['haircut']:+.2f}, trade eseguiti {d['n_tr']})")
|
||||
|
||||
return dict(name=name, tf=tf, causal=caus["ok"], earns_slot=sm["earns_slot"],
|
||||
marginal=sm["marginal_verdict"], corr_skh=sk, day_boundary=db["verdict"],
|
||||
haircut=hc)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MAIN
|
||||
# ===========================================================================
|
||||
def main():
|
||||
print("=" * 78)
|
||||
print("FILONE B — INTRADAY REGIME BTC/ETH (intraday_regime.py)")
|
||||
print("=" * 78)
|
||||
tp01 = al.tp01_baseline_daily()
|
||||
print(f"Baseline TP01 (50/50) full Sharpe ~{al._sh(tp01):.2f} "
|
||||
f"hold ~{al._sh(tp01[tp01.index >= al.HOLDOUT]):.2f} (riferimento marginale)")
|
||||
|
||||
# ---- Griglie (compatte: plateau leggibile, no overfit di griglia gigante) ----
|
||||
erm_grid = [dict(L_days=L, thr=t, long_flat=lf)
|
||||
for L in (1.0, 2.0, 3.0) for t in (0.35, 0.50) for lf in (False, True)]
|
||||
vem_grid = [dict(Lmom_days=lm, Lshort_days=2.0, Llong_days=10.0, long_flat=lf)
|
||||
for lm in (1.0, 3.0) for lf in (False, True)]
|
||||
vbr_grid = [dict(k=k, atr_win=14, long_flat=lf)
|
||||
for k in (0.5, 1.0, 1.5) for lf in (False, True)]
|
||||
tod_grid = [dict(long_flat=lf) for lf in (False, True)]
|
||||
|
||||
fam = {
|
||||
"ERM": (make_erm, erm_grid, SCREEN_TFS),
|
||||
"VEM": (make_vem, vem_grid, ("4h", "6h", "8h", "12h")),
|
||||
"VBR": (make_vbr, vbr_grid, ("4h", "6h", "8h", "12h")),
|
||||
"TOD": (make_tod, tod_grid, ("1h",)),
|
||||
}
|
||||
screens = {}
|
||||
for name, (factory, grid, tfs) in fam.items():
|
||||
screens[name] = screen_family(name, factory, grid, tfs)
|
||||
|
||||
# ---- Vincitore per famiglia (best min_hold tra le fee-surviving con min_full>0) ----
|
||||
print("\n" + "=" * 78)
|
||||
print("VINCITORI PER FAMIGLIA (best min_hold tra fee-surviving, min_full>0)")
|
||||
print("=" * 78)
|
||||
winners = {}
|
||||
for name, (factory, grid, tfs) in fam.items():
|
||||
ok = [r for r in screens[name] if r["fee_ok"] and r["min_full"] > 0]
|
||||
pool = ok if ok else screens[name]
|
||||
w = max(pool, key=lambda r: r["min_hold"])
|
||||
winners[name] = w
|
||||
print(f" {name}: tf={w['tf']} {w['params']} minFull={w['min_full']:+.2f} "
|
||||
f"minHold={w['min_hold']:+.2f} f@.20={w['min_f20']:+.2f} feeOK={w['fee_ok']}")
|
||||
|
||||
# ---- Deep-dive sui due meccanismi piu' promettenti (per min_hold) + il controllo TOD ----
|
||||
ranked = sorted(["ERM", "VEM", "VBR"],
|
||||
key=lambda n: winners[n]["min_hold"], reverse=True)
|
||||
deep = []
|
||||
for name in ranked[:2]:
|
||||
w = winners[name]
|
||||
factory = fam[name][0]
|
||||
fn = factory(tf=w["tf"], **w["params"])
|
||||
deep.append(deep_dive(f"{name} {w['params']}", fn, w["tf"], calendar=False))
|
||||
|
||||
# controllo calendario: TOD passa SEMPRE per day_boundary_robust
|
||||
wt = winners["TOD"]
|
||||
fn_tod = make_tod(tf=wt["tf"], **wt["params"])
|
||||
deep.append(deep_dive(f"TOD {wt['params']}", fn_tod, wt["tf"], calendar=True))
|
||||
|
||||
# ---- Analisi extra sul vincitore ERM (plateau fine + vs book TP01+SKH01) ----
|
||||
we = winners["ERM"]
|
||||
fn_erm = make_erm(tf=we["tf"], **we["params"])
|
||||
plateau_erm(we["tf"])
|
||||
vs_book(fn_erm, we["tf"])
|
||||
|
||||
# ---- Verdetto sintetico ----
|
||||
print("\n" + "=" * 78)
|
||||
print("SINTESI")
|
||||
print("=" * 78)
|
||||
for d in deep:
|
||||
print(f" {d['name']:<26} tf={d['tf']:>3} | marginal={d['marginal']:<9} "
|
||||
f"earns_slot={d['earns_slot']!s:<5} corrSKH(full/hold)="
|
||||
f"{d['corr_skh']['corr_full']}/{d['corr_skh']['corr_hold']} "
|
||||
f"day_boundary={d['day_boundary']}")
|
||||
any_slot = any(d["earns_slot"] for d in deep)
|
||||
print(f"\n => earns_slot su qualche meccanismo? {any_slot}")
|
||||
print(" (vedi diario docs/diary/2026-06-29-intraday-regime.md per il verdetto ragionato)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,458 @@
|
||||
"""macro_regime_gate.py — Filone D: MACRO REGIME-GATE sul book crypto (eseguibile).
|
||||
|
||||
TESI
|
||||
----
|
||||
Usare segnali macro/cross-market — equity (SPY/QQQ/IWM), credito (HYG/LQD), oro (GLD/SLV),
|
||||
tassi (TLT/IEF) — come GATE risk-on/risk-off applicato al book crypto (BTC/ETH) per migliorare
|
||||
il TIMING del drawdown di TP01. Quando il regime macro e' risk-off (credito che cede, equity
|
||||
sotto trend, fuga sui bond) -> riduci/azzera l'esposizione crypto; risk-on -> lascia agire TP01.
|
||||
E' ESEGUIBILE perche' GATA solo BTC/ETH perp (non aggiunge gambe).
|
||||
|
||||
NON e' un lead-lag direzionale (gia' esplorato e morto: vedi 2026-06-22/-23 leadlag diaries).
|
||||
L'angolo nuovo = un OVERLAY binario/continuo di DE-RISK sul book.
|
||||
|
||||
IL RISCHIO (da CLAUDE.md): il gate di de-risk rischia di essere RIDONDANTE col trend — TP01 e'
|
||||
gia' long-flat e va a 0 nei crash (lezione DVOL-spike "ridondante col trend, Delta 0.00"). Questo
|
||||
script DEVE dimostrare che il gate aggiunge OLTRE quel che TP01 fa da solo, altrimenti SCARTATO.
|
||||
|
||||
CAUSALITA' (fusi orari, regola di prim'ordine)
|
||||
----------------------------------------------
|
||||
- Barre equity: open-labeled a 00:00 del giorno di trading; il CLOSE e' ~20:00-21:00 UTC dello
|
||||
STESSO giorno (NYSE 16:00 ET).
|
||||
- Barre crypto 1d: open-labeled a 00:00; il CLOSE e' a 00:00 UTC del giorno DOPO. TP01 decide la
|
||||
posizione a close[i] e la TIENE durante la barra i+1 (eval_weights shift-a per te).
|
||||
- Quindi: gate[i] allineato (merge_asof backward, equity-label <= crypto-label day i) usa il
|
||||
close equity del giorno i (noto ~20:00 day i) per la posizione tenuta durante la barra i+1
|
||||
(giorno i+1). Margine causale >= 4h. Leak-free. Variante STRICT (equity-label < crypto-label)
|
||||
come margine extra. Verifica con al.causality_ok + day_boundary_robust.
|
||||
|
||||
USO: uv run python scripts/research/macro_regime_gate.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))
|
||||
sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
import altlib as al # noqa: E402
|
||||
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402
|
||||
|
||||
DATA = _ROOT / "data" / "raw"
|
||||
HOLDOUT = al.HOLDOUT
|
||||
ASSETS = ("BTC", "ETH")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MACRO FRAME — ETF daily, allineati causalmente sul calendario SPY (master).
|
||||
# ===========================================================================
|
||||
def _load_eq(sym: str) -> pd.DataFrame:
|
||||
p = DATA / f"eq_{sym}_1d.parquet"
|
||||
df = pd.read_parquet(p).sort_values("timestamp").reset_index(drop=True)
|
||||
df["dt"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
return df[["timestamp", "dt", "close"]].rename(columns={"close": sym})
|
||||
|
||||
|
||||
def macro_frame(syms=("spy", "qqq", "iwm", "hyg", "lqd", "gld", "slv", "tlt", "ief")) -> pd.DataFrame:
|
||||
"""Frame macro sul calendario NYSE (master = SPY). Ogni colonna e' il close equity,
|
||||
allineato causale (merge_asof backward) -> nessun valore futuro per riga."""
|
||||
base = _load_eq("spy")[["timestamp", "dt"]].copy()
|
||||
out = base
|
||||
for s in syms:
|
||||
e = _load_eq(s)[["timestamp", s]]
|
||||
out = pd.merge_asof(out, e, on="timestamp", direction="backward")
|
||||
return out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# GATE BUILDERS — ognuno ritorna (timestamp_ms, gate in [0,1]) sul calendario equity.
|
||||
# Tutti CAUSALI: la riga i usa solo close <= riga i (rolling/SMA, niente expanding-future).
|
||||
# gate=1 => risk-on (TP01 pieno); gate=g_off (0 o 0.5) => risk-off (de-risk).
|
||||
# ===========================================================================
|
||||
def _sma(x: np.ndarray, n: int) -> np.ndarray:
|
||||
return pd.Series(x).rolling(n, min_periods=n).mean().values
|
||||
|
||||
|
||||
def _ret(x: np.ndarray, n: int) -> np.ndarray:
|
||||
r = np.full(len(x), np.nan)
|
||||
r[n:] = x[n:] / x[:-n] - 1.0
|
||||
return r
|
||||
|
||||
|
||||
def gate_trend(mf: pd.DataFrame, col: str, n: int, g_off: float) -> pd.DataFrame:
|
||||
"""Risk-on se col_close > SMA(col, n). Filtro di trend classico (SPY200, HYG, ...)."""
|
||||
c = mf[col].values.astype(float)
|
||||
on = c > _sma(c, n)
|
||||
g = np.where(on, 1.0, g_off)
|
||||
g[~np.isfinite(c)] = np.nan
|
||||
g[np.isnan(_sma(c, n))] = np.nan
|
||||
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
||||
|
||||
|
||||
def gate_ratio(mf: pd.DataFrame, num: str, den: str, n: int, g_off: float) -> pd.DataFrame:
|
||||
"""Risk-on se ratio num/den (proxy spread di credito) > la sua SMA(n).
|
||||
HYG/LQD o HYG/IEF in calo = spread che si allarga = risk-off."""
|
||||
ratio = (mf[num].values.astype(float) / mf[den].values.astype(float))
|
||||
on = ratio > _sma(ratio, n)
|
||||
g = np.where(on, 1.0, g_off)
|
||||
g[~np.isfinite(ratio)] = np.nan
|
||||
g[np.isnan(_sma(ratio, n))] = np.nan
|
||||
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
||||
|
||||
|
||||
def gate_combo(mf: pd.DataFrame, n: int, g_off: float, thr: float = 0.5,
|
||||
continuous: bool = False) -> pd.DataFrame:
|
||||
"""Score di regime = media di 3 voti risk-on: SPY>SMA, HYG>SMA, HYG/LQD>SMA.
|
||||
continuous=True -> gate = g_off + (1-g_off)*score (de-risk graduale).
|
||||
continuous=False -> gate = 1 se score>=thr else g_off (binario su maggioranza)."""
|
||||
spy = mf["spy"].values.astype(float)
|
||||
hyg = mf["hyg"].values.astype(float)
|
||||
ratio = hyg / mf["lqd"].values.astype(float)
|
||||
votes = np.vstack([spy > _sma(spy, n), hyg > _sma(hyg, n), ratio > _sma(ratio, n)]).astype(float)
|
||||
warm = np.isnan(_sma(spy, n)) | np.isnan(_sma(hyg, n)) | np.isnan(_sma(ratio, n))
|
||||
score = votes.mean(axis=0)
|
||||
if continuous:
|
||||
g = g_off + (1.0 - g_off) * score
|
||||
else:
|
||||
g = np.where(score >= thr, 1.0, g_off)
|
||||
g[warm] = np.nan
|
||||
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
||||
|
||||
|
||||
def gate_flight(mf: pd.DataFrame, safe: str, risk: str, n: int, g_off: float) -> pd.DataFrame:
|
||||
"""Fuga verso la sicurezza: risk-off quando il safe-asset (TLT/GLD) sale MENTRE il risk
|
||||
(SPY) scende sull'orizzonte n. Divergenza risk-off classica (flight-to-quality)."""
|
||||
s = mf[safe].values.astype(float)
|
||||
rk = mf[risk].values.astype(float)
|
||||
off = (_ret(s, n) > 0) & (_ret(rk, n) < 0)
|
||||
g = np.where(off, g_off, 1.0)
|
||||
warm = np.isnan(_ret(s, n)) | np.isnan(_ret(rk, n))
|
||||
g[warm] = np.nan
|
||||
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
||||
|
||||
|
||||
def gate_eqvol(mf: pd.DataFrame, n: int, win: int, z: float, g_off: float) -> pd.DataFrame:
|
||||
"""Regime di vol equity: de-risk quando la vol realizzata SPY (win g) e' alta vs la sua
|
||||
storia espandente-causale (z-score > z). Proxy 'VIX spike' senza VIX."""
|
||||
spy = mf["spy"].values.astype(float)
|
||||
r = np.zeros(len(spy)); r[1:] = spy[1:] / spy[:-1] - 1.0
|
||||
rv = pd.Series(r).rolling(win, min_periods=win).std().values
|
||||
zsc = al.zscore(rv, n)
|
||||
off = zsc > z
|
||||
g = np.where(off, g_off, 1.0)
|
||||
g[~np.isfinite(zsc)] = np.nan
|
||||
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ALIGN GATE -> CRYPTO (causale) + GATED TARGET
|
||||
# ===========================================================================
|
||||
def align_gate(gate_df: pd.DataFrame, crypto_df: pd.DataFrame, strict: bool = False) -> np.ndarray:
|
||||
"""Allinea il gate (calendario equity) alle barre crypto. merge_asof backward:
|
||||
crypto-label day i -> ultimo gate equity con label <= day i (strict: < day i).
|
||||
NaN pre-storia -> gate=1 (nessun de-risk quando non c'e' info)."""
|
||||
left = pd.DataFrame({"timestamp": crypto_df["timestamp"].astype("int64").values})
|
||||
g = gate_df.dropna(subset=["gate"]).sort_values("timestamp")
|
||||
m = pd.merge_asof(left, g, on="timestamp", direction="backward",
|
||||
allow_exact_matches=not strict)
|
||||
return pd.Series(m["gate"].values).ffill().fillna(1.0).values
|
||||
|
||||
|
||||
def tp01_pos(df: pd.DataFrame) -> np.ndarray:
|
||||
return TrendPortfolio(**CANONICAL).target_series(df)
|
||||
|
||||
|
||||
def make_target_fn(gate_builder, strict: bool = False):
|
||||
"""Ritorna target_fn(df, asset) = posizione TP01 * gate macro allineato (causale).
|
||||
gate_builder() costruisce il gate sul calendario equity una volta (cache esterna)."""
|
||||
_MF = macro_frame()
|
||||
gate_df = gate_builder(_MF)
|
||||
|
||||
def target_fn(df: pd.DataFrame, asset: str = "") -> np.ndarray:
|
||||
pos = tp01_pos(df)
|
||||
g = align_gate(gate_df, df, strict=strict)
|
||||
return pos * g
|
||||
return target_fn, gate_df
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# EVALUATION — solo vs gated, combo 50/50 + per-asset, FULL/HOLD/DD/CAGR.
|
||||
# ===========================================================================
|
||||
def _combo_daily(target_fn) -> pd.Series:
|
||||
series = {}
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
ev = al.eval_weights(df, target_fn(df, a))
|
||||
series[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
return al._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]])
|
||||
|
||||
|
||||
def _sh(s):
|
||||
return al._sh(s)
|
||||
|
||||
|
||||
def _metrics(s: pd.Series) -> dict:
|
||||
sh = _sh(s)
|
||||
eq = np.cumprod(1.0 + s.values)
|
||||
pk = np.maximum.accumulate(eq)
|
||||
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
yrs = max((s.index[-1] - s.index[0]).days / 365.25, 1e-6)
|
||||
cagr = eq[-1] ** (1 / yrs) - 1 if len(eq) and eq[-1] > 0 else -1.0
|
||||
return dict(sharpe=round(sh, 3), dd=round(dd, 4), cagr=round(cagr, 4))
|
||||
|
||||
|
||||
def eval_solo_vs_gated(target_fn):
|
||||
"""Combo 50/50: solo (gate=1) vs gated. Ritorna dict con FULL e HOLD per entrambi."""
|
||||
solo_fn = lambda df, a="": tp01_pos(df)
|
||||
solo = _combo_daily(solo_fn)
|
||||
gated = _combo_daily(target_fn)
|
||||
J = pd.concat({"solo": solo, "gated": gated}, axis=1, join="inner").dropna()
|
||||
JH = J[J.index >= HOLDOUT]
|
||||
return dict(
|
||||
full_solo=_metrics(J["solo"]), full_gated=_metrics(J["gated"]),
|
||||
hold_solo=_metrics(JH["solo"]), hold_gated=_metrics(JH["gated"]),
|
||||
n=len(J), nh=len(JH), solo_series=J["solo"], gated_series=J["gated"])
|
||||
|
||||
|
||||
def per_asset_table(target_fn) -> dict:
|
||||
out = {}
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
solo = al.eval_weights(df, tp01_pos(df))
|
||||
gated = al.eval_weights(df, target_fn(df, a))
|
||||
out[a] = dict(
|
||||
full_solo=dict(sharpe=solo["full"]["sharpe"], dd=solo["full"]["maxdd"], cagr=solo["full"]["cagr"]),
|
||||
full_gated=dict(sharpe=gated["full"]["sharpe"], dd=gated["full"]["maxdd"], cagr=gated["full"]["cagr"]),
|
||||
hold_solo=dict(sharpe=solo["holdout"].get("sharpe", 0.0)),
|
||||
hold_gated=dict(sharpe=gated["holdout"].get("sharpe", 0.0)))
|
||||
return out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# REDUNDANCY DIAGNOSTIC — il controllo DECISIVO contro il trend.
|
||||
# ===========================================================================
|
||||
def redundancy_diag(gate_df: pd.DataFrame) -> dict:
|
||||
"""Quanto FA davvero il gate, dato che TP01 e' gia' flat nei crash?
|
||||
- exposure TP01 nei giorni risk-off (gate<1) vs risk-on: se gia' ~0 -> ridondante.
|
||||
- quota di giorni in cui il gate riduce una posizione NON gia' flat (lavoro effettivo).
|
||||
- corr fra (1-gate) e (1-exposure_norm)."""
|
||||
lev = CANONICAL["leverage"]
|
||||
rows = []
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
pos = tp01_pos(df)
|
||||
expo = np.clip(np.abs(pos) / lev, 0, 1) # esposizione normalizzata 0..1
|
||||
g = align_gate(gate_df, df)
|
||||
dt = pd.to_datetime(df["datetime"], utc=True)
|
||||
m = pd.DataFrame({"dt": dt, "expo": expo, "g": g})
|
||||
m = m[m["dt"] >= dt.iloc[0]] # tutto
|
||||
roff = m["g"] < 0.999
|
||||
ron = ~roff
|
||||
flat_thr = 0.05
|
||||
# lavoro effettivo: gate<1 E TP01 non gia' flat
|
||||
work = roff & (m["expo"] > flat_thr)
|
||||
rows.append(dict(
|
||||
asset=a,
|
||||
expo_riskoff=round(float(m.loc[roff, "expo"].mean()) if roff.any() else 0.0, 3),
|
||||
expo_riskon=round(float(m.loc[ron, "expo"].mean()) if ron.any() else 0.0, 3),
|
||||
pct_days_riskoff=round(float(roff.mean()), 3),
|
||||
pct_days_gate_works=round(float(work.mean()), 3),
|
||||
corr_1mg_1mexpo=round(float(np.corrcoef(1 - m["g"], 1 - m["expo"])[0, 1])
|
||||
if m["g"].std() > 0 else float("nan"), 3),
|
||||
))
|
||||
return {r["asset"]: r for r in rows}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# RUNNER
|
||||
# ===========================================================================
|
||||
GATES = {
|
||||
# binari classici
|
||||
"SPY>MA200": lambda mf: gate_trend(mf, "spy", 200, 0.0),
|
||||
"SPY>MA100": lambda mf: gate_trend(mf, "spy", 100, 0.0),
|
||||
"SPY>MA50": lambda mf: gate_trend(mf, "spy", 50, 0.0),
|
||||
"SPY>MA200_half": lambda mf: gate_trend(mf, "spy", 200, 0.5),
|
||||
"HYG>MA100": lambda mf: gate_trend(mf, "hyg", 100, 0.0),
|
||||
"HYG/LQD>MA50": lambda mf: gate_ratio(mf, "hyg", "lqd", 50, 0.0),
|
||||
"HYG/LQD>MA100": lambda mf: gate_ratio(mf, "hyg", "lqd", 100, 0.0),
|
||||
# combinati / score
|
||||
"COMBO_maj100": lambda mf: gate_combo(mf, 100, 0.0, thr=0.5),
|
||||
"COMBO_all100": lambda mf: gate_combo(mf, 100, 0.0, thr=0.99),
|
||||
"COMBO_cont100": lambda mf: gate_combo(mf, 100, 0.0, continuous=True),
|
||||
"COMBO_cont100h": lambda mf: gate_combo(mf, 100, 0.5, continuous=True),
|
||||
# flight-to-quality / vol
|
||||
"TLTup&SPYdn20": lambda mf: gate_flight(mf, "tlt", "spy", 20, 0.0),
|
||||
"GLDup&SPYdn20": lambda mf: gate_flight(mf, "gld", "spy", 20, 0.0),
|
||||
"SPYvol_z1": lambda mf: gate_eqvol(mf, 250, 20, 1.0, 0.0),
|
||||
}
|
||||
|
||||
|
||||
def delever_frontier(target_vols=(0.10, 0.12, 0.14, 0.16, 0.18, 0.20)) -> dict:
|
||||
"""CONTROLLO DECISIVO (lezione DVOL-overlay): per meno DD la leva e' target_vol, non un
|
||||
overlay. Frontiera DD/Sharpe di TP01 puro a target_vol decrescente. Se il miglior gate
|
||||
sta SOPRA (DD piu' alto a parita' di Sharpe, o Sharpe piu' basso a parita' di DD) di questa
|
||||
frontiera, il suo taglio di DD e' solo de-levering replicabile meglio senza macro."""
|
||||
out = {}
|
||||
for tv in target_vols:
|
||||
cfg = {**CANONICAL, "target_vol": tv}
|
||||
fn = lambda df, a="", cfg=cfg: TrendPortfolio(**cfg).target_series(df)
|
||||
c = _combo_daily(fn)
|
||||
out[tv] = _metrics(c)
|
||||
return out
|
||||
|
||||
|
||||
def fmt_cmp(label, m_solo, m_gated) -> str:
|
||||
ds = m_gated["sharpe"] - m_solo["sharpe"]
|
||||
dd = m_gated["dd"] - m_solo["dd"]
|
||||
dc = m_gated.get("cagr", 0) - m_solo.get("cagr", 0)
|
||||
return (f" {label:5s} Sh {m_solo['sharpe']:+.2f}->{m_gated['sharpe']:+.2f} (d{ds:+.2f}) "
|
||||
f"DD {m_solo['dd']*100:4.1f}%->{m_gated['dd']*100:4.1f}% (d{dd*100:+.1f}pp) "
|
||||
f"CAGR {m_solo.get('cagr',0)*100:+5.1f}%->{m_gated.get('cagr',0)*100:+5.1f}% (d{dc*100:+.1f}pp)")
|
||||
|
||||
|
||||
def main():
|
||||
pd.set_option("display.width", 160)
|
||||
print("=" * 92)
|
||||
print("MACRO REGIME-GATE sul book crypto (TP01 BTC/ETH) — Filone D")
|
||||
print(f" TP01 CANONICAL = {CANONICAL}")
|
||||
print(f" HOLD-OUT >= {HOLDOUT.date()} fee {al.FEE_SIDE*2*100:.2f}%RT")
|
||||
mf = macro_frame()
|
||||
print(f" Macro frame: {len(mf)} barre {mf['dt'].iloc[0].date()} -> {mf['dt'].iloc[-1].date()} "
|
||||
f"cols={[c for c in mf.columns if c not in ('timestamp','dt')]}")
|
||||
print("=" * 92)
|
||||
|
||||
# ---- 1) SWEEP DI TUTTI I GATE (combo 50/50) -------------------------------------
|
||||
print("\n[1] SWEEP GATE — combo 50/50 BTC+ETH, FULL & HOLD-OUT, vs TP01-solo\n")
|
||||
results = {}
|
||||
for name, builder in GATES.items():
|
||||
tf, gate_df = make_target_fn(builder)
|
||||
cmp = eval_solo_vs_gated(tf)
|
||||
results[name] = (cmp, gate_df, tf)
|
||||
print(f"GATE {name}")
|
||||
print(fmt_cmp("FULL", cmp["full_solo"], cmp["full_gated"]))
|
||||
print(fmt_cmp("HOLD", cmp["hold_solo"], cmp["hold_gated"]))
|
||||
|
||||
# baseline (solo) numbers come from any cmp
|
||||
any_cmp = next(iter(results.values()))[0]
|
||||
print(f"\n [baseline TP01-solo] FULL Sh {any_cmp['full_solo']['sharpe']} DD {any_cmp['full_solo']['dd']*100:.1f}% "
|
||||
f"CAGR {any_cmp['full_solo']['cagr']*100:.1f}% | HOLD Sh {any_cmp['hold_solo']['sharpe']} "
|
||||
f"DD {any_cmp['hold_solo']['dd']*100:.1f}% CAGR {any_cmp['hold_solo']['cagr']*100:.1f}%")
|
||||
|
||||
# ---- 2) SELEZIONE: miglior gate per HOLD-OUT Sharpe, poi per riduzione DD --------
|
||||
def score(name):
|
||||
c = results[name][0]
|
||||
return (c["hold_gated"]["sharpe"], -c["full_gated"]["dd"])
|
||||
best = max(results, key=score)
|
||||
# anche il "miglior DD-cutter" che non peggiora troppo lo Sharpe FULL
|
||||
dd_best = min(results, key=lambda n: results[n][0]["full_gated"]["dd"])
|
||||
print(f"\n[2] Miglior gate per HOLD-OUT Sharpe: {best}")
|
||||
print(f" Miglior gate per DD FULL ridotto : {dd_best}")
|
||||
|
||||
# ---- CONTROLLO DECISIVO: de-lever frontier (target_vol) -------------------------
|
||||
print("\n[2b] CONTROLLO DECISIVO — TP01 puro a target_vol piu' basso (de-lever) vs gate:")
|
||||
fr = delever_frontier()
|
||||
for tv, m in fr.items():
|
||||
print(f" target_vol {tv:.2f}: FULL Sh {m['sharpe']:+.2f} DD {m['dd']*100:4.1f}% CAGR {m['cagr']*100:+5.1f}%")
|
||||
print(" -> i gate de-leveranti (COMBO_cont, SPYvol) vanno confrontati con QUESTA frontiera:")
|
||||
for n in ("COMBO_cont100", "COMBO_cont100h", "SPYvol_z1", "SPY>MA200_half"):
|
||||
if n in results:
|
||||
g = results[n][0]["full_gated"]
|
||||
print(f" {n:16s}: FULL Sh {g['sharpe']:+.2f} DD {g['dd']*100:4.1f}% CAGR {g['cagr']*100:+5.1f}%")
|
||||
|
||||
for tag, name in [("BEST-HOLD", best), ("BEST-DD", dd_best)]:
|
||||
if tag == "BEST-DD" and dd_best == best:
|
||||
continue
|
||||
deep_dive(tag, name, results)
|
||||
|
||||
|
||||
def deep_dive(tag, name, results):
|
||||
cmp, gate_df, tf = results[name]
|
||||
print("\n" + "=" * 92)
|
||||
print(f"[DEEP DIVE {tag}] GATE = {name}")
|
||||
print("=" * 92)
|
||||
|
||||
# per-asset
|
||||
print("\n Per-asset (TP01-solo -> TP01+gate):")
|
||||
pa = per_asset_table(tf)
|
||||
for a in ASSETS:
|
||||
d = pa[a]
|
||||
print(f" {a}: FULL Sh {d['full_solo']['sharpe']:+.2f}->{d['full_gated']['sharpe']:+.2f} "
|
||||
f"DD {d['full_solo']['dd']*100:.0f}%->{d['full_gated']['dd']*100:.0f}% "
|
||||
f"CAGR {d['full_solo']['cagr']*100:+.0f}%->{d['full_gated']['cagr']*100:+.0f}% | "
|
||||
f"HOLD Sh {d['hold_solo']['sharpe']:+.2f}->{d['hold_gated']['sharpe']:+.2f}")
|
||||
|
||||
# ---- 3) CONTROLLO RIDONDANZA COL TREND ------------------------------------------
|
||||
print("\n [3] CONTROLLO RIDONDANZA COL TREND (il test decisivo):")
|
||||
rd = redundancy_diag(gate_df)
|
||||
for a in ASSETS:
|
||||
r = rd[a]
|
||||
print(f" {a}: exposure TP01 nei giorni risk-off={r['expo_riskoff']} vs risk-on={r['expo_riskon']} "
|
||||
f"| giorni risk-off {r['pct_days_riskoff']*100:.0f}% "
|
||||
f"giorni in cui il gate LAVORA (riduce pos non-flat) {r['pct_days_gate_works']*100:.0f}% "
|
||||
f"| corr(1-gate, 1-expo)={r['corr_1mg_1mexpo']}")
|
||||
print(" -> se exposure-risk-off ~ exposure-risk-on e 'gate-lavora' e' basso => RIDONDANTE col trend.")
|
||||
|
||||
# ---- 4) MARGINAL SCORER -----------------------------------------------------------
|
||||
print("\n [4] MARGINAL SCORER vs TP01 (gate come candidato-sleeve):")
|
||||
rep = al.study_marginal(f"GATE[{name}]", tf, tf="1d")
|
||||
print(al.fmt_marginal(rep))
|
||||
|
||||
# overlay-delta: lo STREAM incrementale del gate = gated - solo (e' alpha o hedge?)
|
||||
delta = (cmp["gated_series"] - cmp["solo_series"]).dropna()
|
||||
print("\n overlay-delta (gated - solo) come stream a se':")
|
||||
md = al.marginal_vs_tp01(delta)
|
||||
print(f" verdict={md.get('marginal_verdict')} corr->TP01 {md.get('corr_full')} "
|
||||
f"is_hedge={md.get('is_hedge')} uplift TP01-up {md.get('uplift_tp01_up')} / "
|
||||
f"TP01-down {md.get('uplift_tp01_down')} cand-Sh full {md.get('cand_full_sharpe')}")
|
||||
|
||||
# ---- 5) FEE SWEEP -----------------------------------------------------------------
|
||||
print("\n [5] FEE SWEEP (combo 50/50 gated, FULL Sharpe):")
|
||||
for f in al.FEE_SWEEP:
|
||||
series = {}
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
ev = al.eval_weights(df, tf(df, a), fee_side=f)
|
||||
series[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
d = al._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]])
|
||||
print(f" {2*f*100:.2f}%RT -> Sh {_sh(d):+.2f}")
|
||||
|
||||
# ---- 6) ESEGUIBILITA' a $600 ------------------------------------------------------
|
||||
print("\n [6] ESEGUIBILITA' a $600 (eval_weights_smallcap, haircut reale):")
|
||||
for a in ASSETS:
|
||||
df = al.get(a, "1d")
|
||||
sc = al.eval_weights_smallcap(df, tf(df, a), capital=600, min_order=5)
|
||||
print(f" {a}: modeled Sh {sc['modeled']['sharpe']:+.2f} -> real Sh {sc['realistic']['sharpe']:+.2f} "
|
||||
f"haircut {sc['sharpe_haircut']:+.2f} trade eseguiti {sc['n_executed_trades']}")
|
||||
|
||||
# ---- 7) LEAK / BOUNDARY -----------------------------------------------------------
|
||||
print("\n [7] LEAK-FREE & BOUNDARY:")
|
||||
cz = al.causality_ok(tf, tf="1d")
|
||||
print(f" causality_ok={cz['ok']} (max_tail_diff {cz['max_tail_diff']}, checked {cz['checked']})")
|
||||
# variante STRICT (equity-label < crypto-label): margine causale extra
|
||||
tf_strict, _ = make_target_fn(GATES[name], strict=True)
|
||||
cmp_s = eval_solo_vs_gated(tf_strict)
|
||||
print(f" STRICT align (1 barra equity extra di lag): FULL Sh {cmp_s['full_gated']['sharpe']:+.2f} "
|
||||
f"(vs {cmp['full_gated']['sharpe']:+.2f}) HOLD Sh {cmp_s['hold_gated']['sharpe']:+.2f} "
|
||||
f"(vs {cmp['hold_gated']['sharpe']:+.2f}) -> robusto se ~uguale")
|
||||
db = al.day_boundary_robust(tf, tf="1d")
|
||||
print(f" day_boundary_robust={db['verdict']} (spread {db.get('spread')}, per-offset {db.get('per_offset')})")
|
||||
|
||||
# ---- 8) PLATEAU (solo per i trend MA) ---------------------------------------------
|
||||
if name.startswith("SPY>MA") or name.startswith("HYG"):
|
||||
print("\n [8] PLATEAU su finestra MA (SPY trend), g_off=0:")
|
||||
for n in (50, 100, 150, 200, 250):
|
||||
tfn, _ = make_target_fn(lambda mf, n=n: gate_trend(mf, "spy", n, 0.0))
|
||||
cc = eval_solo_vs_gated(tfn)
|
||||
print(f" SPY>MA{n:3d}: FULL Sh {cc['full_gated']['sharpe']:+.2f} DD {cc['full_gated']['dd']*100:.1f}% "
|
||||
f"HOLD Sh {cc['hold_gated']['sharpe']:+.2f} DD {cc['hold_gated']['dd']*100:.1f}%")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,433 @@
|
||||
"""XSEC v2 — segnali cross-sectional NON-MOMENTUM su 51 asset Hyperliquid (STAT-MODE).
|
||||
|
||||
TESI (filone C). XS01 (sleeve attivo) e' momentum cross-sectional sui 19 major. Lezione del
|
||||
progetto (diari 2026-06-19/20): ESPANDERE IL NUMERO di asset NON aiuta il momentum (gli small-cap
|
||||
diluiscono/invertono il segnale). Quindi qui NON ri-proviamo l'espansione-universo: cerchiamo un
|
||||
MECCANISMO DIVERSO dal momentum che, market-neutral e scorrelato, possa diversificare il portafoglio.
|
||||
|
||||
Meccanismi provati (tutti L/S dollar-neutral, vol-target ~20%, ribilancio periodico, CAUSALI):
|
||||
REV - short-term REVERSAL cross-sectional grezzo (long i loser di breve, short i winner).
|
||||
IREV - REVERSAL IDIOSINCRATICO: reversal sul RESIDUO dopo aver tolto il mercato (beta-adjusted).
|
||||
LOWVOL - factor LOW-VOL: long bassa vol realizzata / short alta vol (betting-against-vol).
|
||||
IMOM - MOMENTUM IDIOSINCRATICO: momentum sul residuo (toglie il fattore mercato, != raw mom).
|
||||
BAB - betting-against-beta: long basso beta / short alto beta.
|
||||
MOM - (riferimento) momentum grezzo, per confronto.
|
||||
|
||||
GATE (CLAUDE.md, metodologia obbligatoria):
|
||||
1. CAUSALE: score a close[i], peso tenuto in i+1 (l'engine shifta: W[i-1]*dret[i]); vol=0 gata.
|
||||
2. NETTO fee 0.10% RT su OGNI gamba a OGNI ribilancio + sweep fee.
|
||||
3. OOS hold-out 2025-01-01 + plateau su (lookback, H, k) + 2 universi (51 vs 19 major).
|
||||
4. Storia ~2.5 anni + molte config -> DEFLATED Sharpe (multiple-testing) e onesta' brutale.
|
||||
5. Confronto: Sharpe standalone FULL/HOLD/DD, corr vs XS01 e TP01, uplift del portafoglio a 4->5
|
||||
sleeve (portfolio.py, riusa active_sleeves senza modificarli).
|
||||
6. CAVEAT: book a molte gambe NON eseguibile a $600 -> STAT-MODE / forward-monitor, non deploy.
|
||||
|
||||
uv run python scripts/research/xsec_v2_nonmom.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, glob, math
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.stats import norm
|
||||
|
||||
from src.portfolio.portfolio import to_daily, metrics, HOLDOUT, Sleeve, StrategyPortfolio
|
||||
from src.portfolio.sleeves import tp01_sleeve, xsec_sleeve, active_sleeves, XS_UNIVERSE
|
||||
|
||||
RAW = PROJECT_ROOT / "data" / "raw"
|
||||
FEE = 0.001 # 0.10% RT (Deribit taker): fee per gamba per lato = FEE/2 = 0.0005
|
||||
TV = 0.20 # vol-target annuo
|
||||
DPY = 365.25
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DATI — matrice prezzi/volumi (outer-join: ragged start, NaN prima del listing)
|
||||
# ===========================================================================
|
||||
def load_matrix(universe=None):
|
||||
px, vol = {}, {}
|
||||
files = sorted(glob.glob(str(RAW / "hl_*_1d.parquet")))
|
||||
for f in files:
|
||||
sym = Path(f).stem.replace("hl_", "").replace("_1d", "").upper()
|
||||
if universe is not None and sym not in universe:
|
||||
continue
|
||||
d = pd.read_parquet(f)
|
||||
idx = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||
px[sym] = pd.Series(d["close"].values.astype(float), index=idx)
|
||||
vol[sym] = pd.Series(d["volume"].values.astype(float), index=idx)
|
||||
PX = pd.concat(px, axis=1).sort_index()
|
||||
VOL = pd.concat(vol, axis=1).sort_index().reindex_like(PX)
|
||||
return PX, VOL
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ENGINE cross-sectional NaN-aware (causale). score_at(i)->(score[A], valid[A]).
|
||||
# Convenzione UNICA: long alto score / short basso score. Ogni meccanismo passa
|
||||
# lo score giusto (es. reversal = -ritorno; low-vol = -vol; bab = -beta).
|
||||
# ===========================================================================
|
||||
def xs_engine(PX, VOL, score_at, H, k, target_vol=TV, fee=FEE, min_assets=10, warmup=0):
|
||||
px = PX.values
|
||||
vol = VOL.values
|
||||
n, A = px.shape
|
||||
dret = np.full((n, A), np.nan)
|
||||
dret[1:] = px[1:] / px[:-1] - 1.0
|
||||
W = np.zeros((n, A))
|
||||
w = np.zeros(A)
|
||||
for i in range(n):
|
||||
if i >= warmup and i % H == 0:
|
||||
score, valid = score_at(i)
|
||||
valid = valid & np.isfinite(score) & (vol[i] > 0)
|
||||
idxv = np.where(valid)[0]
|
||||
if len(idxv) >= min_assets:
|
||||
kk = min(k, len(idxv) // 2)
|
||||
order = idxv[np.argsort(score[idxv])] # ascendente
|
||||
lo, hi = order[:kk], order[-kk:] # basso score / alto score
|
||||
w = np.zeros(A)
|
||||
w[hi] = 0.5 / kk # long alto score
|
||||
w[lo] = -0.5 / kk # short basso score
|
||||
else:
|
||||
w = np.zeros(A)
|
||||
W[i] = w
|
||||
# rendimento book: W[i-1] guadagna dret[i]; NaN (asset non listato) -> 0
|
||||
gross = np.zeros(n)
|
||||
gross[1:] = np.nansum(W[:-1] * np.nan_to_num(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=PX.index)
|
||||
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(DPY)
|
||||
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||
turn_py = float(turn.sum() / (n / DPY)) if n else 0.0
|
||||
return pd.Series(s.values * scale, index=PX.index), turn_py
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SCORE BUILDERS — ognuno ritorna una closure score_at(i) + warmup richiesto.
|
||||
# Tutti CAUSALI: usano dati <= i (close[i] noto al momento della decisione).
|
||||
# ===========================================================================
|
||||
def _precompute(PX):
|
||||
px = PX.values
|
||||
n, A = px.shape
|
||||
DR = PX.pct_change() # ritorni giornalieri (NaN ragged)
|
||||
m = DR.mean(axis=1) # mercato equal-weight (skipna)
|
||||
return px, n, A, DR, m
|
||||
|
||||
|
||||
def make_mom(PX, L, sign=+1):
|
||||
px, n, A, *_ = _precompute(PX)
|
||||
def score_at(i):
|
||||
if i - L < 0:
|
||||
return np.full(A, np.nan), np.zeros(A, bool)
|
||||
r = px[i] / px[i - L] - 1.0
|
||||
valid = np.isfinite(px[i]) & np.isfinite(px[i - L])
|
||||
return sign * r, valid
|
||||
return score_at, L + 1
|
||||
|
||||
|
||||
def make_lowvol(PX, B):
|
||||
px, n, A, DR, m = _precompute(PX)
|
||||
RV = DR.rolling(B, min_periods=int(0.6 * B)).std().values
|
||||
def score_at(i):
|
||||
rv = RV[i]
|
||||
valid = np.isfinite(rv) & np.isfinite(px[i])
|
||||
return -rv, valid # long bassa vol / short alta vol
|
||||
return score_at, B + 1
|
||||
|
||||
|
||||
def _rolling_beta(DR, m, B):
|
||||
mp = int(0.6 * B)
|
||||
Em = m.rolling(B, min_periods=mp).mean()
|
||||
Em2 = (m * m).rolling(B, min_periods=mp).mean()
|
||||
varm = Em2 - Em ** 2
|
||||
Ex = DR.rolling(B, min_periods=mp).mean()
|
||||
Exm = DR.mul(m, axis=0).rolling(B, min_periods=mp).mean()
|
||||
beta = Exm.sub(Ex.mul(Em, axis=0)).div(varm.replace(0, np.nan), axis=0)
|
||||
return beta.values, varm.values
|
||||
|
||||
|
||||
def make_bab(PX, B):
|
||||
px, n, A, DR, m = _precompute(PX)
|
||||
beta, _ = _rolling_beta(DR, m, B)
|
||||
def score_at(i):
|
||||
b = beta[i]
|
||||
valid = np.isfinite(b) & np.isfinite(px[i])
|
||||
return -b, valid # long basso beta / short alto beta
|
||||
return score_at, B + 1
|
||||
|
||||
|
||||
def make_resid(PX, L, B, sign):
|
||||
"""Momentum/reversal IDIOSINCRATICO: residuo = ritorno - beta*mercato (beta su finestra B),
|
||||
cumulato sugli ultimi L giorni. sign=+1 -> momentum residuo; sign=-1 -> reversal residuo."""
|
||||
px, n, A, DR, m = _precompute(PX)
|
||||
beta, _ = _rolling_beta(DR, m, B)
|
||||
SDR = DR.rolling(L, min_periods=int(0.8 * L)).sum().values # somma ritorni asset su L
|
||||
SM = m.rolling(L, min_periods=int(0.8 * L)).sum().values # somma mercato su L
|
||||
cnt = DR.rolling(L, min_periods=1).count().values
|
||||
def score_at(i):
|
||||
b = beta[i]
|
||||
resid_cum = SDR[i] - b * SM[i]
|
||||
valid = np.isfinite(resid_cum) & (cnt[i] >= 0.8 * L) & np.isfinite(px[i])
|
||||
return sign * resid_cum, valid
|
||||
return score_at, max(L, B) + 1
|
||||
|
||||
|
||||
# Catalogo meccanismi: nome -> (builder, lista di config (param dict)).
|
||||
def mechanisms():
|
||||
return {
|
||||
"MOM": (lambda PX, p: make_mom(PX, p["L"], +1),
|
||||
[dict(L=L, H=H, k=k) for L in (30, 60, 90) for H in (5, 10) for k in (5, 8)]),
|
||||
"REV": (lambda PX, p: make_mom(PX, p["L"], -1),
|
||||
[dict(L=L, H=H, k=k) for L in (2, 3, 5, 7, 10) for H in (1, 2, 3, 5) for k in (5, 8)]),
|
||||
"IREV": (lambda PX, p: make_resid(PX, p["L"], p.get("B", 60), -1),
|
||||
[dict(L=L, H=H, k=k, B=60) for L in (3, 5, 7, 10) for H in (2, 3, 5) for k in (5, 8)]),
|
||||
"IMOM": (lambda PX, p: make_resid(PX, p["L"], p.get("B", 60), +1),
|
||||
[dict(L=L, H=H, k=k, B=60) for L in (30, 60, 90) for H in (5, 10) for k in (5, 8)]),
|
||||
"LOWVOL": (lambda PX, p: make_lowvol(PX, p["B"]),
|
||||
[dict(B=B, H=H, k=k) for B in (20, 30, 60) for H in (5, 10) for k in (5, 8)]),
|
||||
"BAB": (lambda PX, p: make_bab(PX, p["B"]),
|
||||
[dict(B=B, H=H, k=k) for B in (30, 60) for H in (5, 10) for k in (5, 8)]),
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# METRICHE / STATISTICA
|
||||
# ===========================================================================
|
||||
def yr_breadth(daily):
|
||||
yr = [float((1 + g).prod() - 1) for _, g in daily.groupby(daily.index.year)]
|
||||
return (sum(v > 0 for v in yr) / len(yr) if yr else 0.0), yr
|
||||
|
||||
|
||||
def deflated_sharpe(sr_ann, all_sr_ann, daily_ret):
|
||||
"""Deflated Sharpe Ratio (Bailey & Lopez de Prado): probabilita' che lo Sharpe vero superi
|
||||
lo Sharpe-massimo atteso sotto il null di N trial indipendenti. Penalizza il multiple-testing.
|
||||
sr_ann: Sharpe annualizzato della config scelta; all_sr_ann: tutti gli Sharpe testati;
|
||||
daily_ret: serie ritorni giornalieri (per skew/kurt/T). Ritorna (DSR, sr0_ann)."""
|
||||
r = np.asarray(pd.Series(daily_ret).dropna().values, float)
|
||||
T = len(r)
|
||||
if T < 30 or np.std(r) == 0:
|
||||
return float("nan"), float("nan")
|
||||
sr = sr_ann / math.sqrt(DPY) # per-osservazione
|
||||
trials = np.asarray([s / math.sqrt(DPY) for s in all_sr_ann if np.isfinite(s)], float)
|
||||
N = max(len(trials), 2)
|
||||
var_tr = float(np.var(trials, ddof=1)) if N > 1 else 0.0
|
||||
emc = 0.5772156649
|
||||
z1 = norm.ppf(1 - 1.0 / N)
|
||||
z2 = norm.ppf(1 - 1.0 / (N * math.e))
|
||||
sr0 = math.sqrt(var_tr) * ((1 - emc) * z1 + emc * z2)
|
||||
sk = float(pd.Series(r).skew())
|
||||
ku = float(pd.Series(r).kurt()) + 3.0 # pandas kurt = excess
|
||||
den = math.sqrt(max(1e-9, 1 - sk * sr + (ku - 1) / 4.0 * sr ** 2))
|
||||
dsr = float(norm.cdf((sr - sr0) * math.sqrt(T - 1) / den))
|
||||
return dsr, sr0 * math.sqrt(DPY)
|
||||
|
||||
|
||||
def evalcfg(daily):
|
||||
f = metrics(daily)
|
||||
h = metrics(daily[daily.index >= HOLDOUT])
|
||||
pct, _ = yr_breadth(daily)
|
||||
return f, h, pct
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# RUN griglia per meccanismo / universo
|
||||
# ===========================================================================
|
||||
def run_grid(PX, VOL, mech_name, builder, cfgs, xs_daily, tp_daily, label):
|
||||
rows = []
|
||||
for p in cfgs:
|
||||
score_at, warm = builder(PX, p)
|
||||
daily, turn = xs_engine(PX, VOL, score_at, p["H"], p["k"], warmup=warm)
|
||||
daily = to_daily(daily)
|
||||
if daily.std() == 0 or len(daily) < 60:
|
||||
continue
|
||||
f, h, pct = evalcfg(daily)
|
||||
cx = _corr(daily, xs_daily)
|
||||
ct = _corr(daily, tp_daily)
|
||||
rows.append(dict(cfg=p, daily=daily, full=f["sharpe"], hold=h["sharpe"], dd=f["maxdd"],
|
||||
ret=f["ret"], pct=pct, corrXS=cx, corrTP=ct, turn=turn))
|
||||
return rows
|
||||
|
||||
|
||||
def _corr(a, b):
|
||||
J = pd.concat({"a": a, "b": b}, axis=1, join="inner").dropna()
|
||||
return float(J["a"].corr(J["b"])) if len(J) > 10 else float("nan")
|
||||
|
||||
|
||||
def tag(p):
|
||||
return " ".join(f"{k}{v}" for k, v in p.items())
|
||||
|
||||
|
||||
MOM_FAMILY = ("MOM", "IMOM") # momentum (anche residuo) -> NON e' "non-momentum"
|
||||
|
||||
|
||||
def causality_prefix_check(PX, VOL, builder, cfg, frac=0.85, tail=60, tol=1e-9):
|
||||
"""Guard look-ahead per l'engine cross-sectional: ricostruisce la serie su un PREFISSO della
|
||||
matrice (primi `frac`) e verifica che la coda combaci con la run completa sugli stessi indici.
|
||||
Un feature non-causale (finestra centrata, statistica full-sample, shift(-k)) divergerebbe."""
|
||||
score_full, warm = builder(PX, cfg)
|
||||
full, _ = xs_engine(PX, VOL, score_full, cfg["H"], cfg["k"], warmup=warm)
|
||||
cut = int(len(PX) * frac)
|
||||
PXc, VOLc = PX.iloc[:cut], VOL.iloc[:cut]
|
||||
score_pre, warm2 = builder(PXc, cfg)
|
||||
pre, _ = xs_engine(PXc, VOLc, score_pre, cfg["H"], cfg["k"], warmup=warm2)
|
||||
lo = max(0, cut - tail)
|
||||
a = full.values[lo:cut]
|
||||
b = pre.values[lo:cut]
|
||||
worst = float(np.max(np.abs(a - b))) if len(a) else float("nan")
|
||||
return dict(ok=bool(worst <= tol), max_tail_diff=worst, cut=cut, tail=len(a))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PORTAFOGLIO — uplift 4 -> 5 sleeve (riusa active_sleeves SENZA modificarli)
|
||||
# ===========================================================================
|
||||
def portfolio_uplift(cand_fn, fractions=(0.10, 0.15)):
|
||||
base = active_sleeves() # 4 sleeve validati
|
||||
pf0 = StrategyPortfolio(base)
|
||||
bt0 = pf0.backtest() # popola le cache degli sleeve
|
||||
base_full = metrics(pf0.combined_daily())
|
||||
base_hold = metrics(pf0.combined_daily(lo=HOLDOUT))
|
||||
out = {"base": (base_full, base_hold), "variants": {}}
|
||||
for fr in fractions:
|
||||
wraw = fr / (1.0 - fr) # cand_frac = wraw/(sum_base + wraw), sum_base=1
|
||||
cand = Sleeve("XSV2_cand", wraw, cand_fn)
|
||||
pf1 = StrategyPortfolio(base + [cand]) # riusa le cache di base
|
||||
cf = metrics(pf1.combined_daily())
|
||||
ch = metrics(pf1.combined_daily(lo=HOLDOUT))
|
||||
out["variants"][fr] = (cf, ch, pf1.weights().get("XSV2_cand", 0.0))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 100)
|
||||
print(" XSEC v2 — CROSS-SECTIONAL NON-MOMENTUM su Hyperliquid (STAT-MODE, storia ~2.5 anni)")
|
||||
print("=" * 100)
|
||||
|
||||
tp_daily = tp01_sleeve().daily()
|
||||
xs_daily = xsec_sleeve().daily()
|
||||
print(f" riferimenti: TP01 (corr target) e XS01 (momentum, sleeve attivo).")
|
||||
|
||||
universes = {
|
||||
"51-all": None,
|
||||
"19-major": XS_UNIVERSE,
|
||||
}
|
||||
mats = {}
|
||||
for uname, u in universes.items():
|
||||
PX, VOL = load_matrix(u)
|
||||
mats[uname] = (PX, VOL)
|
||||
print(f" universo {uname:<9}: {PX.shape[1]} asset, {PX.shape[0]} giorni "
|
||||
f"[{PX.index[0].date()} -> {PX.index[-1].date()}]")
|
||||
|
||||
mechs = mechanisms()
|
||||
all_sr = [] # per deflated-Sharpe (tutti i trial)
|
||||
best_per_mech = {} # (uname, mech) -> best row by hold
|
||||
for uname, (PX, VOL) in mats.items():
|
||||
print("\n" + "#" * 100)
|
||||
print(f"# UNIVERSO {uname}")
|
||||
print("#" * 100)
|
||||
for mech_name, (builder, cfgs) in mechs.items():
|
||||
rows = run_grid(PX, VOL, mech_name, builder, cfgs, xs_daily, tp_daily, uname)
|
||||
if not rows:
|
||||
continue
|
||||
all_sr.extend([r["full"] for r in rows])
|
||||
pos_full = sum(r["full"] > 0 for r in rows)
|
||||
# migliore per HOLD-OUT (diversificatore: vogliamo OOS robusto)
|
||||
best = max(rows, key=lambda r: r["hold"])
|
||||
best_per_mech[(uname, mech_name)] = best
|
||||
print(f"\n [{mech_name}] {len(rows)} config | plateau full>0: {pos_full}/{len(rows)}"
|
||||
f" | best-hold: {tag(best['cfg'])}")
|
||||
print(f" {'cfg':<22}{'FULL':>7}{'HOLD':>7}{'DD%':>6}{'ret%':>7}{'anni+':>7}"
|
||||
f"{'corrXS':>8}{'corrTP':>8}{'turn/y':>8}")
|
||||
# mostra le top-3 per HOLD per leggere il plateau
|
||||
for r in sorted(rows, key=lambda r: -r["hold"])[:3]:
|
||||
print(f" {tag(r['cfg']):<22}{r['full']:>7.2f}{r['hold']:>7.2f}{r['dd']*100:>6.0f}"
|
||||
f"{r['ret']*100:>+7.0f}{r['pct']*100:>6.0f}%{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}"
|
||||
f"{r['turn']:>8.0f}")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# SELEZIONE: miglior candidato NON-MOMENTUM (escluse le famiglie momentum MOM/IMOM).
|
||||
# gate standalone: FULL>0.5, HOLD>0, |corrXS|<0.6 -> ranking per (FULL+HOLD)/2.
|
||||
# IMOM/MOM restano in tabella come RIFERIMENTO (sono momentum, non il target del filone).
|
||||
# -------------------------------------------------------------------
|
||||
print("\n" + "=" * 100)
|
||||
print(" SELEZIONE CANDIDATO non-momentum — gate: FULL>0.5, HOLD>0, |corrXS|<0.6 (escluse MOM/IMOM)")
|
||||
print("=" * 100)
|
||||
nm = [s for s in all_sr if np.isfinite(s)]
|
||||
pool = [(u, mn, r) for (u, mn), r in best_per_mech.items()]
|
||||
nonmom = [(u, mn, r) for (u, mn, r) in pool if mn not in MOM_FAMILY]
|
||||
elig = [(u, mn, r) for (u, mn, r) in nonmom
|
||||
if r["full"] > 0.5 and r["hold"] > 0 and abs(r["corrXS"]) < 0.6]
|
||||
elig.sort(key=lambda x: -0.5 * (x[2]["full"] + x[2]["hold"]))
|
||||
for u, mn, r in sorted(pool, key=lambda x: -0.5 * (x[2]["full"] + x[2]["hold"])):
|
||||
fam = "(momentum-ref)" if mn in MOM_FAMILY else ""
|
||||
flag = "OK" if (u, mn, r) in elig else "--"
|
||||
print(f" [{flag}] {mn:<7} {u:<9} {tag(r['cfg']):<20} FULL {r['full']:+.2f} HOLD {r['hold']:+.2f}"
|
||||
f" DD {r['dd']*100:.0f}% corrXS {r['corrXS']:+.2f} corrTP {r['corrTP']:+.2f} {fam}")
|
||||
|
||||
if not elig:
|
||||
print("\n >>> NESSUN candidato NON-momentum supera il gate standalone. SCARTATO.")
|
||||
_final_note()
|
||||
return
|
||||
|
||||
print(f"\n candidati idonei (non-momentum): {len(elig)}")
|
||||
|
||||
# valuta UPLIFT PORTAFOGLIO per i top-3 idonei (LOWVOL/BAB/...): cache base riusata
|
||||
base = active_sleeves()
|
||||
pf0 = StrategyPortfolio(base); pf0.backtest()
|
||||
bf = metrics(pf0.combined_daily()); bh = metrics(pf0.combined_daily(lo=HOLDOUT))
|
||||
print("\n UPLIFT PORTAFOGLIO (active_sleeves 4 -> 5 sleeve; candidato come 5o sleeve):")
|
||||
print(f" BASE (4 sleeve) FULL Sh {bf['sharpe']:.2f} DD {bf['maxdd']*100:.0f}%"
|
||||
f" | HOLD Sh {bh['sharpe']:.2f} DD {bh['maxdd']*100:.0f}%")
|
||||
uplifts = {}
|
||||
for u, mn, r in elig[:3]:
|
||||
cand_fn = (lambda d: (lambda: d))(r["daily"])
|
||||
best_var = None
|
||||
for fr in (0.10, 0.15):
|
||||
wraw = fr / (1.0 - fr)
|
||||
cand = Sleeve("XSV2_cand", wraw, cand_fn)
|
||||
pf1 = StrategyPortfolio(base + [cand])
|
||||
cf = metrics(pf1.combined_daily()); ch = metrics(pf1.combined_daily(lo=HOLDOUT))
|
||||
wgt = pf1.weights().get("XSV2_cand", 0.0)
|
||||
print(f" +{mn:<6} [{u}] {tag(r['cfg']):<16} @{wgt*100:>4.1f}% "
|
||||
f"FULL {cf['sharpe']:.2f} ({cf['sharpe']-bf['sharpe']:+.2f}) DD {cf['maxdd']*100:.0f}%"
|
||||
f" | HOLD {ch['sharpe']:.2f} ({ch['sharpe']-bh['sharpe']:+.2f})")
|
||||
d_full, d_hold = cf['sharpe'] - bf['sharpe'], ch['sharpe'] - bh['sharpe']
|
||||
if best_var is None or (d_full + d_hold) > best_var:
|
||||
best_var = d_full + d_hold
|
||||
uplifts[(u, mn)] = best_var
|
||||
|
||||
# TOP candidato = miglior non-momentum idoneo
|
||||
u, mn, best = elig[0]
|
||||
daily = best["daily"]
|
||||
f, h, pct = evalcfg(daily)
|
||||
dsr, sr0 = deflated_sharpe(f["sharpe"], all_sr, daily)
|
||||
caus = causality_prefix_check(*mats[u], mechs[mn][0], best["cfg"])
|
||||
print("\n" + "=" * 100)
|
||||
print(f" TOP CANDIDATO non-momentum: {mn} [{u}] {tag(best['cfg'])}")
|
||||
print("=" * 100)
|
||||
print(f" FULL Sharpe {f['sharpe']:.2f} | HOLD {h['sharpe']:.2f} | DD {f['maxdd']*100:.0f}%"
|
||||
f" | ret {f['ret']*100:+.0f}% | anni+ {pct*100:.0f}% | turnover/y {best['turn']:.0f}")
|
||||
print(f" corr vs XS01 {best['corrXS']:+.2f} | corr vs TP01 {best['corrTP']:+.2f}")
|
||||
print(f" CAUSALITA' (prefix-check): ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}")
|
||||
print(f" DEFLATED Sharpe (N={len(nm)} trial GLOBALI): {dsr:.3f}"
|
||||
f" | soglia Sharpe-max-null annualizz. {sr0:.2f} (serve DSR>0.95)")
|
||||
_, yrs = yr_breadth(daily)
|
||||
per = [(int(y), round(v, 3)) for y, v in zip([yy for yy, _ in daily.groupby(daily.index.year)], yrs)]
|
||||
print(f" per-anno: {per}")
|
||||
|
||||
helps = (uplifts.get((u, mn), -9) or -9) > 0.10 # uplift combinato full+hold meaningful
|
||||
robust = dsr > 0.95 and best["hold"] > 0.3 and best["full"] > 0.7 and caus["ok"]
|
||||
print("\n VERDETTO INDICATIVO:",
|
||||
"PASS-LEAD (forward-monitor)" if (helps and robust) else
|
||||
("DEBOLE/forward-monitor" if (helps or (best['full'] > 0.7 and best['hold'] > 0.3)) else "SCARTATO"))
|
||||
_final_note()
|
||||
|
||||
|
||||
def _final_note():
|
||||
print("\n CAVEAT (immutabili): storia ~2.5 anni (deflated-Sharpe + multiple-testing), book a molte")
|
||||
print(" gambe NON eseguibile a $600 -> STAT-MODE / forward-monitor, MAI deploy. Nessuno sleeve")
|
||||
print(" registrato: questo e' solo lavoro statistico (vincoli del filone C).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user