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