research(exit-lab): 34 agenti su exit dinamiche → EXIT-16 close-confirm SL PROMOSSO a livello PORT06
23 famiglie esplorate (harness condiviso exit_lab, train/OOS embargo nov-2023, tutto lo storico 1h 2018-2026) + 10 verifiche avversariali + test PORT06. 'Cavalcare il prezzo' non esiste (4a conferma: oltre il TP=media non c'e' coda). Scoperta: lo SL intrabar fisso e' il distruttore di valore n.1 delle fade (stop da wick = falsi negativi). Forma robusta: SL solo su CHIUSURA oltre sl0±0.5·ATR14 — PORT06 FULL Sharpe 6.47→7.84 DD 4.10→2.60, OOS 8.82→10.06. Collaterali: bias gap-through dell'engine sugli stop stretti; ramo -2% del worker morto con sl=0. Diario: docs/diary/2026-06-04-exit-lab.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
"""TEST DECISIVO: impatto di EXIT-16 (close_confirm_sl, buffer=0.5 ATR) sul PORT06,
|
||||
nel PATH CANONICO del backtest (NON exit_lab).
|
||||
|
||||
EXIT-16: lo SL intrabar e' DISATTIVATO; si esce al close del bar j solo se il close
|
||||
ha sfondato il livello di buffer*ATR14:
|
||||
long (d=1): esci a close[j] se close[j] < sl0 - 0.5*atr14[j]
|
||||
short (d=-1): esci a close[j] se close[j] > sl0 + 0.5*atr14[j]
|
||||
TP intrabar al livello e max_bars al close restano INVARIATI.
|
||||
|
||||
Metodo (come fu fatto per il loss-guard Hurst):
|
||||
1. build_everything() canonico -> equity giornaliere di TUTTI gli sleeve (cache intatta).
|
||||
2. ricostruisco le 6 equity fade in variante EXIT-16 replicando ESATTAMENTE
|
||||
fade_daily_equity/build_trades (stessi segnali fn(df,**params), trend_max=3.0,
|
||||
fee 0.10%RT*lev3, pos 0.15, compounding, non-overlap), cambiando SOLO il ramo SL.
|
||||
3. PARITA': con la SL-rule originale il replay deve riprodurre le equity canoniche.
|
||||
4. PORT06 base vs EXIT-16 con la STESSA matematica dei pesi (Portfolio.backtest):
|
||||
weighting cap, caps PAIRS 0.33, ribilancio 1D, metriche FULL e OOS.
|
||||
|
||||
NB: la leva 2x del portfolios.yml NON entra nel backtest (Portfolio.backtest la ignora;
|
||||
e' un knob live). Le equity fade gia' includono lev=3 dentro build_trades.
|
||||
"""
|
||||
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 src.data.downloader import load_data
|
||||
from scripts.analysis.strategy_research import atr
|
||||
from scripts.analysis.risk_management import strats_for, FEE_RT, LEV, POS, INIT
|
||||
from scripts.analysis.combine_portfolio import (
|
||||
fade_daily_equity, _norm, IDX, port_returns, metrics, SPLIT, OOS_DATE,
|
||||
)
|
||||
from scripts.portfolios._defs import PORTFOLIOS
|
||||
from src.portfolio import weighting as W
|
||||
|
||||
BUFFER = 0.5 # EXIT-16 promossa: close-confirm con buffer 0.5 ATR
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- engine replay
|
||||
def build_trades_variant(ents, df, mode, buffer=BUFFER,
|
||||
lev=LEV, fee_rt=FEE_RT, trend_max=3.0, ema_long=200):
|
||||
"""Replica ESATTA di risk_management.build_trades, cambiando SOLO il ramo SL.
|
||||
|
||||
mode="orig" : SL intrabar al livello (SL prima del TP) == canonico.
|
||||
mode="exit16" : SL intrabar DISATTIVATO; close-confirm sul close[j]:
|
||||
long esci a close[j] se close[j] < sl0 - buffer*atr14[j]
|
||||
short esci a close[j] se close[j] > sl0 + buffer*atr14[j]
|
||||
TP intrabar al livello e max_bars al close INVARIATI.
|
||||
"""
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
n = len(c)
|
||||
a = atr(df, 14)
|
||||
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
|
||||
fee = fee_rt * lev
|
||||
out = []
|
||||
last = -1
|
||||
for e in ents:
|
||||
i, d = e["i"], e["d"]
|
||||
if i <= last or i + 1 >= n:
|
||||
continue
|
||||
if trend_max is not None and a[i] and abs(c[i] - el[i]) / a[i] > trend_max:
|
||||
continue
|
||||
entry = c[i]
|
||||
tp, sl0, mb = e["tp"], e["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
|
||||
if mode == "orig":
|
||||
hs = (d == 1 and l[j] <= sl0) or (d == -1 and h[j] >= sl0)
|
||||
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if hs:
|
||||
exit_p = sl0
|
||||
break
|
||||
if ht:
|
||||
exit_p = tp
|
||||
break
|
||||
if k == mb:
|
||||
exit_p = c[j]
|
||||
else: # exit16: no SL intrabar; TP intrabar; poi close-confirm SL al close[j]
|
||||
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if ht:
|
||||
exit_p = tp
|
||||
break
|
||||
aj = a[j] if np.isfinite(a[j]) else 0.0
|
||||
confirm = (d == 1 and c[j] < sl0 - buffer * aj) or \
|
||||
(d == -1 and c[j] > sl0 + buffer * aj)
|
||||
if confirm:
|
||||
exit_p = c[j]
|
||||
break
|
||||
if k == mb:
|
||||
exit_p = c[j]
|
||||
ret = (exit_p - entry) / entry * d * lev - fee
|
||||
out.append((i, j, ret))
|
||||
last = j
|
||||
return out
|
||||
|
||||
|
||||
def fade_equity_variant(asset, fn, params, mode):
|
||||
"""Stesso flusso di combine_portfolio.fade_daily_equity ma con build_trades_variant."""
|
||||
df = load_data(asset, "1h")
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
trades = build_trades_variant(fn(df, **params), df, mode=mode, trend_max=3.0)
|
||||
n = len(df)
|
||||
eq = np.full(n, INIT, dtype=float)
|
||||
cap = INIT
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pesi PORT06
|
||||
def port_metrics(members: dict[str, pd.Series], weights: dict[str, float]):
|
||||
dr = port_returns(members, weights)
|
||||
return metrics(dr), metrics(dr, lo=SPLIT)
|
||||
|
||||
|
||||
def main():
|
||||
p = PORTFOLIOS["PORT06"]
|
||||
fade_ids = [s.sid for s in p.sleeves if s.sid.startswith("MR")]
|
||||
print("=" * 96)
|
||||
print(" TEST DECISIVO EXIT-16 (close_confirm_sl buffer=0.5 ATR) su PORT06 — path canonico")
|
||||
print(f" fade sleeve: {fade_ids}")
|
||||
print("=" * 96)
|
||||
|
||||
# --- 1. equity canoniche di TUTTI gli sleeve (cache intatta) ---
|
||||
print("\n[1] build_everything() canonico (pesante, ~2-3 min)...")
|
||||
from src.portfolio.sleeves import all_sleeve_equities
|
||||
eq_base = dict(all_sleeve_equities()) # {sid: equity giornaliera}
|
||||
print(f" sleeve totali: {len(eq_base)}")
|
||||
|
||||
# --- 2. PARITA': replay 'orig' deve riprodurre le equity canoniche ---
|
||||
print("\n[2] PARITA' replay (mode=orig) vs canonico (fade_daily_equity):")
|
||||
print(f" {'sleeve':<10s}{'corr':>10s}{'ret_canon%':>14s}{'ret_replay%':>14s}{'diff%':>9s}")
|
||||
parity_ok = True
|
||||
eq_orig, eq_e16 = {}, {}
|
||||
for asset in ("BTC", "ETH"):
|
||||
for nm, (fn, params) in strats_for(asset).items():
|
||||
sid = f"{nm}_{asset}"
|
||||
if sid not in fade_ids:
|
||||
continue
|
||||
eq_orig[sid] = fade_equity_variant(asset, fn, params, mode="orig")
|
||||
eq_e16[sid] = fade_equity_variant(asset, fn, params, mode="exit16")
|
||||
base = eq_base[sid]
|
||||
rep = eq_orig[sid]
|
||||
corr = base.pct_change().fillna(0).corr(rep.pct_change().fillna(0))
|
||||
rb = (base.iloc[-1] / base.iloc[0] - 1) * 100
|
||||
rr = (rep.iloc[-1] / rep.iloc[0] - 1) * 100
|
||||
diff = rr - rb
|
||||
flag = "" if (corr > 0.999 and abs(diff) <= max(1.0, abs(rb) * 0.01)) else " <-- MISMATCH"
|
||||
if flag:
|
||||
parity_ok = False
|
||||
print(f" {sid:<10s}{corr:>10.5f}{rb:>14.1f}{rr:>14.1f}{diff:>+9.2f}{flag}")
|
||||
print(f"\n PARITA' {'OK' if parity_ok else 'FALLITA'} "
|
||||
f"(corr>0.999 e ret finale entro 1%).")
|
||||
if not parity_ok:
|
||||
print("\n >>> Parita' non raggiunta: NON forzo. Diagnostico sopra. STOP.")
|
||||
return
|
||||
|
||||
# --- 3. PORT06 base vs EXIT-16: stessi pesi cap, stessa matematica ---
|
||||
members_base = dict(eq_base)
|
||||
members_e16 = dict(eq_base)
|
||||
for sid in fade_ids:
|
||||
members_e16[sid] = eq_e16[sid] # sostituisco SOLO le 6 colonne fade
|
||||
|
||||
ids = p.sleeve_ids
|
||||
# pesi cap canonici (gli stessi che usa Portfolio.backtest)
|
||||
dr_base = pd.DataFrame({i: members_base[i].pct_change().fillna(0.0) for i in ids})
|
||||
w_base = W.weight_vector(p.weighting, ids, dr_base, weights=p.weights,
|
||||
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
||||
dr_e16 = pd.DataFrame({i: members_e16[i].pct_change().fillna(0.0) for i in ids})
|
||||
w_e16 = W.weight_vector(p.weighting, ids, dr_e16, weights=p.weights,
|
||||
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
||||
|
||||
f_b, o_b = port_metrics({i: members_base[i] for i in ids}, w_base)
|
||||
f_e, o_e = port_metrics({i: members_e16[i] for i in ids}, w_e16)
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print(f" [3] PORT06 — pesi={p.weighting} caps={p.caps} | OOS da {OOS_DATE} | leva3x interna fade, pos0.15")
|
||||
print("=" * 96)
|
||||
print(f" {'variante':<14s}{'FULL Sh':>9s}{'FULL DD%':>10s}{'FULL CAGR':>11s}"
|
||||
f" | {'OOS Sh':>8s}{'OOS DD%':>9s}{'OOS CAGR':>10s}")
|
||||
print(" " + "-" * 90)
|
||||
print(f" {'BASE':<14s}{f_b['sharpe']:>9.2f}{f_b['dd']:>10.2f}{f_b['cagr']:>10.0f}%"
|
||||
f" | {o_b['sharpe']:>8.2f}{o_b['dd']:>9.2f}{o_b['cagr']:>9.0f}%")
|
||||
print(f" {'EXIT-16':<14s}{f_e['sharpe']:>9.2f}{f_e['dd']:>10.2f}{f_e['cagr']:>10.0f}%"
|
||||
f" | {o_e['sharpe']:>8.2f}{o_e['dd']:>9.2f}{o_e['cagr']:>9.0f}%")
|
||||
print(" " + "-" * 90)
|
||||
print(f" {'DELTA':<14s}{f_e['sharpe']-f_b['sharpe']:>+9.2f}{f_e['dd']-f_b['dd']:>+10.2f}"
|
||||
f"{f_e['cagr']-f_b['cagr']:>+10.0f}% | {o_e['sharpe']-o_b['sharpe']:>+8.2f}"
|
||||
f"{o_e['dd']-o_b['dd']:>+9.2f}{o_e['cagr']-o_b['cagr']:>+9.0f}%")
|
||||
|
||||
# --- per-sleeve fade: differenze principali ---
|
||||
print("\n Per-sleeve fade (equity FULL ret%, EXIT-16 vs orig-replay):")
|
||||
print(f" {'sleeve':<10s}{'orig ret%':>12s}{'exit16 ret%':>14s}{'delta%':>10s}"
|
||||
f"{'orig DD%':>10s}{'e16 DD%':>10s}")
|
||||
for sid in fade_ids:
|
||||
ro = eq_orig[sid]; re = eq_e16[sid]
|
||||
def _dd(s):
|
||||
pk = s.cummax(); return float(((pk - s) / pk).max() * 100)
|
||||
rro = (ro.iloc[-1] / ro.iloc[0] - 1) * 100
|
||||
rre = (re.iloc[-1] / re.iloc[0] - 1) * 100
|
||||
print(f" {sid:<10s}{rro:>12.1f}{rre:>14.1f}{rre-rro:>+10.1f}"
|
||||
f"{_dd(ro):>10.1f}{_dd(re):>10.1f}")
|
||||
|
||||
# --- GATE ---
|
||||
print("\n" + "=" * 96)
|
||||
print(" GATE (stesso del loss-guard): PROMOSSO se OOS Sharpe migliora/pari E DD non peggiora")
|
||||
print(" materialmente, E in FULL non degrada.")
|
||||
print("=" * 96)
|
||||
oos_sh_ok = o_e['sharpe'] >= o_b['sharpe'] - 0.02
|
||||
oos_dd_ok = o_e['dd'] <= o_b['dd'] + 0.20 # no peggioramento materiale DD
|
||||
full_ok = f_e['sharpe'] >= f_b['sharpe'] - 0.02 and f_e['dd'] <= f_b['dd'] + 0.20
|
||||
promoted = oos_sh_ok and oos_dd_ok and full_ok
|
||||
print(f" OOS Sharpe {o_b['sharpe']:.2f} -> {o_e['sharpe']:.2f} "
|
||||
f"({'OK' if oos_sh_ok else 'KO'})")
|
||||
print(f" OOS DD% {o_b['dd']:.2f} -> {o_e['dd']:.2f} "
|
||||
f"({'OK' if oos_dd_ok else 'KO'})")
|
||||
print(f" FULL Sharpe {f_b['sharpe']:.2f} -> {f_e['sharpe']:.2f} | "
|
||||
f"FULL DD {f_b['dd']:.2f} -> {f_e['dd']:.2f} ({'OK' if full_ok else 'KO'})")
|
||||
print("\n VERDETTO: " + (">>> PROMOSSO <<<" if promoted else ">>> BOCCIATO <<<"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user