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:
Adriano Dal Pastro
2026-07-01 23:21:59 +00:00
parent ccf5e38101
commit 491411ac77
17 changed files with 3538 additions and 14 deletions
+383
View File
@@ -0,0 +1,383 @@
"""r0701_breadth_internals.py — BREADTH / MARKET-INTERNALS del mercato ALT come segnale su BTC/ETH.
TESI (filone 2026-07-01)
------------------------
Gli "internals" del mercato crypto — la partecipazione degli alt — come segnale direzionale o gate
di de-risk su BTC/ETH perp (2 gambe, eseguibile a ~$600):
FAM-MA : % di alt sopra la propria SMA(N) (breadth classica)
FAM-AD : advance/decline — frazione di advancers, SMA(N) (partecipazione giornaliera)
FAM-RS : % di alt che BATTONO BTC sul ritorno a N giorni (risk-appetite relativo, ~mkt-neutral)
FAM-TH : breadth-THRUST — delta della breadth MA20 su N giorni (thrust/collapse)
Forme: LS (long/short), LF (long/flat), GATE (TP01 * gate binario). Tutte vol-target 20% cap 2x
(LS/LF) o ereditano il sizing TP01 (GATE).
RISCHI NOTI IN PARTENZA (CLAUDE.md, prior art)
----------------------------------------------
1. MACRO regime-gate (2026-06-29) = SCARTATO: corr->TP01 0.989, il gate lavorava nel 2-3% dei
giorni (TP01 e' gia' flat nei crash). Un breadth-gate rischia di essere LO STESSO artefatto:
TP01 travestito. Qui DOBBIAMO riportare corr->TP01 + verdetto marginale + "giorni in cui il
gate lavora" (gate off E TP01 non gia' flat).
2. trend-multiasset = ridondante (corr 0.74): la breadth degli alt e' correlata alla direzione
del mercato -> il rischio che breadth>soglia == "BTC sopra trend" e' concreto.
3. is_hedge: un segnale che paga solo quando TP01 soffre e' un hedge, non alpha.
4. STORIA: l'universo HL parte dal 2024-01 -> ~2.2 anni utili post-warmup. In-sample (pre-HOLDOUT
2025-01-01) = SOLO ~8 mesi del 2024. Limite strutturale DICHIARATO: qualunque esito e' al
massimo un LEAD, la selezione in-sample poggia su una finestra corta.
METODO (obbligatorio, CLAUDE.md)
--------------------------------
- Dati: 51 parquet certificati data/raw/hl_*_1d.parquet; PANEL = 49 alt (esclusi hl_btc/hl_eth
dalla breadth; hl_btc usato solo come riferimento per FAM-RS). Barre a volume<=0 = sintetiche
-> close mascherato NaN. Breadth definita solo con >= MIN_VALID(20) asset validi alla data.
- Causalita': barre HL 1d e barre BTC/ETH 1d (altlib.get, resample Deribit) sono entrambe
open-labeled 00:00 UTC -> il close del giorno D e' noto allo stesso istante su entrambe.
Allineamento merge_asof backward (allow_exact) sul timestamp; eval_weights shifta la posizione
(decisa a close[i], tenuta in i+1). Verifica al.causality_ok sul target end-to-end.
- Selezione ONESTA (lezione SELECTION-ON-HOLDOUT 2026-06-29): la cella si sceglie con il SOLO
Sharpe in-sample (pre-2025) sul candidato 50/50, MAI sull'hold-out; deflated Sharpe (Bailey &
Lopez de Prado) su TUTTE le celle cercate; poi al.marginal_vs_tp01 (multi-cut, has_insample_edge,
is_hedge) sulla cella scelta. NB: non si usa al.study_family_honest stock perche' la breadth non
esiste pre-2024 e il padding (LS/LF=flat, GATE=TP01 pieno) contaminerebbe il ranking in-sample
full-history (le celle GATE erediterebbero lo Sharpe 2019-2024 di TP01); la procedura qui sotto
e' il MIRROR esatto di select_cell_insample + deflated_sharpe + study_marginal sulla FINESTRA
COMUNE 2024-05+ (stessa libreria, stessi gate).
- Fee 0.10% RT + sweep 0-0.30% RT; eval_weights_smallcap a $600.
USO: uv run python scripts/research/r0701_breadth_internals.py
"""
from __future__ import annotations
import glob
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
RAW = _ROOT / "data" / "raw"
HOLDOUT = al.HOLDOUT
ASSETS = ("BTC", "ETH")
MIN_VALID = 20 # asset validi minimi perche' la breadth esista
START = pd.Timestamp("2026-01-01", tz="UTC") # placeholder, ridefinito sotto
# finestra comune: HL parte 2024-01-01; warmup max = FAM-TH N=100 (20g MA + 100g delta) ~ 120g
START = pd.Timestamp("2024-05-05", tz="UTC")
MA_GRID = (20, 50, 100)
THR_GRID = (0.3, 0.5, 0.7)
FORMS = ("ls", "lf", "gate")
FAMS = ("ma", "ad", "rs", "th")
# ===========================================================================
# PANEL ALT (49 asset, vol=0 mascherato) + riferimento BTC (hl_btc)
# ===========================================================================
def load_panel():
px, vol = {}, {}
for f in sorted(glob.glob(str(RAW / "hl_*_1d.parquet"))):
sym = Path(f).stem.replace("hl_", "").replace("_1d", "").upper()
d = pd.read_parquet(f)
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)
PX = PX.mask(VOL <= 0) # barre sintetiche (vol=0) -> NaN (lezione 2026-06-20)
btc_ref = PX["BTC"].copy() # riferimento FAM-RS (stessa venue/stesso close time)
ALTS = PX.drop(columns=["BTC", "ETH"]) # breadth = SOLO alt
return ALTS, btc_ref
def _mask_min_valid(score: pd.Series, n_valid: pd.Series) -> pd.Series:
s = score.copy()
s[n_valid < MIN_VALID] = np.nan
return s
def breadth_ma(ALTS: pd.DataFrame, N: int) -> pd.Series:
"""% di alt validi con close > SMA(N). Causale (SMA su dati <= i)."""
sma = ALTS.rolling(N, min_periods=N).mean()
valid = ALTS.notna() & sma.notna()
above = (ALTS > sma) & valid
n_valid = valid.sum(axis=1)
return _mask_min_valid(above.sum(axis=1) / n_valid.replace(0, np.nan), n_valid)
def breadth_ad(ALTS: pd.DataFrame, N: int) -> pd.Series:
"""Advance/decline: frazione di advancers (ret 1g > 0) tra i validi, SMA(N)."""
dr = ALTS.pct_change(fill_method=None)
valid = dr.notna()
adv = ((dr > 0) & valid).sum(axis=1)
n_valid = valid.sum(axis=1)
frac = adv / n_valid.replace(0, np.nan)
frac[n_valid < MIN_VALID] = np.nan
return frac.rolling(N, min_periods=N).mean()
def breadth_rs(ALTS: pd.DataFrame, btc_ref: pd.Series, N: int) -> pd.Series:
"""% di alt che battono BTC sul ritorno a N giorni (risk-appetite relativo)."""
altret = ALTS / ALTS.shift(N) - 1.0
btcret = (btc_ref / btc_ref.shift(N) - 1.0)
valid = altret.notna() & btcret.notna().values[:, None]
beat = altret.gt(btcret, axis=0) & valid
n_valid = valid.sum(axis=1)
return _mask_min_valid(beat.sum(axis=1) / n_valid.replace(0, np.nan), n_valid)
def breadth_th(ALTS: pd.DataFrame, N: int) -> pd.Series:
"""Breadth-THRUST: 0.5 + delta a N giorni della breadth MA20 (thrust>0.5, collapse<0.5)."""
b20 = breadth_ma(ALTS, 20)
return 0.5 + (b20 - b20.shift(N))
# ===========================================================================
# FACTORY — target_fn(df, asset) per una cella (fam, N, thr, form)
# ===========================================================================
_ALTS, _BTC_REF = load_panel()
_BREADTH: dict[tuple, pd.Series] = {}
for _N in MA_GRID:
_BREADTH[("ma", _N)] = breadth_ma(_ALTS, _N)
_BREADTH[("ad", _N)] = breadth_ad(_ALTS, _N)
_BREADTH[("rs", _N)] = breadth_rs(_ALTS, _BTC_REF, _N)
_BREADTH[("th", _N)] = breadth_th(_ALTS, _N)
_TP01_POS: dict[str, np.ndarray] = {}
def tp01_pos(df: pd.DataFrame, asset: str) -> np.ndarray:
if asset not in _TP01_POS:
_TP01_POS[asset] = TrendPortfolio(**CANONICAL).target_series(df)
return _TP01_POS[asset]
_EPOCH = pd.Timestamp("1970-01-01", tz="UTC")
def _align(b: pd.Series, df: pd.DataFrame) -> np.ndarray:
"""Breadth (calendario HL) -> barre BTC/ETH. merge_asof backward, exact ok (stesso
istante di close 00:00 UTC). NaN dove la breadth non esiste.
NB timestamp via epoca esplicita: .view('int64') su DatetimeIndex tz-aware a risoluzione
non-ns (pandas 2.x) da' la SCALA SBAGLIATA -> merge_asof matchava tutto all'ULTIMO valore
(broadcast del futuro su tutta la storia = look-ahead che causality_ok non vede, perche'
la serie breadth e' un input esterno fisso). Bug trovato e corretto in questa ricerca."""
ts_ms = ((b.index - _EPOCH) // pd.Timedelta(milliseconds=1)).astype("int64")
g = pd.DataFrame({"timestamp": ts_ms, "b": b.values})
g = g.dropna(subset=["b"]).sort_values("timestamp")
left = pd.DataFrame({"timestamp": df["timestamp"].astype("int64").values})
m = pd.merge_asof(left, g, on="timestamp", direction="backward")
return m["b"].values.astype(float)
def factory(tf: str = "1d", fam: str = "ma", N: int = 50, thr: float = 0.5, form: str = "ls"):
b_series = _BREADTH[(fam, N)]
def target_fn(df: pd.DataFrame, asset: str = "") -> np.ndarray:
b = _align(b_series, df)
if form == "gate":
g = np.where(np.isfinite(b), np.where(b >= thr, 1.0, 0.0), 1.0) # no info -> no de-risk
return tp01_pos(df, asset) * g
if form == "ls":
d = np.where(np.isfinite(b), np.where(b >= thr, 1.0, -1.0), 0.0)
else: # lf
d = np.where(np.isfinite(b), np.where(b >= thr, 1.0, 0.0), 0.0)
return al.vol_target(d, df, 0.20, 30, 2.0)
return target_fn
GRID = [dict(fam=f, N=n, thr=t, form=fo)
for f in FAMS for n in MA_GRID for t in THR_GRID for fo in FORMS]
# ===========================================================================
# DRIVER ONESTO sulla finestra comune (mirror di study_family_honest, stessi gate altlib)
# ===========================================================================
def cand_trim(fn) -> pd.Series:
return al.candidate_daily(fn, tf="1d")[lambda s: s.index >= START]
def abs_verdict_trimmed(fn) -> dict:
"""study_weights-equivalente sulla finestra comune 2024-05+ (fee sweep incluso)."""
per_asset = {}
fee_ok_all = True
for a in ASSETS:
df = al.get(a, "1d")
tgt = np.asarray(fn(df, a), float)
mask = (pd.to_datetime(df["datetime"], utc=True) >= START).values
dft = df[mask].reset_index(drop=True)
tgtt = tgt[mask]
base = al.eval_weights(dft, tgtt, fee_side=al.FEE_SIDE)
sweep = {f"{2*f*100:.2f}%RT": al.eval_weights(dft, tgtt, fee_side=f)["full"]["sharpe"]
for f in al.FEE_SWEEP}
fee_ok_all = fee_ok_all and sweep.get("0.20%RT", -9) > 0
per_asset[a] = dict(full=base["full"], holdout=base["holdout"],
tim=base["time_in_market"], turnover=base["turnover_per_year"],
fee_sweep=sweep, yearly=base["yearly"])
cell = dict(tf="1d", per_asset=per_asset,
min_asset_full_sharpe=round(min(per_asset[a]["full"]["sharpe"] for a in ASSETS), 3),
min_asset_holdout_sharpe=round(min(per_asset[a]["holdout"].get("sharpe", 0.0) for a in ASSETS), 3),
full_sharpe=round(float(np.mean([per_asset[a]["full"]["sharpe"] for a in ASSETS])), 3),
fee_survives=fee_ok_all)
return dict(cells=[cell], verdict=al._verdict([cell]))
def gate_work_diag(fn, thr_flat: float = 1e-6) -> dict:
"""Deep-dive ridondanza (lezione macro-gate): nei giorni in cui il segnale vorrebbe stare
fuori/short, TP01 e' gia' flat da solo? 'lavora' = segnale off/short E TP01 non flat."""
out = {}
for a in ASSETS:
df = al.get(a, "1d")
mask = (pd.to_datetime(df["datetime"], utc=True) >= START).values
tgt = np.asarray(fn(df, a), float)[mask]
tp = tp01_pos(df, a)[mask]
off = tgt <= thr_flat # segnale fuori (o short per LS: qui solo "non long")
works = off & (tp > thr_flat)
out[a] = dict(days_off=round(float(off.mean()), 3),
days_gate_works=round(float(works.mean()), 3),
tp01_flat_days=round(float((tp <= thr_flat).mean()), 3),
corr_pos=round(float(np.corrcoef(tgt, tp)[0, 1]), 3)
if np.std(tgt) > 0 and np.std(tp) > 0 else None)
return out
def cell_activity(p: dict) -> float:
"""Frazione di giorni nello STATO DI MINORANZA del segnale (criterio STRUTTURALE, non di
performance: una cella sempre-on e' buy&hold/TP01 travestito, non un segnale di breadth).
ls: min(on, off); lf: frazione on... comunque = minoranza; gate: frazione off."""
b = _BREADTH[(p["fam"], p["N"])]
bb = b[(b.index >= START)].dropna()
on = float((bb >= p["thr"]).mean())
return round(min(on, 1.0 - on) if p["form"] == "ls" else
(on if p["form"] == "lf" else 1.0 - on), 3)
def full_report(label: str, chosen: dict, all_full: list, n_trials: int) -> dict:
p = chosen["params"]
fn = factory(**p)
daily = cand_trim(fn)
dsr, sr0 = al.deflated_sharpe(al._sh(daily), all_full, daily)
print(f"\n==== {label}: {p} (attivita' {chosen['act']}) ====")
print(f" standalone (finestra comune): IS {chosen['insample_sharpe']:+.2f} "
f"FULL {chosen['full_sharpe']:+.2f} HOLD {chosen['hold_sharpe']:+.2f}")
print(f" deflated Sharpe (su TUTTI i {n_trials} trial cercati): DSR={dsr:.3f} "
f"(null-max atteso {sr0:.2f}) PASS>=0.95: {dsr >= 0.95}")
marg = al.marginal_vs_tp01(daily)
absr = abs_verdict_trimmed(fn)
abs_grade = absr["verdict"]["grade"]
earns_slot = (abs_grade != "FAIL" and marg.get("marginal_verdict") == "ADDS"
and marg.get("robust_oos", False) and marg.get("beats_noise_null", False)
and not marg.get("is_hedge", False))
rep = dict(name=f"BREADTH {p}", marginal=marg, abs_grade=abs_grade,
marginal_verdict=marg.get("marginal_verdict"), earns_slot=earns_slot)
print("\n" + al.fmt_marginal(rep))
honest = earns_slot and dsr >= 0.95
print(f" earns_slot_honest = earns_slot({earns_slot}) AND DSR>=0.95({dsr >= 0.95}) "
f"=> {honest}")
# assoluto trimmed + fee sweep
c = absr["cells"][0]
print(f"\n---- ASSOLUTO (finestra comune, verdetto {abs_grade}): "
f"minFull {c['min_asset_full_sharpe']:+.2f} minHold {c['min_asset_holdout_sharpe']:+.2f} "
f"feeOK={c['fee_survives']}")
for a in ASSETS:
pa = c["per_asset"][a]
yr = " ".join(f"{y}:{d['ret']*100:+.0f}%" for y, d in pa["yearly"].items())
print(f" {a}: full Sh {pa['full']['sharpe']:+.2f} DD {pa['full']['maxdd']*100:.0f}% "
f"hold Sh {pa['holdout'].get('sharpe', 0):+.2f} tim {pa['tim']} "
f"turn/y {pa['turnover']} | {yr}")
print(f" fee sweep: {pa['fee_sweep']}")
# causalita' + smallcap $600
ca = al.causality_ok(fn, tf="1d")
print(f"\n---- causality_ok: {ca['ok']} (max_tail_diff {ca['max_tail_diff']}, checked {ca['checked']})")
for a in ASSETS:
df = al.get(a, "1d")
mask = (pd.to_datetime(df["datetime"], utc=True) >= START).values
dft = df[mask].reset_index(drop=True)
tgtt = np.asarray(fn(df, a), float)[mask]
sc = al.eval_weights_smallcap(dft, tgtt, capital=600.0, min_order=5.0)
print(f" smallcap $600 {a}: modeled Sh {sc['modeled']['sharpe']:+.2f} -> "
f"real {sc['realistic']['sharpe']:+.2f} (haircut {sc['sharpe_haircut']:+.2f}, "
f"{sc['n_executed_trades']} trade eseguiti)")
# deep-dive ridondanza col trend (lezione macro-gate)
print("---- RIDONDANZA COL TREND (il rischio n.1):")
for a, d in gate_work_diag(fn).items():
print(f" {a}: corr(pos, TP01pos) {d['corr_pos']} giorni segnale-off {d['days_off']} "
f"TP01-gia'-flat {d['tp01_flat_days']} GIORNI IN CUI LAVORA {d['days_gate_works']}")
return dict(params=p, dsr=round(float(dsr), 3), earns_slot=earns_slot,
earns_slot_honest=honest, marginal_verdict=marg.get("marginal_verdict"),
abs_grade=abs_grade, corr_tp01=marg.get("corr_full"),
is_hedge=marg.get("is_hedge"))
def main():
print(f"=== r0701 BREADTH/INTERNALS — panel: {_ALTS.shape[1]} alt, "
f"{_ALTS.index[0].date()} -> {_ALTS.index[-1].date()} | finestra analisi {START.date()}+ "
f"(in-sample {START.date()} -> {HOLDOUT.date()} = ~8 mesi; storia ~2.2y: LIMITE DICHIARATO)")
nv = _ALTS.notna().sum(axis=1)
print(f" asset validi/D: min {int(nv.min())} med {int(nv.median())} max {int(nv.max())} "
f"(MIN_VALID={MIN_VALID})")
# ---- 1. tutte le celle: Sharpe in-sample (selezione) + full (DSR) sulla finestra comune
rows = []
for p in GRID:
fn = factory(**p)
daily = cand_trim(fn)
ins = daily[daily.index < HOLDOUT]
if len(ins) < 60 or daily.std() == 0:
continue
rows.append(dict(params=p, act=cell_activity(p),
insample_sharpe=round(al._sh(ins), 3),
full_sharpe=round(al._sh(daily), 3),
hold_sharpe=round(al._sh(daily[daily.index >= HOLDOUT]), 3)))
rows.sort(key=lambda r: r["insample_sharpe"], reverse=True)
all_full = [r["full_sharpe"] for r in rows]
print(f"\n---- GRIGLIA: {len(GRID)} celle, {len(rows)} valutabili; "
f"full>0: {sum(1 for s in all_full if s > 0)}/{len(all_full)}")
print("---- TOP-15 per Sharpe IN-SAMPLE (selezione onesta: MAI sull'hold-out) "
"[hold mostrato solo per trasparenza; act = frazione stato di minoranza]")
for r in rows[:15]:
p = r["params"]
print(f" {p['fam']:>2s} N={p['N']:>3d} thr={p['thr']} {p['form']:>4s} act={r['act']:.2f} | "
f"IS {r['insample_sharpe']:+.2f} full {r['full_sharpe']:+.2f} hold {r['hold_sharpe']:+.2f}")
# ---- 2. PRIMARIO: cella scelta in-sample su TUTTA la griglia (procedura onesta pura)
out1 = full_report("CELLA SCELTA IN-SAMPLE (tutta la griglia)", rows[0], all_full, len(rows))
# ---- 3. SECONDARIO (dichiarato): sole celle ATTIVE (minoranza >=10% — criterio strutturale
# deciso a priori, NON di performance; DSR sempre deflazionato su TUTTI i trial).
active = [r for r in rows if r["act"] >= 0.10]
print(f"\n---- CELLE ATTIVE (act>=0.10): {len(active)}/{len(rows)}")
out2 = None
if active and active[0]["params"] != rows[0]["params"]:
out2 = full_report("SECONDARIO: best cella ATTIVA in-sample", active[0], all_full, len(rows))
# ---- 4. marginal sui best per-forma tra le ATTIVE (trasparenza: il verdetto per forma)
print("\n---- VERDETTO MARGINALE dei best-IN-SAMPLE ATTIVI per forma (contesto):")
for fo in FORMS:
sub = [r for r in active if r["params"]["form"] == fo]
if not sub:
continue
rp = sub[0]["params"]
m = al.marginal_vs_tp01(cand_trim(factory(**rp)))
uh = m["blends"]["w25"]["uplift_hold"]
print(f" {fo:>4s} {rp} act={sub[0]['act']:.2f}: {m.get('marginal_verdict')} "
f"corr {m.get('corr_full')} IS-edge {m.get('cand_insample_sharpe')} "
f"is_hedge {m.get('is_hedge')} uplift w25 full {m['blends']['w25']['uplift_full']:+.3f} "
f"hold {uh if uh is None else format(uh, '+.3f')} multicut {m.get('multicut_uplift')}")
print("\n==== SINTESI ====")
print(f" primario: {out1}")
print(f" secondario (attive): {out2}")
print("==== FINE r0701 ====")
if __name__ == "__main__":
main()
+387
View File
@@ -0,0 +1,387 @@
"""r0701_funding_ts — FUNDING RATE come segnale TIME-SERIES direzionale su BTC/ETH (NON carry).
2026-07-01. Ipotesi: il funding orario Hyperliquid (proxy di POSIZIONAMENTO/sentiment dei perp)
contiene informazione direzionale a orizzonte giornaliero su BTC/ETH. Famiglia (griglia modesta):
- FADE : z-score del funding estremo-positivo = affollamento long -> SHORT (e viceversa)
- FOLLOW : funding in espansione = domanda long persistente -> LONG (sentiment momentum)
- GATE : trend TP01-like long-flat, FLAT quando il funding e' affollato (z>=thr, de-risk)
- DIVERGE : momentum prezzo 20d con funding NON affollato -> follow; affollato -> fade
Griglia: 4 forme x lookback z {7,14,30,60}g x soglia {0.5,1.0,1.5} = 48 celle, solo 1d.
PRIOR ART (non ripetuto): FC01 carry cross-sectional delta-neutral -> SCARTATO
(docs/diary/2026-06-22-funding-carry-hl.md); funding price-clock intraday -> FAIL (onda intraday).
Qui il funding e' un SEGNALE time-series direzionale su BTC/ETH perp (2 gambe, eseguibile a ~$600),
non un cashflow da incassare.
DATI: data/raw/hlfund_{btc,eth}_1h.parquet (funding orario HL: 2023-05-12 -> 2026-06-22; primi
~27 giorni a cadenza 8h, poi oraria, 0 gap; certificato nel diario 2026-06-22). Prezzi certificati
Deribit via altlib.get (1d resampled leak-free).
CAUSALITA' (il punto delicato): le barre 1d sono OPEN-LABELED (datetime = 00:00 UTC del giorno D;
il close della barra D e' noto alle 00:00 di D+1). Il feature-day D aggrega i SOLI stamp funding
in [D 00:00, D+24h) — l'ultimo alle 23:00 — quindi tutto e' noto PRIMA della decisione al close
della barra D. eval_weights poi shifta: target[D] e' tenuto durante la barra D+1. Nessun leak
strutturale; in piu' prefix-check esplicito.
VALUTAZIONE su finestra TRONCATA alla copertura funding (2023-05-12..2026-06-21), NON sul frame
prezzi 2018+: fuori copertura il target sarebbe zero per costruzione e i giorni-zero (a) GONFIANO
il T del deflated-Sharpe (anti-conservativo) e (b) DILUISCONO cand_insample_sharpe (gate
has_insample_edge scatterebbe a vuoto). La logica di study_family_honest e' replicata ESATTAMENTE
sui frame troncati coi primitivi altlib: selezione cella IN-SAMPLE-ONLY (mai sul hold-out) ->
study_marginal gates (ADDS + robust_oos + has_insample_edge + not is_hedge) -> deflated-Sharpe
>= 0.95 sull'INTERA griglia. Cross-check con study_marginal non-troncato riportato in coda.
CAVEAT STORIA: funding solo dal 2023-05 (~3.1 anni). In-sample pre-HOLDOUT ~1.6 anni (meno il
warmup z), hold-out 2025-01+ ~1.5 anni. Finestra corta: qualunque PASS andrebbe comunque in
forward-monitor, e un FAIL su questa finestra non e' appellabile a "regime sfortunato".
Run: cd /opt/docker/PythagorasGoal && uv run python scripts/research/r0701_funding_ts.py
"""
from __future__ import annotations
import json
import sys
from functools import lru_cache
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parent / "alt"))
import altlib as al # noqa: E402
ASSETS = ("BTC", "ETH")
FORMS = ("fade", "follow", "gate", "diverge")
LOOKBACKS = (7, 14, 30, 60)
THRESHOLDS = (0.5, 1.0, 1.5)
EARLY_8H_END = pd.Timestamp("2023-06-11", tz="UTC") # fino a qui cadenza 8h (3 stamp/giorno)
# ===========================================================================
# DATI FUNDING — aggregazione giornaliera causale
# ===========================================================================
@lru_cache(maxsize=16)
def daily_funding(asset: str, back_h: int = 0) -> pd.DataFrame:
"""Funding giornaliero = SOMMA degli stamp orari nella finestra [D-back_h, D+24h-back_h).
back_h=0 (default) = giorno UTC pieno [D, D+24h): tutti gli stamp (ultimo 23:00) sono noti
al close della barra open-labeled D (= 00:00 di D+1) -> causale. back_h>0 sposta la finestra
INDIETRO (sempre causale) — usato solo dal boundary-shift check.
'valid' = giorno con copertura piena (>=20 stamp orari; >=3 nell'era 8h iniziale)."""
p = al.DATA_DIR / f"hlfund_{asset.lower()}_1h.parquet"
d = pd.read_parquet(p)
idx = pd.DatetimeIndex(pd.to_datetime(d.index, utc=True)) + pd.Timedelta(hours=back_h)
day = idx.floor("1D")
g = pd.Series(d["funding"].values.astype(float), index=day).groupby(level=0)
out = pd.DataFrame({"fday": g.sum(), "n": g.count()})
early = out.index <= EARLY_8H_END
out["valid"] = np.where(early, out["n"] >= 3, out["n"] >= 20)
return out
@lru_cache(maxsize=1)
def fund_window() -> tuple:
"""Intersezione BTC/ETH dei giorni funding validi (a back_h=0)."""
los, his = [], []
for a in ASSETS:
v = daily_funding(a)
vd = v.index[v["valid"].values]
los.append(vd.min()); his.append(vd.max())
return max(los), min(his)
@lru_cache(maxsize=8)
def get_trunc(asset: str, tf: str = "1d") -> pd.DataFrame:
"""Prezzi certificati troncati alla copertura funding (vedi docstring modulo)."""
lo, hi = fund_window()
df = al.get(asset, tf)
day = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)).floor("1D")
m = (day >= lo) & (day <= hi)
return df.loc[m].reset_index(drop=True)
def aligned_fday(df: pd.DataFrame, asset: str, back_h: int = 0) -> np.ndarray:
"""Funding giornaliero allineato alle barre di df (NaN dove manca/incompleto)."""
fd = daily_funding(asset, back_h)
day = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)).floor("1D")
return fd["fday"].where(fd["valid"]).reindex(day).values.astype(float)
# ===========================================================================
# FAMIGLIA DI SEGNALI — factory(tf, form, lb, thr) -> target_fn(df, asset)
# ===========================================================================
def make_target(tf: str = "1d", form: str = "fade", lb: int = 30, thr: float = 1.0,
back_h: int = 0):
def target(df: pd.DataFrame, asset: str) -> np.ndarray:
f = aligned_fday(df, asset, back_h)
z = al.zscore(f, lb) # causale: rolling fino a i incluso
c = pd.Series(df["close"].values.astype(float))
if form == "fade": # affollamento long -> short (e viceversa)
d = np.where(z >= thr, -1.0, np.where(z <= -thr, 1.0, 0.0))
elif form == "follow": # funding come sentiment momentum
d = np.where(z >= thr, 1.0, np.where(z <= -thr, -1.0, 0.0))
elif form == "gate": # trend long-flat, flat se affollato
m30 = np.nan_to_num(np.sign(c.pct_change(30).values))
m90 = np.nan_to_num(np.sign(c.pct_change(90).values))
trend = ((m30 + m90) > 0).astype(float)
zz = np.where(np.isfinite(z), z, np.inf) # z ignoto -> conservativo: flat
d = trend * (zz < thr).astype(float)
elif form == "diverge": # mossa non affollata -> follow; affollata -> fade
mom = np.nan_to_num(np.sign(c.pct_change(20).values))
d = np.where(z >= thr, -mom, np.where(z <= -thr, mom, 0.0))
else:
raise ValueError(form)
return al.vol_target(np.nan_to_num(d), df, 0.20, 30, 2.0)
return target
# ===========================================================================
# VALUTAZIONE (replica study_family_honest su frame troncati)
# ===========================================================================
def cell_daily(target_fn, fee_side: float = al.FEE_SIDE) -> pd.Series:
"""Serie daily netta 50/50 BTC+ETH del candidato (convenzione candidate_daily)."""
series = {}
for a in ASSETS:
df = get_trunc(a)
ev = al.eval_weights(df, 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)
return al._to_daily(0.5 * J["BTC"] + 0.5 * J["ETH"])
def scan_family() -> list[dict]:
rows = []
for form in FORMS:
for lb in LOOKBACKS:
for thr in THRESHOLDS:
daily = cell_daily(make_target(form=form, lb=lb, thr=thr))
ins = daily[daily.index < al.HOLDOUT]
hold = daily[daily.index >= al.HOLDOUT]
rows.append(dict(
form=form, lb=lb, thr=thr,
insample_sharpe=round(al._sh(ins), 3) if len(ins) > 60 else float("nan"),
full_sharpe=round(al._sh(daily), 3),
hold_sharpe=round(al._sh(hold), 3) if len(hold) > 60 else float("nan")))
return rows
def absolute_study(target_fn, name: str) -> dict:
"""study_weights-equivalente sui frame troncati (fee sweep 0.00-0.30% RT incluso)."""
per_asset = {}
fee_ok_all = True
for a in ASSETS:
df = get_trunc(a)
tgt = target_fn(df, a)
base = al.eval_weights(df, tgt, fee_side=al.FEE_SIDE)
sweep = {f"{2 * f * 100:.2f}%RT": al.eval_weights(df, tgt, fee_side=f)["full"]["sharpe"]
for f in al.FEE_SWEEP}
fee_ok_all = fee_ok_all and sweep.get("0.20%RT", -9) > 0
per_asset[a] = dict(full=base["full"], holdout=base["holdout"],
tim=base["time_in_market"], turnover=base["turnover_per_year"],
fee_sweep=sweep, yearly=base["yearly"])
cells = [dict(tf="1d", per_asset=per_asset,
min_asset_full_sharpe=round(min(per_asset[a]["full"]["sharpe"] for a in ASSETS), 3),
min_asset_holdout_sharpe=round(min(per_asset[a]["holdout"].get("sharpe", 0.0) for a in ASSETS), 3),
full_sharpe=round(float(np.mean([per_asset[a]["full"]["sharpe"] for a in ASSETS])), 3),
fee_survives=fee_ok_all)]
return dict(name=name, kind="weights", cells=cells, verdict=al._verdict(cells))
def prefix_check(target_fn, tail: int = 60) -> float:
"""Consistenza online (guardia look-ahead): il target ricalcolato su un prefisso troncato
deve coincidere col target(full) sugli stessi indici. Ritorna il max scostamento."""
worst = 0.0
for a in ASSETS:
df = get_trunc(a)
full = np.nan_to_num(np.asarray(target_fn(df, a), float))
n = len(df)
for cut in (int(n * 0.80), int(n * 0.92)):
sub = df.iloc[:cut].reset_index(drop=True)
s = np.nan_to_num(np.asarray(target_fn(sub, a), float))
worst = max(worst, float(np.max(np.abs(s[cut - tail:cut] - full[cut - tail:cut]))))
return worst
def boundary_check(form: str, lb: int, thr: float, offsets=(0, 3, 6, 9, 12)) -> dict:
"""Lezione day_boundary: sposto INDIETRO di back_h ore la finestra di aggregazione del
funding (sempre causale). Un effetto di posizionamento reale non cambia segno."""
B = al.tp01_baseline_daily()
out = {}
for off in offsets:
daily = cell_daily(make_target(form=form, lb=lb, thr=thr, back_h=off))
J = pd.concat({"B": B, "C": daily}, axis=1, join="inner").dropna()
up = al._sh(0.75 * J["B"] + 0.25 * J["C"]) - al._sh(J["B"]) if len(J) > 30 else float("nan")
out[off] = dict(full_sharpe=round(al._sh(daily), 3), uplift_w25=round(up, 3))
ups = [v["uplift_w25"] for v in out.values() if np.isfinite(v["uplift_w25"])]
shs = [v["full_sharpe"] for v in out.values()]
return dict(per_offset=out,
sharpe_sign_stable=bool(min(shs) * max(shs) >= 0 or max(map(abs, shs)) < 0.1),
uplift_spread=round(max(ups) - min(ups), 3) if ups else None)
def trend_only_target(df: pd.DataFrame, asset: str = "") -> np.ndarray:
"""CONTROLLO DECISIVO (lezione TP01-DVOL-overlay): lo STESSO trend long-flat della forma
'gate' ma SENZA il gate funding. Se fa uguale/meglio, il funding non aggiunge nulla."""
c = pd.Series(df["close"].values.astype(float))
m30 = np.nan_to_num(np.sign(c.pct_change(30).values))
m90 = np.nan_to_num(np.sign(c.pct_change(90).values))
trend = ((m30 + m90) > 0).astype(float)
return al.vol_target(trend, df, 0.20, 30, 2.0)
def smallcap_check(target_fn) -> dict:
out = {}
for a in ASSETS:
df = get_trunc(a)
sc = al.eval_weights_smallcap(df, target_fn(df, a), capital=600.0, min_order=5.0)
out[a] = dict(modeled_sh=sc["modeled"]["sharpe"], realistic_sh=sc["realistic"]["sharpe"],
haircut=sc["sharpe_haircut"], n_trades=sc["n_executed_trades"])
return out
# ===========================================================================
def main():
print("=" * 88)
print("r0701_funding_ts — funding HL come segnale TS direzionale su BTC/ETH (non carry)")
print("=" * 88)
# --- 1. data-first: qualita'/copertura --------------------------------------------
lo, hi = fund_window()
print("\n[1] DATI FUNDING")
for a in ASSETS:
fd = daily_funding(a)
v = fd["valid"]
ann = fd.loc[v, "fday"].mean() * 365.25 * 100
print(f" {a}: giorni validi {int(v.sum())}/{len(fd)} "
f"finestra {fd.index[0].date()} -> {fd.index[-1].date()} "
f"funding medio {ann:+.1f}%/anno "
f"std daily {fd.loc[v, 'fday'].std() * 1e4:.2f} bps")
print(f" finestra comune valida: {lo.date()} -> {hi.date()} "
f"({(hi - lo).days} giorni, ~{(hi - lo).days / 365.25:.1f} anni)")
n_ins = (al.HOLDOUT - lo).days
n_hold = (hi - al.HOLDOUT).days
print(f" in-sample pre-HOLDOUT ~{n_ins}g ({n_ins / 365.25:.1f}a), "
f"hold-out ~{n_hold}g ({n_hold / 365.25:.1f}a) <-- STORIA CORTA, caveat")
# --- 2. scan famiglia (48 celle, selezione IN-SAMPLE-ONLY) -------------------------
print("\n[2] SCAN FAMIGLIA (4 forme x lb{7,14,30,60} x thr{0.5,1.0,1.5} = 48 celle, 1d)")
rows = scan_family()
valid = [r for r in rows if np.isfinite(r["insample_sharpe"])]
valid.sort(key=lambda r: r["insample_sharpe"], reverse=True)
print(f" celle valide {len(valid)}/{len(rows)}; top-8 per Sharpe IN-SAMPLE "
f"(hold-out mostrato SOLO per trasparenza, mai per selezione):")
print(f" {'form':8s} {'lb':>3s} {'thr':>4s} {'IS':>7s} {'FULL':>7s} {'HOLD':>7s}")
for r in valid[:8]:
print(f" {r['form']:8s} {r['lb']:3d} {r['thr']:4.1f} {r['insample_sharpe']:7.2f} "
f"{r['full_sharpe']:7.2f} {r['hold_sharpe']:7.2f}")
per_form = {f: max((r["insample_sharpe"] for r in valid if r["form"] == f), default=float("nan"))
for f in FORMS}
print(f" best IS per forma: {per_form}")
chosen = valid[0]
print(f"\n CELLA SCELTA (in-sample-only): {chosen['form']} lb={chosen['lb']} thr={chosen['thr']} "
f"(IS {chosen['insample_sharpe']}, FULL {chosen['full_sharpe']}, HOLD {chosen['hold_sharpe']})")
fn = make_target(form=chosen["form"], lb=chosen["lb"], thr=chosen["thr"])
daily = cell_daily(fn)
# --- 3. deflated Sharpe sull'INTERA griglia ----------------------------------------
all_full = [r["full_sharpe"] for r in rows]
dsr, sr0 = al.deflated_sharpe(al._sh(daily), all_full, daily)
dsr_pass = bool(np.isfinite(dsr) and dsr >= 0.95)
print(f"\n[3] DEFLATED SHARPE (griglia {len(rows)} celle): "
f"DSR={dsr:.3f} (null-max atteso {sr0:.2f}) PASS(>=0.95)={dsr_pass}")
# --- 4. assoluto + marginale (gates study_marginal) --------------------------------
print("\n[4] ASSOLUTO (frame troncati, fee sweep 0.00-0.30% RT)")
absolute = absolute_study(fn, f"R0701-FUND-{chosen['form'].upper()}")
print(al.fmt(absolute))
c0 = absolute["cells"][0]
for a in ASSETS:
pa = c0["per_asset"][a]
print(f" {a}: TIM={pa['tim']} turnover/anno={pa['turnover']} fee_sweep={pa['fee_sweep']}")
print("\n[5] MARGINALE vs TP01 (finestra comune col baseline)")
marg = al.marginal_vs_tp01(daily)
abs_grade = absolute["verdict"]["grade"]
earns_slot = (abs_grade != "FAIL" and marg.get("marginal_verdict") == "ADDS"
and marg.get("robust_oos", False) and marg.get("beats_noise_null", False)
and not marg.get("is_hedge", False))
rep = dict(name=f"R0701-FUND {chosen['form']} lb{chosen['lb']} thr{chosen['thr']}",
absolute=absolute, marginal=marg, abs_grade=abs_grade,
marginal_verdict=marg.get("marginal_verdict"), earns_slot=earns_slot)
print(al.fmt_marginal(rep))
earns_honest = bool(earns_slot and dsr_pass)
print(f"\n EARNS_SLOT (marginal) = {earns_slot} EARNS_SLOT_HONEST (con DSR) = {earns_honest}")
# --- 5bis. controllo di attribuzione: il funding aggiunge qualcosa al trend nudo? ----
print("\n[5bis] CONTROLLO DECISIVO — trend long-flat IDENTICO ma SENZA gate funding")
d_tr = cell_daily(trend_only_target)
tr_ins, tr_hold = d_tr[d_tr.index < al.HOLDOUT], d_tr[d_tr.index >= al.HOLDOUT]
JJ = pd.concat({"G": daily, "T": d_tr}, axis=1, join="inner").dropna()
print(f" trend NUDO: IS {al._sh(tr_ins):.2f} FULL {al._sh(d_tr):.2f} HOLD {al._sh(tr_hold):.2f}")
print(f" trend+GATE: IS {chosen['insample_sharpe']:.2f} FULL {chosen['full_sharpe']:.2f} "
f"HOLD {chosen['hold_sharpe']:.2f}")
print(f" corr(gated, nudo) = {JJ['G'].corr(JJ['T']):.3f} "
f"delta FULL = {al._sh(JJ['G']) - al._sh(JJ['T']):+.3f} "
f"delta HOLD = {al._sh(JJ['G'][JJ.index >= al.HOLDOUT]) - al._sh(JJ['T'][JJ.index >= al.HOLDOUT]):+.3f}")
attribution = dict(trend_nudo=dict(IS=round(al._sh(tr_ins), 3), FULL=round(al._sh(d_tr), 3),
HOLD=round(al._sh(tr_hold), 3)),
corr_gated_nudo=round(float(JJ["G"].corr(JJ["T"])), 3),
delta_full=round(al._sh(JJ["G"]) - al._sh(JJ["T"]), 3),
delta_hold=round(al._sh(JJ["G"][JJ.index >= al.HOLDOUT])
- al._sh(JJ["T"][JJ.index >= al.HOLDOUT]), 3))
# --- 5ter. la migliore cella PURO-funding (fade/follow/diverge, senza trend) ---------
pure = [r for r in valid if r["form"] != "gate"]
bp = pure[0] if pure else None
if bp:
print(f"\n[5ter] MIGLIOR CELLA PURO-FUNDING (no trend): {bp['form']} lb={bp['lb']} "
f"thr={bp['thr']} IS {bp['insample_sharpe']} FULL {bp['full_sharpe']} "
f"HOLD {bp['hold_sharpe']}")
d_bp = cell_daily(make_target(form=bp["form"], lb=bp["lb"], thr=bp["thr"]))
m_bp = al.marginal_vs_tp01(d_bp)
print(f" marginale vs TP01: {m_bp.get('marginal_verdict')} corr {m_bp.get('corr_full')} "
f"IS-edge {m_bp.get('cand_insample_sharpe')} "
f"uplift w25 full {m_bp['blends']['w25']['uplift_full']:+.3f} / "
f"hold {m_bp['blends']['w25']['uplift_hold']:+.3f}")
# --- 6. realism: prefix / boundary / smallcap ---------------------------------------
print("\n[6] REALISM CHECKS (cella scelta)")
worst = prefix_check(fn)
print(f" prefix-consistency (guardia look-ahead): max diff = {worst:.2e} "
f"({'OK' if worst < 1e-9 else 'ATTENZIONE'})")
bnd = boundary_check(chosen["form"], chosen["lb"], chosen["thr"])
print(f" boundary-shift (finestra funding -0/3/6/9/12h): {bnd['per_offset']}")
print(f" sharpe_sign_stable={bnd['sharpe_sign_stable']} uplift_spread={bnd['uplift_spread']}")
sc = smallcap_check(fn)
print(f" smallcap $600 (min order $5): {sc}")
# --- 7. cross-check non troncato (footnote) -----------------------------------------
print("\n[7] CROSS-CHECK study_marginal NON troncato (frame 2018+, diluito dagli zeri "
"pre-copertura: footnote, non il giudizio primario)")
sm_full = al.study_marginal(f"R0701-FUND-XCHK {chosen['form']}", fn, tf="1d")
print(f" abs={sm_full['abs_grade']} marginal={sm_full['marginal_verdict']} "
f"earns_slot={sm_full['earns_slot']}")
# --- 8. verdetto --------------------------------------------------------------------
summary = dict(
chosen=dict(form=chosen["form"], lb=chosen["lb"], thr=chosen["thr"]),
insample_sharpe=chosen["insample_sharpe"], full_sharpe=chosen["full_sharpe"],
hold_sharpe=chosen["hold_sharpe"], dsr=round(float(dsr), 3), dsr_pass=dsr_pass,
abs_grade=abs_grade, marginal_verdict=marg.get("marginal_verdict"),
corr_tp01_full=marg.get("corr_full"), cand_insample_sharpe=marg.get("cand_insample_sharpe"),
has_insample_edge=marg.get("has_insample_edge"), is_hedge=marg.get("is_hedge"),
robust_oos=marg.get("robust_oos"), multicut=marg.get("multicut_uplift"),
earns_slot=earns_slot, earns_slot_honest=earns_honest,
smallcap=sc, boundary_uplift_spread=bnd["uplift_spread"],
attribution_vs_trend_nudo=attribution,
best_pure_funding=(dict(form=bp["form"], lb=bp["lb"], thr=bp["thr"],
IS=bp["insample_sharpe"], FULL=bp["full_sharpe"],
HOLD=bp["hold_sharpe"]) if bp else None),
n_cells=len(rows), history_years=round((hi - lo).days / 365.25, 1))
print("\n[8] SUMMARY JSON")
print(json.dumps(summary, default=str))
return summary
if __name__ == "__main__":
main()
+472
View File
@@ -0,0 +1,472 @@
"""r0701_portfolio_opt — MIGLIORARE IL PORTAFOGLIO ATTIVO SENZA NUOVE STRATEGIE (2026-07-01).
(A) RI-OTTIMIZZAZIONE DEI PESI STATICI dei 4 sleeve (TP01/XS01/VRP01/SKH01).
I pesi correnti (41.25/18.75/15/25) sono ad-hoc. Test onesto:
- ottimizza SOLO in-sample (pre-cut), valuta OOS (post-cut), multi-cut {2024-01, 2024-07, 2025-01};
- criteri: MAXSH (max-Sharpe simulato), RP (inverse-vol), ERC (equal-risk-contribution),
MINVAR-R (min-varianza con vincolo di ritorno >= ritorno dei pesi correnti in-sample);
- null di riferimento: EW (25x4) e CURRENT (pesi correnti);
- vincoli: 0.05 <= w_i <= 0.60, sum(w)=1;
- sleeve con < MIN_IS_DAYS giorni in-sample al cut (XS01 al 2024-01) -> PINNED al peso corrente
(l'ottimizzatore non ha dati per giudicarli), gli altri ottimizzati nel budget residuo;
- il combine replica ESATTAMENTE src/portfolio/portfolio.combined_daily (outer-join,
pesi rinormalizzati per-riga sugli sleeve attivi).
Verdetto: MIGLIORA solo se l'uplift OOS vs CURRENT e' PERSISTENTE (positivo a ogni cut),
non su una finestra sola.
(B) GUARDIA-DRAWDOWN a livello portafoglio (replica del soft-guard vincente del tail-hedge lab
2026-06-23, portato sul 4-sleeve). CAUSALE: l'esposizione del giorno D usa l'equity fino a D-1.
Trigger/re-entry sul NAV OMBRA (equity non-guardata: lezione stops_lab — sull'equity congelata
a expo=0 il DD non rientra mai). Isteresi: de-risk quando DD>X, re-risk quando DD<0.4*X.
Griglia modesta X in {3,4,5,6}%, derisk in {0, 0.5}. Selezione cella IN-SAMPLE (pre-cut,
criterio Calmar = CAGR/MaxDD), verifica OOS multi-cut.
NULL DECISIVO (lezione tp01-dvol-overlay): esposizione COSTANTE c calibrata in-sample per
pareggiare il MaxDD della guardia — lo Sharpe di c*r e' INVARIANTE, quindi la guardia "vale"
solo se a pari MaxDD trattiene piu' CAGR/Sharpe del semplice de-levering.
(C) Combinazione A+B se entrambe reggono.
NB (contesto): il thread meta-allocazione (2026-06-29) ha gia' mostrato che l'allocazione DINAMICA
fra sleeve perde vs pesi fissi — qui NON la rifacciamo: (A) e' statico, (B) e' un overlay di
esposizione aggregata, non riallocazione fra sleeve.
Solo ricerca: NON tocca src/ ne' scripts/live/. Propone pesi, non li applica.
uv run python scripts/research/r0701_portfolio_opt.py
"""
from __future__ import annotations
import sys
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.optimize import minimize
from src.portfolio.sleeves import active_sleeves
from src.portfolio.portfolio import HOLDOUT, DAYS_PER_YEAR
CUTS = [pd.Timestamp(c, tz="UTC") for c in ("2024-01-01", "2024-07-01", "2025-01-01")]
LO_W, HI_W = 0.05, 0.60 # vincoli pesi
MIN_IS_DAYS = 120 # sotto -> sleeve PINNED al peso corrente (niente dati per giudicarlo)
GUARD_TRIGGERS = (0.03, 0.04, 0.05, 0.06)
GUARD_DERISK = (0.0, 0.5)
GUARD_REC_FRAC = 0.4 # re-risk quando DD < 0.4*trigger (isteresi, come tail-hedge lab)
# --------------------------------------------------------------------------- dati
def load_matrix() -> tuple[pd.DataFrame, list[str], np.ndarray]:
"""Matrice daily OUTER-join dei 4 sleeve (NaN dove lo sleeve non e' ancora partito)."""
sl = active_sleeves()
names = [s.name for s in sl]
w_cur = np.array([s.weight for s in sl], float)
w_cur = w_cur / w_cur.sum()
J = pd.concat({s.name: s.daily() for s in sl}, axis=1, join="outer").sort_index()
J = J[J.notna().any(axis=1)]
return J, names, w_cur
def combo(J: pd.DataFrame, w: np.ndarray) -> pd.Series:
"""Replica ESATTA di portfolio.combined_daily: pesi rinormalizzati per-riga sugli attivi."""
active = J.notna().values * w
rowsum = active.sum(axis=1, keepdims=True)
wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0)
return pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index)
def met(r: pd.Series | np.ndarray) -> dict:
v = np.asarray(pd.Series(r).dropna().values, float)
if len(v) < 20 or v.std() == 0:
return dict(sh=0.0, cagr=0.0, dd=0.0, calmar=0.0, n=len(v))
eq = np.cumprod(1 + v); pk = np.maximum.accumulate(eq)
yrs = len(v) / DAYS_PER_YEAR
cagr = float(eq[-1] ** (1 / yrs) - 1) if eq[-1] > 0 else -1.0
dd = float(np.max((pk - eq) / pk))
return dict(sh=float(v.mean() / v.std() * np.sqrt(DAYS_PER_YEAR)), cagr=cagr, dd=dd,
calmar=(cagr / dd if dd > 0 else 0.0), n=len(v))
def fmt_w(names, w):
return " ".join(f"{n.split('_')[0]} {x*100:.1f}%" for n, x in zip(names, w))
# --------------------------------------------------------------------------- (A) ottimizzatori
def _pinned_mask(J_is: pd.DataFrame, w_cur: np.ndarray) -> np.ndarray:
"""True = sleeve senza abbastanza storia in-sample -> peso fissato al corrente."""
return np.array([J_is[c].notna().sum() < MIN_IS_DAYS for c in J_is.columns])
def _solve(J_is, w_cur, objective, extra_cons=None, hi_bounds=None) -> np.ndarray:
"""SLSQP su pesi liberi (i pinned restano al corrente), bounds+somma, multi-start.
hi_bounds: array di upper-bound per-sleeve (default HI_W) — per i cap STRUTTURALI."""
k = len(w_cur)
hi = np.full(k, HI_W) if hi_bounds is None else np.asarray(hi_bounds, float)
pin = _pinned_mask(J_is, w_cur)
free = ~pin
budget = 1.0 - w_cur[pin].sum()
nf = int(free.sum())
def full_w(wf):
w = w_cur.copy(); w[free] = wf
return w
cons = [dict(type="eq", fun=lambda wf: wf.sum() - budget)]
if extra_cons:
cons += [dict(type="ineq", fun=lambda wf, f=f: f(full_w(wf))) for f in extra_cons]
bounds = [(LO_W, float(h)) for h in hi[free]]
hif = hi[free]
starts = [w_cur[free], np.full(nf, budget / nf)]
rng = np.random.default_rng(7)
for _ in range(4):
x = rng.uniform(LO_W, hif); starts.append(x / x.sum() * budget)
best, bval = w_cur.copy(), np.inf
for x0 in starts:
x0 = np.clip(x0, LO_W, hif); x0 = x0 / x0.sum() * budget
res = minimize(lambda wf: objective(full_w(wf)), x0, method="SLSQP",
bounds=bounds, constraints=cons,
options=dict(maxiter=300, ftol=1e-10))
if res.success and res.fun < bval:
bval, best = res.fun, full_w(res.x)
best = np.clip(best, LO_W, hi)
return best / best.sum()
def opt_maxsh(J_is, w_cur):
return _solve(J_is, w_cur, lambda w: -met(combo(J_is, w))["sh"])
def opt_minvar_ret(J_is, w_cur):
"""Min-varianza con vincolo: media in-sample >= media dei pesi correnti in-sample."""
mu_cur = float(combo(J_is, w_cur).mean())
return _solve(J_is, w_cur, lambda w: float(combo(J_is, w).var()),
extra_cons=[lambda w: float(combo(J_is, w).mean()) - mu_cur])
def opt_rp(J_is, w_cur):
"""Inverse-vol (risk-parity naive) sulla vol in-sample di ogni sleeve (giorni attivi propri)."""
pin = _pinned_mask(J_is, w_cur); free = ~pin
vol = np.array([J_is[c].dropna().std() for c in J_is.columns])
w = w_cur.copy()
iv = 1.0 / np.where(vol[free] > 0, vol[free], np.inf)
w[free] = iv / iv.sum() * (1.0 - w_cur[pin].sum())
for _ in range(50): # clip ai bounds + rinormalizza
w = np.clip(w, LO_W, HI_W)
if abs(w.sum() - 1.0) < 1e-9:
break
adj = ~np.isclose(w, LO_W) & ~np.isclose(w, HI_W)
if not adj.any():
break
w[adj] += (1.0 - w.sum()) * (w[adj] / w[adj].sum())
return w / w.sum()
def opt_erc(J_is, w_cur):
"""Equal-risk-contribution sulla cov in-sample pairwise-complete (submatrice dei non-pinned)."""
pin = _pinned_mask(J_is, w_cur)
cov = J_is.cov().values # pairwise-complete
idx = np.where(~pin)[0]
S = cov[np.ix_(idx, idx)]
S = np.nan_to_num(S, nan=0.0)
def obj(w):
wf = w[idx]
port = wf @ S @ wf
if port <= 0:
return 1e6
rc = wf * (S @ wf) / port
return float(np.sum((rc - 1.0 / len(idx)) ** 2))
return _solve(J_is, w_cur, obj)
def _struct_caps(J_is) -> np.ndarray:
"""Cap STRUTTURALI (prudenza, non statistica): VRP01 <= 15% (sleeve MODELLATO su DVOL ATM,
stress-f non catturato — 'niente short-vol da modello in deploy'), XS01 <= 25% (STAT-MODE,
storia ~2.5 anni). Gli ottimizzatori naive caricano VRP01 (vol 7.7% ma e' la vol del MODELLO)."""
return np.array([0.15 if c.startswith("VRP") else (0.25 if c.startswith("XS") else HI_W)
for c in J_is.columns])
def opt_maxsh_struct(J_is, w_cur):
return _solve(J_is, w_cur, lambda w: -met(combo(J_is, w))["sh"], hi_bounds=_struct_caps(J_is))
CRITERIA = [("MAXSH", opt_maxsh), ("RP-invvol", opt_rp), ("ERC", opt_erc), ("MINVAR-R", opt_minvar_ret),
("MAXSH-STR", opt_maxsh_struct)]
# --------------------------------------------------------------------------- (B) guardia-DD
def dd_guard(r: pd.Series, trigger: float, derisk: float, rec_frac: float = GUARD_REC_FRAC):
"""Soft-guard causale: expo[t] decisa dal DD del NAV OMBRA (non-guardato) fino a t-1.
DD>trigger -> expo=derisk; DD<rec_frac*trigger -> expo=1 (isteresi). Ritorna (serie, expo)."""
v = r.values
eq = np.cumprod(1 + v); pk = np.maximum.accumulate(eq)
n = len(v); expo = np.ones(n); on = True
for i in range(1, n):
ddi = (pk[i - 1] - eq[i - 1]) / pk[i - 1]
if ddi > trigger:
on = False
elif ddi < trigger * rec_frac:
on = True
expo[i] = 1.0 if on else derisk
return pd.Series(expo * v, index=r.index), expo
def const_expo_match_dd(r: pd.Series, target_dd: float) -> float:
"""Esposizione costante c (in-sample) che pareggia il MaxDD target — il null de-levering."""
lo, hi = 0.05, 1.0
if met(r)["dd"] <= target_dd:
return 1.0
for _ in range(40):
mid = 0.5 * (lo + hi)
if met(pd.Series(mid * r.values, index=r.index))["dd"] > target_dd:
hi = mid
else:
lo = mid
return lo
# --------------------------------------------------------------------------- report
def section_A(J, names, w_cur):
print("\n" + "=" * 108)
print(" (A) RI-OTTIMIZZAZIONE PESI STATICI — ottimizza IN-SAMPLE (pre-cut), valuta OOS (post-cut), multi-cut")
print("=" * 108)
# correlazioni e vol per contesto (full, pairwise)
C = J.corr()
print("\n corr pairwise (full):")
for i, a in enumerate(names):
print(" " + a.split("_")[0].ljust(6) + " ".join(f"{C.iloc[i, j]:+.2f}" for j in range(len(names))))
print(" vol annua per-sleeve (full): " +
" ".join(f"{n.split('_')[0]} {J[n].dropna().std()*np.sqrt(DAYS_PER_YEAR)*100:.1f}%" for n in names))
candidates = {} # nome -> pesi ottimizzati su pre-HOLDOUT (headline)
J_is_main = J[J.index < HOLDOUT]
for cname, fn in CRITERIA:
candidates[cname] = fn(J_is_main, w_cur)
candidates["EW"] = np.full(len(names), 1.0 / len(names))
caps = _struct_caps(J)
w_ewstr = np.full(len(names), 1.0 / len(names)) # EW sotto i cap strutturali (null senza fit)
for _ in range(20):
over = w_ewstr > caps + 1e-12
if not over.any():
break
excess = float((w_ewstr[over] - caps[over]).sum())
w_ewstr[over] = caps[over]
room = w_ewstr < caps - 1e-12 # redistribuisci SOLO a chi ha spazio
w_ewstr[room] += excess / max(int(room.sum()), 1)
candidates["EW-STR"] = w_ewstr
candidates["CURRENT"] = w_cur
print(f"\n PESI (ottimizzati su in-sample pre-{HOLDOUT.date()}):")
for cname, w in candidates.items():
print(f" {cname:<10s} {fmt_w(names, w)}")
# headline: FULL + HOLD-OUT
print(f"\n {'criterio':<10s} {'FULL Sh':>8s} {'FULL DD':>8s} {'FULL CAGR':>9s} | {'HOLD Sh':>8s} {'HOLD DD':>8s} {'HOLD CAGR':>9s}")
rows = {}
for cname, w in candidates.items():
f = met(combo(J, w)); h = met(combo(J[J.index >= HOLDOUT], w))
rows[cname] = (f, h)
print(f" {cname:<10s} {f['sh']:>8.2f} {f['dd']*100:>7.1f}% {f['cagr']*100:>+8.1f}% |"
f" {h['sh']:>8.2f} {h['dd']*100:>7.1f}% {h['cagr']*100:>+8.1f}%")
# multi-cut: ottimizza su <cut, valuta su >=cut, uplift vs CURRENT sulla stessa finestra OOS
print("\n MULTI-CUT (pesi ri-ottimizzati a ogni cut sul solo pre-cut; ΔSh OOS vs CURRENT):")
header = f" {'criterio':<10s}" + "".join(f" | {c.date()} Sh_oos ΔSh " for c in CUTS)
print(header)
persist = {}
fixed = {"EW": candidates["EW"], "EW-STR": candidates["EW-STR"]}
for cname, fn in CRITERIA + [("EW", None), ("EW-STR", None)]:
cells = []
ok = True
for cut in CUTS:
J_is, J_oos = J[J.index < cut], J[J.index >= cut]
w = fixed[cname] if cname in fixed else fn(J_is, w_cur)
sh_o = met(combo(J_oos, w))["sh"]
sh_c = met(combo(J_oos, w_cur))["sh"]
d = sh_o - sh_c
ok &= d > 0
cells.append(f" | {sh_o:>10.2f} {d:>+5.2f}")
persist[cname] = ok
print(f" {cname:<10s}" + "".join(cells) + (" <-- PERSISTENTE" if ok else ""))
print(" ⚠ caveat: le 3 finestre OOS sono ANNIDATE (tutte contengono il 2025-26) — non 3 conferme")
print(" indipendenti. Il test disgiunto vero e' il per-anno qui sotto.")
# per-anno (finestre DISGIUNTE): ritorno annuo del candidato vs CURRENT, pesi fissi pre-2025
print("\n PER-ANNO (pesi headline fissi; Δret vs CURRENT, finestre disgiunte):")
sel = ["MAXSH", "MINVAR-R", "MAXSH-STR", "EW", "EW-STR"]
years = sorted(set(J.index.year))
print(" " + "anno".ljust(6) + "".join(f"{c:>12s}" for c in ["CURRENT"] + sel))
wins = {c: 0 for c in sel}
for y in years:
Jy = J[J.index.year == y]
base_ret = float(np.prod(1 + combo(Jy, w_cur).values) - 1)
row = f" {y} {base_ret*100:>+10.1f}%"
for c in sel:
ry = float(np.prod(1 + combo(Jy, candidates[c]).values) - 1)
wins[c] += ry > base_ret
row += f"{(ry-base_ret)*100:>+11.1f}p"
print(row)
print(" anni vinti vs CURRENT: " + " ".join(f"{c} {wins[c]}/{len(years)}" for c in sel))
# verdetto: la persistenza statistica NON basta — i pesi devono rispettare i cap STRUTTURALI
# (VRP01<=15% perche' MODELLATO, XS01<=25% perche' STAT-MODE/storia corta), altrimenti l'uplift
# sta comprando rischio-modello che il backtest non vede (punto cieco tipo CC01).
winners = [c for c, ok in persist.items() if ok]
respects = {c: bool(np.all(candidates[c] <= caps + 1e-3)) for c in candidates} # tol 0.1pp (jitter clip/renorm)
struct_winners = [c for c in winners if respects[c]]
print("\n cap strutturali: " + fmt_w(names, caps))
if winners:
print(" persistenti: " + ", ".join(f"{c} ({'dentro i cap' if respects[c] else 'VIOLA i cap'})"
for c in winners))
if struct_winners:
wbest = candidates[struct_winners[0]]
fb, fc = met(combo(J, wbest)), met(combo(J, w_cur))
hb, hc = met(combo(J[J.index >= HOLDOUT], wbest)), met(combo(J[J.index >= HOLDOUT], w_cur))
verdict = (f"MIGLIORA (OOS) — {struct_winners} persistente E dentro i cap strutturali. "
f"Pesi candidati ({struct_winners[0]}): {fmt_w(names, wbest)}.\n"
f" TRADE-OFF onesto: HOLD Sh {hc['sh']:.2f}->{hb['sh']:.2f}, HOLD CAGR "
f"{hc['cagr']*100:+.1f}%->{hb['cagr']*100:+.1f}%, MA FULL Sh {fc['sh']:.2f}->{fb['sh']:.2f} "
f"e FULL DD {fc['dd']*100:.1f}%->{fb['dd']*100:.1f}% — l'uplift e' un TILT di ritorno verso "
f"gli sleeve research-grade (piu' SKH01/XS01, meno TP01), non un free lunch risk-adjusted. "
f"NB: nessun OTTIMIZZATORE aggiunge nulla oltre questo null parameter-free "
f"(MAXSH in-sample vince 1/8 anni: l'ottimizzazione qui overfitta, la de-concentrazione no).")
elif winners:
verdict = ("NON ADOTTABILE — l'uplift persistente esiste SOLO violando i cap strutturali "
"(tutti i vincitori caricano VRP01>15%, sleeve MODELLATO il cui rischio di coda "
"il backtest non cattura). Dentro i cap l'ottimizzatore converge ~ai pesi correnti "
"e non e' persistente -> per il book reale I PESI CORRENTI REGGONO.")
else:
verdict = "NON MIGLIORA — nessun criterio batte i pesi CORRENTI in modo persistente. I pesi correnti reggono."
print("\n VERDETTO (A): " + verdict)
return candidates, persist, struct_winners
def section_B(J, names, w_cur, label="CURRENT"):
print("\n" + "=" * 108)
print(f" (B) GUARDIA-DRAWDOWN a livello portafoglio (pesi {label}) — trigger X, de-risk d, isteresi 0.4X")
print("=" * 108)
r_full = combo(J, w_cur)
# griglia completa su FULL/IS/HOLD per trasparenza (selezione = solo in-sample)
r_is = r_full[r_full.index < HOLDOUT]
r_ho = r_full[r_full.index >= HOLDOUT]
b_is, b_ho, b_f = met(r_is), met(r_ho), met(r_full)
print(f"\n baseline IS Sh {b_is['sh']:.2f} DD {b_is['dd']*100:.1f}% CAGR {b_is['cagr']*100:+.1f}%"
f" | HOLD Sh {b_ho['sh']:.2f} DD {b_ho['dd']*100:.1f}% CAGR {b_ho['cagr']*100:+.1f}%"
f" | FULL Sh {b_f['sh']:.2f} DD {b_f['dd']*100:.1f}%")
print(f"\n {'cella':<16s} {'IS Sh':>6s} {'IS DD':>6s} {'IS CAGR':>8s} {'IS Calmar':>9s} {'%off':>5s} |"
f" {'HOLD Sh':>7s} {'HOLD DD':>7s} {'HOLD CAGR':>9s}")
grid = {}
for trig in GUARD_TRIGGERS:
for dr in GUARD_DERISK:
g_full, expo = dd_guard(r_full, trig, dr)
g_is = g_full[g_full.index < HOLDOUT]; g_ho = g_full[g_full.index >= HOLDOUT]
mi, mh = met(g_is), met(g_ho)
off = float((expo[: len(r_is)] < 1.0).mean())
grid[(trig, dr)] = dict(is_=mi, ho=mh, full=met(g_full))
print(f" X={trig*100:.0f}% d={dr:<4.1f} {mi['sh']:>6.2f} {mi['dd']*100:>5.1f}% {mi['cagr']*100:>+7.1f}%"
f" {mi['calmar']:>9.2f} {off*100:>4.0f}% | {mh['sh']:>7.2f} {mh['dd']*100:>6.1f}% {mh['cagr']*100:>+8.1f}%")
# selezione IN-SAMPLE (Calmar), poi null de-levering a pari MaxDD in-sample
best_cell = max(grid, key=lambda k: grid[k]["is_"]["calmar"])
trig, dr = best_cell
print(f"\n cella selezionata IN-SAMPLE (max Calmar): X={trig*100:.0f}% derisk={dr}")
tgt_dd = grid[best_cell]["is_"]["dd"]
c = const_expo_match_dd(r_is, tgt_dd)
cnull_full = pd.Series(c * r_full.values, index=r_full.index)
n_is, n_ho = met(cnull_full[cnull_full.index < HOLDOUT]), met(cnull_full[cnull_full.index >= HOLDOUT])
g = grid[best_cell]
print(f" NULL de-levering: expo costante c={c:.2f} pareggia il MaxDD IS della guardia ({tgt_dd*100:.1f}%)")
print(f" {'':<14s} {'IS Sh':>6s} {'IS CAGR':>8s} | {'HOLD Sh':>7s} {'HOLD DD':>7s} {'HOLD CAGR':>9s}")
print(f" guardia {g['is_']['sh']:>6.2f} {g['is_']['cagr']*100:>+7.1f}% | {g['ho']['sh']:>7.2f}"
f" {g['ho']['dd']*100:>6.1f}% {g['ho']['cagr']*100:>+8.1f}%")
print(f" const c={c:.2f} {n_is['sh']:>6.2f} {n_is['cagr']*100:>+7.1f}% | {n_ho['sh']:>7.2f}"
f" {n_ho['dd']*100:>6.1f}% {n_ho['cagr']*100:>+8.1f}%")
# multi-cut: selezione sul pre-cut, valutazione OOS post-cut (guardia vs baseline vs null)
inert = True
print("\n MULTI-CUT (cella riscelta a ogni cut sul solo pre-cut; OOS post-cut):")
print(f" {'cut':<12s} {'cella':<14s} {'OOS Sh(g)':>9s} {'OOS Sh(b)':>9s} {'ΔSh':>6s} {'OOS DD(g)':>9s}"
f" {'OOS DD(b)':>9s} {'ΔCAGR':>7s} {'null c: ΔSh':>11s}")
persist_sh, persist_dd = True, True
for cut in CUTS:
r_pre = r_full[r_full.index < cut]
cells = {}
for t in GUARD_TRIGGERS:
for d in GUARD_DERISK:
gpre, _ = dd_guard(r_pre, t, d)
cells[(t, d)] = met(gpre)["calmar"]
(t_, d_) = max(cells, key=cells.get)
g_full, _ = dd_guard(r_full, t_, d_) # guard sul path completo (stato causale)
g_oos = g_full[g_full.index >= cut]
b_oos = r_full[r_full.index >= cut]
gm, bm = met(g_oos), met(b_oos)
cc = const_expo_match_dd(r_pre, met(dd_guard(r_pre, t_, d_)[0])["dd"])
nm = met(pd.Series(cc * b_oos.values, index=b_oos.index))
dsh = gm["sh"] - bm["sh"]; ddd = gm["dd"] - bm["dd"]; dcagr = gm["cagr"] - bm["cagr"]
persist_sh &= dsh >= 0; persist_dd &= ddd < 0
inert &= abs(dsh) < 0.005 and abs(ddd) < 1e-4
print(f" {str(cut.date()):<12s} X={t_*100:.0f}% d={d_:<6.1f} {gm['sh']:>9.2f} {bm['sh']:>9.2f} {dsh:>+6.2f}"
f" {gm['dd']*100:>8.1f}% {bm['dd']*100:>8.1f}% {dcagr*100:>+6.1f}pp {gm['sh']-nm['sh']:>+11.2f}")
if inert:
verdict = ("NON MIGLIORA — INERTE OOS: la cella scelta in-sample non scatta MAI post-cut "
"(il DD OOS del 4-sleeve resta sotto il trigger: la diversificazione fa gia' il lavoro "
"della guardia). Trigger piu' stretti (X=3-4%) scattano ma COSTANO Sharpe su HOLD. "
"Coerente col thread meta-allocazione 2026-06-29 (drawdown-control ridondante).")
elif persist_sh and persist_dd:
verdict = f"MIGLIORA — la guardia X={trig*100:.0f}%/d={dr} taglia il DD OOS senza costo Sharpe persistente"
elif persist_dd:
verdict = ("TAGLIA IL DD MA COSTA — DD ridotto a ogni cut ma Sharpe/CAGR inferiori: "
"trade-off assicurativo, confrontare col null de-levering prima di adottarla")
else:
verdict = "NON MIGLIORA — la guardia non riduce il DD OOS in modo persistente (o e' solo de-levering)"
print("\n VERDETTO (B): " + verdict)
return grid, best_cell
def section_C(J, names, w_cur, candidates, struct_winners, grid, best_cell):
print("\n" + "=" * 108)
print(" (C) COMBINAZIONE A+B")
print("=" * 108)
wA = candidates[struct_winners[0]] if struct_winners else w_cur
labA = struct_winners[0] if struct_winners else "CURRENT (A non adottabile)"
trig, dr = best_cell
r = combo(J, wA)
g, _ = dd_guard(r, trig, dr)
for lbl, s in [("baseline CURRENT", combo(J, w_cur)),
(f"pesi {labA}", r),
(f"pesi {labA} + guard X={trig*100:.0f}%/d={dr}", g)]:
f = met(s); h = met(s[s.index >= HOLDOUT])
print(f" {lbl:<52s} FULL Sh {f['sh']:>5.2f} DD {f['dd']*100:>4.1f}% CAGR {f['cagr']*100:>+5.1f}%"
f" | HOLD Sh {h['sh']:>5.2f} DD {h['dd']*100:>4.1f}% CAGR {h['cagr']*100:>+5.1f}%")
if not struct_winners:
print(" -> (A) non adottabile e (B) inerte OOS: la combinazione non ha una cella da proporre.")
def main():
print("=" * 108)
print(" r0701_portfolio_opt — pesi statici + guardia-DD sul portafoglio attivo a 4 sleeve")
print(f" hold-out bloccato {HOLDOUT.date()}+ | vincoli pesi [{LO_W},{HI_W}] | cuts {[str(c.date()) for c in CUTS]}")
print("=" * 108)
J, names, w_cur = load_matrix()
print(f"\n sleeve: {names}")
print(f" pesi CORRENTI: {fmt_w(names, w_cur)}")
print(f" finestra: {J.index[0].date()} -> {J.index[-1].date()} ({len(J)} giorni)")
for n in names:
s = J[n].dropna()
print(f" {n:<16s} dal {s.index[0].date()} ({len(s)} giorni)")
b_f = met(combo(J, w_cur)); b_h = met(combo(J[J.index >= HOLDOUT], w_cur))
print(f"\n BASELINE (pesi correnti): FULL Sh {b_f['sh']:.2f} DD {b_f['dd']*100:.1f}% CAGR {b_f['cagr']*100:+.1f}%"
f" | HOLD Sh {b_h['sh']:.2f} DD {b_h['dd']*100:.1f}% CAGR {b_h['cagr']*100:+.1f}%")
candidates, persistA, struct_winners = section_A(J, names, w_cur)
grid, best_cell = section_B(J, names, w_cur)
section_C(J, names, w_cur, candidates, struct_winners, grid, best_cell)
if __name__ == "__main__":
main()
+304
View File
@@ -0,0 +1,304 @@
"""r0701_portfolio_skeptic — VERIFICA AVVERSARIALE del tilt EW-STR (TP30/XS25/VRP15/SKH30).
Oggetto: la proposta di r0701_portfolio_opt (diario 2026-07-01-portfolio-weights-ddguard.md) di
spostare i pesi del portafoglio a 4 sleeve da CURRENT (41.25/18.75/15/25) a EW-STR (30/25/15/30).
Claim: HOLD Sh 2.21->2.35, HOLD CAGR +16.0->+19.7%, multi-cut +0.06/+0.13/+0.14, 7/8 anni vinti.
Linee di attacco (agente scettico):
1. RIPRODUZIONE indipendente via src.portfolio.StrategyPortfolio (path di PRODUZIONE, non lo
script dell'agente).
2. SELEZIONE-SULL'HOLD-OUT DI 2° ORDINE via SKH01/XS01: SKH01 fu ammesso (2026-06-23) perche'
alzava l'hold-out 2025-26; XS01 fu affinato (blend+gate) conoscendo l'hold-out. Un tilt verso
di loro eredita quella selezione? Test: (a) per-anno SOLO 2019-2024; (b) tilt scomposti
(solo-XS con SKH pinned 25, solo-SKH con XS pinned); (c) finestre OOS DISGIUNTE (i 3 cut del
diario sono ANNIDATI); (d) contributo per-sleeve all'uplift hold-out.
3. "7/8 ANNI VINTI": quale metrica (ritorno, non Sharpe), margini per anno, Sharpe per-anno.
4. PLATEAU: griglia 2.5pp fra CURRENT e EW-STR (TP x SKH, VRP=15, XS=residuo cap 25).
5. REALISMO: pesi RINORMALIZZATI per era (outer-join: nel 2019-20 attivi solo TP+SKH!), haircut
d'esecuzione su SKH01/XS01 come DRAG (r' = r - h*mean(r), modello-costi, non de-levering).
6. FORKING PATHS: quante config viste sull'hold-out; null di tilt CASUALI cap-respecting — se
quasi ogni tilt anti-TP01 batte CURRENT sull'hold-out, il claim e' generico, non informativo.
Solo lettura/analisi: NON tocca src/ ne' scripts/live/.
uv run python scripts/research/r0701_portfolio_skeptic.py
"""
from __future__ import annotations
import sys
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 src.portfolio.sleeves import active_sleeves
from src.portfolio.portfolio import StrategyPortfolio, HOLDOUT, DAYS_PER_YEAR, metrics
CUTS = [pd.Timestamp(c, tz="UTC") for c in ("2024-01-01", "2024-07-01", "2025-01-01")]
LO_W = 0.05
# pesi per PREFISSO sleeve (ordine risolto sui nomi del registry)
W_CURRENT = {"TP01": 0.4125, "XS01": 0.1875, "VRP01": 0.15, "SKH01": 0.25}
W_EWSTR = {"TP01": 0.30, "XS01": 0.25, "VRP01": 0.15, "SKH01": 0.30}
W_ONLY_XS = {"TP01": 0.35, "XS01": 0.25, "VRP01": 0.15, "SKH01": 0.25} # SKH pinned al corrente
W_ONLY_SKH = {"TP01": 0.3625, "XS01": 0.1875, "VRP01": 0.15, "SKH01": 0.30} # XS pinned al corrente
CAPS = {"TP01": 0.60, "XS01": 0.25, "VRP01": 0.15, "SKH01": 0.60}
def prefix(name: str) -> str:
return name.split("_")[0]
def wvec(names: list[str], wd: dict) -> np.ndarray:
v = np.array([wd[prefix(n)] for n in names], float)
return v / v.sum()
def combo(J: pd.DataFrame, w: np.ndarray) -> pd.Series:
"""Combine outer-join con pesi rinormalizzati per-riga (stessa semantica di combined_daily;
verificata sotto contro il path di produzione)."""
act = J.notna().values * w
rs = act.sum(axis=1, keepdims=True)
wn = np.divide(act, rs, out=np.zeros_like(act), where=rs > 0)
return pd.Series(np.nansum(np.nan_to_num(J.values) * wn, axis=1), index=J.index)
def met(s) -> dict:
return metrics(pd.Series(s).dropna()) # metriche di PRODUZIONE (src.portfolio)
def sh(s) -> float:
return met(s)["sharpe"]
def yr_ret(s) -> float:
v = pd.Series(s).dropna().values
return float(np.prod(1 + v) - 1)
def main():
print("=" * 110)
print(" r0701_portfolio_skeptic — verifica avversariale EW-STR (30/25/15/30) vs CURRENT (41.25/18.75/15/25)")
print("=" * 110)
sl = active_sleeves()
names = [s.name for s in sl]
J = pd.concat({s.name: s.daily() for s in sl}, axis=1, join="outer").sort_index()
J = J[J.notna().any(axis=1)]
w_cur = wvec(names, W_CURRENT)
w_ew = wvec(names, W_EWSTR)
print(f"\n finestra {J.index[0].date()} -> {J.index[-1].date()} ({len(J)} giorni)")
for n in names:
s = J[n].dropna()
print(f" {n:<16s} dal {s.index[0].date()} ({len(s)} giorni)")
# ------------------------------------------------ 1) RIPRODUZIONE (path di produzione)
print("\n" + "-" * 110)
print(" [1] RIPRODUZIONE INDIPENDENTE — StrategyPortfolio.combined_daily (produzione), non lo script agente")
print("-" * 110)
for lbl, wd in [("CURRENT", W_CURRENT), ("EW-STR", W_EWSTR)]:
for s, sleeve_w in zip(sl, [wd[prefix(n)] for n in names]):
s.weight = sleeve_w
port = StrategyPortfolio(sl)
full = port.combined_daily()
hold = port.combined_daily(lo=HOLDOUT)
# check che il mio combine coincida col path di produzione
mine = combo(J, wvec(names, wd))
dmax = float(np.max(np.abs(mine.values - full.values)))
f, h = met(full), met(hold)
print(f" {lbl:<8s} FULL Sh {f['sharpe']:.2f} DD {f['maxdd']*100:.1f}% CAGR {f['cagr']*100:+.1f}%"
f" | HOLD Sh {h['sharpe']:.2f} DD {h['maxdd']*100:.1f}% CAGR {h['cagr']*100:+.1f}%"
f" (|combine mio - produzione|max = {dmax:.2e})")
print(" multi-cut ΔSh OOS EW-STR vs CURRENT (finestre ANNIDATE, come nel diario):")
for cut in CUTS:
Jo = J[J.index >= cut]
d = sh(combo(Jo, w_ew)) - sh(combo(Jo, w_cur))
print(f" cut {cut.date()} ΔSh {d:+.2f}")
# ------------------------------------------------ 2) SELEZIONE-SULL'HOLD-OUT DI 2° ORDINE
print("\n" + "-" * 110)
print(" [2] SELEZIONE HOLD-OUT DI 2° ORDINE — SKH01 ammesso PERCHE' alzava l'hold-out; XS01 affinato idem")
print("-" * 110)
# (d prima: contesto) Sharpe standalone per-sleeve, pre-2025 vs hold-out
print(" Sharpe standalone per sleeve (pre-2025 | hold-out 2025+):")
for n in names:
s = J[n].dropna()
print(f" {n:<16s} IS {sh(s[s.index < HOLDOUT]):>5.2f} | HOLD {sh(s[s.index >= HOLDOUT]):>5.2f}")
# (a) per-anno SOLO 2019-2024 (pre-selezione di SKH01/holdout)
print("\n (a) per-anno SOLO 2019-2024 (finestra NON toccata dalla selezione hold-out):")
print(f" {'anno':<6s} {'ret CUR':>9s} {'ret EW-STR':>10s} {'Δret':>8s} | {'Sh CUR':>7s} {'Sh EW-STR':>9s} {'ΔSh':>6s}")
wins_ret_pre = wins_sh_pre = 0
years_pre = [y for y in sorted(set(J.index.year)) if y < 2025]
for y in years_pre:
Jy = J[J.index.year == y]
rc, re = combo(Jy, w_cur), combo(Jy, w_ew)
dr = yr_ret(re) - yr_ret(rc); dsh = sh(re) - sh(rc)
wins_ret_pre += dr > 0; wins_sh_pre += dsh > 0
print(f" {y:<6d} {yr_ret(rc)*100:>+8.1f}% {yr_ret(re)*100:>+9.1f}% {dr*100:>+7.1f}p |"
f" {sh(rc):>7.2f} {sh(re):>9.2f} {dsh:>+6.2f}")
print(f" -> vittorie EW-STR 2019-2024: ritorno {wins_ret_pre}/{len(years_pre)}, Sharpe {wins_sh_pre}/{len(years_pre)}")
Jpre = J[J.index < HOLDOUT]
print(f" pre-2025 aggregato: Sh CUR {sh(combo(Jpre, w_cur)):.2f} vs EW-STR {sh(combo(Jpre, w_ew)):.2f}"
f" (ΔSh {sh(combo(Jpre, w_ew)) - sh(combo(Jpre, w_cur)):+.2f})")
# (b) tilt scomposti
print("\n (b) tilt SCOMPOSTI (chi porta l'uplift?): HOLD Sh + multi-cut ΔSh vs CURRENT")
for lbl, wd in [("solo-XS (SKH pinned 25): TP35 /XS25/VRP15/SKH25", W_ONLY_XS),
("solo-SKH (XS pinned 18.75): TP36.25/XS18.75/VRP15/SKH30", W_ONLY_SKH),
("EW-STR: TP30 /XS25/VRP15/SKH30", W_EWSTR)]:
w = wvec(names, wd)
hold_sh = sh(combo(J[J.index >= HOLDOUT], w))
cuts_d = [sh(combo(J[J.index >= c], w)) - sh(combo(J[J.index >= c], w_cur)) for c in CUTS]
print(f" {lbl:<58s} HOLD Sh {hold_sh:.2f} multi-cut " +
" ".join(f"{d:+.2f}" for d in cuts_d))
# (c) finestre OOS DISGIUNTE (i cut del diario sono annidati)
print("\n (c) finestre OOS DISGIUNTE (ΔSh e Δret EW-STR vs CURRENT):")
windows = [("2024-01 -> 2024-07", "2024-01-01", "2024-07-01"),
("2024-07 -> 2025-01", "2024-07-01", "2025-01-01"),
("2025-01 -> fine ", "2025-01-01", None)]
for lbl, lo, hi in windows:
m = (J.index >= pd.Timestamp(lo, tz="UTC"))
if hi:
m &= (J.index < pd.Timestamp(hi, tz="UTC"))
Jw = J[m]
rc, re = combo(Jw, w_cur), combo(Jw, w_ew)
print(f" {lbl} ΔSh {sh(re) - sh(rc):+.2f} Δret {(yr_ret(re) - yr_ret(rc))*100:+.1f}pp")
# (d) contributo per-sleeve all'uplift hold-out (pesi statici: sul hold-out tutti attivi)
print("\n (d) contributo per-sleeve al Δritorno annualizzato sull'hold-out (Δw_i x mean_i x 365):")
Jh = J[J.index >= HOLDOUT]
for n in names:
dw = W_EWSTR[prefix(n)] - W_CURRENT[prefix(n)]
mu = float(Jh[n].dropna().mean()) * DAYS_PER_YEAR
print(f" {n:<16s} Δw {dw*100:>+6.2f}pp x ret_ann {mu*100:>+6.1f}% = {dw*mu*100:>+5.2f}pp/anno")
# ------------------------------------------------ 3) "7/8 ANNI VINTI"
print("\n" + "-" * 110)
print(" [3] '7/8 ANNI VINTI' — metrica = RITORNO composto per anno (non Sharpe). Tabella completa + margini")
print("-" * 110)
print(f" {'anno':<6s} {'ret CUR':>9s} {'ret EW-STR':>10s} {'Δret':>8s} | {'Sh CUR':>7s} {'Sh EW-STR':>9s} {'ΔSh':>6s} |"
f" {'DD CUR':>7s} {'DD EW':>7s}")
wins_ret = wins_sh = 0
years = sorted(set(J.index.year))
for y in years:
Jy = J[J.index.year == y]
rc, re = combo(Jy, w_cur), combo(Jy, w_ew)
mc, me = met(rc), met(re)
dr = yr_ret(re) - yr_ret(rc); dsh = sh(re) - sh(rc)
wins_ret += dr > 0; wins_sh += dsh > 0
print(f" {y:<6d} {yr_ret(rc)*100:>+8.1f}% {yr_ret(re)*100:>+9.1f}% {dr*100:>+7.1f}p |"
f" {sh(rc):>7.2f} {sh(re):>9.2f} {dsh:>+6.2f} | {mc['maxdd']*100:>6.1f}% {me['maxdd']*100:>6.1f}%")
print(f" -> vittorie EW-STR: RITORNO {wins_ret}/{len(years)} | SHARPE {wins_sh}/{len(years)}"
f" (2026 = anno parziale)")
# ------------------------------------------------ 4) PLATEAU
print("\n" + "-" * 110)
print(" [4] PLATEAU — griglia 2.5pp: TP x SKH, VRP=15 fisso, XS = residuo (cap 25). HOLD Sh | min multi-cut ΔSh")
print("-" * 110)
tps = [0.30, 0.325, 0.35, 0.375, 0.40, 0.4125]
skhs = [0.25, 0.275, 0.30]
Jh = J[J.index >= HOLDOUT]
hold_cur = sh(combo(Jh, w_cur))
print(f" (HOLD Sh CURRENT = {hold_cur:.2f}; celle con XS>25% = viola cap, marcate *)")
hdr = " TP\\SKH " + "".join(f"{s*100:>18.1f}%" for s in skhs)
print(hdr)
for tp in tps:
row = f" {tp*100:>6.2f}%"
for skh in skhs:
xs = 1.0 - 0.15 - tp - skh
tag = "*" if xs > CAPS["XS01"] + 1e-9 else " "
if xs < LO_W - 1e-9:
row += f"{'':>19s}"
continue
w = np.array([{"TP01": tp, "XS01": xs, "VRP01": 0.15, "SKH01": skh}[prefix(n)] for n in names])
hs = sh(combo(Jh, w))
dmin = min(sh(combo(J[J.index >= c], w)) - sh(combo(J[J.index >= c], w_cur)) for c in CUTS)
row += f" {hs:>5.2f}|{dmin:+.2f}{tag}"
print(row)
# ------------------------------------------------ 5) REALISMO
print("\n" + "-" * 110)
print(" [5] REALISMO — pesi EFFETTIVI per era (outer-join rinormalizza!) + haircut d'esecuzione")
print("-" * 110)
# pesi rinormalizzati per era
eras = [("2019-2020 (TP+SKH)", "2019-06-01"), ("2021-2023 (TP+VRP+SKH)", "2022-06-01"),
("2024+ (tutti)", "2025-06-01")]
print(" pesi EFFETTIVI (rinormalizzati sugli sleeve attivi) per era:")
for lbl, probe in eras:
t = pd.Timestamp(probe, tz="UTC")
i = J.index.get_indexer([t], method="nearest")[0]
act = J.iloc[i].notna().values
for wl, w in [("CURRENT", w_cur), ("EW-STR ", w_ew)]:
wn = w * act; wn = wn / wn.sum()
s = " ".join(f"{prefix(n)} {x*100:.0f}%" for n, x in zip(names, wn) if act[list(names).index(n)])
print(f" {lbl:<24s} {wl}: {s}")
print(" -> nel 2019-23 EW-STR tiene SKH01 al 40-50% effettivo (research-grade, book 230m).")
# haircut d'esecuzione come DRAG (r' = r - h*mean_full(r)): modello-costi, non de-levering
print("\n haircut esecuzione (drag costante = h x mean ritorno full dello sleeve):")
print(f" {'scenario':<34s} {'CUR HOLD Sh':>11s} {'EW HOLD Sh':>11s} {'ΔSh':>6s} | {'CUR HOLD CAGR':>13s} {'EW HOLD CAGR':>13s}")
scen = [("nessun haircut", {}, 0.0),
("SKH01 -20%", {"SKH01"}, 0.20), ("SKH01 -30%", {"SKH01"}, 0.30),
("SKH01+XS01 -20%", {"SKH01", "XS01"}, 0.20), ("SKH01+XS01 -30%", {"SKH01", "XS01"}, 0.30)]
for lbl, targets, h in scen:
Jx = J.copy()
for n in names:
if prefix(n) in targets:
mu = float(J[n].dropna().mean())
Jx[n] = J[n] - h * mu # drag solo dove lo sleeve e' attivo (NaN restano NaN)
Jxh = Jx[Jx.index >= HOLDOUT]
rc, re = combo(Jxh, w_cur), combo(Jxh, w_ew)
mc, me = met(rc), met(re)
print(f" {lbl:<34s} {mc['sharpe']:>11.2f} {me['sharpe']:>11.2f} {me['sharpe']-mc['sharpe']:>+6.2f} |"
f" {mc['cagr']*100:>+12.1f}% {me['cagr']*100:>+12.1f}%")
# ------------------------------------------------ 6) FORKING PATHS
print("\n" + "-" * 110)
print(" [6] FORKING PATHS — config viste sull'hold-out + null di tilt casuali cap-respecting")
print("-" * 110)
n_configs = 7 + 8 # 7 vettori pesi (MAXSH,RP,ERC,MINVAR-R,MAXSH-STR,EW,EW-STR) + 8 celle guardia
print(f" config valutate sull'hold-out nello script agente: >= {n_configs} (7 pesi + 8 celle guardia),")
print(" EW-STR costruito DOPO aver visto EW vincere (ammesso nel diario).")
rng = np.random.default_rng(42)
caps = np.array([CAPS[prefix(n)] for n in names])
N = 500
dsh_hold, dsh_full, all_cuts_pos = [], [], 0
Jh = J[J.index >= HOLDOUT]
hold_c = sh(combo(Jh, w_cur)); full_c = sh(combo(J, w_cur))
cuts_J = {c: J[J.index >= c] for c in CUTS}
cuts_base = {c: sh(combo(cuts_J[c], w_cur)) for c in CUTS}
for _ in range(N):
w = rng.dirichlet(np.ones(len(names)))
for _ in range(60): # proiezione su [LO_W, caps], somma 1
w = np.clip(w, LO_W, caps)
d = 1.0 - w.sum()
if abs(d) < 1e-9:
break
if d > 0:
room = caps - w
w += d * room / max(room.sum(), 1e-12)
else:
room = w - LO_W
w += d * room / max(room.sum(), 1e-12)
dh = sh(combo(Jh, w)) - hold_c
dsh_hold.append(dh)
dsh_full.append(sh(combo(J, w)) - full_c)
all_cuts_pos += all(sh(combo(cuts_J[c], w)) - cuts_base[c] > 0 for c in CUTS)
dsh_hold = np.array(dsh_hold); dsh_full = np.array(dsh_full)
dew = sh(combo(Jh, w_ew)) - hold_c
print(f" {N} tilt casuali dentro i cap (VRP<=15, XS<=25, w>=5): quota che batte CURRENT")
print(f" su HOLD-OUT: {float((dsh_hold > 0).mean())*100:.0f}% (mediana ΔSh {np.median(dsh_hold):+.2f})")
print(f" su FULL: {float((dsh_full > 0).mean())*100:.0f}% (mediana ΔSh {np.median(dsh_full):+.2f})")
print(f" su TUTTI e 3 cut: {all_cuts_pos/N*100:.0f}%")
print(f" percentile di EW-STR (ΔSh HOLD {dew:+.2f}) fra i tilt casuali: {float((dsh_hold < dew).mean())*100:.0f}°")
print(" lettura: se quasi ogni tilt cap-respecting batte CURRENT sull'hold-out, il 'vince OOS'")
print(" e' una proprieta' del PERIODO (hold-out pro-SKH/XS per costruzione), non del vettore EW-STR.")
print("\n" + "=" * 110)
print(" fine verifica — vedi addendum nel diario 2026-07-01-portfolio-weights-ddguard.md")
print("=" * 110)
if __name__ == "__main__":
main()
+369
View File
@@ -0,0 +1,369 @@
"""r0701_vrp_refine — AFFINAMENTO VRP01 (gate/sizing) dentro i limiti del modello (2026-07-01).
Baseline = VRP01 combo (sleeves._vrp_combo_returns): put credit spread settimanale -0.28/-0.10,
f=1.0, tenor 7d, gate VRP>0 (DVOL>RV30 causale) AND IV-rank>0.30 AND crash-skip IV-rank>0.90,
fee 12.5% del premio. FULL Sh ~1.10 / HOLD ~0.60 / DD ~12%.
Celle NUOVE (mai provate — verificato nei diari; l'active management intra-trade e' gia'
SCARTATO in 2026-06-20-vrp-active-management.md e NON si ripete):
1. SIZING sul gap IV-RV (il carry atteso): size lineare clip(vrp/scale,0,1) o percentile
espandente causale del VRP, invece del (o in aggiunta al) gate binario IV-rank.
NB: il gate composito "IV-rank>0.30 AND IV-RV>0" e' GIA' il baseline (gate_vrp=True).
2. Filtro DVOL-MOMENTUM: non vendere vol mentre DVOL sta salendo (dv[i]-dv[i-k] > thr).
(Diverso da dvol_directional 2026-06-29: la' il DVOL-mom era segnale DIREZIONALE sul perp.)
3. Gate di REGIME da TP01: de-risk (skip o half-size) quando TP01 e' flat su BTC e ETH
(risk-off). Rischio ridondanza col trend -> riporto la frequenza d'intervento REALE.
4. Croce completa delle manopole (griglia contenuta, 105 celle, TUTTE contate nel DSR).
Metodo: stessa pipeline di options_vrp_v2 (pricing BS su DVOL reale, payoff sul path
certificato, stesse fee) — cambiano SOLO gate/sizing. Selezione cella IN-SAMPLE (pre-2025),
hold-out 2025-26, multi-cut (5 tagli), deflated-Sharpe su tutti i trial, effetto a livello
portafoglio 4-sleeve (TP01 41.25 / XS01 18.75 / VRP 15 / SKH01 25).
ONESTA': il premio resta MODELLATO su DVOL ATM (no skew), book 1d, f di stress non catturato.
Il verdetto massimo possibile e' "sleeve modellato migliorato", MAI deploy pieno.
uv run python scripts/research/r0701_vrp_refine.py [--skip-portfolio]
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt"))
from collections import Counter
from functools import lru_cache
import numpy as np
import pandas as pd
from scripts.research.options_vrp_lab import bs_put, strike_from_delta, load_series, m_weekly, per_year
from altlib import deflated_sharpe
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
WK_PER_YEAR = 365.25 / 7.0
CUTS = [pd.Timestamp(c, tz="UTC") for c in
("2023-01-01", "2023-07-01", "2024-01-01", "2024-07-01", "2025-01-01")]
MIN_IS_ACTIVE = 0.20 # attivita' minima in-sample per candidarsi (baseline ~41%)
# --- parametri FISSI del baseline VRP01 (NON toccati: cambia solo gate/sizing) ---
SHORT_DELTA, LONG_DELTA, F, TENOR_D = -0.28, -0.10, 1.0, 7
CRASH_SKIP, FEE_FRAC = 0.90, 0.125
# ----------------------------- pre-compute per asset (causale) -----------------------------
@lru_cache(maxsize=None)
def prep(asset: str):
"""px/dvol allineati + VRP causale (DVOL - RV30) e IV-rank espandente per OGNI giorno.
vrp[i] usa i 30 log-ret che finiscono a close[i]; ivr[i] = percentile di dv[i] in dv[:i]."""
J = load_series(asset)
px = J["px"].values.astype(float)
dv = J["dvol"].values.astype(float) / 100.0
idx = J.index
n = len(px)
lr = np.diff(np.log(px)) # lr[k] = log(px[k+1]/px[k])
vrp = np.full(n, np.nan)
for i in range(31, n):
vrp[i] = dv[i] - float(np.std(lr[i - 30:i]) * np.sqrt(365.25)) # come baseline (ddof=0)
ivr = np.full(n, np.nan)
for i in range(60, n):
ivr[i] = float((dv[:i] < dv[i]).mean())
return px, dv, idx, vrp, ivr
@lru_cache(maxsize=None)
def tp01_avg_target():
"""Serie giornaliera del target medio TP01 (BTC+ETH)/2. target[i] usa solo dati <= close[i]
-> noto alla sell-date del VRP (stessa close). Long-flat: 0.0 = risk-off pieno."""
from src.data.downloader import load_data
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_1d
tp = TrendPortfolio(**CANONICAL)
cols = {}
for a in ("BTC", "ETH"):
df = resample_1d(load_data(a, "1h"))
t = pd.Series(np.nan_to_num(tp.target_series(df), nan=0.0),
index=pd.to_datetime(df["datetime"]))
if t.index.tz is None:
t.index = t.index.tz_localize("UTC")
cols[a] = t
J = pd.concat(cols, axis=1, join="inner")
return J.mean(axis=1)
# ----------------------------- motore settimanale (unica differenza: gate/sizing) -----------------------------
def vrp_weekly(asset: str, sizing="bin", prop_scale=0.10, ivr_gate=0.30,
mom_k=0, mom_thr=0.0, tp_mode="off") -> tuple[pd.Series, Counter]:
"""Put credit spread settimanale come VRP01, con gate/sizing parametrici. CAUSALE:
strike/premio/gate/size usano solo dati <= sell-date; payoff a scadenza sul path certificato.
Ordine gate: prima i gate BASELINE (vrp/crash/ivr), poi i NUOVI (mom, tp) -> i counter dei
nuovi gate contano l'intervento MARGINALE (settimane altrimenti tradabili)."""
px, dv, idx, vrp_a, ivr_a = prep(asset)
n = len(px); T = TENOR_D / 365.25
tpv = None
if tp_mode != "off":
tpv = tp01_avg_target().reindex(idx, method="ffill").values
rets = {}; st = Counter()
i = 60
while i + TENOR_D < n:
st["weeks"] += 1
S0 = px[i]; sig = dv[i]; vrp = vrp_a[i]; ivr = ivr_a[i]
blocked = None
# --- gate BASELINE (identici a VRP01) ---
if np.isnan(vrp) or vrp <= 0:
blocked = "vrp"
elif not np.isnan(ivr) and ivr > CRASH_SKIP:
blocked = "crash"
elif ivr_gate > 0 and not np.isnan(ivr) and ivr < ivr_gate:
blocked = "ivr"
# --- gate NUOVI (contati sul residuo tradabile) ---
if blocked is None and mom_k > 0 and i >= mom_k:
if (dv[i] - dv[i - mom_k]) > mom_thr:
blocked = "mom"
size = 1.0
if blocked is None and tp_mode != "off" and tpv is not None and tpv[i] <= 1e-12:
if tp_mode == "skip":
blocked = "tp"
else: # half-size in risk-off
size *= 0.5; st["tp_half"] += 1
if blocked is None and sizing != "bin":
if sizing == "lin": # size ∝ gap IV-RV (carry atteso)
size *= float(np.clip(vrp / prop_scale, 0.0, 1.0))
elif sizing == "rank": # percentile espandente causale del VRP
hist = vrp_a[31:i]; hist = hist[~np.isnan(hist)]
size *= float((hist < vrp).mean()) if len(hist) >= 30 else 0.5
if blocked is not None:
st[f"blk_{blocked}"] += 1
rets[idx[i + TENOR_D]] = 0.0
i += TENOR_D
continue
st["traded"] += 1; st["size_sum"] += size
Ks = strike_from_delta(S0, T, sig, SHORT_DELTA)
Kl = strike_from_delta(S0, T, sig, LONG_DELTA)
net_prem = (bs_put(S0, Ks, T, sig) - bs_put(S0, Kl, T, sig)) * F
S1 = px[i + TENOR_D]
payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1)
pnl = net_prem - payoff - FEE_FRAC * abs(net_prem)
rets[idx[i + TENOR_D]] = size * pnl / Ks # cash-secured su strike corto
i += TENOR_D
return pd.Series(rets), st
def book(**kw) -> tuple[pd.Series, Counter]:
rB, sB = vrp_weekly("BTC", **kw)
rE, sE = vrp_weekly("ETH", **kw)
b = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1).sort_index()
return b, sB + sE
# ----------------------------- metriche -----------------------------
def sh_wk(r: pd.Series) -> float:
r = r.dropna()
if len(r) < 8 or r.std() == 0:
return float("nan")
return float(r.mean() / r.std() * np.sqrt(WK_PER_YEAR))
def cell_metrics(b: pd.Series) -> dict:
is_ = b[b.index < HOLDOUT]; ho = b[b.index >= HOLDOUT]
full = m_weekly(b)
return dict(full_sh=full["sh"], full_dd=full["dd"], full_cagr=full["cagr"],
is_sh=sh_wk(is_), hold_sh=sh_wk(ho), worst=float(b.min()),
active=float((b != 0).mean()), is_active=float((is_ != 0).mean()))
def multicut(cand: pd.Series, base: pd.Series) -> list[tuple[str, float, float, float]]:
out = []
for c in CUTS:
sc, sb = sh_wk(cand[cand.index >= c]), sh_wk(base[base.index >= c])
out.append((str(c.date()), sc, sb, sc - sb))
return out
# ----------------------------- griglia -----------------------------
def grid_cells():
sizings = [("bin", 0.0, 0.30), ("lin", 0.08, 0.30), ("lin", 0.08, 0.0),
("lin", 0.12, 0.30), ("lin", 0.12, 0.0), ("rank", 0.0, 0.30), ("rank", 0.0, 0.0)]
moms = [(0, 0.0), (5, 0.0), (5, 0.05), (10, 0.0), (10, 0.05)]
tps = ["off", "skip", "half"]
cells = []
for sz, scale, ivr in sizings:
for mk, mth in moms:
for tp in tps:
name = (f"{sz}{f'{scale:g}' if sz == 'lin' else ''}"
f"|ivr{ivr:g}|mom{mk}k{mth:g}|tp-{tp}")
cells.append(dict(name=name, sizing=sz, prop_scale=scale, ivr_gate=ivr,
mom_k=mk, mom_thr=mth, tp_mode=tp))
return cells
BASELINE_NAME = "bin|ivr0.3|mom0k0|tp-off"
# ----------------------------- portafoglio 4-sleeve -----------------------------
def weekly_to_daily_lump(wk: pd.Series) -> pd.Series:
"""Come sleeves._vrp_combo_returns: rendimento settimanale sul giorno di scadenza, 0 altrove."""
days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(), freq="1D", tz="UTC")
daily = pd.Series(0.0, index=days)
daily.loc[wk.index.normalize()] = wk.values
return daily
def portfolio_compare(base_wk: pd.Series, cand_wk: pd.Series, cand_name: str):
"""4-sleeve con VRP baseline vs VRP variante (stessi TP01/XS01/SKH01, cache condivisa)."""
from src.portfolio.sleeves import tp01_sleeve, xsec_sleeve, skyhook_sleeve
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, metrics
tp, xs, sk = tp01_sleeve(weight=0.4125), xsec_sleeve(weight=0.1875), skyhook_sleeve(weight=0.25)
rows = []
for tag, wk in (("VRP01 baseline", base_wk), (f"VRP variante [{cand_name}]", cand_wk)):
daily = weekly_to_daily_lump(wk)
vrp = Sleeve("VRP01_shortvol", 0.15, lambda d=daily: d)
port = StrategyPortfolio([tp, xs, vrp, sk])
full = metrics(port.combined_daily())
hold = metrics(port.combined_daily(lo=HOLDOUT))
rows.append((tag, full, hold))
print(f" {tag:<38} FULL Sh {full['sharpe']:>5.2f} DD {full['maxdd']*100:>4.1f}% "
f"CAGR {full['cagr']*100:>+5.1f}% | HOLD Sh {hold['sharpe']:>5.2f} DD {hold['maxdd']*100:>4.1f}%")
return rows
# ----------------------------- main -----------------------------
def main():
skip_port = "--skip-portfolio" in sys.argv
print("=" * 110)
print(" r0701 VRP REFINE — sizing IV-RV / filtro DVOL-momentum / gate TP01 (griglia onesta, sel. in-sample)")
print("=" * 110)
cells = grid_cells()
print(f" griglia: {len(cells)} celle (TUTTE contate nel deflated-Sharpe). "
f"IS = pre-2025, HOLD = 2025-01-01+.\n")
results = {}
for c in cells:
b, st = book(sizing=c["sizing"], prop_scale=c["prop_scale"], ivr_gate=c["ivr_gate"],
mom_k=c["mom_k"], mom_thr=c["mom_thr"], tp_mode=c["tp_mode"])
results[c["name"]] = dict(cfg=c, b=b, st=st, **cell_metrics(b))
base = results[BASELINE_NAME]
print(f" (0) BASELINE riprodotto [{BASELINE_NAME}]:")
print(f" FULL Sh {base['full_sh']:.2f} DD {base['full_dd']*100:.0f}% CAGR {base['full_cagr']*100:+.0f}% "
f"worst {base['worst']*100:+.1f}% IS Sh {base['is_sh']:.2f} HOLD Sh {base['hold_sh']:.2f} "
f"attivo {base['active']*100:.0f}% (atteso ~ FULL 1.10 / HOLD 0.60 / DD 12%)")
# ---- frequenza d'intervento dei gate NUOVI (sul baseline + singola manopola) ----
print("\n (1) FREQUENZA D'INTERVENTO dei gate nuovi (settimane altrimenti tradabili, book BTC+ETH):")
probes = [("mom k=5 thr=0", dict(mom_k=5, mom_thr=0.0)),
("mom k=5 thr=5pt", dict(mom_k=5, mom_thr=0.05)),
("mom k=10 thr=0", dict(mom_k=10, mom_thr=0.0)),
("mom k=10 thr=5pt", dict(mom_k=10, mom_thr=0.05)),
("tp01-skip", dict(tp_mode="skip")),
("tp01-half", dict(tp_mode="half"))]
base_traded = base["st"]["traded"]
for label, kw in probes:
_, st = book(**kw)
blk = st.get("blk_mom", 0) + st.get("blk_tp", 0)
half = st.get("tp_half", 0)
extra = f" (+{half} sett. a mezza size)" if half else ""
print(f" {label:<18} blocca {blk:>3} / {base_traded} settimane-trade del baseline "
f"({100*blk/max(base_traded,1):>4.1f}%){extra}")
tgt = tp01_avg_target()
pxB, _, idxB, _, _ = prep("BTC")
tp_on_grid = tgt.reindex(idxB, method="ffill")
print(f" [contesto] TP01 flat (BTC+ETH entrambi 0): {100*float((tp_on_grid <= 1e-12).mean()):.0f}% dei giorni della finestra DVOL")
# ---- classifica IN-SAMPLE (selezione onesta: nessuno sguardo all'hold-out) ----
ranked = sorted((r for r in results.values() if r["is_active"] >= MIN_IS_ACTIVE),
key=lambda r: r["is_sh"], reverse=True)
print(f"\n (2) TOP-10 per Sharpe IN-SAMPLE (pre-2025; filtro attivita' IS >= {MIN_IS_ACTIVE:.0%}):")
print(f" {'cella':<34}{'IS Sh':>7}{'FULL':>7}{'HOLD':>7}{'DD':>6}{'worst':>8}{'att.':>6}")
for r in ranked[:10]:
print(f" {r['cfg']['name']:<34}{r['is_sh']:>7.2f}{r['full_sh']:>7.2f}{r['hold_sh']:>7.2f}"
f"{r['full_dd']*100:>5.0f}%{r['worst']*100:>+7.1f}%{r['active']*100:>5.0f}%")
# ---- varianti a SINGOLA manopola vs baseline (tabella diario) ----
print("\n (2b) VARIANTI A SINGOLA MANOPOLA vs baseline (stessa tabella, nessuna selezione):")
singles = ["lin0.08|ivr0.3|mom0k0|tp-off", "lin0.12|ivr0.3|mom0k0|tp-off",
"rank|ivr0.3|mom0k0|tp-off", "lin0.08|ivr0|mom0k0|tp-off",
"rank|ivr0|mom0k0|tp-off",
"bin|ivr0.3|mom5k0.05|tp-off", "bin|ivr0.3|mom10k0.05|tp-off",
"bin|ivr0.3|mom5k0|tp-off", "bin|ivr0.3|mom10k0|tp-off",
"bin|ivr0.3|mom0k0|tp-skip", "bin|ivr0.3|mom0k0|tp-half"]
print(f" {'cella':<34}{'IS Sh':>7}{'FULL':>7}{'HOLD':>7}{'DD':>6}{'worst':>8}{'att.':>6}")
r = base
print(f" {'BASELINE ' + BASELINE_NAME:<34}{r['is_sh']:>7.2f}{r['full_sh']:>7.2f}{r['hold_sh']:>7.2f}"
f"{r['full_dd']*100:>5.0f}%{r['worst']*100:>+7.1f}%{r['active']*100:>5.0f}%")
for nm in singles:
r = results[nm]
print(f" {nm:<34}{r['is_sh']:>7.2f}{r['full_sh']:>7.2f}{r['hold_sh']:>7.2f}"
f"{r['full_dd']*100:>5.0f}%{r['worst']*100:>+7.1f}%{r['active']*100:>5.0f}%")
n_beat_hold = sum(1 for r in results.values() if r["hold_sh"] > base["hold_sh"])
print(f" [onesta'] celle che battono l'HOLD-OUT del baseline: {n_beat_hold}/{len(results)}"
f"NON selezionabili (sarebbe selection-on-holdout, gate 2026-06-29).")
cand = ranked[0]
is_baseline_best = cand["cfg"]["name"] == BASELINE_NAME
print(f"\n -> cella scelta IN-SAMPLE: [{cand['cfg']['name']}] IS Sh {cand['is_sh']:.2f} "
f"(baseline IS {base['is_sh']:.2f}, Δ {cand['is_sh']-base['is_sh']:+.2f})")
# ---- hold-out multi-cut vs baseline ----
print("\n (3) MULTI-CUT hold-out (Sharpe da ogni taglio a fine storia; uplift = cand - baseline):")
mc = multicut(cand["b"], base["b"])
pos = sum(1 for _, _, _, u in mc if u > 0)
for cut, sc, sb, u in mc:
print(f" cut {cut}: cand {sc:>5.2f} base {sb:>5.2f} uplift {u:>+5.2f}")
print(f" uplift positivo in {pos}/{len(mc)} tagli (richiesti >= 4/5)")
# ---- deflated Sharpe (tutti i trial della griglia) ----
all_sh = [r["full_sh"] for r in results.values()]
dsr_c, null_max = deflated_sharpe(cand["full_sh"], all_sh, cand["b"].values, dpy=WK_PER_YEAR)
dsr_b, _ = deflated_sharpe(base["full_sh"], all_sh, base["b"].values, dpy=WK_PER_YEAR)
print(f"\n (4) DEFLATED SHARPE (N={len(all_sh)} trial di questa griglia; PASS >= 0.95):")
print(f" cand DSR {dsr_c:.3f} (null-max Sh {null_max:.2f}) | baseline DSR {dsr_b:.3f}")
print(" NB: le celle della griglia sono fortemente correlate fra loro (stesso trade sottostante)")
print(" -> il DSR qui e' anti-conservativo sul multiple-testing; in piu' VRP01 stesso viene da")
print(" ~20 config precedenti (options_vrp_lab/_v2). Leggere il DSR come limite SUPERIORE.")
# ---- per-anno cand vs base ----
print("\n (5) PER-ANNO (ritorno composto):")
pyc, pyb = per_year(cand["b"]), per_year(base["b"])
print(" base: " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in pyb.items()))
print(" cand: " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in pyc.items()))
# ---- portafoglio 4-sleeve ----
if not skip_port:
print("\n (6) PORTAFOGLIO 4-SLEEVE (TP01 41.25 / XS01 18.75 / VRP 15 / SKH01 25), VRP base vs variante:")
try:
portfolio_compare(base["b"], cand["b"], cand["cfg"]["name"])
except Exception as e: # dati HL/5m mancanti in qualche ambiente
print(f" [saltato: {type(e).__name__}: {e}]")
else:
print("\n (6) portafoglio: saltato (--skip-portfolio)")
# ---- verdetto ----
print("\n" + "=" * 110)
improves = (not is_baseline_best
and cand["is_sh"] > base["is_sh"]
and pos >= 4
and (cand["hold_sh"] > base["hold_sh"])
and dsr_c >= 0.95)
if is_baseline_best:
print(" VERDETTO: NON MIGLIORA — il baseline VRP01 vince gia' la selezione in-sample.")
elif improves:
print(f" VERDETTO: MIGLIORA (variante {cand['cfg']['name']}) — batte il baseline in-sample,")
print(f" su hold-out multi-cut ({pos}/{len(mc)}) e DSR {dsr_c:.2f}>=0.95. Resta SLEEVE MODELLATO")
print(" (premio DVOL ATM, book 1d, f di stress non catturato): NON deploy pieno.")
else:
why = []
if cand["is_sh"] <= base["is_sh"]:
why.append("non batte il baseline in-sample")
if pos < 4:
why.append(f"multi-cut {pos}/{len(mc)} (<4)")
if cand["hold_sh"] <= base["hold_sh"]:
why.append("hold-out non migliore")
if dsr_c < 0.95:
why.append(f"DSR {dsr_c:.2f}<0.95")
print(f" VERDETTO: NON MIGLIORA — cella IS-best [{cand['cfg']['name']}] bocciata: " + "; ".join(why) + ".")
print("=" * 110)
if __name__ == "__main__":
main()
+439
View File
@@ -0,0 +1,439 @@
"""r0701_xs — RESIDUAL (IDIOSYNCRATIC) MOMENTUM cross-sectional sui 19 major Hyperliquid.
TESI (2026-07-01). STATARB-RESID (thread 4, 2026-06-29) ha mostrato che il MOMENTUM del residuo
ETH−β·BTC (β OLS rolling, sgn=+1: le dislocazioni CONTINUANO a 1d) passa quasi tutti i gate su
2 gambe, fallendo SOLO il deflated-Sharpe (0.929<0.95). Angolo nuovo: lo stesso meccanismo
CROSS-SECTIONAL sui 19 major di XS01 — per ogni alt, residuo vs β·BTC (β OLS rolling B giorni),
momentum del residuo su lookback (blend z-score [30,90] come XS01, o singolo), rank cross-section,
long top-k / short bottom-k, vol-target 20%. Ipotesi: la breadth (18 stream invece di 1) alza il
DSR dove il 2-gambe falliva.
DISTINZIONE da quanto gia' testato:
* IREV (xsec_v2_nonmom, idio-REVERSAL, sgn=-1): FALLITO. Qui sgn=+1 (idio-MOMENTUM).
* IMOM (xsec_v2_nonmom): residuo vs mercato EQUAL-WEIGHT, B=60 fisso, no blend, era solo
"riferimento momentum". Qui: fattore = BTC (come STATARB-RESID), B in griglia, blend z-score
[30,90] + probe con gate di dispersione (parita' strutturale con XS01), selezione IN-SAMPLE.
IL BAR (fondamentale): una variante di XS01 e' utile SOLO se (a) SOSTITUISCE XS01 (meglio
standalone E nel portafoglio 4-sleeve) oppure (b) AGGIUNGE come 5o sleeve (corr bassa a XS01 E
TP01, uplift del PORTAFOGLIO). corr>0.6 a XS01 senza batterlo -> REDUNDANT/SCARTATO.
Baseline XS01: standalone FULL Sh ~1.50 / HOLD ~1.71 / DD ~11%.
GATE (CLAUDE.md, metodologia obbligatoria):
1. CAUSALE: score a close[i], peso tenuto in i+1 (engine shifta W[i-1]*dret[i]); barre vol=0
escluse; prefix-check di causalita' sulla cella scelta.
2. NETTO fee 0.10% RT per gamba per ribilancio + sweep {0.05, 0.10, 0.20, 0.30}% RT/gamba.
3. Selezione cella IN-SAMPLE-ONLY (pre-2025, anti selection-on-holdout), poi hold-out bloccato.
4. DEFLATED Sharpe su TUTTI i trial della griglia (serve >=0.95).
5. Confronto PORTAFOGLIO: sostituzione di XS01 a parita' di peso + aggiunta 5o sleeve @10/15%
(riusa StrategyPortfolio/active_sleeves senza modificarli) + marginal vs il BOOK a 4 sleeve.
6. CAVEAT IMMUTABILI: storia HL ~2.5 anni; book L/S a ~2k gambe -> STAT-MODE a $600 (dichiaro
comunque l'haircut small-cap $600/min$5).
uv run python scripts/research/r0701_xs_residmom.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
sys.path.insert(0, str(PROJECT_ROOT))
import numpy as np
import pandas as pd
import xsec_v2_nonmom as xv # harness collaudato (load_matrix, deflated_sharpe, portfolio, ...)
from src.portfolio.sleeves import XS_UNIVERSE
DPY, TV, FEE, HOLDOUT = xv.DPY, xv.TV, xv.FEE, xv.HOLDOUT
FACTOR = "BTC" # il fattore del residuo (come STATARB-RESID), NON tradato
BLEND = (30, 90) # blend z-score come XS01
# griglia (modesta, da mandato): beta-window x lookback x k x H (gate=off)
BETAS = (60, 90, 120)
LOOKS = ("blend", 30, 90)
KS = (3, 5)
HS = (5, 10, 20)
# probe a parita' STRUTTURALE con XS01 (blend + gate dispersione p30, H10 k5) — contano nei trial
GATED_PROBES = [dict(B=B, L="blend", k=5, H=10, gate=30) for B in BETAS]
# ===========================================================================
# SCORE BUILDER — residual momentum vs beta*BTC. CAUSALE (dati <= i).
# Ritorna score_at(i) -> (score_blend_z[A], valid[A], disp_raw_i) + warmup.
# disp_raw_i = dispersione cross-section del momentum RESIDUO grezzo (per il gate: lo z-score
# blended ha std ~1 per costruzione, quindi la dispersione va misurata sul grezzo).
# ===========================================================================
def make_residmom(PX: pd.DataFrame, B: int, L):
lookbacks = BLEND if L == "blend" else (int(L),)
px = PX.values
n, A = px.shape
fi = list(PX.columns).index(FACTOR)
DR = PX.pct_change()
m = DR[FACTOR]
beta, _ = xv._rolling_beta(DR, m, B) # beta_j vs BTC su finestra B (<= i)
SDR = {Lk: DR.rolling(Lk, min_periods=int(0.8 * Lk)).sum().values for Lk in lookbacks}
SM = {Lk: m.rolling(Lk, min_periods=int(0.8 * Lk)).sum().values for Lk in lookbacks}
CNT = {Lk: DR.rolling(Lk, min_periods=1).count().values for Lk in lookbacks}
def score_at(i):
b = beta[i]
valid = np.isfinite(px[i]) & np.isfinite(b)
valid[fi] = False # BTC = fattore, fuori dal cross-section
resids = []
for Lk in lookbacks:
resid = SDR[Lk][i] - b * SM[Lk][i] # momentum del residuo r_j - beta_j*r_btc
valid = valid & np.isfinite(resid) & (CNT[Lk][i] >= 0.8 * Lk)
resids.append(resid)
score = np.full(A, np.nan)
disp = np.nan
nv = int(valid.sum())
if nv >= 2:
acc = np.zeros(nv)
cnt = 0
stds = []
for resid in resids:
r = resid[valid]
sd = float(r.std())
stds.append(sd)
if sd > 0:
acc += (r - r.mean()) / sd
cnt += 1
if cnt:
score[valid] = acc / cnt # blend: media z-score cross-sectional
disp = float(np.mean(stds)) # dispersione del momentum residuo GREZZO
return score, valid, disp
return score_at, max(max(lookbacks), B) + 1
# ===========================================================================
# ENGINE locale (= xv.xs_engine + ritorno di W/scale + gate di dispersione opzionale).
# L'uguaglianza con xv.xs_engine sulle celle non-gated e' VERIFICATA in main().
# ===========================================================================
def xs_engine_w(PX, VOL, score_at, H, k, target_vol=TV, fee=FEE, min_assets=10, warmup=0,
disp_pct=0, disp_minhist=20):
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)
disp_hist = []
for i in range(n):
if i >= warmup and i % H == 0:
score, valid, disp = score_at(i)
valid = valid & np.isfinite(score) & (vol[i] > 0)
idxv = np.where(valid)[0]
if len(idxv) >= min_assets:
thr = (np.percentile(disp_hist, disp_pct)
if (disp_pct > 0 and len(disp_hist) >= disp_minhist) else -np.inf)
if not (disp_pct > 0) or (np.isfinite(disp) and disp >= thr):
kk = min(k, len(idxv) // 2)
order = idxv[np.argsort(score[idxv])]
lo, hi = order[:kk], order[-kk:]
w = np.zeros(A)
w[hi] = 0.5 / kk # long alto residual-momentum (sgn=+1)
w[lo] = -0.5 / kk # short basso
else:
w = np.zeros(A) # regime compatto -> flat (gate)
if disp_pct > 0 and np.isfinite(disp):
disp_hist.append(disp)
else:
w = np.zeros(A)
W[i] = w
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, W, scale
def run_cell(PX, VOL, cfg, fee=FEE):
score_at, warm = make_residmom(PX, cfg["B"], cfg["L"])
daily, turn, W, scale = xs_engine_w(PX, VOL, score_at, cfg["H"], cfg["k"], fee=fee,
warmup=warm, disp_pct=cfg.get("gate", 0))
return xv.to_daily(daily), turn, W, scale
# ===========================================================================
# CAUSALITA' (prefix-check, pattern di xv.causality_prefix_check sul nostro engine)
# ===========================================================================
def causality_prefix_check(PX, VOL, cfg, frac=0.85, tail=60, tol=1e-9):
score_full, warm = make_residmom(PX, cfg["B"], cfg["L"])
full, *_ = xs_engine_w(PX, VOL, score_full, cfg["H"], cfg["k"], warmup=warm,
disp_pct=cfg.get("gate", 0))
cut = int(len(PX) * frac)
PXc, VOLc = PX.iloc[:cut], VOL.iloc[:cut]
score_pre, warm2 = make_residmom(PXc, cfg["B"], cfg["L"])
pre, *_ = xs_engine_w(PXc, VOLc, score_pre, cfg["H"], cfg["k"], warmup=warm2,
disp_pct=cfg.get("gate", 0))
lo = max(0, cut - tail)
a, b = full.values[lo:cut], 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))
# ===========================================================================
# SMALL-CAP $600 (dichiarativo: il book resta STAT-MODE comunque, come XS01).
# Pesi EFFETTIVI = W[i] * scale[i+1] (scale[i+1] usa net fino a i via shift(1) -> noto a close i).
# Un cambio-gamba con |dw|*capital < min_order NON si esegue. Confronto realistico vs modeled
# sulla STESSA simulazione a pesi (coerente internamente).
# ===========================================================================
def smallcap_check(PX, W, scale, fee=FEE, capital=600.0, min_order=5.0):
px = PX.values
n, A = px.shape
dret = np.nan_to_num(np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]))
sc = np.roll(scale, -1)
sc[-1] = scale[-1]
T = W * sc[:, None] # target effettivo deciso a close i
def sim(min_ord):
held = np.zeros((n, A))
cur = np.zeros(A)
n_tr = 0
for i in range(n):
d = np.abs(T[i] - cur) * capital
ex = d >= min_ord
n_tr += int(ex.sum())
cur = np.where(ex, T[i], cur)
held[i] = cur
pos = np.zeros((n, A))
pos[1:] = held[:-1]
turn = np.abs(np.diff(held, axis=0, prepend=np.zeros((1, A)))).sum(axis=1)
net = (pos * dret).sum(axis=1) - turn * (fee / 2.0)
r = net[np.isfinite(net)]
sh = float(r.mean() / r.std() * np.sqrt(DPY)) if r.std() > 0 else 0.0
return sh, n_tr, float(turn.sum() / (n / DPY))
sh_real, ntr_real, turn_real = sim(min_order)
sh_mod, ntr_mod, turn_mod = sim(0.0)
return dict(sharpe_modeled=round(sh_mod, 3), sharpe_realistic=round(sh_real, 3),
haircut=round(sh_mod - sh_real, 3), n_executed=ntr_real, n_modeled=ntr_mod,
turnover_real=round(turn_real, 1), turnover_modeled=round(turn_mod, 1))
# ===========================================================================
# PORTAFOGLIO — sostituzione XS01 + aggiunta 5o sleeve (pattern di xsec_v3_momstruct)
# ===========================================================================
_BASE = None
_BASE_M = None
def _base():
global _BASE, _BASE_M
if _BASE is None:
_BASE = xv.active_sleeves()
pf = xv.StrategyPortfolio(_BASE)
pf.backtest()
_BASE_M = (xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT)))
return _BASE, _BASE_M
def add_uplift(daily, fr):
base, _ = _base()
wraw = fr / (1.0 - fr)
cand = xv.Sleeve("R0701_cand", wraw, lambda d=daily: d)
pf = xv.StrategyPortfolio(base + [cand])
return (xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT)),
pf.weights().get("R0701_cand", 0.0))
def substitute_xs01(daily):
base, _ = _base()
sub = [xv.Sleeve("R0701_sub", s.weight, lambda d=daily: d) if s.name == "XS01_xsec_hl" else s
for s in base]
pf = xv.StrategyPortfolio(sub)
return xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT))
def marginal_vs_book(daily):
"""Corr + uplift del blend 0.9*BOOK+0.1*cand vs il BOOK a 4 sleeve (full/hold + multi-cut)."""
base, _ = _base()
book = xv.StrategyPortfolio(base).combined_daily()
J = pd.concat({"B": book, "C": daily}, axis=1, join="inner").dropna()
def _sh(s):
r = np.asarray(s.dropna().values, float)
return float(r.mean() / r.std() * np.sqrt(DPY)) if len(r) > 2 and r.std() > 0 else 0.0
def _up(sub):
return _sh(0.9 * sub["B"] + 0.1 * sub["C"]) - _sh(sub["B"])
JH = J[J.index >= HOLDOUT]
cuts = {}
for y in sorted(set(J.index.year))[1:]:
sub = J[J.index >= pd.Timestamp(f"{y}-01-01", tz="UTC")]
if len(sub) >= 120:
cuts[int(y)] = round(_up(sub), 3)
return dict(corr_book=round(float(J["B"].corr(J["C"])), 3),
uplift_full=round(_up(J), 3),
uplift_hold=round(_up(JH), 3) if len(JH) > 30 else None,
multicut=cuts)
# ===========================================================================
def per_year(daily):
return [(int(y), round(float((1 + g).prod() - 1), 3)) for y, g in daily.groupby(daily.index.year)]
def tag(cfg):
g = f" gate{cfg['gate']}" if cfg.get("gate") else ""
return f"B{cfg['B']} L{cfg['L']} k{cfg['k']} H{cfg['H']}{g}"
def main():
print("=" * 104)
print(" r0701_xs — RESIDUAL MOMENTUM cross-sectional (residuo vs beta*BTC) sui 19 major HL — STAT-MODE")
print("=" * 104)
PX, VOL = xv.load_matrix(XS_UNIVERSE)
print(f" universo 19-major: {PX.shape[1]} asset, {PX.shape[0]} giorni "
f"[{PX.index[0].date()} -> {PX.index[-1].date()}] fattore={FACTOR} (escluso dal cross-section)")
tp_daily = xv.tp01_sleeve().daily()
xs_daily = xv.xsec_sleeve().daily()
xs_f = xv.metrics(xs_daily)
xs_h = xv.metrics(xs_daily[xs_daily.index >= HOLDOUT])
print(f" baseline XS01 (sleeve attivo): FULL Sh {xs_f['sharpe']:.2f} DD {xs_f['maxdd']*100:.0f}%"
f" | HOLD Sh {xs_h['sharpe']:.2f}")
# --- sanity: engine locale == xv.xs_engine sulle celle non-gated -----------------
chk_cfg = dict(B=90, L=30, k=5, H=10)
score_at, warm = make_residmom(PX, chk_cfg["B"], chk_cfg["L"])
mine, *_ = xs_engine_w(PX, VOL, score_at, chk_cfg["H"], chk_cfg["k"], warmup=warm)
ref, _ = xv.xs_engine(PX, VOL, lambda i: score_at(i)[:2], chk_cfg["H"], chk_cfg["k"], warmup=warm)
dmax = float(np.nanmax(np.abs(mine.values - ref.values)))
assert dmax < 1e-12, f"engine locale diverge da xv.xs_engine: {dmax}"
print(f" [sanity] engine locale == xv.xs_engine (maxdiff {dmax:.1e})")
# --- griglia -----------------------------------------------------------------------
grid = [dict(B=B, L=L, k=k, H=H) for B in BETAS for L in LOOKS for k in KS for H in HS]
grid += GATED_PROBES
rows = []
for cfg in grid:
daily, turn, W, scale = run_cell(PX, VOL, cfg)
if daily.std() == 0 or len(daily) < 60:
continue
f, h, pct = xv.evalcfg(daily)
ins = daily[daily.index < HOLDOUT]
is_sh = xv.metrics(ins)["sharpe"] if len(ins) > 60 else float("nan")
rows.append(dict(cfg=cfg, daily=daily, W=W, scale=scale, turn=turn,
full=f["sharpe"], hold=h["sharpe"], dd=f["maxdd"], ret=f["ret"],
pct=pct, is_sh=is_sh,
corrXS=xv._corr(daily, xs_daily), corrTP=xv._corr(daily, tp_daily)))
all_sr = [r["full"] for r in rows]
print(f"\n griglia: {len(rows)} celle valide su {len(grid)} "
f"(trial per deflated-Sharpe = {len(all_sr)}; il conteggio VERO del programma e' >>)")
hdr = f" {'cfg':<26}{'IS':>7}{'FULL':>7}{'HOLD':>7}{'DD%':>6}{'anni+':>7}{'corrXS':>8}{'corrTP':>8}{'turn/y':>8}"
valid = [r for r in rows if np.isfinite(r["is_sh"])]
print("\n TOP-5 per Sharpe IN-SAMPLE (pre-2025) — la selezione ONESTA:")
print(hdr)
for r in sorted(valid, key=lambda r: -r["is_sh"])[:5]:
print(f" {tag(r['cfg']):<26}{r['is_sh']:>7.2f}{r['full']:>7.2f}{r['hold']:>7.2f}"
f"{r['dd']*100:>6.0f}{r['pct']*100:>6.0f}%{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}{r['turn']:>8.0f}")
print("\n TOP-5 per HOLD (solo trasparenza — selezionare qui = selection-on-holdout):")
print(hdr)
for r in sorted(rows, key=lambda r: -r["hold"])[:5]:
iss = f"{r['is_sh']:.2f}" if np.isfinite(r["is_sh"]) else "n/a"
print(f" {tag(r['cfg']):<26}{iss:>7}{r['full']:>7.2f}{r['hold']:>7.2f}"
f"{r['dd']*100:>6.0f}{r['pct']*100:>6.0f}%{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}{r['turn']:>8.0f}")
if not valid:
print("\n >>> nessuna cella con in-sample valutabile. SCARTATO.")
return
pick = max(valid, key=lambda r: r["is_sh"])
daily = pick["daily"]
print("\n" + "=" * 104)
print(f" CELLA SCELTA (in-sample-only): {tag(pick['cfg'])}")
print("=" * 104)
print(f" IS Sh {pick['is_sh']:.2f} | FULL {pick['full']:.2f} | HOLD {pick['hold']:.2f}"
f" | DD {pick['dd']*100:.0f}% | ret {pick['ret']*100:+.0f}% | anni+ {pick['pct']*100:.0f}%"
f" | turnover/y {pick['turn']:.0f}")
print(f" corr vs XS01 {pick['corrXS']:+.2f} | corr vs TP01 {pick['corrTP']:+.2f}"
f" | per-anno {per_year(daily)}")
caus = causality_prefix_check(PX, VOL, pick["cfg"])
print(f" CAUSALITA' (prefix-check): ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}")
dsr, sr0 = xv.deflated_sharpe(pick["full"], all_sr, daily)
print(f" DEFLATED Sharpe (N={len(all_sr)} trial di QUESTA griglia): {dsr:.3f}"
f" | soglia null-max annualizz. {sr0:.2f} (serve >=0.95)")
print(" fee sweep (RT per gamba):", end=" ")
for fee in (0.0005, 0.001, 0.002, 0.003):
d_f, *_ = run_cell(PX, VOL, pick["cfg"], fee=fee)
ff = xv.metrics(d_f)
hh = xv.metrics(d_f[d_f.index >= HOLDOUT])
print(f"{fee*100:.2f}%: F{ff['sharpe']:+.2f}/H{hh['sharpe']:+.2f}", end=" ")
print()
sc = smallcap_check(PX, pick["W"], pick["scale"])
print(f" SMALL-CAP $600/min$5 (dichiarativo, resta STAT-MODE): modeled Sh {sc['sharpe_modeled']:.2f}"
f" -> realistic {sc['sharpe_realistic']:.2f} (haircut {sc['haircut']:+.2f});"
f" fill eseguiti {sc['n_executed']}/{sc['n_modeled']}"
f" turn/y {sc['turnover_real']:.0f} vs {sc['turnover_modeled']:.0f}")
# --- confronto PORTAFOGLIO -----------------------------------------------------------
print("\n PORTAFOGLIO (TP01+XS01+VRP01+SKH01, pesi canonici):")
_, (bf, bh) = _base()
print(f" BASE 4-sleeve FULL Sh {bf['sharpe']:.2f} DD {bf['maxdd']*100:.1f}%"
f" | HOLD Sh {bh['sharpe']:.2f} DD {bh['maxdd']*100:.1f}%")
sf, sh_ = substitute_xs01(daily)
sub_full_d, sub_hold_d = sf["sharpe"] - bf["sharpe"], sh_["sharpe"] - bh["sharpe"]
print(f" SOSTITUZIONE XS01 -> cand FULL Sh {sf['sharpe']:.2f} ({sub_full_d:+.2f}) DD {sf['maxdd']*100:.1f}%"
f" | HOLD Sh {sh_['sharpe']:.2f} ({sub_hold_d:+.2f}) DD {sh_['maxdd']*100:.1f}%")
up_best = (-9.0, -9.0)
for fr in (0.10, 0.15):
cf, ch, wgt = add_uplift(daily, fr)
d_f, d_h = cf["sharpe"] - bf["sharpe"], ch["sharpe"] - bh["sharpe"]
if d_h > up_best[1]:
up_best = (d_f, d_h)
print(f" AGGIUNTA 5o sleeve @{wgt*100:>4.1f}% FULL Sh {cf['sharpe']:.2f} ({d_f:+.2f}) DD {cf['maxdd']*100:.1f}%"
f" | HOLD Sh {ch['sharpe']:.2f} ({d_h:+.2f}) DD {ch['maxdd']*100:.1f}%")
mb = marginal_vs_book(daily)
print(f" MARGINAL vs BOOK: corr {mb['corr_book']:+.2f} | uplift@10% full {mb['uplift_full']:+.3f}"
f" hold {mb['uplift_hold']:+.3f} | multi-cut {mb['multicut']}")
# --- VERDETTO -------------------------------------------------------------------------
print("\n" + "=" * 104)
beats_xs_standalone = (pick["full"] > xs_f["sharpe"] and pick["hold"] > xs_h["sharpe"])
dominates = sub_full_d > 0.02 and sub_hold_d > 0.05 and beats_xs_standalone
diversifies = (abs(pick["corrXS"]) < 0.6 and abs(pick["corrTP"]) < 0.5
and up_best[1] > 0.05 and mb["uplift_hold"] is not None and mb["uplift_hold"] > 0
and all(u > 0 for u in mb["multicut"].values()))
dsr_ok = np.isfinite(dsr) and dsr >= 0.95
if not caus["ok"]:
verdict, why = "SCARTATO", "prefix-check di causalita' fallito"
elif pick["full"] <= 0.3 or pick["hold"] <= 0:
verdict, why = "SCARTATO", f"standalone debole (FULL {pick['full']:+.2f}, HOLD {pick['hold']:+.2f})"
elif abs(pick["corrXS"]) > 0.6 and not dominates:
verdict, why = "REDUNDANT", f"corrXS {pick['corrXS']:+.2f}>0.6 e non batte XS01 (sub HOLD {sub_hold_d:+.2f})"
elif dominates and dsr_ok:
verdict, why = "CANDIDATO-SLEEVE (sostituto)", "batte XS01 standalone E nel book, DSR>=0.95"
elif diversifies and dsr_ok:
verdict, why = "CANDIDATO-SLEEVE (5o)", "scorrelato, uplift book persistente, DSR>=0.95"
elif dominates or diversifies:
verdict, why = "LEAD-forward", f"profilo utile ma DSR {dsr:.2f}<0.95 (storia ~2.5a, multiple-testing)"
else:
verdict, why = "SCARTATO", (f"ne' sostituto (sub HOLD {sub_hold_d:+.2f}) ne' additivo"
f" (uplift HOLD {up_best[1]:+.2f}, corrXS {pick['corrXS']:+.2f})")
print(f" VERDETTO: {verdict}{why}")
print(" CAVEAT immutabili: storia HL ~2.5 anni; in-sample = solo 2024 (selezione su finestra corta);")
print(" book L/S multi-gamba -> STAT-MODE a $600 (come XS01), mai deploy a questo capitale.")
print("=" * 104)
if __name__ == "__main__":
main()
+295
View File
@@ -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()