"""GATE PORT06: i top candidati sostituiscono MR02/ETH. Misura FULL+OOS Sharpe/DD. Ogni candidato genera i trade ETH 1h con l'ENGINE INTRABAR identico al sleeve canonico (explore_lab.simulate: SL/TP intrabar al livello, fee 0.10% RT, lev 3x), equity giornaliera normalizzata su IDX (2021-01-01 -> 2026-05-26), swap su all_sleeve_equities()['MR02_ETH'], e ri-misura PORT06 (cap weighting PAIRS 0.33 / SHAPE 0.0588). uv run python scripts/analysis/mr02eth_port06_gate.py """ from __future__ import annotations import sys from pathlib import Path import numpy as np import pandas as pd PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT)) from scripts.analysis.explore_lab import get_df, atr, ema from scripts.analysis.combine_portfolio import IDX, SPLIT, OOS_DATE, metrics, port_returns, _norm from src.portfolio.sleeves import all_sleeve_equities from src.portfolio import weighting as W FEE_RT, LEV, POS = 0.001, 3.0, 0.15 CAPS = {"PAIRS": 0.33, "SHAPE": 0.0588} # ----------------------- engine intrabar (== explore_lab.simulate) ----------------------- def build_trades(entries, df, fee_rt=FEE_RT, lev=LEV): h, l, c = df["high"].values, df["low"].values, df["close"].values n = len(c); out = []; last = -1 for e in entries: i, d = e["i"], e["d"] if i <= last or i + 1 >= n: continue entry = c[i]; tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"] exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1) for k in range(1, mb + 1): j = i + k if j >= n: exit_p = c[n - 1]; break hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)) hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)) if hit_sl: exit_p = sl; break if hit_tp: exit_p = tp; break if k == mb: exit_p = c[j] out.append((i, j, (exit_p - entry) / entry * d * lev - fee_rt * lev)); last = j return out def build_trades_exit16(entries, df, sl_confirm=0.5, fee_rt=FEE_RT, lev=LEV, dvol=None, otm=None, skew=1.10, tenor_mult=1.0): """Engine EXIT-16 close-confirm (== config LIVE): SL intrabar OFF, lo stop scatta solo se il CLOSE sfonda sl ∓ sl_confirm*ATR14; TP intrabar ha priorita'. Se dvol+otm sono dati, AGGIUNGE un overlay opzione (put se long / call se short) a otm OTM.""" from scripts.analysis.option_overlay_lab import bs_put, bs_call h, l, c = df["high"].values, df["low"].values, df["close"].values a = atr(df, 14); n = len(c); out = []; last = -1 for e in entries: i, d = e["i"], e["d"] if i <= last or i + 1 >= n: continue entry = c[i]; tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"] exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1) for k in range(1, mb + 1): j = i + k if j >= n: exit_p = c[n - 1]; break hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)) if hit_tp: exit_p = tp; break if sl is not None and not np.isnan(a[j]): buf = sl_confirm * a[j] if (d == 1 and c[j] < sl - buf) or (d == -1 and c[j] > sl + buf): exit_p = c[j]; break if k == mb: exit_p = c[j] ret = (exit_p - entry) / entry * d * lev - fee_rt * lev if dvol is not None and otm is not None: T = max(mb * tenor_mult, 1.0) / _HOURS_YEAR; sig = dvol[i] * skew if d == 1: K = entry * (1 - otm); prem = bs_put(entry, K, T, sig) / entry; pay = max(K - exit_p, 0.0) / entry else: K = entry * (1 + otm); prem = bs_call(entry, K, T, sig) / entry; pay = max(exit_p - K, 0.0) / entry ret += lev * (pay - prem) out.append((i, j, ret)); last = j return out def blend_equity(eqs, weights=None) -> pd.Series: """Combina N equity giornaliere mediando i rendimenti giornalieri (ribilancio daily).""" drs = [e.pct_change().fillna(0.0) for e in eqs] w = weights or [1.0 / len(drs)] * len(drs) dr = sum(wi * di for wi, di in zip(w, drs)) return _norm((1 + dr).cumprod()) def daily_equity(trades, df) -> pd.Series: ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) n = len(df); eq = np.full(n, 1000.0); cap = 1000.0 for i, j, ret in sorted(trades, key=lambda t: t[1]): cap = max(cap + cap * POS * ret, 10.0); eq[j:] = cap s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill() return _norm(s) # ----------------------- indicatori ----------------------- def choppiness(df, n=14): h, l, c = df["high"].values, df["low"].values, df["close"].values pc = np.roll(c, 1); pc[0] = c[0] tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc))) str_ = pd.Series(tr).rolling(n).sum().values hh = pd.Series(h).rolling(n).max().values ll = pd.Series(l).rolling(n).min().values rng = hh - ll with np.errstate(divide="ignore", invalid="ignore"): ci = 100.0 * np.log10(str_ / rng) / np.log10(n) return ci def var_ratio(close, k=30, win=100): r1 = pd.Series(close).pct_change() rk = pd.Series(close).pct_change(k) v1 = r1.rolling(win).var().values vk = rk.rolling(win).var().values with np.errstate(divide="ignore", invalid="ignore"): vr = vk / (k * v1) return vr def donchian_levels(df, n): hh = pd.Series(df["high"].values).rolling(n).max().shift(1).values ll = pd.Series(df["low"].values).rolling(n).min().shift(1).values return hh, ll # ----------------------- generatori di segnale (candidati) ----------------------- def gen_donchian_base(df, n=20, sl_atr=2.0, max_bars=24, trend_max=None, ema_long=200, gate=None, use_sl=True): """gate(i)->bool: True = consenti il segnale alla barra i. None = sempre. use_sl=False -> sl=None (no-SL).""" h, l, c = df["high"].values, df["low"].values, df["close"].values a = atr(df, 14); hh, ll = donchian_levels(df, n); em = ema(c, ema_long) ents = [] for i in range(max(n, ema_long, 14) + 1, len(c)): if np.isnan(hh[i]) or np.isnan(ll[i]) or np.isnan(a[i]) or a[i] <= 0: continue if trend_max is not None and not np.isnan(em[i]) and abs(c[i] - em[i]) / a[i] > trend_max: continue if gate is not None and not gate(i): continue tp = (hh[i] + ll[i]) / 2.0 if c[i] < ll[i] and c[i - 1] >= ll[i - 1]: ents.append({"i": i, "d": 1, "tp": tp, "sl": (c[i] - sl_atr * a[i]) if use_sl else None, "max_bars": max_bars}) elif c[i] > hh[i] and c[i - 1] <= hh[i - 1]: ents.append({"i": i, "d": -1, "tp": tp, "sl": (c[i] + sl_atr * a[i]) if use_sl else None, "max_bars": max_bars}) return ents # ----------------------- engine intrabar + overlay opzione (per i candidati no-SL) ----------------------- _HOURS_YEAR = 24 * 365.0 def build_trades_hedged(entries, df, dvol, otm=0.10, skew=1.10, tenor_mult=1.0, fee_rt=FEE_RT, lev=LEV): from scripts.analysis.option_overlay_lab import bs_put, bs_call h, l, c = df["high"].values, df["low"].values, df["close"].values n = len(c); out = []; last = -1 for e in entries: i, d = e["i"], e["d"] if i <= last or i + 1 >= n: continue entry = c[i]; tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"] exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1) for k in range(1, mb + 1): j = i + k if j >= n: exit_p = c[n - 1]; break hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)) hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)) if hit_sl: exit_p = sl; break if hit_tp: exit_p = tp; break if k == mb: exit_p = c[j] base = (exit_p - entry) / entry * d * lev - fee_rt * lev T = max(mb * tenor_mult, 1.0) / _HOURS_YEAR; sig = dvol[i]; sigb = sig * skew if d == 1: K = entry * (1.0 - otm); prem = bs_put(entry, K, T, sigb) / entry; pay = max(K - exit_p, 0.0) / entry else: K = entry * (1.0 + otm); prem = bs_call(entry, K, T, sigb) / entry; pay = max(exit_p - K, 0.0) / entry out.append((i, j, base + lev * (pay - prem))); last = j return out def cand_choppiness_gate_fade(df): ci = choppiness(df, 14) return gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=None, gate=lambda i: not np.isnan(ci[i]) and ci[i] >= 50.0) def cand_choppiness_donchian(df): ci = choppiness(df, 14) return gen_donchian_base(df, n=14, sl_atr=2.0, trend_max=3.0, gate=lambda i: not np.isnan(ci[i]) and ci[i] > 61.8) def cand_varratio_gate_fade(df): vr = var_ratio(df["close"].values, k=30, win=100) return gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0, gate=lambda i: not np.isnan(vr[i]) and vr[i] < 0.95) def cand_baseline_recon(df): """MR02/ETH canonico ricostruito col MIO engine (sanity check vs all_sleeve_equities).""" return gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0) def cand_vrp_neg_dvol_low(df): from scripts.analysis.regime_lab import load, regime_features rdf = load("ETH", "1h") R = regime_features(rdf) # allinea per indice posizionale (regime_lab.load parte da get_df, stesso ordinamento) vrp = R["vrp"]; dvp = R["dvol_pct"] m = min(len(df), len(vrp)) def gate(i): if i >= m: return False return (not np.isnan(vrp[i]) and vrp[i] < 0) and (not np.isnan(dvp[i]) and dvp[i] < 0.60) return gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=None, gate=gate) # ----------------------- gate PORT06 ----------------------- def port_metrics(members, ids): w = W.weight_vector("cap", ids, None, caps=CAPS) drp = port_returns({i: members[i] for i in ids}, w) return metrics(drp), metrics(drp, lo=SPLIT) def main(): print("=" * 104) print(f" GATE PORT06 — sostituire MR02/ETH | finestra {IDX[0].date()}..{IDX[-1].date()} | OOS da {OOS_DATE}") print("=" * 104) eq = dict(all_sleeve_equities()) ids = [k for k in eq if k in { "MR01_BTC","MR02_BTC","MR07_BTC","MR01_ETH","MR02_ETH","MR07_ETH", "DIP01_BTC","TR01_basket","ROT02_rot", "PR_ETHBTC","PR_LTCETH","PR_ADAETH","PR_BTCLTC","PR_ETHSOL","TSM01","SH_BTC","SH_ETH"}] print(f" sleeve PORT06: {len(ids)} | MR02_ETH presente: {'MR02_ETH' in ids}") f_b, o_b = port_metrics(eq, ids) print(f"\n {'variante':<22s}{'FULL Sh':>8s}{'FULL DD':>8s}{'FULL CAGR':>10s} |{'OOS Sh':>8s}{'OOS DD':>8s}{'OOS CAGR':>9s}") print(" " + "-" * 100) print(f" {'BASELINE (canonico)':<22s}{f_b['sharpe']:>8.2f}{f_b['dd']:>8.2f}{f_b['cagr']:>9.0f}% |{o_b['sharpe']:>8.2f}{o_b['dd']:>8.2f}{o_b['cagr']:>8.0f}%") df = get_df("ETH", "1h") from scripts.analysis.option_overlay_lab import dvol_for dvol = dvol_for(df, "ETH") # candidati: (nome, builder) dove builder(df)->trades def b_signal(fn): return lambda df: build_trades(fn(df), df) base_ents = gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0) def b_exit16(df): return build_trades_exit16(base_ents, df, sl_confirm=0.5) def b_exit16_put10(df): return build_trades_exit16(base_ents, df, sl_confirm=0.5, dvol=dvol, otm=0.10) def b_noSL(df): return build_trades(gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0, use_sl=False), df) def b_noSL_put10(df): ents = gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0, use_sl=False) return build_trades_hedged(ents, df, dvol, otm=0.10) cands = { "MR02recon(sanity)": b_signal(cand_baseline_recon), "varratio_gate": b_signal(cand_varratio_gate_fade), "choppiness_donch": b_signal(cand_choppiness_donchian), "vrp_neg_dvol_low": b_signal(cand_vrp_neg_dvol_low), "EXIT16(live)": b_exit16, "EXIT16+put10%OTM": b_exit16_put10, "noSL_raw": b_noSL, "noSL_put10%OTM": b_noSL_put10, } rows = [] equ = {} # salva le equity per i blend for name, fn in cands.items(): try: tr = fn(df) ce = daily_equity(tr, df) equ[name] = ce m2 = dict(eq); m2["MR02_ETH"] = ce f_c, o_c = port_metrics(m2, ids) rows.append((name, len(tr), f_c, o_c)) print(f" {name:<22s}{f_c['sharpe']:>8.2f}{f_c['dd']:>8.2f}{f_c['cagr']:>9.0f}% |{o_c['sharpe']:>8.2f}{o_c['dd']:>8.2f}{o_c['cagr']:>8.0f}%" f" ({len(tr)} trade)") except Exception as ex: print(f" {name:<22s} ERRORE: {ex}") # ---- BLEND within-sleeve: riempi lo sleeve ETH con EXIT-16 + un candidato decorrelato ---- print(" " + "-" * 100 + "\n BLEND within-sleeve (lo sleeve ETH = mix di 2 strategie, peso PORT06 invariato):") blends = { "50/50 EXIT16+varratio": (["EXIT16(live)", "varratio_gate"], [0.5, 0.5]), "50/50 EXIT16+chopDonch": (["EXIT16(live)", "choppiness_donch"], [0.5, 0.5]), "50/50 EXIT16+vrp": (["EXIT16(live)", "vrp_neg_dvol_low"], [0.5, 0.5]), "70/30 EXIT16+vrp": (["EXIT16(live)", "vrp_neg_dvol_low"], [0.7, 0.3]), "50/50 EXIT16put+vrp": (["EXIT16+put10%OTM", "vrp_neg_dvol_low"], [0.5, 0.5]), "tri EXIT16put+vrp+chop": (["EXIT16+put10%OTM", "vrp_neg_dvol_low", "choppiness_donch"], [0.5, 0.25, 0.25]), } for name, (keys, wts) in blends.items(): try: be = blend_equity([equ[k] for k in keys], wts) m2 = dict(eq); m2["MR02_ETH"] = be f_c, o_c = port_metrics(m2, ids) rows.append((name, -1, f_c, o_c)) print(f" {name:<22s}{f_c['sharpe']:>8.2f}{f_c['dd']:>8.2f}{f_c['cagr']:>9.0f}% |{o_c['sharpe']:>8.2f}{o_c['dd']:>8.2f}{o_c['cagr']:>8.0f}%") except Exception as ex: print(f" {name:<22s} ERRORE: {ex}") # riferimento ONESTO = EXIT-16 (config LIVE), non il canonico intrabar-SL ex = next((r for r in rows if r[0] == "EXIT16(live)"), None) f_l, o_l = (ex[2], ex[3]) if ex else (f_b, o_b) print("\n " + "=" * 100) print(f" GATE vs LIVE EXIT-16 (FULL {f_l['sharpe']:.2f}/{f_l['dd']:.2f} OOS {o_l['sharpe']:.2f}/{o_l['dd']:.2f}):") print(" MIGLIORIA = nessuna metrica peggiora oltre il rumore E almeno una migliora (Sharpe +>=0.03 o DD -)") print(" " + "-" * 100) for name, ntr, f_c, o_c in rows: if name.startswith("MR02recon") or name == "EXIT16(live)": continue dfs, dfd = f_c['sharpe'] - f_l['sharpe'], f_c['dd'] - f_l['dd'] dos, dod = o_c['sharpe'] - o_l['sharpe'], o_c['dd'] - o_l['dd'] no_worse = dfs >= -0.03 and dos >= -0.03 and dfd <= 0.05 and dod <= 0.03 better = dfs >= 0.03 or dos >= 0.03 or dfd <= -0.03 or dod <= -0.03 ok = no_worse and better print(f" {name:<22s} ΔFULL Sh {dfs:+.2f} DD {dfd:+.2f} | ΔOOS Sh {dos:+.2f} DD {dod:+.2f} -> " f"{'>>> MIGLIORIA' if ok else ('= pari' if no_worse else 'peggiora')}") if __name__ == "__main__": main()