aad69f9790
Ricerca onesta su BTC/ETH + universo HL, branch separato (nessun impatto live).
Harness condiviso altlib (causale, fee 0.10% RT, marginal vs TP01, day-boundary,
haircut $600). Test 19/19 verdi.
- A DVOL direzionale -> LEAD hedge/DD-dampener, NON sleeve (buy-the-fear; is_hedge).
- B Intraday ERM 8h -> LEAD forte / forward-monitor: earns_slot=True, ADDS oltre
SKH01 (TP01+SKH+ERM 60/25/15 FULL 1.88/HOLD 1.46/DD 8.9%).
Caveat: plateau hold-out single-row, multiple-testing non
deflazionato, exec 8h. Controllo TOD = FAIL atteso.
- C Cross-sectional non-mom (low-vol HL) -> DEBOLE/forward-monitor (deflated-Sh 0.13,
storia 2.5a, non eseguibile $600) STAT-MODE.
- D Macro regime-gate -> RIDONDANTE col trend (corr->TP01 0.989), SCARTATO.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
459 lines
22 KiB
Python
459 lines
22 KiB
Python
"""macro_regime_gate.py — Filone D: MACRO REGIME-GATE sul book crypto (eseguibile).
|
|
|
|
TESI
|
|
----
|
|
Usare segnali macro/cross-market — equity (SPY/QQQ/IWM), credito (HYG/LQD), oro (GLD/SLV),
|
|
tassi (TLT/IEF) — come GATE risk-on/risk-off applicato al book crypto (BTC/ETH) per migliorare
|
|
il TIMING del drawdown di TP01. Quando il regime macro e' risk-off (credito che cede, equity
|
|
sotto trend, fuga sui bond) -> riduci/azzera l'esposizione crypto; risk-on -> lascia agire TP01.
|
|
E' ESEGUIBILE perche' GATA solo BTC/ETH perp (non aggiunge gambe).
|
|
|
|
NON e' un lead-lag direzionale (gia' esplorato e morto: vedi 2026-06-22/-23 leadlag diaries).
|
|
L'angolo nuovo = un OVERLAY binario/continuo di DE-RISK sul book.
|
|
|
|
IL RISCHIO (da CLAUDE.md): il gate di de-risk rischia di essere RIDONDANTE col trend — TP01 e'
|
|
gia' long-flat e va a 0 nei crash (lezione DVOL-spike "ridondante col trend, Delta 0.00"). Questo
|
|
script DEVE dimostrare che il gate aggiunge OLTRE quel che TP01 fa da solo, altrimenti SCARTATO.
|
|
|
|
CAUSALITA' (fusi orari, regola di prim'ordine)
|
|
----------------------------------------------
|
|
- Barre equity: open-labeled a 00:00 del giorno di trading; il CLOSE e' ~20:00-21:00 UTC dello
|
|
STESSO giorno (NYSE 16:00 ET).
|
|
- Barre crypto 1d: open-labeled a 00:00; il CLOSE e' a 00:00 UTC del giorno DOPO. TP01 decide la
|
|
posizione a close[i] e la TIENE durante la barra i+1 (eval_weights shift-a per te).
|
|
- Quindi: gate[i] allineato (merge_asof backward, equity-label <= crypto-label day i) usa il
|
|
close equity del giorno i (noto ~20:00 day i) per la posizione tenuta durante la barra i+1
|
|
(giorno i+1). Margine causale >= 4h. Leak-free. Variante STRICT (equity-label < crypto-label)
|
|
come margine extra. Verifica con al.causality_ok + day_boundary_robust.
|
|
|
|
USO: uv run python scripts/research/macro_regime_gate.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
_ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(_ROOT))
|
|
sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt"))
|
|
|
|
import altlib as al # noqa: E402
|
|
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402
|
|
|
|
DATA = _ROOT / "data" / "raw"
|
|
HOLDOUT = al.HOLDOUT
|
|
ASSETS = ("BTC", "ETH")
|
|
|
|
|
|
# ===========================================================================
|
|
# MACRO FRAME — ETF daily, allineati causalmente sul calendario SPY (master).
|
|
# ===========================================================================
|
|
def _load_eq(sym: str) -> pd.DataFrame:
|
|
p = DATA / f"eq_{sym}_1d.parquet"
|
|
df = pd.read_parquet(p).sort_values("timestamp").reset_index(drop=True)
|
|
df["dt"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
|
return df[["timestamp", "dt", "close"]].rename(columns={"close": sym})
|
|
|
|
|
|
def macro_frame(syms=("spy", "qqq", "iwm", "hyg", "lqd", "gld", "slv", "tlt", "ief")) -> pd.DataFrame:
|
|
"""Frame macro sul calendario NYSE (master = SPY). Ogni colonna e' il close equity,
|
|
allineato causale (merge_asof backward) -> nessun valore futuro per riga."""
|
|
base = _load_eq("spy")[["timestamp", "dt"]].copy()
|
|
out = base
|
|
for s in syms:
|
|
e = _load_eq(s)[["timestamp", s]]
|
|
out = pd.merge_asof(out, e, on="timestamp", direction="backward")
|
|
return out
|
|
|
|
|
|
# ===========================================================================
|
|
# GATE BUILDERS — ognuno ritorna (timestamp_ms, gate in [0,1]) sul calendario equity.
|
|
# Tutti CAUSALI: la riga i usa solo close <= riga i (rolling/SMA, niente expanding-future).
|
|
# gate=1 => risk-on (TP01 pieno); gate=g_off (0 o 0.5) => risk-off (de-risk).
|
|
# ===========================================================================
|
|
def _sma(x: np.ndarray, n: int) -> np.ndarray:
|
|
return pd.Series(x).rolling(n, min_periods=n).mean().values
|
|
|
|
|
|
def _ret(x: np.ndarray, n: int) -> np.ndarray:
|
|
r = np.full(len(x), np.nan)
|
|
r[n:] = x[n:] / x[:-n] - 1.0
|
|
return r
|
|
|
|
|
|
def gate_trend(mf: pd.DataFrame, col: str, n: int, g_off: float) -> pd.DataFrame:
|
|
"""Risk-on se col_close > SMA(col, n). Filtro di trend classico (SPY200, HYG, ...)."""
|
|
c = mf[col].values.astype(float)
|
|
on = c > _sma(c, n)
|
|
g = np.where(on, 1.0, g_off)
|
|
g[~np.isfinite(c)] = np.nan
|
|
g[np.isnan(_sma(c, n))] = np.nan
|
|
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
|
|
|
|
|
def gate_ratio(mf: pd.DataFrame, num: str, den: str, n: int, g_off: float) -> pd.DataFrame:
|
|
"""Risk-on se ratio num/den (proxy spread di credito) > la sua SMA(n).
|
|
HYG/LQD o HYG/IEF in calo = spread che si allarga = risk-off."""
|
|
ratio = (mf[num].values.astype(float) / mf[den].values.astype(float))
|
|
on = ratio > _sma(ratio, n)
|
|
g = np.where(on, 1.0, g_off)
|
|
g[~np.isfinite(ratio)] = np.nan
|
|
g[np.isnan(_sma(ratio, n))] = np.nan
|
|
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
|
|
|
|
|
def gate_combo(mf: pd.DataFrame, n: int, g_off: float, thr: float = 0.5,
|
|
continuous: bool = False) -> pd.DataFrame:
|
|
"""Score di regime = media di 3 voti risk-on: SPY>SMA, HYG>SMA, HYG/LQD>SMA.
|
|
continuous=True -> gate = g_off + (1-g_off)*score (de-risk graduale).
|
|
continuous=False -> gate = 1 se score>=thr else g_off (binario su maggioranza)."""
|
|
spy = mf["spy"].values.astype(float)
|
|
hyg = mf["hyg"].values.astype(float)
|
|
ratio = hyg / mf["lqd"].values.astype(float)
|
|
votes = np.vstack([spy > _sma(spy, n), hyg > _sma(hyg, n), ratio > _sma(ratio, n)]).astype(float)
|
|
warm = np.isnan(_sma(spy, n)) | np.isnan(_sma(hyg, n)) | np.isnan(_sma(ratio, n))
|
|
score = votes.mean(axis=0)
|
|
if continuous:
|
|
g = g_off + (1.0 - g_off) * score
|
|
else:
|
|
g = np.where(score >= thr, 1.0, g_off)
|
|
g[warm] = np.nan
|
|
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
|
|
|
|
|
def gate_flight(mf: pd.DataFrame, safe: str, risk: str, n: int, g_off: float) -> pd.DataFrame:
|
|
"""Fuga verso la sicurezza: risk-off quando il safe-asset (TLT/GLD) sale MENTRE il risk
|
|
(SPY) scende sull'orizzonte n. Divergenza risk-off classica (flight-to-quality)."""
|
|
s = mf[safe].values.astype(float)
|
|
rk = mf[risk].values.astype(float)
|
|
off = (_ret(s, n) > 0) & (_ret(rk, n) < 0)
|
|
g = np.where(off, g_off, 1.0)
|
|
warm = np.isnan(_ret(s, n)) | np.isnan(_ret(rk, n))
|
|
g[warm] = np.nan
|
|
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
|
|
|
|
|
def gate_eqvol(mf: pd.DataFrame, n: int, win: int, z: float, g_off: float) -> pd.DataFrame:
|
|
"""Regime di vol equity: de-risk quando la vol realizzata SPY (win g) e' alta vs la sua
|
|
storia espandente-causale (z-score > z). Proxy 'VIX spike' senza VIX."""
|
|
spy = mf["spy"].values.astype(float)
|
|
r = np.zeros(len(spy)); r[1:] = spy[1:] / spy[:-1] - 1.0
|
|
rv = pd.Series(r).rolling(win, min_periods=win).std().values
|
|
zsc = al.zscore(rv, n)
|
|
off = zsc > z
|
|
g = np.where(off, g_off, 1.0)
|
|
g[~np.isfinite(zsc)] = np.nan
|
|
return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g})
|
|
|
|
|
|
# ===========================================================================
|
|
# ALIGN GATE -> CRYPTO (causale) + GATED TARGET
|
|
# ===========================================================================
|
|
def align_gate(gate_df: pd.DataFrame, crypto_df: pd.DataFrame, strict: bool = False) -> np.ndarray:
|
|
"""Allinea il gate (calendario equity) alle barre crypto. merge_asof backward:
|
|
crypto-label day i -> ultimo gate equity con label <= day i (strict: < day i).
|
|
NaN pre-storia -> gate=1 (nessun de-risk quando non c'e' info)."""
|
|
left = pd.DataFrame({"timestamp": crypto_df["timestamp"].astype("int64").values})
|
|
g = gate_df.dropna(subset=["gate"]).sort_values("timestamp")
|
|
m = pd.merge_asof(left, g, on="timestamp", direction="backward",
|
|
allow_exact_matches=not strict)
|
|
return pd.Series(m["gate"].values).ffill().fillna(1.0).values
|
|
|
|
|
|
def tp01_pos(df: pd.DataFrame) -> np.ndarray:
|
|
return TrendPortfolio(**CANONICAL).target_series(df)
|
|
|
|
|
|
def make_target_fn(gate_builder, strict: bool = False):
|
|
"""Ritorna target_fn(df, asset) = posizione TP01 * gate macro allineato (causale).
|
|
gate_builder() costruisce il gate sul calendario equity una volta (cache esterna)."""
|
|
_MF = macro_frame()
|
|
gate_df = gate_builder(_MF)
|
|
|
|
def target_fn(df: pd.DataFrame, asset: str = "") -> np.ndarray:
|
|
pos = tp01_pos(df)
|
|
g = align_gate(gate_df, df, strict=strict)
|
|
return pos * g
|
|
return target_fn, gate_df
|
|
|
|
|
|
# ===========================================================================
|
|
# EVALUATION — solo vs gated, combo 50/50 + per-asset, FULL/HOLD/DD/CAGR.
|
|
# ===========================================================================
|
|
def _combo_daily(target_fn) -> pd.Series:
|
|
series = {}
|
|
for a in ASSETS:
|
|
df = al.get(a, "1d")
|
|
ev = al.eval_weights(df, target_fn(df, a))
|
|
series[a] = pd.Series(ev["net"], index=ev["idx"])
|
|
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
|
return al._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]])
|
|
|
|
|
|
def _sh(s):
|
|
return al._sh(s)
|
|
|
|
|
|
def _metrics(s: pd.Series) -> dict:
|
|
sh = _sh(s)
|
|
eq = np.cumprod(1.0 + s.values)
|
|
pk = np.maximum.accumulate(eq)
|
|
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
|
yrs = max((s.index[-1] - s.index[0]).days / 365.25, 1e-6)
|
|
cagr = eq[-1] ** (1 / yrs) - 1 if len(eq) and eq[-1] > 0 else -1.0
|
|
return dict(sharpe=round(sh, 3), dd=round(dd, 4), cagr=round(cagr, 4))
|
|
|
|
|
|
def eval_solo_vs_gated(target_fn):
|
|
"""Combo 50/50: solo (gate=1) vs gated. Ritorna dict con FULL e HOLD per entrambi."""
|
|
solo_fn = lambda df, a="": tp01_pos(df)
|
|
solo = _combo_daily(solo_fn)
|
|
gated = _combo_daily(target_fn)
|
|
J = pd.concat({"solo": solo, "gated": gated}, axis=1, join="inner").dropna()
|
|
JH = J[J.index >= HOLDOUT]
|
|
return dict(
|
|
full_solo=_metrics(J["solo"]), full_gated=_metrics(J["gated"]),
|
|
hold_solo=_metrics(JH["solo"]), hold_gated=_metrics(JH["gated"]),
|
|
n=len(J), nh=len(JH), solo_series=J["solo"], gated_series=J["gated"])
|
|
|
|
|
|
def per_asset_table(target_fn) -> dict:
|
|
out = {}
|
|
for a in ASSETS:
|
|
df = al.get(a, "1d")
|
|
solo = al.eval_weights(df, tp01_pos(df))
|
|
gated = al.eval_weights(df, target_fn(df, a))
|
|
out[a] = dict(
|
|
full_solo=dict(sharpe=solo["full"]["sharpe"], dd=solo["full"]["maxdd"], cagr=solo["full"]["cagr"]),
|
|
full_gated=dict(sharpe=gated["full"]["sharpe"], dd=gated["full"]["maxdd"], cagr=gated["full"]["cagr"]),
|
|
hold_solo=dict(sharpe=solo["holdout"].get("sharpe", 0.0)),
|
|
hold_gated=dict(sharpe=gated["holdout"].get("sharpe", 0.0)))
|
|
return out
|
|
|
|
|
|
# ===========================================================================
|
|
# REDUNDANCY DIAGNOSTIC — il controllo DECISIVO contro il trend.
|
|
# ===========================================================================
|
|
def redundancy_diag(gate_df: pd.DataFrame) -> dict:
|
|
"""Quanto FA davvero il gate, dato che TP01 e' gia' flat nei crash?
|
|
- exposure TP01 nei giorni risk-off (gate<1) vs risk-on: se gia' ~0 -> ridondante.
|
|
- quota di giorni in cui il gate riduce una posizione NON gia' flat (lavoro effettivo).
|
|
- corr fra (1-gate) e (1-exposure_norm)."""
|
|
lev = CANONICAL["leverage"]
|
|
rows = []
|
|
for a in ASSETS:
|
|
df = al.get(a, "1d")
|
|
pos = tp01_pos(df)
|
|
expo = np.clip(np.abs(pos) / lev, 0, 1) # esposizione normalizzata 0..1
|
|
g = align_gate(gate_df, df)
|
|
dt = pd.to_datetime(df["datetime"], utc=True)
|
|
m = pd.DataFrame({"dt": dt, "expo": expo, "g": g})
|
|
m = m[m["dt"] >= dt.iloc[0]] # tutto
|
|
roff = m["g"] < 0.999
|
|
ron = ~roff
|
|
flat_thr = 0.05
|
|
# lavoro effettivo: gate<1 E TP01 non gia' flat
|
|
work = roff & (m["expo"] > flat_thr)
|
|
rows.append(dict(
|
|
asset=a,
|
|
expo_riskoff=round(float(m.loc[roff, "expo"].mean()) if roff.any() else 0.0, 3),
|
|
expo_riskon=round(float(m.loc[ron, "expo"].mean()) if ron.any() else 0.0, 3),
|
|
pct_days_riskoff=round(float(roff.mean()), 3),
|
|
pct_days_gate_works=round(float(work.mean()), 3),
|
|
corr_1mg_1mexpo=round(float(np.corrcoef(1 - m["g"], 1 - m["expo"])[0, 1])
|
|
if m["g"].std() > 0 else float("nan"), 3),
|
|
))
|
|
return {r["asset"]: r for r in rows}
|
|
|
|
|
|
# ===========================================================================
|
|
# RUNNER
|
|
# ===========================================================================
|
|
GATES = {
|
|
# binari classici
|
|
"SPY>MA200": lambda mf: gate_trend(mf, "spy", 200, 0.0),
|
|
"SPY>MA100": lambda mf: gate_trend(mf, "spy", 100, 0.0),
|
|
"SPY>MA50": lambda mf: gate_trend(mf, "spy", 50, 0.0),
|
|
"SPY>MA200_half": lambda mf: gate_trend(mf, "spy", 200, 0.5),
|
|
"HYG>MA100": lambda mf: gate_trend(mf, "hyg", 100, 0.0),
|
|
"HYG/LQD>MA50": lambda mf: gate_ratio(mf, "hyg", "lqd", 50, 0.0),
|
|
"HYG/LQD>MA100": lambda mf: gate_ratio(mf, "hyg", "lqd", 100, 0.0),
|
|
# combinati / score
|
|
"COMBO_maj100": lambda mf: gate_combo(mf, 100, 0.0, thr=0.5),
|
|
"COMBO_all100": lambda mf: gate_combo(mf, 100, 0.0, thr=0.99),
|
|
"COMBO_cont100": lambda mf: gate_combo(mf, 100, 0.0, continuous=True),
|
|
"COMBO_cont100h": lambda mf: gate_combo(mf, 100, 0.5, continuous=True),
|
|
# flight-to-quality / vol
|
|
"TLTup&SPYdn20": lambda mf: gate_flight(mf, "tlt", "spy", 20, 0.0),
|
|
"GLDup&SPYdn20": lambda mf: gate_flight(mf, "gld", "spy", 20, 0.0),
|
|
"SPYvol_z1": lambda mf: gate_eqvol(mf, 250, 20, 1.0, 0.0),
|
|
}
|
|
|
|
|
|
def delever_frontier(target_vols=(0.10, 0.12, 0.14, 0.16, 0.18, 0.20)) -> dict:
|
|
"""CONTROLLO DECISIVO (lezione DVOL-overlay): per meno DD la leva e' target_vol, non un
|
|
overlay. Frontiera DD/Sharpe di TP01 puro a target_vol decrescente. Se il miglior gate
|
|
sta SOPRA (DD piu' alto a parita' di Sharpe, o Sharpe piu' basso a parita' di DD) di questa
|
|
frontiera, il suo taglio di DD e' solo de-levering replicabile meglio senza macro."""
|
|
out = {}
|
|
for tv in target_vols:
|
|
cfg = {**CANONICAL, "target_vol": tv}
|
|
fn = lambda df, a="", cfg=cfg: TrendPortfolio(**cfg).target_series(df)
|
|
c = _combo_daily(fn)
|
|
out[tv] = _metrics(c)
|
|
return out
|
|
|
|
|
|
def fmt_cmp(label, m_solo, m_gated) -> str:
|
|
ds = m_gated["sharpe"] - m_solo["sharpe"]
|
|
dd = m_gated["dd"] - m_solo["dd"]
|
|
dc = m_gated.get("cagr", 0) - m_solo.get("cagr", 0)
|
|
return (f" {label:5s} Sh {m_solo['sharpe']:+.2f}->{m_gated['sharpe']:+.2f} (d{ds:+.2f}) "
|
|
f"DD {m_solo['dd']*100:4.1f}%->{m_gated['dd']*100:4.1f}% (d{dd*100:+.1f}pp) "
|
|
f"CAGR {m_solo.get('cagr',0)*100:+5.1f}%->{m_gated.get('cagr',0)*100:+5.1f}% (d{dc*100:+.1f}pp)")
|
|
|
|
|
|
def main():
|
|
pd.set_option("display.width", 160)
|
|
print("=" * 92)
|
|
print("MACRO REGIME-GATE sul book crypto (TP01 BTC/ETH) — Filone D")
|
|
print(f" TP01 CANONICAL = {CANONICAL}")
|
|
print(f" HOLD-OUT >= {HOLDOUT.date()} fee {al.FEE_SIDE*2*100:.2f}%RT")
|
|
mf = macro_frame()
|
|
print(f" Macro frame: {len(mf)} barre {mf['dt'].iloc[0].date()} -> {mf['dt'].iloc[-1].date()} "
|
|
f"cols={[c for c in mf.columns if c not in ('timestamp','dt')]}")
|
|
print("=" * 92)
|
|
|
|
# ---- 1) SWEEP DI TUTTI I GATE (combo 50/50) -------------------------------------
|
|
print("\n[1] SWEEP GATE — combo 50/50 BTC+ETH, FULL & HOLD-OUT, vs TP01-solo\n")
|
|
results = {}
|
|
for name, builder in GATES.items():
|
|
tf, gate_df = make_target_fn(builder)
|
|
cmp = eval_solo_vs_gated(tf)
|
|
results[name] = (cmp, gate_df, tf)
|
|
print(f"GATE {name}")
|
|
print(fmt_cmp("FULL", cmp["full_solo"], cmp["full_gated"]))
|
|
print(fmt_cmp("HOLD", cmp["hold_solo"], cmp["hold_gated"]))
|
|
|
|
# baseline (solo) numbers come from any cmp
|
|
any_cmp = next(iter(results.values()))[0]
|
|
print(f"\n [baseline TP01-solo] FULL Sh {any_cmp['full_solo']['sharpe']} DD {any_cmp['full_solo']['dd']*100:.1f}% "
|
|
f"CAGR {any_cmp['full_solo']['cagr']*100:.1f}% | HOLD Sh {any_cmp['hold_solo']['sharpe']} "
|
|
f"DD {any_cmp['hold_solo']['dd']*100:.1f}% CAGR {any_cmp['hold_solo']['cagr']*100:.1f}%")
|
|
|
|
# ---- 2) SELEZIONE: miglior gate per HOLD-OUT Sharpe, poi per riduzione DD --------
|
|
def score(name):
|
|
c = results[name][0]
|
|
return (c["hold_gated"]["sharpe"], -c["full_gated"]["dd"])
|
|
best = max(results, key=score)
|
|
# anche il "miglior DD-cutter" che non peggiora troppo lo Sharpe FULL
|
|
dd_best = min(results, key=lambda n: results[n][0]["full_gated"]["dd"])
|
|
print(f"\n[2] Miglior gate per HOLD-OUT Sharpe: {best}")
|
|
print(f" Miglior gate per DD FULL ridotto : {dd_best}")
|
|
|
|
# ---- CONTROLLO DECISIVO: de-lever frontier (target_vol) -------------------------
|
|
print("\n[2b] CONTROLLO DECISIVO — TP01 puro a target_vol piu' basso (de-lever) vs gate:")
|
|
fr = delever_frontier()
|
|
for tv, m in fr.items():
|
|
print(f" target_vol {tv:.2f}: FULL Sh {m['sharpe']:+.2f} DD {m['dd']*100:4.1f}% CAGR {m['cagr']*100:+5.1f}%")
|
|
print(" -> i gate de-leveranti (COMBO_cont, SPYvol) vanno confrontati con QUESTA frontiera:")
|
|
for n in ("COMBO_cont100", "COMBO_cont100h", "SPYvol_z1", "SPY>MA200_half"):
|
|
if n in results:
|
|
g = results[n][0]["full_gated"]
|
|
print(f" {n:16s}: FULL Sh {g['sharpe']:+.2f} DD {g['dd']*100:4.1f}% CAGR {g['cagr']*100:+5.1f}%")
|
|
|
|
for tag, name in [("BEST-HOLD", best), ("BEST-DD", dd_best)]:
|
|
if tag == "BEST-DD" and dd_best == best:
|
|
continue
|
|
deep_dive(tag, name, results)
|
|
|
|
|
|
def deep_dive(tag, name, results):
|
|
cmp, gate_df, tf = results[name]
|
|
print("\n" + "=" * 92)
|
|
print(f"[DEEP DIVE {tag}] GATE = {name}")
|
|
print("=" * 92)
|
|
|
|
# per-asset
|
|
print("\n Per-asset (TP01-solo -> TP01+gate):")
|
|
pa = per_asset_table(tf)
|
|
for a in ASSETS:
|
|
d = pa[a]
|
|
print(f" {a}: FULL Sh {d['full_solo']['sharpe']:+.2f}->{d['full_gated']['sharpe']:+.2f} "
|
|
f"DD {d['full_solo']['dd']*100:.0f}%->{d['full_gated']['dd']*100:.0f}% "
|
|
f"CAGR {d['full_solo']['cagr']*100:+.0f}%->{d['full_gated']['cagr']*100:+.0f}% | "
|
|
f"HOLD Sh {d['hold_solo']['sharpe']:+.2f}->{d['hold_gated']['sharpe']:+.2f}")
|
|
|
|
# ---- 3) CONTROLLO RIDONDANZA COL TREND ------------------------------------------
|
|
print("\n [3] CONTROLLO RIDONDANZA COL TREND (il test decisivo):")
|
|
rd = redundancy_diag(gate_df)
|
|
for a in ASSETS:
|
|
r = rd[a]
|
|
print(f" {a}: exposure TP01 nei giorni risk-off={r['expo_riskoff']} vs risk-on={r['expo_riskon']} "
|
|
f"| giorni risk-off {r['pct_days_riskoff']*100:.0f}% "
|
|
f"giorni in cui il gate LAVORA (riduce pos non-flat) {r['pct_days_gate_works']*100:.0f}% "
|
|
f"| corr(1-gate, 1-expo)={r['corr_1mg_1mexpo']}")
|
|
print(" -> se exposure-risk-off ~ exposure-risk-on e 'gate-lavora' e' basso => RIDONDANTE col trend.")
|
|
|
|
# ---- 4) MARGINAL SCORER -----------------------------------------------------------
|
|
print("\n [4] MARGINAL SCORER vs TP01 (gate come candidato-sleeve):")
|
|
rep = al.study_marginal(f"GATE[{name}]", tf, tf="1d")
|
|
print(al.fmt_marginal(rep))
|
|
|
|
# overlay-delta: lo STREAM incrementale del gate = gated - solo (e' alpha o hedge?)
|
|
delta = (cmp["gated_series"] - cmp["solo_series"]).dropna()
|
|
print("\n overlay-delta (gated - solo) come stream a se':")
|
|
md = al.marginal_vs_tp01(delta)
|
|
print(f" verdict={md.get('marginal_verdict')} corr->TP01 {md.get('corr_full')} "
|
|
f"is_hedge={md.get('is_hedge')} uplift TP01-up {md.get('uplift_tp01_up')} / "
|
|
f"TP01-down {md.get('uplift_tp01_down')} cand-Sh full {md.get('cand_full_sharpe')}")
|
|
|
|
# ---- 5) FEE SWEEP -----------------------------------------------------------------
|
|
print("\n [5] FEE SWEEP (combo 50/50 gated, FULL Sharpe):")
|
|
for f in al.FEE_SWEEP:
|
|
series = {}
|
|
for a in ASSETS:
|
|
df = al.get(a, "1d")
|
|
ev = al.eval_weights(df, tf(df, a), fee_side=f)
|
|
series[a] = pd.Series(ev["net"], index=ev["idx"])
|
|
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
|
d = al._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]])
|
|
print(f" {2*f*100:.2f}%RT -> Sh {_sh(d):+.2f}")
|
|
|
|
# ---- 6) ESEGUIBILITA' a $600 ------------------------------------------------------
|
|
print("\n [6] ESEGUIBILITA' a $600 (eval_weights_smallcap, haircut reale):")
|
|
for a in ASSETS:
|
|
df = al.get(a, "1d")
|
|
sc = al.eval_weights_smallcap(df, tf(df, a), capital=600, min_order=5)
|
|
print(f" {a}: modeled Sh {sc['modeled']['sharpe']:+.2f} -> real Sh {sc['realistic']['sharpe']:+.2f} "
|
|
f"haircut {sc['sharpe_haircut']:+.2f} trade eseguiti {sc['n_executed_trades']}")
|
|
|
|
# ---- 7) LEAK / BOUNDARY -----------------------------------------------------------
|
|
print("\n [7] LEAK-FREE & BOUNDARY:")
|
|
cz = al.causality_ok(tf, tf="1d")
|
|
print(f" causality_ok={cz['ok']} (max_tail_diff {cz['max_tail_diff']}, checked {cz['checked']})")
|
|
# variante STRICT (equity-label < crypto-label): margine causale extra
|
|
tf_strict, _ = make_target_fn(GATES[name], strict=True)
|
|
cmp_s = eval_solo_vs_gated(tf_strict)
|
|
print(f" STRICT align (1 barra equity extra di lag): FULL Sh {cmp_s['full_gated']['sharpe']:+.2f} "
|
|
f"(vs {cmp['full_gated']['sharpe']:+.2f}) HOLD Sh {cmp_s['hold_gated']['sharpe']:+.2f} "
|
|
f"(vs {cmp['hold_gated']['sharpe']:+.2f}) -> robusto se ~uguale")
|
|
db = al.day_boundary_robust(tf, tf="1d")
|
|
print(f" day_boundary_robust={db['verdict']} (spread {db.get('spread')}, per-offset {db.get('per_offset')})")
|
|
|
|
# ---- 8) PLATEAU (solo per i trend MA) ---------------------------------------------
|
|
if name.startswith("SPY>MA") or name.startswith("HYG"):
|
|
print("\n [8] PLATEAU su finestra MA (SPY trend), g_off=0:")
|
|
for n in (50, 100, 150, 200, 250):
|
|
tfn, _ = make_target_fn(lambda mf, n=n: gate_trend(mf, "spy", n, 0.0))
|
|
cc = eval_solo_vs_gated(tfn)
|
|
print(f" SPY>MA{n:3d}: FULL Sh {cc['full_gated']['sharpe']:+.2f} DD {cc['full_gated']['dd']*100:.1f}% "
|
|
f"HOLD Sh {cc['hold_gated']['sharpe']:+.2f} DD {cc['hold_gated']['dd']*100:.1f}%")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|