df8bfbceaa
Ricerca sostituto/miglioria a MR02/ETH (127 strategie + 18 overlay opzioni, verifica avversariale, gate PORT06). Esito: miglioria = no-SL (gia' ~catturata da EXIT-16 live); tail-hedge opzioni NO-GO empirico su prezzi reali. Infrastruttura opzioni REALE (muro ARGO/GEX caduto): - options_fetcher.py: importa lo storico per-strike + market_snapshots da cerbero-bite -> data/options/ (chain bid/ask/IV/greche/OI + pannello regime con net-GEX dealer/gamma-flip/VRP/funding/liquidation). - options_chain.py: loader + skew_curve/premium_levels (aggregati reali) + quote() causale + load_market() (pannello regime). - option_overlay_lab.py: overlay opzioni BS su DVOL (pricing sintetico). - mr02eth_port06_gate.py / eth_collar_gate.py: gate swap-sleeve e collar. - mr02eth_search/options.workflow.js: i 2 workflow. Numeri reali: skew put 10%OTM ~1.1 (liquido), premio ~1.0%/mese; niente strike 10%OTM a 24h (overlay per-trade infattibile); collar standing paga nei crash ma net-negativo a PORT06 (alza OOS DD). Diario + CLAUDE.md aggiornati. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
339 lines
15 KiB
Python
339 lines
15 KiB
Python
"""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()
|