From 3accc91f8464049be56dea8fdd2e7ef964a210b6 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 4 Jun 2026 19:48:45 +0000 Subject: [PATCH] =?UTF-8?q?docs(diary):=20exit=20ladder=2080/20=20con=20ru?= =?UTF-8?q?nner+stop=20dinamico=20SCARTATO=20(il=20runner=20non=20corre:?= =?UTF-8?q?=20TP=3Dmedia,=20~3%=20oltre=20TP,=20=E2=88=9238%=20ret=20su=20?= =?UTF-8?q?MR02=20ETH)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test onesto scripts/analysis/partial_tp_ladder.py: stessi segnali, engine intrabar fade_base, fee identiche. Il ladder compra win-rate/DD pagando i winner migliori — stesso profilo del vol-target gia' scartato. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/diary/2026-06-04-partial-tp-ladder.md | 63 ++++++++ scripts/analysis/partial_tp_ladder.py | 164 +++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 docs/diary/2026-06-04-partial-tp-ladder.md create mode 100644 scripts/analysis/partial_tp_ladder.py diff --git a/docs/diary/2026-06-04-partial-tp-ladder.md b/docs/diary/2026-06-04-partial-tp-ladder.md new file mode 100644 index 0000000..cdefaed --- /dev/null +++ b/docs/diary/2026-06-04-partial-tp-ladder.md @@ -0,0 +1,63 @@ +# 2026-06-04 — Exit ladder (80% del TP → esci 80%, runner con stop dinamico): SCARTATO + +## La proposta + +"E se all'80% del TP usciamo con l'80% della posizione, mettiamo uno SL dinamico +su quella soglia (profitto lockato) e lasciamo correre il 20% restante?" + +Idea: uscita scalare — bancare prima la maggior parte del profitto (la soglia +all'80% viene toccata più spesso del TP pieno) e tenere un runner free-ride con +worst-case alla soglia, best-case oltre il TP. + +## Il test (onesto, stessi segnali) + +`scripts/analysis/partial_tp_ladder.py`: replay intrabar degli STESSI segnali +delle 3 fade (params live: `trend_max=3.0, ema_long=200, hurst_max=0.55, +min_tp_frac=0.0015`), engine identico a `fade_base` (ingresso a `close[i]`, +SL pieno prioritario nello stesso bar, fee 0.10% RT × leva 3 — il ladder NON +paga fee extra: due uscite ma stesso notional). Convenzioni conservative: se il +bar che tocca la soglia chiude oltre (rientro), il runner è stoppato subito. +Griglia: base vs ladder (f=0.8 q=0.8 — la proposta —, f=0.8 q=0.5, f=0.5 q=0.5), +FULL + OOS (nov 2023→), BTC+ETH. + +## Risultati (proposta f0.8 q0.8) + +| Sleeve | ret% FULL base→ladder | DD% | ret% OOS | win% OOS | +|---|---|---|---|---| +| MR01 BTC | 92 → 92 | 13.8 → 10.0 | 41 → 39 | 60 → 65 | +| MR01 ETH | 194 → 195 | 16.5 → 15.3 | 134 → 147 | 70 → 75 | +| MR02 BTC | 129 → 112 | 19.0 → 17.2 | 66 → 54 | 55 → 59 | +| **MR02 ETH** | **2135 → 1319** | 16.2 → 12.7 | **893 → 651** | 75 → 80 | +| MR07 BTC | 78 → 78 | 6.8 → 5.2 | 43 → 40 | 64 → 66 | +| MR07 ETH | 115 → 96 | 10.6 → 10.6 | 68 → 64 | 66 → 70 | + +## Verdetto: NO-GO (gate fallito) + +1. **Il runner non corre.** Su centinaia di trade finisce oltre il TP quasi mai + (~3% dei partial; 0-15 casi per sleeve) e viene **stoppato alla soglia nel + ~60% dei tocchi**. È la fisica mean-reversion: il TP delle fade sta ALLA + MEDIA — lì il movimento è esaurito per costruzione. Il runner è un + trend-follow della coda, e i breakout rientrano (lezione squeeze). +2. **Il costo è concentrato sui winner migliori**: ogni TP pieno ora esce + all'80% della strada sulla quota grossa → MR02 ETH (lo sleeve più forte) + −38% ret FULL / −27% OOS. Sharpe per-trade ~piatto: non migliora il + rischio/rendimento, scambia ritorno con DD. +3. **Quello che compra** (win-rate +4-5pp, DD −1-4pp) è lo stesso profilo del + **vol-target sizing già scartato** ("abbassa il DD ma sacrifica ritorno"); + il lavoro anti-DD lo fa già il loss-guard Hurst, che il gate l'ha passato + perché taglia SOLO i loser. + +Nota dal grid: `f0.5 q0.5` su MR07 ETH fa Sharpe OOS 6.58 / win 82%, ma +distrugge MR02 (2135→531) → nessuna variante robusta su tutti gli sleeve, +niente cherry-picking per-sleeve. + +Argomento esecutivo in più: il ladder raddoppierebbe la complessità dello +shadow appena deployato (v1.0.7: limit reduce-only al TP) — due resting order +per posizione, due riconciliazioni, e lo stop dinamico avrebbe il problema dei +trigger Deribit (nuovo order_id al trigger → fill non verificabile per +order_id). + +**Conferma della lezione di famiglia:** i tweak d'exit che toccano i winner +falliscono il gate (time-stop, vol-target, ADX, vratio… e ora il ladder); +l'unico anti-perdite che passa resta il loss-guard Hurst, che riduce +l'esposizione nel regime tossico senza toccare le uscite. diff --git a/scripts/analysis/partial_tp_ladder.py b/scripts/analysis/partial_tp_ladder.py new file mode 100644 index 0000000..4f18b18 --- /dev/null +++ b/scripts/analysis/partial_tp_ladder.py @@ -0,0 +1,164 @@ +"""Exit 'ladder' per le fade: al tocco di una frazione f del TP esce la quota q, +il runner (1-q) corre con STOP DINAMICO bloccato alla soglia (profitto lockato). + +Proposta 2026-06-04 ("e se all'80% del TP usciamo con 80% e mettiamo un SL +dinamico su quella soglia e lo lasciamo correre?"). Confronto ONESTO con l'exit +canonico (TP pieno al livello) sugli STESSI segnali, stesso engine intrabar di +fade_base (ingresso a close[i], SL prioritario nello stesso bar, fee 0.10% RT +x leva su tutto il notional — il ladder NON paga fee extra: due uscite ma +stesso notional totale). + +Convenzioni intrabar del ladder (oneste/conservative): + - SL pieno prioritario sulla soglia nello stesso bar; + - se il bar che tocca la soglia CHIUDE oltre la soglia (rientro), il runner + si considera stoppato subito alla soglia (non gli si regala il bar); + - il runner non ha TP (corre), esce su ri-tocco della soglia o a max_bars. + +Gate (metodologia repo): il ladder deve migliorare ret E DD (o chiaramente il +rischio/rendimento) su ENTRAMBI gli asset, full E OOS, per tutte e 3 le fade. + + uv run python scripts/analysis/partial_tp_ladder.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 src.data.downloader import load_data # noqa: E402 +from src.live.strategy_loader import load_strategy # noqa: E402 + +LIVE_PARAMS = dict(trend_max=3.0, ema_long=200, hurst_max=0.55, min_tp_frac=0.0015) +OOS_START = pd.Timestamp("2023-11-01", tz="UTC") +LEV, POS, FEE_RT = 3.0, 0.15, 0.001 +CODES = ["MR01_bollinger_fade", "MR02_donchian_fade", "MR07_return_reversal"] + + +def simulate(signals, df, ts, policy: str, f: float = 0.8, q: float = 0.8, + start=None) -> dict: + """Replay intrabar degli stessi segnali con exit 'base' o 'ladder'.""" + h, l, c = df["high"].values, df["low"].values, df["close"].values + n = len(c) + fee = FEE_RT * LEV + capital = peak = 1000.0 + max_dd = 0.0 + last_exit = -1 + trades = wins = 0 + runner_beyond_tp = runner_stopped = 0 + rets = [] + + for sig in signals: + i, d = sig.idx, sig.direction + if start is not None and ts.iloc[i] < start: + continue + if i <= last_exit or i + 1 >= n: + continue + entry = c[i] + tp, sl, mb = sig.metadata["tp"], sig.metadata["sl"], sig.metadata["max_bars"] + j = i + + if policy == "base": + exit_p = c[min(i + mb, n - 1)] + for step in range(1, mb + 1): + j = i + step + if j >= n: + j = n - 1; exit_p = c[j]; break + hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl) + hit_tp = (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 step == mb: + exit_p = c[j] + ret = (exit_p - entry) / entry * d * LEV - fee + else: # ladder + th = entry + f * (tp - entry) # soglia f del percorso verso il TP + p1 = p2 = None # fill quota q / runner (1-q) + state = "full" + for step in range(1, mb + 1): + j = i + step + if j >= n: + j = n - 1 + if p1 is None: + p1 = c[j] + p2 = c[j] + break + if state == "full": + hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl) + hit_th = (d == 1 and h[j] >= th) or (d == -1 and l[j] <= th) + if hit_sl: # SL pieno prioritario (conservativo) + p1 = p2 = sl; break + if hit_th: + p1 = th; state = "runner" + # il bar chiude oltre la soglia -> runner stoppato subito + if (d == 1 and c[j] < th) or (d == -1 and c[j] > th): + p2 = th; runner_stopped += 1; break + if step == mb: + p2 = c[j]; break + continue + if step == mb: + p1 = p2 = c[j]; break + else: # runner: stop dinamico = soglia + hit_stop = (d == 1 and l[j] <= th) or (d == -1 and h[j] >= th) + if hit_stop: + p2 = th; runner_stopped += 1; break + if step == mb: + p2 = c[j]; break + if p2 is not None and (p2 - tp) * d > 0: + runner_beyond_tp += 1 + ret = (q * (p1 - entry) + (1 - q) * (p2 - entry)) / entry * d * LEV - fee + + capital = max(capital + capital * POS * ret, 10.0) + peak = max(peak, capital) + max_dd = max(max_dd, (peak - capital) / peak) + last_exit = j + trades += 1 + wins += ret > 0 + rets.append(ret) + + if trades == 0: + return {} + rets = np.array(rets) + return { + "ret_pct": (capital / 1000.0 - 1) * 100, + "dd_pct": max_dd * 100, + "trades": trades, + "win_pct": wins / trades * 100, + "avg_ret_bps": rets.mean() * 1e4, + "sharpe_t": rets.mean() / rets.std() * np.sqrt(len(rets)) if rets.std() else 0, + "runner_beyond_tp": runner_beyond_tp, + "runner_stopped": runner_stopped, + } + + +def main() -> None: + variants = [("base", None, None), ("ladder", 0.8, 0.8), + ("ladder", 0.8, 0.5), ("ladder", 0.5, 0.5)] + for code in CODES: + strat = load_strategy(code) + for asset in ("BTC", "ETH"): + df = load_data(asset, "1h") + ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + signals = strat.generate_signals(df, ts, **LIVE_PARAMS) + print(f"\n=== {code} {asset} 1h — {len(signals)} segnali (params live) ===") + print(f"{'policy':<16}{'periodo':<6}{'ret%':>10}{'DD%':>8}{'win%':>7}" + f"{'avg bps':>9}{'Sh(t)':>7}{'n':>6}{'run>TP':>8}{'run-stop':>9}") + for policy, f, q in variants: + tag = "base" if policy == "base" else f"ladder f{f} q{q}" + for label, start in (("FULL", None), ("OOS", OOS_START)): + r = simulate(signals, df, ts, policy, f or 0.8, q or 0.8, start) + if not r: + continue + print(f"{tag:<16}{label:<6}{r['ret_pct']:>10.0f}{r['dd_pct']:>8.1f}" + f"{r['win_pct']:>7.1f}{r['avg_ret_bps']:>9.1f}{r['sharpe_t']:>7.2f}" + f"{r['trades']:>6}{r['runner_beyond_tp']:>8}{r['runner_stopped']:>9}") + + +if __name__ == "__main__": + main()