diff --git a/docs/diary/2026-06-04-exit-lab.md b/docs/diary/2026-06-04-exit-lab.md new file mode 100644 index 0000000..e55c58a --- /dev/null +++ b/docs/diary/2026-06-04-exit-lab.md @@ -0,0 +1,142 @@ +# 2026-06-04 — EXIT LAB: ricerca massiva exit dinamiche (34 agenti) → EXIT-16 close-confirm SL PROMOSSO + +## Obiettivo + +Cercare exit migliori per le fade attive: TP dinamici, SL dinamici/protettivi, +meccanismi per "cavalcare il prezzo". Mandato: ≥20 agenti con specifiche +differenti, guardia anti-overfitting, test su tutto lo storico disponibile. + +**Esito in una riga: "cavalcare" non esiste (17ª conferma), ma la ricerca ha +trovato una cosa più grossa — LO STOP-LOSS INTRABAR FISSO È IL COMPONENTE CHE +DISTRUGGE PIÙ VALORE NELLE FADE, e la forma robusta del fix (SL confermato in +chiusura, EXIT-16) è PROMOSSA a livello PORT06: OOS Sharpe 8.82→10.06, +FULL DD 4.10→2.60.** + +## Infrastruttura (riusabile) + +- `scripts/analysis/exit_lab.py` — harness CONDIVISO: cache segnali live-params + delle 3 fade (`data/cache/exit_lab_signals.pkl`, 73.6k barre 1h 2018→2026-05, + BTC+ETH), engine intrabar identico a `fade_base` (parità verificata al punto + percentuale su tutti e 6 gli sleeve), contratto anti-look-ahead esplicito + (`levels(j)` solo dati ≤ j-1; `after_bar(j)` sul close = eseguibile al poll), + protocollo train/OOS (selezione parametri SOLO su pre-2023-11; OOS letto una + volta), fill parziali, `ExitPolicy` pluggable. +- `scripts/analysis/exit_policies/` — 23 policy + 10 script di verifica. +- `scripts/analysis/exit16_port06_impact.py` — replay canonico `build_trades`/ + `fade_daily_equity` con parità ESATTA (corr 1.00000) + innesto EXIT-16 + + ricalcolo PORT06 (cap pairs 0.33, leva 2x). + +## Fase 1 — Explore: 23 agenti, una famiglia ciascuno + +Tally: **3 PROMISING, 5 NEUTRAL, 15 REJECT.** + +REJECT (la famiglia "cavalca il prezzo" è stata falcidiata): trail_atr_ride, +trail_pct, breakeven, sar_trail, tp_decay, tp_extend_momentum, tp_moving_mean, +partial_tp_trail, hurst_exit, giveback, loser_timestop, donchian_trail, +ema_cross_exit, vol_rescale, sl_tp_ride. Autopsia ricorrente: **il TP delle +fade sta ALLA MEDIA; lì il movimento è esaurito per costruzione → oltre quel +punto non c'è coda da cavalcare** (il runner finiva oltre il TP ~3% delle +volte). Caso da manuale `trail_atr_ride`: train 5/6 a favore, **OOS 0/6** +(MR01 ETH ret 134%→1%) — senza l'embargo train/OOS l'avremmo promosso. + +NEUTRAL: ratchet, sl_tighten, z_overshoot, wide_sl_trail, swing_stop +(miglioramenti misti, mai 5/6). + +PROMISING — tre meccanismi diversi, UNA stessa tesi ("gli stop intrabar da +wick sono falsi negativi: l'overshoot che buca lo stop è esattamente ciò che +la fade vuole fadare, e rientra"): +- **EXIT-02 trail_atr_keep_tp** (chandelier k=1.5 + TP fisso): 6/6 train, 5/6 OOS. +- **EXIT-16 close_confirm_sl** (SL solo se la barra CHIUDE oltre sl0±0.5·ATR): + **6/6 train E 6/6 OOS**, plateau. +- **EXIT-22 no_sl** (diagnostica: SL rimosso): 6/6+6/6, plateau monotono + perfetto `SL stretto < base < SL 2x < niente SL`. + +## Fase 2 — Verify: 10 avversari (leakage / overfit / stress × 3 + tail-risk) + +- **EXIT-02 CONFUTATO (2/3).** (a) Overfit: è **Pareto-dominato da no_sl a + ZERO parametri** su tutti i 6 sleeve OOS (Sharpe base→trail→noSL: MR01 BTC + 2.77→3.08→5.67; MR02 ETH 12.35→11.41→17.24 — il trail PEGGIORA lo sleeve di + punta) → overfit di selezione: la forma parametrizzata sbagliata di una tesi + giusta. (b) Leakage: codice pulito MA **scoperta di metodo: il 54% degli + stop del trail è GAP-THROUGH** (l'open del bar è già oltre il livello; + gap mediano 62-174 bps): l'engine che filla gli stop "al livello" SOVRASTIMA + ogni policy a stop stretti; col fill realistico `worse(level, open)` il + confronto si ribalta. **Da oggi ogni ricerca con stop tight deve modellare + il gap-through.** Lo SL largo della base ne è quasi immune. +- **EXIT-16 SUPERA 4/4.** Leakage: contratto pulito, robusto a 1-3 bar di lag + e al fill a `open[j+1]` (esce deliberatamente tardi → il timing fine è + irrilevante per costruzione). Overfit: plateau monotono su buffer 0.4→1.0 + FUORI griglia, il ponte SL-largo k=1.5→4 converge, 4 finestre temporali ok, + e **indipendente dal loss-guard Hurst** (segnali rigenerati senza guard: + la tesi regge anche nel regime trending). Stress: fee 2x 6/6, slippage + 20 bps simmetrico 6/6, bear 2021-22 ok. +- **EXIT-22 invalidato SENZA mitigazione (tail-risk).** p99 MAE ~raddoppia + (MR01 ETH −38.8→−59.7%); finestra FTX 2022-11: MR02 ETH **−39% (worst −48%, + MAE −70%)** dove la base faceva +2.7% uscendo in 2-3 barre. E un fatto di + wiring: con `sl=0` il worker cade nel ramo `elif max_bars` → **il fallback + −2% è codice morto: no_sl live girerebbe SENZA ALCUNO stop**. Il disaster-SL + 4× tiene ~95% del ritorno ma il worst pooled resta −47%. +- **Raccomandazione convergente del tail-auditor: EXIT-16** — neutralizza + quasi tutta la coda DA SOLO (p99/worst ≈ base; FTX: +2.4%) mantenendo + l'immunità ai wick. Mai usare uno stop stretto −2% come fallback (cappa + l'edge insieme alla coda). + +## Fase 3 — Test decisivo a livello PORT06 (path canonico) + +Replay di `build_trades`/`fade_daily_equity` con **parità ESATTA** (corr +1.00000, ritorni identici, PORT06 base == `Portfolio.backtest()` al centesimo: +FULL 6.47/4.10%, OOS 8.82/1.30% — i numeri 6.07/8.19 citati in CLAUDE.md sono +pre-refresh-dati). Poi SOLO il ramo SL sostituito col close-confirm 0.5·ATR: + +| PORT06 | FULL Sharpe | FULL DD | FULL CAGR | OOS Sharpe | OOS DD | OOS CAGR | +|--------|------------|---------|-----------|-----------|--------|----------| +| base | 6.47 | 4.10% | 61% | 8.82 | 1.30% | 86% | +| **EXIT-16** | **7.84** | **2.60%** | **79%** | **10.06** | **1.15%** | **102%** | + +Gate (lo stesso del loss-guard Hurst): **PROMOSSO** — migliora OGNI metrica, +FULL e OOS. Per-sleeve: ritorni FULL ~4-10x (MR01 BTC 299→1963%) e DD giù su +5/6 (eccezione MR02 ETH +3.4pp, assorbita in aggregato). + +## Verdetto e piano di deploy (DA FARE, non ancora implementato) + +**EXIT-16 close_confirm_sl, buffer 0.5·ATR14, SOLO sulle 6 fade.** +1. Param `sl_confirm_atr=0.5` nelle fade (default None = comportamento + attuale, per non rompere i backtest canonici/regression-lock). +2. `FadeStrategy.backtest` + `StrategyWorker.tick`: disattivare l'uscita + intrabar a sl0; aggiungere il ramo sul CLOSE del bar (long: esci se + `close < sl0 − 0.5·ATR14`; short speculare). TP intrabar e max_bars + INVARIATI. ATR14 causale già disponibile. +3. Shadow reale: nessun nuovo tipo d'ordine (il close-confirm esce al close + del worker → market reduce-only come oggi; il limit resting al TP di + v1.0.7 resta identico). +4. DIP01/pairs/rotation/tsmom/shape NON toccati. + +## Caveat onesti + +- L'uscita al close può essere PEGGIO di sl0 sui veri sfondamenti (già nei + numeri del backtest, ma la coda dipende dai gap: campione 1h crypto, + stress estremi sottorappresentati). +- Niente stop fisico sul book → rischio gap/disconnessione fra i poll + (mitigazione possibile in futuro: disaster-SL intrabar a 4× come cintura, + costa ~5% del guadagno). +- avg_bars +~18% (esposizione su; già nei numeri via non-overlap). +- I numeri per-sleeve FULL (ret 4-10x) vanno letti col solito sconto del + compounding su 8 anni; la decisione è presa sul gate PORT06, non su quelli. + +## Scoperte collaterali da NON perdere + +1. **Gap-through fill**: l'engine intrabar filla gli stop al livello anche + quando l'open è già oltre → bias sistematico PRO stop-stretti. Aggiungere + il fill `worse(level, open)` all'harness prima di future ricerche su stop. +2. **Wiring worker**: una strategia con `sl=0`+`max_bars` gira nel ramo + `elif max_bars` → il fallback −2% non si applica. Oggi nessuna strategia + attiva emette sl=0, ma il ramo va reso esplicito se mai si deployasse + una policy senza SL. +3. Il prior ladder di stamattina è confermato e generalizzato: 4 conferme + indipendenti che oltre il TP non c'è nulla da cavalcare. + +## Costi della ricerca + +34 agenti (23 explore + 10 verify + 1 PORT06), ~1.65M token subagente, +~70 minuti wall-clock su 2 core. Workflow: `exit-policy-explore` +(wf_4b23e922-f48) + `exit-policy-verify` (wf_f2bb98df-c43). diff --git a/scripts/analysis/exit16_port06_impact.py b/scripts/analysis/exit16_port06_impact.py new file mode 100644 index 0000000..589b4e3 --- /dev/null +++ b/scripts/analysis/exit16_port06_impact.py @@ -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() diff --git a/scripts/analysis/exit_lab.py b/scripts/analysis/exit_lab.py new file mode 100644 index 0000000..954d0bd --- /dev/null +++ b/scripts/analysis/exit_lab.py @@ -0,0 +1,259 @@ +"""EXIT LAB — harness onesto e CONDIVISO per la ricerca di policy di uscita +(TP dinamico, SL dinamico/trailing, partial, ride) sulle fade attive. + +Ricerca 2026-06-04 (>=20 agenti): ogni agente implementa una ExitPolicy in +scripts/analysis/exit_policies/_.py e la valuta QUI, sugli STESSI +segnali (cache su disco) e con lo stesso engine intrabar di fade_base. + +CONTRATTO ANTI-LOOK-AHEAD (vincolante, verra' verificato da agenti avversari): + - i livelli attivi nel bar j (`levels(j)`) possono usare SOLO dati <= j-1 + (il worker live li fissa al close del bar precedente, poi il bar j li tocca); + - `after_bar(j)` decide sul CLOSE del bar j (eseguibile al poll del tick); + - indicatori: usare l'indice j-1 degli array causali (es. ctx["atr14"][j-1]). + +PROTOCOLLO ANTI-OVERFIT (vincolante): + - TRAIN = storico fino al 2023-11-01, OOS = dopo. La SELEZIONE dei parametri + si fa SOLO sul train; l'OOS si guarda una volta, per il verdetto. + - gate: il miglioramento deve tenere su ENTRAMBI gli asset e su TUTTE e 3 le + strategie (train E oos), con plateau sulla griglia (non una cella isolata). + - fee 0.10% RT x leva su tutto il notional; nessuna fee scontata sui limit. + +Baseline = exit attuale (TP/SL fissi dall'entrata + max_bars): la parita' con +`partial_tp_ladder.py --base` e' verificata da `parity_check()`. + + uv run python scripts/analysis/exit_lab.py # build cache + parity check +""" +from __future__ import annotations + +import pickle +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_MS = int(pd.Timestamp("2023-11-01", tz="UTC").value // 1e6) +LEV, POS, FEE_RT = 3.0, 0.15, 0.001 +CODES = ["MR01_bollinger_fade", "MR02_donchian_fade", "MR07_return_reversal"] +ASSETS = ("BTC", "ETH") +CACHE = PROJECT_ROOT / "data" / "cache" / "exit_lab_signals.pkl" +HARD_CAP = 240 # bound assoluto ai bar in posizione (policy "ride" comprese) + + +# ----------------------------------------------------------------------------- dati + +def _atr14(h: np.ndarray, l: np.ndarray, c: np.ndarray) -> np.ndarray: + pc = np.roll(c, 1); pc[0] = c[0] + tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc))) + return pd.Series(tr).rolling(14).mean().values + + +def load_sleeves(refresh: bool = False) -> dict: + """{(code, asset): sleeve} con cache. sleeve = {signals, open, high, low, + close, ts_ms, atr14}. signals = [(i, d, tp0, sl0, mb), ...] dai params LIVE.""" + if CACHE.exists() and not refresh: + with open(CACHE, "rb") as f: + return pickle.load(f) + out = {} + for code in CODES: + strat = load_strategy(code) + for asset in ASSETS: + df = load_data(asset, "1h") + ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + sigs = strat.generate_signals(df, ts, **LIVE_PARAMS) + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + c = df["close"].values.astype(float) + out[(code, asset)] = { + "signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]), + float(s.metadata["sl"]), int(s.metadata["max_bars"])) + for s in sigs], + "open": df["open"].values.astype(float), + "high": h, "low": l, "close": c, + "ts_ms": df["timestamp"].values.astype(np.int64), + "atr14": _atr14(h, l, c), + } + print(f" cache {code} {asset}: {len(sigs)} segnali, {len(c)} barre " + f"({ts.iloc[0].date()} -> {ts.iloc[-1].date()})") + CACHE.parent.mkdir(parents=True, exist_ok=True) + with open(CACHE, "wb") as f: + pickle.dump(out, f) + return out + + +# ----------------------------------------------------------------------------- policy + +class ExitPolicy: + """Baseline = exit live attuale. Le sottoclassi ridefinisco levels/after_bar. + + Una ISTANZA per trade. `ctx` e' il dict sleeve (array completi + indicatori + aggiunti da prepare()): per contratto si legge SOLO fino a j-1 in levels(j) + e fino a j in after_bar(j)/on_partial(j). + """ + name = "base" + + @classmethod + def prepare(cls, ctx: dict, **params) -> None: + """Pre-calcola array causali per-sleeve (una volta), es. SMA/EMA.""" + + def __init__(self, ctx: dict, i: int, d: int, entry: float, + tp0: float, sl0: float, mb: int, **params): + self.ctx, self.i, self.d, self.entry = ctx, i, d, entry + self.tp0, self.sl0, self.mb = tp0, sl0, mb + self.horizon = mb # le sottoclassi possono estendere (cap HARD_CAP) + + def levels(self, j: int): + """Livelli ATTIVI nel bar j -> (tp, sl, tp_frac). None = livello assente. + tp_frac = quota del RESIDUO che esce al tocco del TP (1.0 = tutta).""" + return self.tp0, self.sl0, 1.0 + + def on_partial(self, j: int, price: float, remaining: float) -> None: + """Notifica del fill parziale al TP nel bar j (aggiorna lo stato qui).""" + + def after_bar(self, j: int) -> bool: + """True = chiudi il residuo al close[j] (decisione sul close, eseguibile).""" + return False + + +# ----------------------------------------------------------------------------- engine + +def simulate(policy_cls, sleeve: dict, params: dict | None = None, + start_ms: int | None = None, end_ms: int | None = None) -> dict: + """Replay intrabar dei segnali dello sleeve con la policy. SL prioritario + sul TP nello stesso bar (conservativo); fill parziali pesati; max_bars/ + horizon esce al close; non-overlap (una posizione per volta).""" + params = params or {} + h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"] + n = len(c) + ctx = dict(sleeve) + policy_cls.prepare(ctx, **params) + fee = FEE_RT * LEV + capital = peak = 1000.0 + max_dd = 0.0 + last_exit = -1 + trades = wins = 0 + bars_tot = 0 + rets = [] + + for (i, d, tp0, sl0, mb) in sleeve["signals"]: + if start_ms is not None and ts[i] < start_ms: + continue + if end_ms is not None and ts[i] >= end_ms: + continue + if i <= last_exit or i + 1 >= n: + continue + entry = c[i] + pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params) + horizon = min(int(pol.horizon), HARD_CAP) + fills: list[tuple[float, float]] = [] + remaining = 1.0 + j = i + for step in range(1, horizon + 1): + j = i + step + if j >= n: + j = n - 1 + fills.append((remaining, c[j])); remaining = 0.0 + break + tp, sl, tpfrac = pol.levels(j) + 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: # conservativo: SL prima del TP + fills.append((remaining, sl)); remaining = 0.0 + break + if hit_tp: + f = min(max(tpfrac, 0.0), 1.0) * remaining + if f > 0: + fills.append((f, tp)); remaining -= f + if remaining <= 1e-9: + break + pol.on_partial(j, tp, remaining) + if pol.after_bar(j): + fills.append((remaining, c[j])); remaining = 0.0 + break + if step == horizon: + fills.append((remaining, c[j])); remaining = 0.0 + if remaining > 1e-9: # safety (non dovrebbe accadere) + fills.append((remaining, c[j])) + + ret = sum(f * (p - entry) for f, p in fills) / 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 + bars_tot += j - i + rets.append(ret) + + if trades == 0: + return {} + r = 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": r.mean() * 1e4, + "sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0, + "avg_bars": bars_tot / trades, + } + + +# ----------------------------------------------------------------------------- report + +def evaluate(policy_cls, grid: list[dict], data: dict | None = None, + quiet: bool = False) -> dict: + """Protocollo train/OOS su tutta la griglia. La selezione dei parametri va + fatta SUL TRAIN (l'OOS si riporta, non si ottimizza). Ritorna dict + {params_str: {sleeve: {train: {...}, oos: {...}}}} + baseline.""" + data = data or load_sleeves() + out: dict = {} + rows = [("base", ExitPolicy, {})] + [ + (", ".join(f"{k}={v}" for k, v in g.items()) or "default", policy_cls, g) + for g in grid] + for tag, cls, g in rows: + out[tag] = {} + for (code, asset), sleeve in data.items(): + key = f"{code.split('_')[0]} {asset}" + tr = simulate(cls, sleeve, g, end_ms=OOS_START_MS) + oo = simulate(cls, sleeve, g, start_ms=OOS_START_MS) + out[tag][key] = {"train": tr, "oos": oo} + if not quiet: + print(f"{tag:<28}{key:<10}" + f"TRAIN ret{tr.get('ret_pct', 0):>7.0f}% dd{tr.get('dd_pct', 0):>5.1f} " + f"sh{tr.get('sharpe_t', 0):>5.2f} n{tr.get('trades', 0):>4} | " + f"OOS ret{oo.get('ret_pct', 0):>6.0f}% dd{oo.get('dd_pct', 0):>5.1f} " + f"sh{oo.get('sharpe_t', 0):>5.2f} n{oo.get('trades', 0):>4} " + f"bars{oo.get('avg_bars', 0):>5.1f}") + return out + + +def parity_check() -> None: + """La baseline qui deve riprodurre i numeri FULL di partial_tp_ladder (base): + MR01 BTC ~92%/13.8dd, MR01 ETH ~194%/16.5dd, MR02 ETH ~2135%/16.2dd...""" + data = load_sleeves() + print("\nParity check baseline (FULL, atteso = partial_tp_ladder base):") + expected = {("MR01_bollinger_fade", "BTC"): 92, ("MR01_bollinger_fade", "ETH"): 194, + ("MR02_donchian_fade", "BTC"): 129, ("MR02_donchian_fade", "ETH"): 2135, + ("MR07_return_reversal", "BTC"): 78, ("MR07_return_reversal", "ETH"): 115} + ok = True + for key, sleeve in data.items(): + r = simulate(ExitPolicy, sleeve) + exp = expected[key] + match = abs(r["ret_pct"] - exp) < 1.0 + ok &= match + print(f" {key[0].split('_')[0]} {key[1]}: ret {r['ret_pct']:.0f}% " + f"(atteso ~{exp}) {'OK' if match else 'MISMATCH'}") + print("PARITY", "OK" if ok else "FAILED") + + +if __name__ == "__main__": + load_sleeves(refresh="--refresh" in sys.argv) + parity_check() diff --git a/scripts/analysis/exit_policies/01_trail_atr_ride.py b/scripts/analysis/exit_policies/01_trail_atr_ride.py new file mode 100644 index 0000000..f5b8539 --- /dev/null +++ b/scripts/analysis/exit_policies/01_trail_atr_ride.py @@ -0,0 +1,75 @@ +"""EXIT-01 — trail_atr_ride: TP RIMOSSO, cavalcata pura con SL trailing chandelier. + +Idea: le fade mean-reversion escono oggi al TP fisso (alla media) + SL + max_bars. +Qui togliamo il TP e lasciamo correre il trade, proteggendolo con un SL trailing +"chandelier" a k*ATR dal massimo favorevole raggiunto. Lo stop puo' solo stringersi +(mai allargarsi). Orizzonte esteso (cap HARD_CAP=240) per dare spazio al runner. + + Long: stop(j) = max( sl0, max(high[i..j-1]) - k*atr14[j-1] ) (sale, mai scende) + Short: stop(j) = min( sl0, min(low[i..j-1]) + k*atr14[j-1] ) (scende, mai sale) + +ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1: + - massimo/minimo favorevole sullo slice [i .. j-1] (mantenuto incrementalmente, + aggiornato col bar j-1 prima di calcolare lo stop attivo nel bar j); + - atr14[j-1] (indice causale). +Nessun TP -> nessun fill parziale. after_bar non usato (chiusura solo a orizzonte/SL). + +GRID: k in {2.0, 3.0, 4.0} x horizon_mult in {2, 4} (6 celle). horizon = mult*mb cap 240. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from exit_lab import ExitPolicy, evaluate, HARD_CAP + + +class TrailAtrRide(ExitPolicy): + name = "trail_atr_ride" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, k=3.0, horizon_mult=4, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.k = float(k) + self.horizon = min(int(horizon_mult) * mb, HARD_CAP) + # estremo favorevole sullo slice [i..j-1]; inizializzato al bar di entrata i + # (il primo bar valutato e' j=i+1, dove lo slice [i..j-1]=[i..i] e' noto). + self.fav_high = ctx["high"][i] + self.fav_low = ctx["low"][i] + self._last_seen = i # ultimo indice gia' incorporato nell'estremo + # stop trailing monotono: parte da sl0 e puo' solo stringersi + self.cur_stop = sl0 + + def levels(self, j): + h = self.ctx["high"] + l = self.ctx["low"] + atr = self.ctx["atr14"] + # incorpora i bar fino a j-1 (dati causali, gia' chiusi al poll del bar j) + while self._last_seen < j - 1: + self._last_seen += 1 + if h[self._last_seen] > self.fav_high: + self.fav_high = h[self._last_seen] + if l[self._last_seen] < self.fav_low: + self.fav_low = l[self._last_seen] + a = atr[j - 1] + if a != a: # NaN nei primi 14 bar -> resta sullo stop corrente + return None, self.cur_stop, 1.0 + if self.d == 1: + cand = self.fav_high - self.k * a + if cand > self.cur_stop: # lo stop long puo' solo SALIRE (stringersi) + self.cur_stop = cand + else: + cand = self.fav_low + self.k * a + if cand < self.cur_stop: # lo stop short puo' solo SCENDERE (stringersi) + self.cur_stop = cand + return None, self.cur_stop, 1.0 # TP rimosso + + +GRID = [ + {"k": k, "horizon_mult": m} + for k in (2.0, 3.0, 4.0) + for m in (2, 4) +] + + +if __name__ == "__main__": + evaluate(TrailAtrRide, GRID) diff --git a/scripts/analysis/exit_policies/02_trail_atr_keep_tp.py b/scripts/analysis/exit_policies/02_trail_atr_keep_tp.py new file mode 100644 index 0000000..50f274c --- /dev/null +++ b/scripts/analysis/exit_policies/02_trail_atr_keep_tp.py @@ -0,0 +1,68 @@ +"""EXIT-02 — trail_atr_keep_tp. + +Chandelier trailing stop a k*ATR dall'estremo favorevole RAGGIUNTO dall'entrata, +MA il TP fisso tp0 dall'entrata RESTA attivo: si esce al PRIMO dei due (il TP al +livello, oppure il trail). horizon = max_bars (invariato). + +Stop attivo nel bar j (solo dati <= j-1, anti-look-ahead): + long : chand = max(high[i..j-1]) - k*atr14[j-1]; sl = max(sl0, chand) + short: chand = min(low[i..j-1]) + k*atr14[j-1]; sl = min(sl0, chand) + +Il max(sl0, chand) (per il long) tiene la protezione iniziale a sl0 e lascia che +il trail TIGHTEN solo quando il prezzo corre a favore -> "ride" controllato che +non allenta mai il rischio iniziale. Il TP non viene toccato: una fade che torna +alla media esce comunque al TP come la base; il trail morde solo quando il TP non +viene raggiunto e il prezzo ha prima corso a favore e poi ritracciato. + +GRID: k in {1.5, 2.0, 3.0, 4.0} (4 celle). +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from exit_lab import ExitPolicy, evaluate # noqa: E402 + + +class TrailATRKeepTP(ExitPolicy): + name = "trail_atr_keep_tp" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.k = float(params.get("k", 2.0)) + self.high = ctx["high"] + self.low = ctx["low"] + self.atr = ctx["atr14"] + # estremo favorevole running (solo barre <= j-1); init = barra d'entrata i + self.run_hi = self.high[i] + self.run_lo = self.low[i] + self.last_seen = i # ultimo indice gia' incorporato nel running extremum + + def _update_running(self, upto: int) -> None: + """Incorpora le barre (last_seen, upto] nell'estremo running. upto = j-1, + quindi NON tocca il bar j (anti-look-ahead).""" + while self.last_seen < upto: + self.last_seen += 1 + if self.high[self.last_seen] > self.run_hi: + self.run_hi = self.high[self.last_seen] + if self.low[self.last_seen] < self.run_lo: + self.run_lo = self.low[self.last_seen] + + def levels(self, j: int): + self._update_running(j - 1) # solo dati <= j-1 + a = self.atr[j - 1] + if a is None or a != a: # NaN -> nessun trail, usa sl0 + return self.tp0, self.sl0, 1.0 + if self.d == 1: + chand = self.run_hi - self.k * a + sl = max(self.sl0, chand) # piu' protettivo (stop piu' alto) + else: + chand = self.run_lo + self.k * a + sl = min(self.sl0, chand) # piu' protettivo (stop piu' basso) + return self.tp0, sl, 1.0 + + +GRID = [{"k": 1.5}, {"k": 2.0}, {"k": 3.0}, {"k": 4.0}] + + +if __name__ == "__main__": + evaluate(TrailATRKeepTP, GRID) diff --git a/scripts/analysis/exit_policies/03_trail_pct.py b/scripts/analysis/exit_policies/03_trail_pct.py new file mode 100644 index 0000000..49be1c1 --- /dev/null +++ b/scripts/analysis/exit_policies/03_trail_pct.py @@ -0,0 +1,85 @@ +"""EXIT-03 — trail_pct: trailing percentuale dall'high-water-mark favorevole (sui CLOSE). + +Idea: invece di un trail ATR (EXIT-01/02), lo stop segue l'estremo favorevole +RAGGIUNTO dai CLOSE a una distanza percentuale fissa p. Lo stop puo' solo +stringersi (mai allentarsi sotto sl0 lato rischio). + + Long : hwm = max(close[i..j-1]); stop(j) = max(sl0, hwm*(1-p)) (sale, mai scende) + Short: lwm = min(close[i..j-1]); stop(j) = min(sl0, lwm*(1+p)) (scende, mai sale) + +Due varianti: + keep_tp=True -> il TP fisso tp0 dall'entrata RESTA attivo (esci al primo dei due), + horizon = max_bars (invariato). Il trail morde solo se il TP non + viene raggiunto e il prezzo ha prima corso a favore e poi ritracciato. + keep_tp=False -> TP RIMOSSO (cavalcata pura), horizon = 2*max_bars (cap HARD_CAP). + Si esce solo sul trailing-stop o a orizzonte. + +ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1: + - hwm/lwm sullo slice [i..j-1] dei CLOSE (mantenuto incrementalmente col bar j-1); + - p e' una costante -> nessuna dipendenza da indicatori del bar j. + L'hwm sui close (non sugli high) e' deliberato: il close e' il dato su cui il + worker live decide al poll del tick; un hwm sugli high anticiperebbe un livello + che il close non ha ancora confermato. + +GRID: p in {0.005, 0.01, 0.02} x keep_tp in {True, False} (6 celle). +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402 + + +class TrailPct(ExitPolicy): + name = "trail_pct" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, + p=0.01, keep_tp=True, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.p = float(p) + self.keep_tp = bool(keep_tp) + if not self.keep_tp: + self.horizon = min(2 * mb, HARD_CAP) + self.close = ctx["close"] + # high/low-water-mark sui CLOSE, solo barre <= j-1; init = close d'entrata i + self.hwm = self.close[i] + self.lwm = self.close[i] + self.last_seen = i # ultimo indice gia' incorporato + # stop trailing monotono: parte da sl0 e puo' solo stringersi + self.cur_stop = sl0 + + def _update_wm(self, upto: int) -> None: + """Incorpora le barre (last_seen, upto] nell'estremo sui close. upto = j-1 + -> NON tocca il bar j (anti-look-ahead).""" + while self.last_seen < upto: + self.last_seen += 1 + cv = self.close[self.last_seen] + if cv > self.hwm: + self.hwm = cv + if cv < self.lwm: + self.lwm = cv + + def levels(self, j: int): + self._update_wm(j - 1) # solo dati <= j-1 + if self.d == 1: + cand = self.hwm * (1.0 - self.p) + if cand > self.cur_stop: # lo stop long puo' solo SALIRE + self.cur_stop = cand + else: + cand = self.lwm * (1.0 + self.p) + if cand < self.cur_stop: # lo stop short puo' solo SCENDERE + self.cur_stop = cand + tp = self.tp0 if self.keep_tp else None + return tp, self.cur_stop, 1.0 + + +GRID = [ + {"p": p, "keep_tp": kt} + for p in (0.005, 0.01, 0.02) + for kt in (True, False) +] + + +if __name__ == "__main__": + evaluate(TrailPct, GRID) diff --git a/scripts/analysis/exit_policies/04_breakeven.py b/scripts/analysis/exit_policies/04_breakeven.py new file mode 100644 index 0000000..86db851 --- /dev/null +++ b/scripts/analysis/exit_policies/04_breakeven.py @@ -0,0 +1,74 @@ +"""EXIT-04 — breakeven (protezione). + +Quando il profitto su CLOSE supera k*ATR(entry) (ATR fissato all'entrata, +atr14[i]), sposta lo SL a BREAKEVEN = entry +/- buffer. Il buffer e' a favore +(0.2% = 2*fee_rt*entry) cosi' l'uscita a BE non e' in perdita per le fee. Il TP +fisso tp0 RESTA invariato. horizon = max_bars (invariato). + +Una volta armato il breakeven, lo SL non torna mai indietro (cliquet): protegge +il profitto gia' maturato senza allentare il rischio. + +Stop attivo nel bar j (solo dati <= j-1, anti-look-ahead): + - trigger: si arma quando close[j-1] e' a favore di >= k*atr14[i] dall'entrata + (atr14[i] = ATR all'entrata, costante; close[j-1] = ultimo close noto). + - prima dell'arm: sl = sl0 (protezione iniziale invariata). + - dopo l'arm: + long : be = entry + buffer; sl = max(sl0, be) + short: be = entry - buffer; sl = min(sl0, be) + NB max/min con sl0 fa si' che lo SL non venga mai ALLENTATO: se sl0 fosse + gia' oltre il BE (raro), resta sl0. Tipicamente sl0 e' sotto entry (long) e + il BE lo alza -> stop piu' protettivo. + +GRID: k in {0.5, 1.0, 1.5} x buffer_frac in {0.0, 0.002} (6 celle). + buffer = buffer_frac * entry. buffer_frac 0.002 = 0.2% = 2*fee_rt a favore. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from exit_lab import ExitPolicy, evaluate # noqa: E402 + + +class Breakeven(ExitPolicy): + name = "breakeven" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.k = float(params.get("k", 1.0)) + self.buffer_frac = float(params.get("buffer_frac", 0.002)) + self.close = ctx["close"] + self.atr = ctx["atr14"] + self.atr_entry = self.atr[i] # ATR all'entrata, costante + self.buffer = self.buffer_frac * entry + self.armed = False + + def levels(self, j: int): + # arma il breakeven usando SOLO close[j-1] (ultimo close noto) e atr14[i] + if not self.armed and self.atr_entry is not None and self.atr_entry == self.atr_entry: + cprev = self.close[j - 1] + profit = (cprev - self.entry) * self.d # >0 = a favore + if profit >= self.k * self.atr_entry: + self.armed = True + if not self.armed: + return self.tp0, self.sl0, 1.0 + if self.d == 1: + be = self.entry + self.buffer + sl = max(self.sl0, be) # non allenta mai + else: + be = self.entry - self.buffer + sl = min(self.sl0, be) + return self.tp0, sl, 1.0 + + +GRID = [ + {"k": 0.5, "buffer_frac": 0.0}, + {"k": 0.5, "buffer_frac": 0.002}, + {"k": 1.0, "buffer_frac": 0.0}, + {"k": 1.0, "buffer_frac": 0.002}, + {"k": 1.5, "buffer_frac": 0.0}, + {"k": 1.5, "buffer_frac": 0.002}, +] + + +if __name__ == "__main__": + evaluate(Breakeven, GRID) diff --git a/scripts/analysis/exit_policies/05_ratchet.py b/scripts/analysis/exit_policies/05_ratchet.py new file mode 100644 index 0000000..695a07c --- /dev/null +++ b/scripts/analysis/exit_policies/05_ratchet.py @@ -0,0 +1,93 @@ +"""EXIT-05 — ratchet: stop a cricchetto sul profitto su close (high-water-mark). + +Idea: una volta che il trade ha messo a segno profitto (su CLOSE), proteggiamo +una FRAZIONE f del profitto migliore raggiunto. hwm = miglior close a favore visto +finora; quando hwm e' a favore dell'entrata, lo stop si porta a + long : stop = entry + f*(hwm - entry) + short: stop = entry - f*(entry - hwm) +Lo stop e' SOLO-STRINGENTE (cricchetto): puo' solo avvicinarsi al prezzo, mai +allentarsi. Parte da sl0 (protezione iniziale invariata finche' il ratchet non +supera sl0). + +Differenza dal trailing chandelier (EXIT-01): qui lo stop e' ancorato all'ENTRY ++ frazione del profitto su close (non al massimo high - k*ATR). Cattura profitto +in % del guadagno realizzato; con f<1 lascia un cuscinetto sotto l'hwm. + +VARIANTI: + - keep_tp=True : il TP fisso tp0 resta (exit standard alla media) + ratchet di + backup sullo SL. horizon = max_bars. + - keep_tp=False: TP RIMOSSO -> ride puro protetto dal cricchetto, horizon=2*mb + (cap HARD_CAP). Qui il ratchet e' l'unica uscita oltre l'orizzonte. + +ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1: + - hwm calcolato sui close[i .. j-1] (mantenuto incrementalmente, aggiornato col + bar j-1 prima di calcolare lo stop attivo nel bar j; close[i]=entry e' incluso + cosi' hwm>=entry sempre, lo stop non scende mai sotto entry una volta armato). +Nessun TP nella variante ride -> nessun fill parziale. after_bar non usato. + +GRID: f in {0.3, 0.5, 0.7} x keep_tp in {True, False} (6 celle). +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402 + + +class Ratchet(ExitPolicy): + name = "ratchet" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, f=0.5, keep_tp=True, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.f = float(f) + self.keep_tp = bool(keep_tp) + self.close = ctx["close"] + if not self.keep_tp: + self.horizon = min(2 * mb, HARD_CAP) + # high-water-mark del close a favore sullo slice [i..j-1]; il primo bar + # valutato e' j=i+1 -> slice [i..i] = close[i] = entry (gia' incorporato). + self.hwm = self.close[i] # = entry + self._last_seen = i + self.armed = False # diventa True quando hwm > entry (a favore) + # stop a cricchetto: parte da sl0, puo' solo stringersi + self.cur_stop = sl0 + + def levels(self, j: int): + c = self.close + d = self.d + # incorpora i close fino a j-1 (causali, gia' chiusi al poll del bar j) + while self._last_seen < j - 1: + self._last_seen += 1 + cv = c[self._last_seen] + if d == 1: + if cv > self.hwm: + self.hwm = cv + else: + if cv < self.hwm: + self.hwm = cv + tp = self.tp0 if self.keep_tp else None + # profitto su close del miglior bar a favore (>=0 perche' hwm parte da entry) + prof = (self.hwm - self.entry) * d + if prof > 0: # il trade e' andato a favore: arma il ratchet + self.armed = True + if d == 1: + cand = self.entry + self.f * (self.hwm - self.entry) + if cand > self.cur_stop: # cricchetto: lo stop long solo SALE + self.cur_stop = cand + else: + cand = self.entry - self.f * (self.entry - self.hwm) + if cand < self.cur_stop: # cricchetto: lo stop short solo SCENDE + self.cur_stop = cand + return tp, self.cur_stop, 1.0 + + +GRID = [ + {"f": f, "keep_tp": keep_tp} + for f in (0.3, 0.5, 0.7) + for keep_tp in (True, False) +] + + +if __name__ == "__main__": + evaluate(Ratchet, GRID) diff --git a/scripts/analysis/exit_policies/06_sar_trail.py b/scripts/analysis/exit_policies/06_sar_trail.py new file mode 100644 index 0000000..6d20242 --- /dev/null +++ b/scripts/analysis/exit_policies/06_sar_trail.py @@ -0,0 +1,84 @@ +"""EXIT-06 — sar_trail: Parabolic SAR semplificato come trailing stop, TP rimosso. + +Idea: come EXIT-01 (cavalcata pura senza TP), ma lo stop trailing non e' un +chandelier a k*ATR fisso bensi' un Parabolic SAR semplificato che ACCELERA: il +fattore af parte a 0.02 e cresce di af_step ad ogni NUOVO estremo favorevole +(cap af_max). Lo stop si avvicina sempre piu' in fretta al prezzo man mano che il +trade corre nella direzione giusta -> protegge i runner stringendo aggressivamente. + + sar(j) = sar(j-1) + af * (ep - sar(j-1)) con ep = estremo favorevole (high long / low short) + stop attivo nel bar j = max(sl0, sar) long (min(sl0, sar) short) + af: 0.02 -> +af_step ad ogni nuovo ep -> cap af_max + +TP RIMOSSO, horizon = 4*mb (cap HARD_CAP=240). + +ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1. L'estremo favorevole `ep` e lo +stato SAR vengono aggiornati incorporando i bar fino a j-1 (gia' chiusi al poll +del bar j). sar(j-1) -> sar(j) usa ep aggiornato a j-1. Nessun dato del bar j. +Nessun TP -> nessun fill parziale; after_bar non usato (uscita solo a SL/orizzonte). + +GRID: af_max in {0.1, 0.2} x af_step in {0.02, 0.04} (4 celle). +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from exit_lab import ExitPolicy, evaluate, HARD_CAP + + +class SarTrail(ExitPolicy): + name = "sar_trail" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, af_max=0.2, af_step=0.02, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.af_max = float(af_max) + self.af_step = float(af_step) + self.horizon = min(4 * mb, HARD_CAP) + # estremo favorevole (ep) sullo slice [i..j-1]; inizializzato al bar di entrata + self.ep = ctx["high"][i] if d == 1 else ctx["low"][i] + self.af = 0.02 + # SAR iniziale: lo stop di partenza (sl0). Il SAR converge verso ep dall'sl0. + self.sar = sl0 + self._last_seen = i # ultimo indice gia' incorporato nello stato SAR + self.cur_stop = sl0 # stop monotono (solo si stringe) + + def _step(self, idx): + """Incorpora il bar `idx` (causale, <= j-1) aggiornando ep, af e sar.""" + h = self.ctx["high"][idx] + l = self.ctx["low"][idx] + # avanza il SAR di un passo verso l'estremo favorevole corrente + self.sar = self.sar + self.af * (self.ep - self.sar) + # nuovo estremo favorevole? -> accelera + if self.d == 1: + if h > self.ep: + self.ep = h + self.af = min(self.af + self.af_step, self.af_max) + else: + if l < self.ep: + self.ep = l + self.af = min(self.af + self.af_step, self.af_max) + + def levels(self, j): + # incorpora i bar fino a j-1 (gia' chiusi al poll del bar j) + while self._last_seen < j - 1: + self._last_seen += 1 + self._step(self._last_seen) + if self.d == 1: + if self.sar > self.cur_stop: # lo stop long puo' solo SALIRE (stringersi) + self.cur_stop = self.sar + else: + if self.sar < self.cur_stop: # lo stop short puo' solo SCENDERE + self.cur_stop = self.sar + return None, self.cur_stop, 1.0 # TP rimosso + + +GRID = [ + {"af_max": am, "af_step": st} + for am in (0.1, 0.2) + for st in (0.02, 0.04) +] + + +if __name__ == "__main__": + evaluate(SarTrail, GRID) diff --git a/scripts/analysis/exit_policies/07_tp_decay.py b/scripts/analysis/exit_policies/07_tp_decay.py new file mode 100644 index 0000000..2a44405 --- /dev/null +++ b/scripts/analysis/exit_policies/07_tp_decay.py @@ -0,0 +1,73 @@ +"""EXIT-07 — TP dinamico che DECADE col tempo (verso breakeven). + +PRIOR (vincolante, ieri SCARTATO un ladder 80/20): il TP delle fade sta alla +MEDIA, e li' il movimento e' esaurito -> il prezzo spesso NON arriva al TP e il +trade muore a max_bars (close) o sullo SL. Idea di questa famiglia: invece di +tenere il TP fisso al target ambizioso (la media), farlo DECADERE col passare +delle barre verso un target piu' modesto (breakeven che copre le fee, o meta' +strada). Cosi' un trade che s'e' avvicinato ma non ha toccato il TP pieno puo' +essere chiuso in profitto/pareggio prima di scadere a max_bars potenzialmente +in perdita. SL FISSO (sl0) invariato. horizon = max_bars invariato. + +TP attivo nel bar j: + frac(j) = clamp( ((j - i) / mb) * speed , 0, 1 ) + tp(j) = tp0 + frac(j) * (target_fin - tp0) + - frac dipende SOLO da j (indice di tempo) e da costanti note all'entrata + (i, mb, speed) -> NESSUN dato > j-1 -> anti-look-ahead OK per costruzione. + - long (d=+1): tp0 > entry; target_fin <= tp0 -> il TP SCENDE verso entry. + - short (d=-1): tp0 < entry; target_fin >= tp0 -> il TP SALE verso entry. + - speed>1: il TP raggiunge il target finale prima della fine (clamp a 1). + +target_fin (param "target"): + - "breakeven": entry*(1 + d*0.003) -> copre 0.3% (~ fee 0.10%RT + margine). + Per long sta sopra entry, per short sotto: chiudere li' NON e' in perdita. + - "halfway" : (entry + tp0)/2 -> meta' strada fra entry e il TP pieno. + +GRID: speed in {0.5, 1.0, 1.5} x target in {breakeven, halfway} (6 celle). +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from exit_lab import ExitPolicy, evaluate # noqa: E402 + + +class TpDecay(ExitPolicy): + name = "tp_decay" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.speed = float(params.get("speed", 1.0)) + target = str(params.get("target", "breakeven")) + if target == "breakeven": + self.target_fin = entry * (1.0 + d * 0.003) + elif target == "halfway": + self.target_fin = 0.5 * (entry + tp0) + else: + raise ValueError(f"target sconosciuto: {target}") + # mb puo' essere 0 in teoria; protezione + self.mb_eff = max(int(mb), 1) + + def levels(self, j: int): + # frac dipende SOLO dal tempo (j - i), mb, speed: costanti note all'entrata + frac = ((j - self.i) / self.mb_eff) * self.speed + if frac < 0.0: + frac = 0.0 + elif frac > 1.0: + frac = 1.0 + tp = self.tp0 + frac * (self.target_fin - self.tp0) + return tp, self.sl0, 1.0 + + +GRID = [ + {"speed": 0.5, "target": "breakeven"}, + {"speed": 1.0, "target": "breakeven"}, + {"speed": 1.5, "target": "breakeven"}, + {"speed": 0.5, "target": "halfway"}, + {"speed": 1.0, "target": "halfway"}, + {"speed": 1.5, "target": "halfway"}, +] + + +if __name__ == "__main__": + evaluate(TpDecay, GRID) diff --git a/scripts/analysis/exit_policies/08_sl_tighten.py b/scripts/analysis/exit_policies/08_sl_tighten.py new file mode 100644 index 0000000..149319a --- /dev/null +++ b/scripts/analysis/exit_policies/08_sl_tighten.py @@ -0,0 +1,77 @@ +"""EXIT-08 — SL che si STRINGE linearmente col tempo (time-based stop tighten). + +Lo stop iniziale sl0 si avvicina linearmente a un livello d'arrivo `target` +man mano che il trade invecchia. TP fisso (tp0) e horizon=max_bars invariati. + + frac(j) = clamp( (j - i) / (mb * stretch), 0, 1 ) + sl(j) = sl0 + frac(j) * (target - sl0) + + target ("arrivo"): + - "entry" -> entry (a fine corsa lo stop e' a breakeven) + - "midpoint" -> (entry + sl0)/2 (a fine corsa lo stop e' a meta' strada) + +Direzioni: il segno e' gestito dalla geometria di sl0 vs entry. + long (d=1): sl0 < entry -> target >= sl0 -> lo stop SALE verso entry + short (d=-1): sl0 > entry -> target <= sl0 -> lo stop SCENDE verso entry +In entrambi i casi lo stop si stringe (avvicina al prezzo d'ingresso) col tempo. + +stretch: + - 1.0 : lo stop raggiunge `target` a max_bars (fine horizon). + - 2.0 : lo stop raggiunge `target` a 2*max_bars -> stringe a META' velocita', + quindi a max_bars e' solo a meta' del cammino sl0->target (piu' lasco). + +ANTI-LOOK-AHEAD: sl(j) dipende SOLO da {i, j, mb, stretch, entry, sl0}, tutti +noti all'ENTRATA (close[i]). Nessun dato del bar j o successivi entra nel livello +attivo nel bar j. Il TP resta tp0. -> contratto rispettato per costruzione. + +MECCANISMO ATTESO: stringere lo stop col tempo taglia prima i trade che ristagnano +(non vanno al TP ne' allo SL iniziale e scadrebbero a max_bars vicino al BE/in +perdita). Rischio: stoppa fuori trade che sarebbero rientrati verso il TP (la fade +e' mean-reversion: il movimento contrario e' spesso transitorio) -> puo' alzare lo +stop-rate effettivo e tagliare winner ritardatari. + +GRID: stretch in {1.0, 2.0} x target in {entry, midpoint} (4 celle). +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from exit_lab import ExitPolicy, evaluate # noqa: E402 + + +class SlTighten(ExitPolicy): + name = "sl_tighten" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.stretch = float(params.get("stretch", 1.0)) + self.target_kind = str(params.get("target", "entry")) + if self.target_kind == "entry": + self.target = entry + elif self.target_kind == "midpoint": + self.target = 0.5 * (entry + sl0) + else: + raise ValueError(f"target sconosciuto: {self.target_kind}") + self.denom = max(self.mb * self.stretch, 1e-9) + + def levels(self, j: int): + # frac dipende solo da i, j, mb, stretch -> noti all'entrata. No look-ahead. + frac = (j - self.i) / self.denom + if frac < 0.0: + frac = 0.0 + elif frac > 1.0: + frac = 1.0 + sl = self.sl0 + frac * (self.target - self.sl0) + return self.tp0, sl, 1.0 + + +GRID = [ + {"stretch": 1.0, "target": "entry"}, + {"stretch": 1.0, "target": "midpoint"}, + {"stretch": 2.0, "target": "entry"}, + {"stretch": 2.0, "target": "midpoint"}, +] + + +if __name__ == "__main__": + evaluate(SlTighten, GRID) diff --git a/scripts/analysis/exit_policies/09_tp_extend_momentum.py b/scripts/analysis/exit_policies/09_tp_extend_momentum.py new file mode 100644 index 0000000..4d5cfa9 --- /dev/null +++ b/scripts/analysis/exit_policies/09_tp_extend_momentum.py @@ -0,0 +1,114 @@ +"""EXIT-09 — tp_extend_momentum: cavalca il momentum OLTRE il TP. + +Idea: le fade escono al TP fisso (alla media). Ma quando il movimento NON e' +esaurito e sfonda il TP in chiusura, vogliamo provare a cavalcarlo invece di +prendere solo il TP. State machine a 2 fasi: + + FASE A (default, TP intatto): tp = tp0, sl = sl0. Exit standard alla media. + Finche' close[j-1] NON ha superato tp0 in chiusura, ci si comporta come base. + + FASE B (armata quando close[j-1] supera tp0 a favore): + - TP RIMOSSO (None): non si esce piu' al livello fisso; + - attiva un trail "chandelier" a k*ATR ancorato all'ESTREMO favorevole visto + DAL bar di superamento in poi (high per long, low per short); + - FLOOR a tp0: lo stop trailing non scende mai sotto tp0 (long) / non sale mai + sopra tp0 (short), cosi' NON si esce MAI peggio del TP originale. Lo stop e' + inoltre solo-stringente (cricchetto): puo' solo avvicinarsi al prezzo. + + Trigger di arma (su close[j-1], a favore): + long : close[j-1] > tp0 -> arma + short: close[j-1] < tp0 -> arma + + Trail attivo (FASE B), con floor a tp0: + long : stop = max( tp0, fav_high_post - k*atr14[j-1] ) (cricchetto: solo sale) + short: stop = min( tp0, fav_low_post + k*atr14[j-1] ) (cricchetto: solo scende) + +Orizzonte esteso a 3*mb (cap HARD_CAP=240) per dare spazio al runner post-TP. + +ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1: + - il trigger di arma legge close[j-1] (gia' chiuso al poll del bar j); + - l'estremo favorevole post-superamento e' sullo slice [arm_idx .. j-1] + (mantenuto incrementalmente, incorporando i bar fino a j-1); + - atr14[j-1] (indice causale). +Nessun fill parziale (tp_frac sempre 1.0). after_bar non usato. + +GRID: k in {1.0, 2.0, 3.0} (3 celle). +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402 + + +class TpExtendMomentum(ExitPolicy): + name = "tp_extend_momentum" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, k=2.0, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.k = float(k) + self.horizon = min(3 * mb, HARD_CAP) + self.close = ctx["close"] + self.high = ctx["high"] + self.low = ctx["low"] + self.atr = ctx["atr14"] + # FASE A finche' armed=False. Si arma quando close[j-1] supera tp0 a favore. + self.armed = False + self.arm_idx = None # bar dal quale si ancora l'estremo favorevole + self.fav_high = None # estremo favorevole post-superamento (long) + self.fav_low = None # estremo favorevole post-superamento (short) + self._last_seen = i # ultimo close gia' esaminato per il trigger + self._fav_seen = None # ultimo bar gia' incorporato nell'estremo + # stop trailing monotono in FASE B: parte dal floor tp0, solo si stringe + self.cur_stop = None + + def levels(self, j: int): + c = self.close + d = self.d + # ---- FASE A -> controlla l'arma sui close fino a j-1 (causali) ----------- + if not self.armed: + while self._last_seen < j - 1: + self._last_seen += 1 + cv = c[self._last_seen] + crossed = (cv > self.tp0) if d == 1 else (cv < self.tp0) + if crossed: + # arma: ancora l'estremo favorevole DAL bar di superamento + self.armed = True + self.arm_idx = self._last_seen + self.fav_high = self.high[self.arm_idx] + self.fav_low = self.low[self.arm_idx] + self._fav_seen = self.arm_idx + self.cur_stop = self.tp0 # floor di partenza = tp0 + break + if not self.armed: + return self.tp0, self.sl0, 1.0 # ancora FASE A: TP/SL fissi + + # ---- FASE B -> TP rimosso, trail chandelier ancorato post-superamento ----- + # incorpora i bar [arm_idx .. j-1] nell'estremo favorevole (dati causali) + while self._fav_seen < j - 1: + self._fav_seen += 1 + if self.high[self._fav_seen] > self.fav_high: + self.fav_high = self.high[self._fav_seen] + if self.low[self._fav_seen] < self.fav_low: + self.fav_low = self.low[self._fav_seen] + a = self.atr[j - 1] + if a == a: # non-NaN + if d == 1: + cand = self.fav_high - self.k * a + cand = max(cand, self.tp0) # floor: mai sotto il TP + if cand > self.cur_stop: # cricchetto: solo sale + self.cur_stop = cand + else: + cand = self.fav_low + self.k * a + cand = min(cand, self.tp0) # floor: mai sopra il TP + if cand < self.cur_stop: # cricchetto: solo scende + self.cur_stop = cand + return None, self.cur_stop, 1.0 # TP rimosso in FASE B + + +GRID = [{"k": k} for k in (1.0, 2.0, 3.0)] + + +if __name__ == "__main__": + evaluate(TpExtendMomentum, GRID) diff --git a/scripts/analysis/exit_policies/10_tp_moving_mean.py b/scripts/analysis/exit_policies/10_tp_moving_mean.py new file mode 100644 index 0000000..b18c6b2 --- /dev/null +++ b/scripts/analysis/exit_policies/10_tp_moving_mean.py @@ -0,0 +1,79 @@ +"""EXIT-10 — tp_moving_mean: il TP INSEGUE la media corrente (non quello fisso). + +PRIOR (vincolante): le fade puntano alla MEDIA. Oggi il TP e' CONGELATO all'entrata +(tp0 = SMA_n(close) al close[i-1]). Ma la media SI MUOVE durante il trade: se il +prezzo ci mette qualche barra a rientrare, la media si e' gia' spostata. Idea: +ridefinire il TP come la SMA_n corrente -> tp(j) = SMA_n(close)[j-1]. Il target +"segue" la media verso cui la fade punta, invece di mirare a una media stantia. + + long (d=+1): si compra sotto la media -> tp(j) = SMA_n[j-1] (sopra entry, di norma). + short (d=-1): si vende sopra la media -> tp(j) = SMA_n[j-1] (sotto entry, di norma). + +CAP ONESTO (anti exit-in-perdita): se la media si muove CONTRO (long: media scende +sotto entry; short: media sale sopra entry), il TP diventerebbe un'uscita in perdita +mascherata. Lo evitiamo: + long : tp(j) = max(tp(j), entry*(1+0.002)) + short: tp(j) = min(tp(j), entry*(1-0.002)) +0.002 (~ 0.10% RT fee + margine) garantisce che il tocco del TP sia >= breakeven. + +SL FISSO (sl0) invariato; horizon = max_bars invariato. + +ANTI-LOOK-AHEAD: prepare() precalcola sma_n = rolling(n).mean() su close (causale, +ogni valore dipende solo da close <= quel-indice). In levels(j) si legge sma_n[j-1] +-> solo dati <= j-1, mai il bar j. SL e horizon invariati. OK per costruzione. + +GRID: n in {20, 50, 100} (3 celle). +""" +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from exit_lab import ExitPolicy, evaluate # noqa: E402 + + +class TpMovingMean(ExitPolicy): + name = "tp_moving_mean" + + @classmethod + def prepare(cls, ctx, **params): + n = int(params.get("n", 50)) + key = f"sma_{n}" + if key not in ctx: + c = ctx["close"] + ctx[key] = pd.Series(c).rolling(n).mean().values + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + n = int(params.get("n", 50)) + self.sma = ctx[f"sma_{n}"] + # cap onesto: il TP non puo' diventare un'uscita in perdita + if d == 1: + self.cap = entry * (1.0 + 0.002) + else: + self.cap = entry * (1.0 - 0.002) + + def levels(self, j: int): + # SOLO dati <= j-1 + m = self.sma[j - 1] + if not np.isfinite(m): + # warmup della SMA non ancora pronto -> ricadi sul TP fisso d'entrata + return self.tp0, self.sl0, 1.0 + if self.d == 1: + tp = max(m, self.cap) + else: + tp = min(m, self.cap) + return tp, self.sl0, 1.0 + + +GRID = [ + {"n": 20}, + {"n": 50}, + {"n": 100}, +] + + +if __name__ == "__main__": + evaluate(TpMovingMean, GRID) diff --git a/scripts/analysis/exit_policies/11_z_overshoot.py b/scripts/analysis/exit_policies/11_z_overshoot.py new file mode 100644 index 0000000..a75d1dd --- /dev/null +++ b/scripts/analysis/exit_policies/11_z_overshoot.py @@ -0,0 +1,93 @@ +"""EXIT-11 — z_overshoot: TP OLTRE la media (overshoot di z_off deviazioni std). + +PRIOR (vincolante): le fade puntano alla MEDIA. Oggi il TP e' la SMA_n al close[i-1]. +Ipotesi di questa famiglia: la reversione spesso NON si ferma esattamente alla media, +ma la attraversa (overshoot). Spostando il TP un po' OLTRE la media nella direzione +del trade catturiamo l'overshoot quando c'e', al prezzo di mancare alcuni TP che si +fermano alla media (che poi rientrano o vengono presi da max_bars/SL). + + tp(j) = SMA_n[j-1] + d * z_off * STD_n[j-1] + + long (d=+1): si compra sotto la media -> TP = media + z_off*std (sopra la media: piu' lontano). + short (d=-1): si vende sopra la media -> TP = media - z_off*std (sotto la media: piu' lontano). + +In ENTRAMBI i casi il TP si allontana di z_off*std OLTRE la media nella direzione +del profitto -> target piu' ambizioso. NB: lo z e' un overshoot ADDIZIONALE rispetto +alla media corrente; la media stessa si muove (come EXIT-10) quindi il target insegue +la media + overshoot. + +CAP ONESTO (anti exit-in-perdita): se la media + overshoot finisce contro l'entry +(media e' gia' scappata oltre entry contro di noi), il TP non deve diventare un'uscita +in perdita mascherata. Floor a breakeven+fee come EXIT-10: + long : tp(j) = max(tp(j), entry*(1+0.002)) + short: tp(j) = min(tp(j), entry*(1-0.002)) + +SL FISSO (sl0) invariato; horizon = max_bars invariato. + +ANTI-LOOK-AHEAD: prepare() precalcola sma_n = rolling(n).mean() e std_n = +rolling(n).std(ddof=0) su close (causali: ogni valore dipende solo da close <= indice). +In levels(j) si legge sma_n[j-1] e std_n[j-1] -> solo dati <= j-1, mai il bar j. +SL e horizon invariati. OK per costruzione. + +GRID: n in {20, 50} x z_off in {0.25, 0.5} (4 celle). +""" +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from exit_lab import ExitPolicy, evaluate # noqa: E402 + + +class ZOvershoot(ExitPolicy): + name = "z_overshoot" + + @classmethod + def prepare(cls, ctx, **params): + n = int(params.get("n", 50)) + kmean = f"sma_{n}" + kstd = f"std_{n}" + if kmean not in ctx or kstd not in ctx: + c = pd.Series(ctx["close"]) + ctx[kmean] = c.rolling(n).mean().values + ctx[kstd] = c.rolling(n).std(ddof=0).values + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + n = int(params.get("n", 50)) + self.z_off = float(params.get("z_off", 0.5)) + self.sma = ctx[f"sma_{n}"] + self.std = ctx[f"std_{n}"] + # cap onesto: il TP non puo' diventare un'uscita in perdita + if d == 1: + self.cap = entry * (1.0 + 0.002) + else: + self.cap = entry * (1.0 - 0.002) + + def levels(self, j: int): + # SOLO dati <= j-1 + m = self.sma[j - 1] + s = self.std[j - 1] + if not (np.isfinite(m) and np.isfinite(s)): + # warmup non pronto -> ricadi sul TP fisso d'entrata + return self.tp0, self.sl0, 1.0 + tp = m + self.d * self.z_off * s + if self.d == 1: + tp = max(tp, self.cap) + else: + tp = min(tp, self.cap) + return tp, self.sl0, 1.0 + + +GRID = [ + {"n": 20, "z_off": 0.25}, + {"n": 20, "z_off": 0.5}, + {"n": 50, "z_off": 0.25}, + {"n": 50, "z_off": 0.5}, +] + + +if __name__ == "__main__": + evaluate(ZOvershoot, GRID) diff --git a/scripts/analysis/exit_policies/12_partial_tp_trail.py b/scripts/analysis/exit_policies/12_partial_tp_trail.py new file mode 100644 index 0000000..96a0456 --- /dev/null +++ b/scripts/analysis/exit_policies/12_partial_tp_trail.py @@ -0,0 +1,98 @@ +"""EXIT-12 — partial_tp_trail: partial AL TP PIENO, poi il runner CORRE col trail. + +Idea (diversa dal ladder 80/20 gia' SCARTATO, che metteva uno stop FISSO alla +soglia 80% del TP): qui il partial avviene AL TP PIENO (tp0, alla media), esce una +frazione q del trade, e il RESIDUO resta SENZA TP, protetto da un trailing +chandelier k*ATR ancorato all'estremo favorevole raggiunto DOPO il partial; il +floor dello stop e' tp0 -> il profitto al livello TP e' LOCKATO (il runner non +puo' mai chiudere peggio di tp0). horizon esteso a 3*mb (cap HARD_CAP) per dare +spazio al runner. + + Fase 1 (pre-partial): exit standard = (tp0, sl0). Al tocco di tp0 esce q. + Fase 2 (post-partial): TP rimosso. Trail sul residuo: + Long : stop(j) = max( tp0, max(high[part..j-1]) - k*atr14[j-1] ) (solo sale) + Short: stop(j) = min( tp0, min(low [part..j-1]) + k*atr14[j-1] ) (solo scende) + floor = tp0 -> profitto lockato al livello TP, MAI peggio. + +ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1: + - estremo favorevole post-partial sullo slice [part .. j-1] (mantenuto + incrementalmente, aggiornato col bar j-1 prima di calcolare lo stop in j); + - atr14[j-1] (indice causale). + on_partial(j) registra solo l'indice del bar di partial (j) e il prezzo (tp0): + l'estremo della fase 2 parte da high/low del bar di partial j (<= j, ma il primo + bar valutato in fase 2 e' j+1, slice [j..j] noto al poll di j+1 -> causale). + +GRID: q in {0.5, 0.7} x k in {2.0, 3.0} (4 celle). tp_frac=q. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402 + + +class PartialTpTrail(ExitPolicy): + name = "partial_tp_trail" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, q=0.5, k=3.0, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.q = float(q) + self.k = float(k) + self.horizon = min(3 * mb, HARD_CAP) + self.high = ctx["high"] + self.low = ctx["low"] + self.atr = ctx["atr14"] + # stato fase 2 (post-partial) + self.partial_idx = None # bar in cui e' avvenuto il partial (None = fase 1) + self.fav_high = None # estremo favorevole sullo slice [partial..j-1] + self.fav_low = None + self._last_seen = None # ultimo indice incorporato nell'estremo + self.cur_stop = None # stop trailing fase 2, floor=tp0, monotono + + def levels(self, j): + # ---- Fase 1: pre-partial -> exit standard (tp0, sl0), esce frazione q al TP + if self.partial_idx is None: + return self.tp0, self.sl0, self.q + + # ---- Fase 2: post-partial -> TP rimosso, trail chandelier floor=tp0 + h, l, atr, d = self.high, self.low, self.atr, self.d + # incorpora i bar fino a j-1 (dati causali, gia' chiusi al poll del bar j) + while self._last_seen < j - 1: + self._last_seen += 1 + if h[self._last_seen] > self.fav_high: + self.fav_high = h[self._last_seen] + if l[self._last_seen] < self.fav_low: + self.fav_low = l[self._last_seen] + a = atr[j - 1] + if a != a: # NaN -> resta sullo stop corrente + return None, self.cur_stop, 1.0 + if d == 1: + cand = self.fav_high - self.k * a + if cand > self.cur_stop: # lo stop long puo' solo SALIRE (stringersi) + self.cur_stop = cand + else: + cand = self.fav_low + self.k * a + if cand < self.cur_stop: # lo stop short puo' solo SCENDERE + self.cur_stop = cand + return None, self.cur_stop, 1.0 + + def on_partial(self, j, price, remaining): + # entra in fase 2: ancora l'estremo al bar di partial j (high/low[j] sono + # noti al poll del bar j+1, primo bar valutato in fase 2). floor=tp0. + self.partial_idx = j + self.fav_high = self.high[j] + self.fav_low = self.low[j] + self._last_seen = j + self.cur_stop = self.tp0 # profitto lockato al livello TP, MAI peggio + + +GRID = [ + {"q": q, "k": k} + for q in (0.5, 0.7) + for k in (2.0, 3.0) +] + + +if __name__ == "__main__": + evaluate(PartialTpTrail, GRID) diff --git a/scripts/analysis/exit_policies/13_hurst_exit.py b/scripts/analysis/exit_policies/13_hurst_exit.py new file mode 100644 index 0000000..639f840 --- /dev/null +++ b/scripts/analysis/exit_policies/13_hurst_exit.py @@ -0,0 +1,74 @@ +"""EXIT-13 — hurst_exit: TP condizionato al REGIME (rolling-Hurst causale). + +IDEA. Le fade puntano alla MEDIA: in regime ANTI-PERSISTENTE (Hurst basso) la +reversione tende a completarsi -> ha senso tenere il TP pieno tp0. In regime +PERSISTENTE/trending (Hurst alto) la reversione spesso NON arriva fino in fondo +(il movimento continua, lo stop-loss si concentra li': vedi loss-guard Hurst) -> +conviene "prendere quel che c'e'": TP anticipato a meta' strada (entry+tp0)/2. + + - H[j-1] < h_lo (anti-persistente): tp(j) = tp0 (reversione completa) + - H[j-1] >= h_lo (persistente): tp(j) = (entry+tp0)/2 (TP a meta') + +SL FISSO (sl0) e horizon (max_bars) invariati. + +ANTI-LOOK-AHEAD. prepare() precalcola H = rolling_hurst(close, window=100, step=6) +una volta sullo sleeve. rolling_hurst e' causale: H[k] usa returns[k-window:k] e +returns[m]=diff(log(close))[m] dipende da close[m+1], quindi H[k] dipende solo da +close <= k. In levels(j) leggo H[j-1] -> solo dati <= j-1. SL/horizon invariati. +La decisione del regime e' fissata col bar precedente, il bar j tocca i livelli. + +NB sul TP a meta'. Lo "step=6" significa che H e' costante a tratti di 6 barre; il +regime e' ri-letto ogni bar (j-1) ma cambia valore ogni 6. Il TP a meta' strada +e' SEMPRE >= breakeven per costruzione (e' fra entry e tp0, e tp0 e' gia' oltre il +margine fee per come la fade lo fissa), quindi non maschera uscite in perdita. + +GRID: h_lo in {0.45, 0.50, 0.55} (3 celle). +""" +import sys +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from exit_lab import ExitPolicy, evaluate # noqa: E402 + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from src.fractal.indicators import rolling_hurst # noqa: E402 + + +class HurstExit(ExitPolicy): + name = "hurst_exit" + + @classmethod + def prepare(cls, ctx, **params): + if "hurst100" not in ctx: + c = ctx["close"] + ctx["hurst100"] = rolling_hurst(c, window=100, step=6) + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.h = ctx["hurst100"] + self.h_lo = float(params.get("h_lo", 0.50)) + self.tp_half = (entry + tp0) / 2.0 # TP a meta' strada (persistente) + + def levels(self, j: int): + # SOLO dati <= j-1 + hv = self.h[j - 1] + if not np.isfinite(hv): + return self.tp0, self.sl0, 1.0 + if hv < self.h_lo: + tp = self.tp0 # anti-persistente: reversione completa + else: + tp = self.tp_half # persistente: prendi la meta' + return tp, self.sl0, 1.0 + + +GRID = [ + {"h_lo": 0.45}, + {"h_lo": 0.50}, + {"h_lo": 0.55}, +] + + +if __name__ == "__main__": + evaluate(HurstExit, GRID) diff --git a/scripts/analysis/exit_policies/14_giveback.py b/scripts/analysis/exit_policies/14_giveback.py new file mode 100644 index 0000000..f73b332 --- /dev/null +++ b/scripts/analysis/exit_policies/14_giveback.py @@ -0,0 +1,73 @@ +"""EXIT-14 — protezione GIVEBACK dal picco (trailing sul PROFITTO, non sul prezzo). + +Idea: una fade puo' correre a favore quasi fino al TP (alla media) e poi +ritracciare prima di toccarlo, restituendo gran parte del guadagno gia' maturato. +Questa policy traccia l'high-water-mark (hwm) del PROFITTO non realizzato sul +CLOSE; quando il profitto corrente scende sotto (1-g)*hwm_profit -- cioe' si e' +restituita una frazione g del picco -- e il picco era gia' "significativo" +(hwm_profit > soglia * dist(entry, tp0)), esce al close del bar (after_bar). +Il TP fisso tp0 e lo SL fisso sl0 RESTANO invariati e prioritari intrabar. + +Razionale: protegge il profitto maturato senza toccare i livelli; non e' un +ladder (ieri scartato perche' al TP/media il movimento e' esaurito) ma un +trailing sul drawdown del trade quando ha gia' fatto buona parte del lavoro. + +ANTI-LOOK-AHEAD: + - levels(j) NON e' toccato (TP/SL fissi): nessun dato futuro. + - after_bar(j) decide sul CLOSE del bar j (eseguibile al poll). Aggiorna l'hwm + con il profitto a close[j] e poi confronta: tutto con dati <= j. Il bar j e' + lecito in after_bar per contratto. L'hwm e' un cliquet (non scende mai). + +GRID: g in {0.4, 0.6} x arm_frac in {0.3, 0.5} della distanza al TP (4 celle). + - g = frazione del picco di profitto restituita che fa scattare l'uscita. + - arm_frac = il giveback e' armato solo quando hwm_profit ha superato + arm_frac * dist(entry, tp0): finche' il trade non ha "fatto strada" + (frazione del cammino verso la media) non interviene -> non taglia i trade + che partono storti, solo quelli che hanno gia' guadagnato e poi mollano. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from exit_lab import ExitPolicy, evaluate # noqa: E402 + + +class Giveback(ExitPolicy): + name = "giveback" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.g = float(params.get("g", 0.4)) + self.arm_frac = float(params.get("arm_frac", 0.3)) + self.close = ctx["close"] + # distanza (in prezzo, sempre positiva) dall'entrata al TP iniziale + self.dist_tp = abs(self.tp0 - entry) if self.tp0 is not None else 0.0 + self.arm_level = self.arm_frac * self.dist_tp + self.hwm_profit = 0.0 # high-water-mark del profitto a favore (cliquet) + + def after_bar(self, j: int) -> bool: + if self.dist_tp <= 0.0: + return False + # profitto NON realizzato a favore, valutato sul close del bar j (lecito qui) + profit = (self.close[j] - self.entry) * self.d + if profit > self.hwm_profit: + self.hwm_profit = profit # aggiorna il picco (non scende mai) + # arma solo se il picco ha superato la soglia (trade gia' "in cammino") + if self.hwm_profit < self.arm_level: + return False + # esci se il profitto corrente ha restituito >= g del picco + if profit < (1.0 - self.g) * self.hwm_profit: + return True + return False + + +GRID = [ + {"g": 0.4, "arm_frac": 0.3}, + {"g": 0.4, "arm_frac": 0.5}, + {"g": 0.6, "arm_frac": 0.3}, + {"g": 0.6, "arm_frac": 0.5}, +] + + +if __name__ == "__main__": + evaluate(Giveback, GRID) diff --git a/scripts/analysis/exit_policies/15_loser_timestop.py b/scripts/analysis/exit_policies/15_loser_timestop.py new file mode 100644 index 0000000..329ac10 --- /dev/null +++ b/scripts/analysis/exit_policies/15_loser_timestop.py @@ -0,0 +1,61 @@ +"""EXIT-15 — loser time-stop ("i loser muoiono giovani?"). + +Le fade tengono il trade fino a TP/SL fissi o max_bars. Qui aggiungiamo un +solo extra-exit CONDIZIONATO AL SEGNO della PnL: al k-esimo bar dopo l'entrata, +se il trade e' ancora in PERDITA sul CLOSE (unrealized < 0), chiudi il residuo +al close[j]. Se a quel bar il trade e' in profitto (o piatto), NON tocca nulla +e lascia correre verso TP/SL/max_bars come la base. + + TP/SL fissi (tp0, sl0) INVARIATI; horizon = max_bars INVARIATO. + +Differenza col time-stop semplice (gia' FALLITO il 2026-06-02, tagliava i +winner): qui non si esce per il solo passare del tempo, ma SOLO se la PnL e' +negativa. L'ipotesi e' che un trade ancora in rosso dopo k bar abbia bassa +probabilita' di recuperare e finisca per colpire lo SL (peggio) o scadere a +max_bars in perdita -> uscire prima limita la coda di perdite e libera capitale. + +Anti-look-ahead: la decisione e' in `after_bar(j)`, che per contratto puo' +leggere il CLOSE del bar j (eseguibile al poll del tick). Confronto: + unrealized(j) = (close[j] - entry) * d (>0 = a favore) +Esce SOLO quando j - i == k (controllo una tantum al k-esimo bar). I livelli +TP/SL restano quelli base (nessun uso di dati futuri in levels()). + +GRID: k in {4, 8, 12, 16} (4 celle). +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from exit_lab import ExitPolicy, evaluate # noqa: E402 + + +class LoserTimestop(ExitPolicy): + name = "loser_timestop" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.k = int(params.get("k", 8)) + self.close = ctx["close"] + + # livelli base, invariati (nessun look-ahead) + def levels(self, j: int): + return self.tp0, self.sl0, 1.0 + + def after_bar(self, j: int) -> bool: + # controllo una tantum al k-esimo bar dopo l'entrata + if j - self.i != self.k: + return False + unrealized = (self.close[j] - self.entry) * self.d + return unrealized < 0.0 + + +GRID = [ + {"k": 4}, + {"k": 8}, + {"k": 12}, + {"k": 16}, +] + + +if __name__ == "__main__": + evaluate(LoserTimestop, GRID) diff --git a/scripts/analysis/exit_policies/16_close_confirm_sl.py b/scripts/analysis/exit_policies/16_close_confirm_sl.py new file mode 100644 index 0000000..1c6e1c5 --- /dev/null +++ b/scripts/analysis/exit_policies/16_close_confirm_sl.py @@ -0,0 +1,78 @@ +"""EXIT-16 — close_confirm_sl: STOP-LOSS confermato in CHIUSURA (immune ai wick). + +IDEA. Lo SL intrabar fisso stoppa anche su un wick transitorio che buca sl0 e +poi rientra. Le fade sono mean-reversion: il movimento contrario e' spesso un +overshoot momentaneo. Qui lo SL NON e' piu' un livello toccato intrabar +(disattivato: sl=None nei livelli, l'engine non lo testa MAI sui low/high) ma +una CONFERMA sul CLOSE del bar: si esce solo se il prezzo CHIUDE oltre sl0. + + in levels(j): sl = None (no stop intrabar -> immune ai wick), TP fisso tp0. + in after_bar(j): + long (d=1): esci al close[j] se close[j] < sl0 - buffer*atr14[j] + short (d=-1): esci al close[j] se close[j] > sl0 + buffer*atr14[j] + +ONESTO. L'uscita avviene al CLOSE[j], che puo' essere PEGGIO di sl0 quando il +bar gappa o sfonda di slancio (il backtest paga il close reale, non sl0). Questo +e' il costo del "confermare": si rinuncia a fermarsi esatti a sl0 in cambio di +non farsi cacciare dai wick. Il buffer*atr richiede uno sfondamento ulteriore in +chiusura -> meno uscite ma piu' lontane da sl0 quando scattano. + +ANTI-LOOK-AHEAD. levels(j) ritorna sl=None e tp0 (nessun dato del bar j). La +decisione di uscita e' in after_bar(j), che PER CONTRATTO puo' leggere il bar j: +close[j] e atr14[j] sono entrambi noti al close del bar j (atr14[j] = rolling +mean di TR fino a j; TR[j] usa high/low/close[j] e close[j-1], tutti chiusi a j). +Nessun dato > j entra nella decisione. -> contratto rispettato. + +NB il HARD_CAP/horizon=max_bars resta: se ne' TP ne' close-confirm scattano, il +trade scade al close a max_bars come la base. Quindi un trade puo' restare in +posizione PIU' a lungo della base (la base poteva stopparlo intrabar prima): +l'avg_bars puo' SALIRE -> il ret va pesato per l'esposizione. + +GRID: buffer in {0.0, 0.25, 0.5} ATR (3 celle). +""" +import sys +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from exit_lab import ExitPolicy, evaluate # noqa: E402 + + +class CloseConfirmSl(ExitPolicy): + name = "close_confirm_sl" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.buffer = float(params.get("buffer", 0.0)) + self.close = ctx["close"] + self.atr = ctx["atr14"] + + def levels(self, j: int): + # SL intrabar DISATTIVATO (immune ai wick): l'engine non testa low/high + # contro lo stop. TP fisso tp0 intrabar invariato. + return self.tp0, None, 1.0 + + def after_bar(self, j: int) -> bool: + # Decisione sul CLOSE del bar j -> per contratto j e' leggibile. + a = self.atr[j] + if not np.isfinite(a): + a = 0.0 + cj = self.close[j] + if self.d == 1: + # long: stop confermato se chiude SOTTO sl0 - buffer*atr + return cj < self.sl0 - self.buffer * a + else: + # short: stop confermato se chiude SOPRA sl0 + buffer*atr + return cj > self.sl0 + self.buffer * a + + +GRID = [ + {"buffer": 0.0}, + {"buffer": 0.25}, + {"buffer": 0.5}, +] + + +if __name__ == "__main__": + evaluate(CloseConfirmSl, GRID) diff --git a/scripts/analysis/exit_policies/17_wide_sl_trail.py b/scripts/analysis/exit_policies/17_wide_sl_trail.py new file mode 100644 index 0000000..4eb5531 --- /dev/null +++ b/scripts/analysis/exit_policies/17_wide_sl_trail.py @@ -0,0 +1,86 @@ +"""EXIT-17 — wide_sl_trail: RESPIRA POI PROTEGGI. + +Idea: l'SL fisso all'entrata (sl0) puo' essere troppo stretto -> stoppa fade che +poi sarebbero rientrate alla media. Qui ALLARGHIAMO l'SL iniziale a + sl_w = entry - w*(entry - sl0) (long, w>1 -> stop piu' lontano = piu' respiro) + sl_w = entry + w*(sl0 - entry) (short) +ma attiviamo SUBITO un trailing chandelier a k*ATR dall'estremo favorevole. Lo stop +attivo nel bar j e' il PIU' PROTETTIVO fra l'SL allargato e il chandelier: + long : sl = max(sl_w, max(high[i..j-1]) - k*ATR[j-1]) + short: sl = min(sl_w, min(low[i..j-1]) + k*ATR[j-1]) +TP fisso tp0 invariato; horizon = max_bars invariato. + +Logica: finche' il prezzo NON corre a favore, il rischio e' l'SL allargato (respiro: +si tollera un ritracciamento iniziale piu' ampio nella speranza che la fade rientri). +Appena il prezzo corre a favore, il chandelier sale sopra sl_w e protegge il +profitto come un trail normale. Differenza da EXIT-02 (che usa max(sl0, chand)): +qui il floor iniziale e' PIU' LARGO di sl0, quindi early il rischio per-trade e' +maggiore (peggiora i loser veloci) ma si salvano fade che con sl0 sarebbero state +stoppate giusto prima del rientro. + +ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1 (estremo favorevole su [i..j-1] +mantenuto incrementalmente fino a j-1; atr14[j-1]). after_bar non usato. + +GRID: w in {1.5, 2.0} x k in {2.0, 3.0} (4 celle). +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from exit_lab import ExitPolicy, evaluate # noqa: E402 + + +class WideSLTrail(ExitPolicy): + name = "wide_sl_trail" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, w=1.5, k=2.0, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.w = float(w) + self.k = float(k) + self.high = ctx["high"] + self.low = ctx["low"] + self.atr = ctx["atr14"] + # SL iniziale ALLARGATO: piu' lontano dall'entrata (w>1) + if d == 1: + self.sl_w = entry - self.w * (entry - sl0) + else: + self.sl_w = entry + self.w * (sl0 - entry) + # estremo favorevole running (solo barre <= j-1); init = barra d'entrata i + self.run_hi = self.high[i] + self.run_lo = self.low[i] + self.last_seen = i + + def _update_running(self, upto: int) -> None: + """Incorpora le barre (last_seen, upto] nell'estremo favorevole. upto = j-1 + -> NON tocca il bar j (anti-look-ahead).""" + while self.last_seen < upto: + self.last_seen += 1 + if self.high[self.last_seen] > self.run_hi: + self.run_hi = self.high[self.last_seen] + if self.low[self.last_seen] < self.run_lo: + self.run_lo = self.low[self.last_seen] + + def levels(self, j: int): + self._update_running(j - 1) # solo dati <= j-1 + a = self.atr[j - 1] + if a is None or a != a: # NaN nei primi 14 bar -> usa l'SL allargato + return self.tp0, self.sl_w, 1.0 + if self.d == 1: + chand = self.run_hi - self.k * a + sl = max(self.sl_w, chand) # il piu' protettivo (stop piu' alto) + else: + chand = self.run_lo + self.k * a + sl = min(self.sl_w, chand) # il piu' protettivo (stop piu' basso) + return self.tp0, sl, 1.0 + + +GRID = [ + {"w": w, "k": k} + for w in (1.5, 2.0) + for k in (2.0, 3.0) +] + + +if __name__ == "__main__": + evaluate(WideSLTrail, GRID) diff --git a/scripts/analysis/exit_policies/18_swing_stop.py b/scripts/analysis/exit_policies/18_swing_stop.py new file mode 100644 index 0000000..c5d829a --- /dev/null +++ b/scripts/analysis/exit_policies/18_swing_stop.py @@ -0,0 +1,108 @@ +"""EXIT-18 — SL STRUTTURALE su swing low/high (structural / chandelier-by-structure). + +Idea: invece di uno stop FISSO a sl0 (ATR dall'entrata), uno stop ancorato alla +STRUTTURA recente del prezzo. Per un long: il livello "naturale" sotto cui la tesi +mean-reversion e' invalidata e' il minimo dello swing recente; se il prezzo rompe +sotto il minimo degli ultimi n bar la fade ha torto. Simmetrico per lo short sul +massimo recente. + + long (d=1): level(j) = min(low[j-n .. j-1]) - buf + short (d=-1): level(j) = max(high[j-n .. j-1]) + buf + buf = 0.25 * atr14[j-1] + +SOLO-STRINGENTE: lo stop parte da sl0 (protezione iniziale invariata) e si aggiorna +SOLO se il livello swing e' PIU' PROTETTIVO (cricchetto): + long : cur_stop = max(cur_stop, level) (lo stop puo' solo SALIRE) + short: cur_stop = min(cur_stop, level) (lo stop puo' solo SCENDERE) +Cosi' man mano che il prezzo (per una fade vincente) torna verso la media, lo swing +low/high sale/scende e lo stop segue, bloccando profitto. Non si allenta MAI. + +TP fisso (tp0) e horizon=max_bars invariati: questa famiglia cambia SOLO lo stop. + +ANTI-LOOK-AHEAD (contratto): levels(j) usa SOLO dati con indice <= j-1: + - massimi/minimi sullo slice [j-n .. j-1] (lookback chiuso a j-1); + - buffer da atr14[j-1] (indicatore causale, valore del bar precedente). +Mantengo cur_stop in modo incrementale ma lo aggiorno con i bar fino a j-1, mai j. +Nessun dato del bar j o successivi entra nel livello attivo nel bar j. + +MECCANISMO ATTESO: lo stop strutturale e' tipicamente PIU' LASCO di sl0 all'inizio +(il minimo recente puo' stare oltre sl0): in quel caso il cricchetto lo IGNORA e +resta a sl0 (non allentiamo mai). Quando invece la struttura si stringe (lo swing +low risale verso il prezzo dopo che la fade comincia a funzionare) lo stop SALE, +proteggendo i guadagni di un trade che poi rintraccia prima del TP. +Prior dal repo (ladder scartato): il TP della fade sta alla MEDIA, dove il movimento +e' esaurito -> oltre il TP non c'e' runner. Qui non tocchiamo il TP, quindi non +puntiamo a cavalcare oltre: cerchiamo SOLO di tagliare meglio i loser/ristagni +proteggendo profitto. RISCHIO (fade = mean-reversion): stringere lo stop su un +pullback transitorio stoppa fuori trade che sarebbero rientrati al TP -> winner +tagliati e stop-rate su. Lo misuriamo. + +GRID: n in {5, 10, 20} (3 celle). +""" +import sys +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from exit_lab import ExitPolicy, evaluate # noqa: E402 + + +class SwingStop(ExitPolicy): + name = "swing_stop" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, n=10, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.n = int(n) + self.low = ctx["low"] + self.high = ctx["high"] + self.atr14 = ctx["atr14"] + # stop a cricchetto: parte dalla protezione iniziale, puo' solo stringersi + self.cur_stop = sl0 + # indice fino a cui ho gia' incorporato i bar nel cricchetto (escluso) + self._last_seen = i + + def _swing_level(self, j: int): + """Livello swing causale usando SOLO low/high su [j-n .. j-1] e atr14[j-1].""" + lo = max(self.i, j - self.n) # non andare prima dell'entrata + hi = j # slice [lo:hi] => indici <= j-1 + if hi <= lo: + return None + a = self.atr14[j - 1] + if not np.isfinite(a): + a = 0.0 + buf = 0.25 * a + if self.d == 1: + return float(np.min(self.low[lo:hi])) - buf + else: + return float(np.max(self.high[lo:hi])) + buf + + def levels(self, j: int): + d = self.d + # aggiorna il cricchetto bar-per-bar fino a j-1 (causale). Per ogni bar k + # passato calcolo il livello swing attivo "a quel momento" e stringo lo stop. + while self._last_seen < j: + self._last_seen += 1 + k = self._last_seen + lvl = self._swing_level(k) + if lvl is None: + continue + if d == 1: + if lvl > self.cur_stop: # cricchetto long: lo stop solo SALE + self.cur_stop = lvl + else: + if lvl < self.cur_stop: # cricchetto short: lo stop solo SCENDE + self.cur_stop = lvl + return self.tp0, self.cur_stop, 1.0 + + +GRID = [ + {"n": 5}, + {"n": 10}, + {"n": 20}, +] + + +if __name__ == "__main__": + evaluate(SwingStop, GRID) diff --git a/scripts/analysis/exit_policies/19_donchian_trail.py b/scripts/analysis/exit_policies/19_donchian_trail.py new file mode 100644 index 0000000..24ca446 --- /dev/null +++ b/scripts/analysis/exit_policies/19_donchian_trail.py @@ -0,0 +1,88 @@ +"""EXIT-19 — TP RIMOSSO, exit al CANALE DONCHIAN OPPOSTO (donchian_trail). + +Idea: la fade attuale esce al TP (sulla media) + SL fisso (ATR dall'entrata) + +max_bars. Qui TOGLIAMO il TP e usiamo come unica uscita di prezzo uno STOP +DINAMICO ancorato al canale Donchian OPPOSTO alla direzione del trade: + + long (d=1): stop(j) = min(low[j-n .. j-1]) (canale inferiore) + short (d=-1): stop(j) = max(high[j-n .. j-1]) (canale superiore) + +Per un long che funziona (il prezzo risale verso la media) il canale inferiore +SALE bar dopo bar -> lo stop segue e blocca profitto; usciamo quando il prezzo +ritraccia sotto il minimo recente. Simmetrico short. + +FLOOR a sl0 (mai PEGGIO dello SL originale): il livello attivo e' floorato alla +protezione iniziale -> non si allenta mai oltre sl0. + long : stop = max(channel_low, sl0) + short: stop = min(channel_high, sl0) + +HORIZON = 4*mb (cap HARD_CAP=240): senza TP la posizione puo' restare a lungo, +quindi diamo molto piu' respiro al time-stop; l'uscita "naturale" e' il canale. + +DIFFERENZA da EXIT-18 (swing_stop): qui (a) NON c'e' TP affatto (li' tp0 restava), +(b) niente cricchetto persistente: lo stop e' il canale RICALCOLATO ogni bar (puo' +anche allentarsi rispetto al bar prima, ma mai sotto sl0 grazie al floor), +(c) horizon esteso 4x. E' una uscita puramente trend-following/Donchian innestata +su un ingresso mean-reversion. + +ANTI-LOOK-AHEAD (contratto): levels(j) usa SOLO dati con indice <= j-1: + - min/max sullo slice [j-n .. j-1] (lookback chiuso a j-1, lo[lo:hi] con hi=j); + - nessun dato del bar j entra nel livello attivo nel bar j; + - non si guarda mai high/low[j] per decidere lo stop attivo nel bar j. + +PRIOR dal repo (ladder scartato): il TP della fade sta alla MEDIA, dove il +movimento e' esaurito; "il runner non corre". Quindi togliendo il TP rischiamo di +restare in posizione MENTRE il prezzo ristagna/rientra, pagando giveback e fee. +Il canale opposto dovrebbe limitare il giveback, ma la mean-reversion fa rientrare +il prezzo prima che il canale si stringa -> probabile uscita PEGGIORE del TP. +Lo misuriamo senza pregiudizio. + +GRID: n in {10, 20, 30} (3 celle). +""" +import sys +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from exit_lab import ExitPolicy, evaluate # noqa: E402 + + +class DonchianTrail(ExitPolicy): + name = "donchian_trail" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, n=20, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.n = int(n) + self.low = ctx["low"] + self.high = ctx["high"] + # TP rimosso, horizon esteso 4x (il cap a HARD_CAP lo applica l'engine) + self.horizon = 4 * mb + + def levels(self, j: int): + d = self.d + # canale opposto causale: slice [lo : j] => indici <= j-1 + lo = max(self.i, j - self.n) + hi = j + if hi <= lo: + # primo bar dopo l'entrata: nessuna finestra -> usa solo sl0 (no TP) + return None, self.sl0, 1.0 + if d == 1: + ch = float(np.min(self.low[lo:hi])) + stop = max(ch, self.sl0) # floor: mai sotto sl0 + else: + ch = float(np.max(self.high[lo:hi])) + stop = min(ch, self.sl0) # floor: mai sopra sl0 + return None, stop, 1.0 # TP = None (rimosso) + + +GRID = [ + {"n": 10}, + {"n": 20}, + {"n": 30}, +] + + +if __name__ == "__main__": + evaluate(DonchianTrail, GRID) diff --git a/scripts/analysis/exit_policies/20_ema_cross_exit.py b/scripts/analysis/exit_policies/20_ema_cross_exit.py new file mode 100644 index 0000000..4adb2fe --- /dev/null +++ b/scripts/analysis/exit_policies/20_ema_cross_exit.py @@ -0,0 +1,90 @@ +"""EXIT-20 — ema_cross_exit: esci quando il close attraversa contro-posizione la EMA_m. + +IDEA. Le fade comprano sotto / vendono sopra la media e oggi escono al TP fisso (la +media congelata all'entrata) o a max_bars/SL. Qui usiamo la EMA_m come "linea della +media corrente": finche' il prezzo non l'ha attraversata, la reversione e' ancora in +corso; quando il close la attraversa CONTRO la posizione, la mean-reversion ha +esaurito il suo percorso -> si chiude al close. + + long (d=+1): la fade ha comprato sotto la media; esce quando close[j] < ema_m[j] + (il prezzo e' risalito ABOVE la media e ora la ri-perfora al ribasso, + ovvero il close torna sotto la media -> reversione finita/overshoot). + short (d=-1): esce quando close[j] > ema_m[j]. + +NB sul segno. La condizione "long: close < ema" e' la cross CONTRO la posizione: la +fade long scommette sul rientro VERSO/OLTRE la media; quando il close ricade sotto la +EMA dopo che la reversione e' avvenuta, il segnale di reversione e' consumato. E' un +exit "alla media mobile" che insegue la media invece del target congelato. + +VARIANTI keep_tp: + - keep_tp=True : il TP fisso tp0 RESTA attivo (si esce al primo fra TP-al-livello, + SL, ema-cross, max_bars). horizon = max_bars (invariato). + - keep_tp=False: si RIMUOVE il TP fisso (tp=None) e si lascia che sia la ema-cross a + chiudere il vincente; horizon = 2*max_bars (cap HARD_CAP) per dare + spazio alla cross. SL fisso resta SEMPRE. + +ANTI-LOOK-AHEAD. prepare() precalcola ema_m = EMA(close, span=m) UNA volta: causale, +ema[k] dipende solo da close <= k. La decisione di uscita e' in after_bar(j), che per +contratto puo' leggere il bar j (close[j], ema_m[j]) ed e' eseguibile al close del +poll. levels(j) usa solo sl0/tp0 (costanti) -> nessun dato > j-1. OK per costruzione. + +GRID: m in {5, 10, 20} x keep_tp in {True, False} (6 celle). +""" +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402 + + +class EmaCrossExit(ExitPolicy): + name = "ema_cross_exit" + + @classmethod + def prepare(cls, ctx, **params): + m = int(params.get("m", 10)) + key = f"ema_{m}" + if key not in ctx: + c = ctx["close"] + ctx[key] = pd.Series(c).ewm(span=m, adjust=False).mean().values + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + m = int(params.get("m", 10)) + self.ema = ctx[f"ema_{m}"] + self.keep_tp = bool(params.get("keep_tp", True)) + if not self.keep_tp: + # senza TP la cross deve avere spazio: raddoppia l'orizzonte (cap HARD_CAP) + self.horizon = min(2 * mb, HARD_CAP) + + def levels(self, j: int): + # SL fisso sempre; TP fisso solo se keep_tp (altrimenti None -> lo gestisce la cross) + tp = self.tp0 if self.keep_tp else None + return tp, self.sl0, 1.0 + + def after_bar(self, j: int) -> bool: + # after_bar puo' usare il bar j (close[j], ema[j]) -> eseguibile al close del poll + e = self.ema[j] + if not np.isfinite(e): + return False + c = self.ctx["close"][j] + if self.d == 1: + return c < e # long: close ricade SOTTO la media -> reversione finita + return c > e # short: close risale SOPRA la media + + +GRID = [ + {"m": 5, "keep_tp": True}, + {"m": 10, "keep_tp": True}, + {"m": 20, "keep_tp": True}, + {"m": 5, "keep_tp": False}, + {"m": 10, "keep_tp": False}, + {"m": 20, "keep_tp": False}, +] + + +if __name__ == "__main__": + evaluate(EmaCrossExit, GRID) diff --git a/scripts/analysis/exit_policies/21_vol_rescale.py b/scripts/analysis/exit_policies/21_vol_rescale.py new file mode 100644 index 0000000..bbec05e --- /dev/null +++ b/scripts/analysis/exit_policies/21_vol_rescale.py @@ -0,0 +1,71 @@ +"""EXIT-21 — vol_rescale (livelli che RESPIRANO con la volatilita'). + +I TP/SL fissi della base nascono come multipli dell'ATR all'entrata: + m_tp = |tp0 - entry| / atr14[i] (quanti ATR dista il TP) + m_sl = |sl0 - entry| / atr14[i] (quanti ATR dista lo SL) +Invece di congelare la DISTANZA in prezzo (tp0, sl0), congeliamo il MULTIPLO e +lasciamo che la distanza respiri con l'ATR corrente: + tp(j) = entry + d * m_tp * atr14[j-1] + sl(j) = entry - d * m_sl * atr14[j-1] +Cosi' se la vol si comprime mentre la fade torna alla media, il TP si AVVICINA +(prende profitto prima, coerente col fatto che il movimento residuo si esaurisce); +se la vol esplode, lo SL si ALLARGA (meno stop-out su spike), e viceversa. + +Anti-look-ahead: m_tp/m_sl usano atr14[i] (noto al close del bar d'entrata, dove +il worker fissa i livelli); levels(j) usa SOLO atr14[j-1]. Se atr14[j-1] e' NaN +(warmup), si ricade sui livelli fissi base (tp0/sl0). + +Varianti (mode): + - tp_only : TP respira, SL resta fisso a sl0 + - sl_only : SL respira, TP resta fisso a tp0 + - both : entrambi respirano + +GRID: mode in {tp_only, sl_only, both} (3 celle). +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from exit_lab import ExitPolicy, evaluate # noqa: E402 + + +class VolRescale(ExitPolicy): + name = "vol_rescale" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.mode = params.get("mode", "both") + self.atr = ctx["atr14"] + # ATR all'entrata: noto al close[i] (il worker fissa i livelli qui). + a_i = self.atr[i] + if a_i is None or a_i != a_i or a_i <= 0: + # nessun ATR valido all'entrata -> impossibile derivare i multipli: + # la policy degenera nei livelli fissi base. + self.valid = False + self.m_tp = self.m_sl = 0.0 + else: + self.valid = True + self.m_tp = abs(tp0 - entry) / a_i + self.m_sl = abs(sl0 - entry) / a_i + + def levels(self, j: int): + if not self.valid: + return self.tp0, self.sl0, 1.0 + a = self.atr[j - 1] # solo dati <= j-1 + if a is None or a != a: # NaN -> ricadi sui fissi base + return self.tp0, self.sl0, 1.0 + d = self.d + tp = self.tp0 + sl = self.sl0 + if self.mode in ("tp_only", "both"): + tp = self.entry + d * self.m_tp * a + if self.mode in ("sl_only", "both"): + sl = self.entry - d * self.m_sl * a + return tp, sl, 1.0 + + +GRID = [{"mode": "tp_only"}, {"mode": "sl_only"}, {"mode": "both"}] + + +if __name__ == "__main__": + evaluate(VolRescale, GRID) diff --git a/scripts/analysis/exit_policies/22_no_sl.py b/scripts/analysis/exit_policies/22_no_sl.py new file mode 100644 index 0000000..9121f9b --- /dev/null +++ b/scripts/analysis/exit_policies/22_no_sl.py @@ -0,0 +1,74 @@ +"""EXIT-22 — DIAGNOSTICA DEL VALORE DELLO SL sulle fade (MR01/MR02/MR07). + +Le fade live escono con TP/SL FISSI decisi all'entrata (al close[i]) + max_bars. +Lo SL e' sl0 = entry -/+ k*ATR (per costruzione della strategia). Questa policy +NON cambia TP ne' horizon: tocca SOLO lo SL per misurare quanto lo SL ATTUALE +aggiunge/toglie a ret/DD/Sharpe per sleeve. Tre regimi: + + mode = "none" -> sl = None (SL RIMOSSO: restano TP + max_bars). Il trade + puo' chiudere solo al TP o a scadenza horizon. Esposizione + al rischio di coda massima (nessun taglio della perdita). + mode = "wide2x" -> sl spostato a 2x la distanza entry->sl0 (stop piu' lasco): + sl = entry + 2*(sl0 - entry). Stoppa meno spesso. + mode = "tight05x" -> sl spostato a 0.5x la distanza (stop piu' stretto): + sl = entry + 0.5*(sl0 - entry). Stoppa piu' spesso, ma + perde di meno per stop. + +La geometria col segno e' automatica: per long sl0entry; +scalare (sl0-entry) preserva il lato corretto in entrambi i casi. + +ANTI-LOOK-AHEAD: il livello sl(j) dipende SOLO da {entry, sl0, mode}, tutti noti +all'ENTRATA (close[i]). Costante per tutto il trade, identico al baseline tranne +il fattore di scala. Nessun dato del bar j o futuro entra. TP = tp0 invariato. +-> contratto rispettato per costruzione (livello statico, come il baseline). + +INTERPRETAZIONE (diagnostica, non per forza deployabile): + - se "none" >= base su ret E DD -> lo SL toglie valore (taglia winner che + sarebbero rientrati: la fade e' mean-reversion, il movimento avverso e' + spesso transitorio). + - se "tight05x" peggiora molto -> lo SL e' gia' sul filo: stringerlo morde i + rientri. se "wide2x" ~ "none" -> lo SL attuale e' quasi inerte (raramente + toccato) e il rischio di coda e' contenuto da max_bars/TP. + - DD e' la metrica chiave: lo SL serve a contenere la coda. Se "none" ha DD + simile o piu' basso, lo SL non sta proteggendo nulla di utile. + +GRID: mode in {none, wide2x, tight05x} (3 celle). +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from exit_lab import ExitPolicy, evaluate # noqa: E402 + + +_SCALE = {"none": None, "wide2x": 2.0, "tight05x": 0.5} + + +class NoSl(ExitPolicy): + name = "no_sl" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.mode = str(params.get("mode", "none")) + if self.mode not in _SCALE: + raise ValueError(f"mode sconosciuto: {self.mode}") + scale = _SCALE[self.mode] + if scale is None: + self.sl = None + else: + # sl = entry + scale*(sl0 - entry): preserva il lato per long/short. + self.sl = entry + scale * (sl0 - entry) + + def levels(self, j: int): + return self.tp0, self.sl, 1.0 + + +GRID = [ + {"mode": "none"}, + {"mode": "wide2x"}, + {"mode": "tight05x"}, +] + + +if __name__ == "__main__": + evaluate(NoSl, GRID) diff --git a/scripts/analysis/exit_policies/23_sl_tp_ride.py b/scripts/analysis/exit_policies/23_sl_tp_ride.py new file mode 100644 index 0000000..2a6b048 --- /dev/null +++ b/scripts/analysis/exit_policies/23_sl_tp_ride.py @@ -0,0 +1,99 @@ +"""EXIT-23 — sl_tp_ride: LOCK AL TP E CAVALCA. + +Idea (variante stretta di EXIT-09). Le fade escono al TP fisso (alla media). Qui +si tiene il TP fisso ATTIVO come exit normale finche' il prezzo non lo SUPERA in +chiusura. Quando close[j-1] supera tp0 a favore: + - TP RIMOSSO (None): non si esce piu' al livello fisso; + - SL spostato esattamente a tp0 (il profitto del TP e' LOCKATO: non si esce mai + peggio del TP originale); + - da tp0 in poi un trail chandelier k*ATR ancorato all'estremo favorevole visto + dal bar di superamento in poi, SOLO-STRINGENTE (cricchetto, mai si allenta). + +Differenza dal 09: qui il floor e' ESATTAMENTE tp0 (cosi' come in 09, ma 09 ha +horizon 3*mb fisso e griglia su k 1/2/3); qui il lock scatta subito al primo +superamento in chiusura e la griglia esplora ANCHE l'horizon. State machine: + + FASE A (armed=False): tp=tp0, sl=sl0 -> identica a base. + FASE B (armed): tp=None, sl = max(tp0, fav_high - k*atr) (long, cricchetto) + = min(tp0, fav_low + k*atr) (short, cricchetto) + +ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1: + - trigger di arma su close[j-1] (gia' chiuso al poll del bar j); + - estremo favorevole post-superamento su slice [arm_idx .. j-1] (incrementale); + - atr14[j-1] (indice causale). +Nessun fill parziale (tp_frac sempre 1.0). after_bar non usato. + +GRID: k in {1.5, 2.5} x horizon in {2*mb, 4*mb} (4 celle). +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402 + + +class SlTpRide(ExitPolicy): + name = "sl_tp_ride" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, k=2.5, hmult=4, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.k = float(k) + self.horizon = min(int(hmult) * mb, HARD_CAP) + self.close = ctx["close"] + self.high = ctx["high"] + self.low = ctx["low"] + self.atr = ctx["atr14"] + self.armed = False + self.arm_idx = None + self.fav_high = None + self.fav_low = None + self._last_seen = i # ultimo close esaminato per il trigger di arma + self._fav_seen = None # ultimo bar incorporato nell'estremo favorevole + self.cur_stop = None # stop trailing monotono in FASE B (floor tp0) + + def levels(self, j: int): + c = self.close + d = self.d + # ---- FASE A: controlla l'arma sui close fino a j-1 (causali) ------------- + if not self.armed: + while self._last_seen < j - 1: + self._last_seen += 1 + cv = c[self._last_seen] + crossed = (cv > self.tp0) if d == 1 else (cv < self.tp0) + if crossed: + self.armed = True + self.arm_idx = self._last_seen + self.fav_high = self.high[self.arm_idx] + self.fav_low = self.low[self.arm_idx] + self._fav_seen = self.arm_idx + self.cur_stop = self.tp0 # SL spostato a tp0: profitto lockato + break + if not self.armed: + return self.tp0, self.sl0, 1.0 # FASE A: TP/SL fissi + + # ---- FASE B: TP rimosso, trail chandelier con floor a tp0 ---------------- + while self._fav_seen < j - 1: + self._fav_seen += 1 + if self.high[self._fav_seen] > self.fav_high: + self.fav_high = self.high[self._fav_seen] + if self.low[self._fav_seen] < self.fav_low: + self.fav_low = self.low[self._fav_seen] + a = self.atr[j - 1] + if a == a: # non-NaN + if d == 1: + cand = max(self.fav_high - self.k * a, self.tp0) + if cand > self.cur_stop: # cricchetto: solo sale + self.cur_stop = cand + else: + cand = min(self.fav_low + self.k * a, self.tp0) + if cand < self.cur_stop: # cricchetto: solo scende + self.cur_stop = cand + return None, self.cur_stop, 1.0 # TP rimosso in FASE B + + +GRID = [{"k": k, "hmult": h} for k in (1.5, 2.5) for h in (2, 4)] + + +if __name__ == "__main__": + evaluate(SlTpRide, GRID) diff --git a/scripts/analysis/exit_policies/verify_02_overfit.py b/scripts/analysis/exit_policies/verify_02_overfit.py new file mode 100644 index 0000000..e01bcbf --- /dev/null +++ b/scripts/analysis/exit_policies/verify_02_overfit.py @@ -0,0 +1,245 @@ +"""ADVERSARIAL VERIFY — EXIT-02 trail_atr_keep_tp, LENTE OVERFIT/ROBUSTEZZA. + +Tesi del sopravvissuto: lo SL intrabar fisso distrugge valore nelle fade; il +Chandelier trail (k=1.5) + TP fisso migliora Sharpe/DD ovunque (6/6 train, 5/6 OOS). + +Ipotesi nulla del verificatore: e' un artefatto. Tre attacchi: + (1) JITTER parametri: k vicini non provati (1.25/1.75) + ponte SL fisso a 3x/4x + ATR (no_sl). Il plateau tiene o e' una cresta? + (2) STABILITA' TEMPORALE: train 2018-20 vs 21-22, OOS 23-11/25-01 vs 25-01/26-05. + Il miglioramento c'e' in OGNI finestra o concentrato in un regime? + (3) DIPENDENZA HURST (decisivo): i segnali in cache hanno hurst_max=0.55 (toglie + il regime trending). Rigenero i segnali SENZA hurst (hurst_max=None) IN MEMORIA + (non tocco la cache) e ripeto base-vs-policy: la tesi "SL dannoso" regge anche + dove gli stop servivano (regime persistente)? + + cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \ + scripts/analysis/exit_policies/verify_02_overfit.py +""" +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +HERE = Path(__file__).resolve() +sys.path.insert(0, str(HERE.parents[1])) # scripts/analysis +sys.path.insert(0, str(HERE.parents[3])) # project root + +import exit_lab # noqa: E402 +from exit_lab import (ExitPolicy, simulate, load_sleeves, OOS_START_MS, # noqa: E402 + CODES, ASSETS, LIVE_PARAMS, _atr14) +from importlib import import_module # noqa: E402 + +mod = import_module("exit_policies.02_trail_atr_keep_tp") +TrailATRKeepTP = mod.TrailATRKeepTP + +from src.data.downloader import load_data # noqa: E402 +from src.live.strategy_loader import load_strategy # noqa: E402 + +SLEEVE_KEYS = [(c, a) for c in CODES for a in ASSETS] + + +# --------------------------------------------------------------- fixed-SL bridge +class FixedSLmultATR(ExitPolicy): + """Ponte fra base (SL=sl0) e no_sl: SL fisso a m*ATR(entry) dall'entrata, + TP fisso. Se il trail (k piccolo) batte uno SL fisso GIA' largo (3x/4x), + allora il guadagno e' nel trailing, non solo nell'allontanare lo SL.""" + name = "fixed_sl_mult_atr" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + m = float(params.get("m", 3.0)) + a = ctx["atr14"][i] + if a is None or a != a: + self.sl = sl0 + else: + self.sl = entry - m * a if d == 1 else entry + m * a + + def levels(self, j: int): + return self.tp0, self.sl, 1.0 + + +# --------------------------------------------------------------- no-SL bridge +class NoSL(ExitPolicy): + """Solo TP fisso + horizon, NESSUNO stop. Isola: il valore e' nel TOGLIERE + lo stop (qualsiasi) o nel TRAIL dinamico? Se NoSL ~ trail, il driver e' + 'niente SL'; se il trail batte NoSL, il trail aggiunge.""" + name = "no_sl" + + def levels(self, j: int): + return self.tp0, None, 1.0 + + +def _fmt(r): + if not r: + return " (no trades)" + return (f"ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>6.2f} " + f"n{r['trades']:>4} bars{r['avg_bars']:>5.1f}") + + +def _summary(rows): + """rows: list of (sleeve_key, base_dict, pol_dict). Ritorna conteggi miglioramento.""" + sh_up = dd_dn = ret_up = n = 0 + for _, b, p in rows: + if not b or not p: + continue + n += 1 + sh_up += p["sharpe_t"] > b["sharpe_t"] + dd_dn += p["dd_pct"] < b["dd_pct"] + ret_up += p["ret_pct"] > b["ret_pct"] + return sh_up, dd_dn, ret_up, n + + +# ============================================================= TEST 1: JITTER +def test_jitter(data): + print("\n" + "=" * 78) + print("TEST 1 — JITTER: k vicini (1.25/1.75) + ponte SL fisso 3x/4x ATR + NoSL") + print("=" * 78) + print("\n[1a] Trail k in {1.25, 1.5, 1.75} — plateau o cresta? (OOS)") + for k in (1.25, 1.5, 1.75): + rows = [] + for key in SLEEVE_KEYS: + sl = data[key] + b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS) + p = simulate(TrailATRKeepTP, sl, {"k": k}, start_ms=OOS_START_MS) + rows.append((key, b, p)) + sh, dd, ret, n = _summary(rows) + print(f" k={k:<5} OOS: Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}") + + print("\n[1b] SL fisso a m*ATR dall'entrata (m=3,4) — uno stop largo basta? (OOS)") + for m in (3.0, 4.0): + rows = [] + for key in SLEEVE_KEYS: + sl = data[key] + b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS) + p = simulate(FixedSLmultATR, sl, {"m": m}, start_ms=OOS_START_MS) + rows.append((key, b, p)) + sh, dd, ret, n = _summary(rows) + print(f" m={m:<5} OOS: Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}") + + print("\n[1c] NoSL (solo TP+horizon) — il driver e' 'togliere lo SL'? (OOS)") + rows = [] + for key in SLEEVE_KEYS: + sl = data[key] + b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS) + p = simulate(NoSL, sl, start_ms=OOS_START_MS) + rows.append((key, b, p)) + sh, dd, ret, n = _summary(rows) + print(f" NoSL OOS: Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}") + + print("\n[1d] dettaglio per sleeve: base vs k=1.5 vs NoSL vs SLx3 (OOS)") + for key in SLEEVE_KEYS: + sl = data[key] + b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS) + t = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=OOS_START_MS) + ns = simulate(NoSL, sl, start_ms=OOS_START_MS) + f3 = simulate(FixedSLmultATR, sl, {"m": 3.0}, start_ms=OOS_START_MS) + tag = f"{key[0].split('_')[0]} {key[1]}" + print(f" {tag:<10} base sh{b.get('sharpe_t',0):>6.2f} | trail1.5 sh{t.get('sharpe_t',0):>6.2f} " + f"| NoSL sh{ns.get('sharpe_t',0):>6.2f} | SLx3 sh{f3.get('sharpe_t',0):>6.2f}") + + +# ============================================================= TEST 2: TEMPORAL +def test_temporal(data): + print("\n" + "=" * 78) + print("TEST 2 — STABILITA' TEMPORALE (Sharpe base -> trail k=1.5)") + print("=" * 78) + W = [ + ("train 2018-20", None, int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6)), + ("train 2021-22", int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6), OOS_START_MS), + ("OOS 23-11/25-01", OOS_START_MS, int(pd.Timestamp("2025-01-01", tz="UTC").value // 1e6)), + ("OOS 25-01/26-05", int(pd.Timestamp("2025-01-01", tz="UTC").value // 1e6), None), + ] + for label, s, e in W: + rows = [] + for key in SLEEVE_KEYS: + sl = data[key] + b = simulate(ExitPolicy, sl, start_ms=s, end_ms=e) + p = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=s, end_ms=e) + rows.append((key, b, p)) + sh, dd, ret, n = _summary(rows) + # mediana del delta-Sharpe + deltas = [p["sharpe_t"] - b["sharpe_t"] for _, b, p in rows if b and p] + med = float(np.median(deltas)) if deltas else 0.0 + print(f" {label:<20} Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n} " + f"median dSharpe {med:+.2f}") + + +# ============================================================= TEST 3: HURST +def _build_sleeves_no_hurst(): + """Rigenera i segnali SENZA il loss-guard Hurst (hurst_max=None), IN MEMORIA. + Replica esattamente load_sleeves() ma con LIVE_PARAMS modificati.""" + params = dict(LIVE_PARAMS) + params["hurst_max"] = None + out = {} + for code in CODES: + strat = load_strategy(code) + for asset in ASSETS: + df = load_data(asset, "1h") + ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + sigs = strat.generate_signals(df, ts, **params) + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + c = df["close"].values.astype(float) + out[(code, asset)] = { + "signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]), + float(s.metadata["sl"]), int(s.metadata["max_bars"])) + for s in sigs], + "open": df["open"].values.astype(float), + "high": h, "low": l, "close": c, + "ts_ms": df["timestamp"].values.astype(np.int64), + "atr14": _atr14(h, l, c), + } + return out + + +def test_hurst(data): + print("\n" + "=" * 78) + print("TEST 3 — DIPENDENZA DAL FILTRO HURST (decisivo)") + print("=" * 78) + print("Rigenero segnali con hurst_max=None (loss-guard OFF -> include il regime") + print("trending/persistente dove gli stop dovrebbero servire). Confronto base->trail.") + nh = _build_sleeves_no_hurst() + + # quanti segnali in piu' (il guard ne toglieva) + print("\n segnali: con-guard -> senza-guard") + for key in SLEEVE_KEYS: + ng = len(data[key]["signals"]) + nn = len(nh[key]["signals"]) + tag = f"{key[0].split('_')[0]} {key[1]}" + print(f" {tag:<10} {ng:>4} -> {nn:>4} (+{nn-ng})") + + for scope, s, e in [("TRAIN", None, OOS_START_MS), ("OOS", OOS_START_MS, None)]: + print(f"\n [{scope}] base vs trail k=1.5 — SENZA hurst guard") + rows = [] + for key in SLEEVE_KEYS: + sl = nh[key] + b = simulate(ExitPolicy, sl, start_ms=s, end_ms=e) + p = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=s, end_ms=e) + rows.append((key, b, p)) + tag = f"{key[0].split('_')[0]} {key[1]}" + print(f" {tag:<10} base {_fmt(b)}") + print(f" {'':<10} trail{_fmt(p)}") + sh, dd, ret, n = _summary(rows) + print(f" --> Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}") + + # contro-prova: con-guard sugli STESSI scope, per isolare l'effetto guard + print("\n [CONTROLLO] stesso confronto CON hurst guard (cache):") + for scope, s, e in [("TRAIN", None, OOS_START_MS), ("OOS", OOS_START_MS, None)]: + rows = [] + for key in SLEEVE_KEYS: + sl = data[key] + b = simulate(ExitPolicy, sl, start_ms=s, end_ms=e) + p = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=s, end_ms=e) + rows.append((key, b, p)) + sh, dd, ret, n = _summary(rows) + print(f" [{scope}] con-guard --> Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}") + + +if __name__ == "__main__": + data = load_sleeves() + test_jitter(data) + test_temporal(data) + test_hurst(data) + print("\nDONE") diff --git a/scripts/analysis/exit_policies/verify_02_trail_atr_keep_tp_leakage.py b/scripts/analysis/exit_policies/verify_02_trail_atr_keep_tp_leakage.py new file mode 100644 index 0000000..65c6b07 --- /dev/null +++ b/scripts/analysis/exit_policies/verify_02_trail_atr_keep_tp_leakage.py @@ -0,0 +1,329 @@ +"""VERIFICA AVVERSARIALE — lente LOOK-AHEAD/ESEGUIBILITA' su EXIT-02 (k=1.5). + +Ipotesi nulla avversaria: l'edge del chandelier trail e' un artefatto del +TIMING PERFETTO (livelli fissati a j-1 e tocco a j senza alcun attrito) e/o di +uno SL fillato a un prezzo non eseguibile live. Provo a confutarla. + +Esperimenti: + (A) AUDIT del contratto: ricalcolo i livelli di EXIT-02 fuori dall'engine e + verifico che run_hi/run_lo a j NON incorporino mai high[j]/low[j], e che + atr usato sia atr[j-1]. (statico, ma lo confermo numericamente forzando + un confronto con una variante che USA j -> deve cambiare i numeri.) + + (B) LAG +1: variante che ritarda di UN bar in piu' TUTTI gli input causali + (atr[j-2], estremi fino a j-2). Se l'edge collassa -> appeso al timing. + + (C) ESEGUIBILITA' SL: lo SL fillato a `sl` (prezzo del livello) e' ottimistico? + Confronto col fill conservativo allo SL ma con slippage, e con fill al + WORSE fra sl e open[j] (gap-through). Stima costo. + + (D) ESEGUIBILITA' HORIZON/CLOSE: gli exit a max_bars escono a close[j]. Live + il poll arriva ~al close ma esegue al bar dopo -> rifaccio l'engine con + gli exit a horizon/after_bar a open[j+1] invece di close[j]. Costo? + (EXIT-02 non usa after_bar, ma usa gli exit a horizon -> rilevante con + avg_bars 2.5: il turnover alto AMPLIFICA il costo per-exit.) + +Tutto a leva 3, fee 0.10% RT, OOS_START 2023-11-01. +""" +import sys +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import exit_lab as EL # noqa: E402 +from exit_lab import ExitPolicy, load_sleeves, simulate, OOS_START_MS, LEV, POS, FEE_RT # noqa: E402 +from importlib import import_module # noqa: E402 + +mod = import_module("02_trail_atr_keep_tp") +TrailATRKeepTP = mod.TrailATRKeepTP + +KPICK = 1.5 +DATA = load_sleeves() + + +# ---------------------------------------------------------------- (A) AUDIT statico +class TrailLeak(TrailATRKeepTP): + """VARIANTE SPORCA: usa run_hi/run_lo fino a j (incl. bar j) e atr[j]. + Serve SOLO a mostrare quanto l'edge gonfierebbe col look-ahead -> se i numeri + della policy pulita fossero gia' a quel livello, sarebbe sospetta.""" + name = "trail_LEAK" + + def levels(self, j: int): + self._update_running(j) # SPORCO: incorpora bar j + a = self.atr[j] # SPORCO: atr del bar j + if a is None or a != a: + return self.tp0, self.sl0, 1.0 + if self.d == 1: + chand = self.run_hi - self.k * a + sl = max(self.sl0, chand) + else: + chand = self.run_lo + self.k * a + sl = min(self.sl0, chand) + return self.tp0, sl, 1.0 + + +# ---------------------------------------------------------------- (B) LAG +1 +class TrailLag1(TrailATRKeepTP): + """Ritarda di UN bar in piu': estremi fino a j-2, atr[j-2].""" + name = "trail_LAG1" + + def levels(self, j: int): + self._update_running(j - 2) # estremi solo <= j-2 + idx = j - 2 + a = self.atr[idx] if idx >= 0 else None + if a is None or a != a: + return self.tp0, self.sl0, 1.0 + if self.d == 1: + chand = self.run_hi - self.k * a + sl = max(self.sl0, chand) + else: + chand = self.run_lo + self.k * a + sl = min(self.sl0, chand) + return self.tp0, sl, 1.0 + + +# ------------------------------------- (C)/(D) engine alternativo con attriti exec +def simulate_exec(policy_cls, sleeve, params, *, start_ms=None, end_ms=None, + sl_slip_bps=0.0, sl_gap=False, horizon_open=False): + """Clone di EL.simulate con attriti di esecuzione opzionali: + sl_slip_bps : slippage avverso (bps di prezzo) sul fill allo SL. + sl_gap : fill allo SL = worse(sl, open[j]) (gap-through realistico). + horizon_open: exit a horizon/after_bar a open[j+1] invece di close[j]. + """ + params = params or {} + o = sleeve["open"]; h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"] + n = len(c) + ctx = dict(sleeve) + policy_cls.prepare(ctx, **params) + fee = FEE_RT * LEV + capital = peak = 1000.0 + max_dd = 0.0 + last_exit = -1 + trades = wins = 0 + bars_tot = 0 + rets = [] + + for (i, d, tp0, sl0, mb) in sleeve["signals"]: + if start_ms is not None and ts[i] < start_ms: + continue + if end_ms is not None and ts[i] >= end_ms: + continue + if i <= last_exit or i + 1 >= n: + continue + entry = c[i] + pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params) + horizon = min(int(pol.horizon), EL.HARD_CAP) + fills = [] + remaining = 1.0 + j = i + for step in range(1, horizon + 1): + j = i + step + if j >= n: + j = n - 1 + px = o[min(j + 1, n - 1)] if horizon_open else c[j] + fills.append((remaining, px)); remaining = 0.0 + break + tp, sl, tpfrac = pol.levels(j) + 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: + fill_px = sl + if sl_gap: + # gap-through: se la barra apre gia' oltre lo SL, fill all'open + fill_px = min(sl, o[j]) if d == 1 else max(sl, o[j]) + if sl_slip_bps: + fill_px = fill_px * (1 - d * sl_slip_bps / 1e4) # avverso + fills.append((remaining, fill_px)); remaining = 0.0 + break + if hit_tp: + f = min(max(tpfrac, 0.0), 1.0) * remaining + if f > 0: + fills.append((f, tp)); remaining -= f + if remaining <= 1e-9: + break + pol.on_partial(j, tp, remaining) + if pol.after_bar(j): + px = o[min(j + 1, n - 1)] if horizon_open else c[j] + fills.append((remaining, px)); remaining = 0.0 + break + if step == horizon: + px = o[min(j + 1, n - 1)] if horizon_open else c[j] + fills.append((remaining, px)); remaining = 0.0 + if remaining > 1e-9: + fills.append((remaining, c[j])) + + ret = sum(f * (p - entry) for f, p in fills) / 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 + bars_tot += j - i + rets.append(ret) + + if trades == 0: + return {} + r = np.array(rets) + return { + "ret_pct": (capital / 1000.0 - 1) * 100, + "dd_pct": max_dd * 100, + "trades": trades, + "win_pct": wins / trades * 100, + "sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0, + "avg_bars": bars_tot / trades, + } + + +def oos(cls, sleeve, sim=simulate, **kw): + return sim(cls, sleeve, {"k": KPICK}, start_ms=OOS_START_MS, **kw) + + +def line(tag, r): + return (f"{tag:<26} ret{r.get('ret_pct',0):>7.0f}% dd{r.get('dd_pct',0):>5.1f} " + f"sh{r.get('sharpe_t',0):>5.2f} n{r.get('trades',0):>4} " + f"win{r.get('win_pct',0):>4.0f}% bars{r.get('avg_bars',0):>4.1f}") + + +print("=" * 90) +print("OOS (2023-11-01+) k=1.5 — clean / LEAK(look-ahead) / LAG1(+1 bar) per sleeve") +print("=" * 90) +agg = {"clean": [], "leak": [], "lag1": []} +for (code, asset), sleeve in DATA.items(): + key = f"{code.split('_')[0]} {asset}" + rc = oos(TrailATRKeepTP, sleeve) + rk = oos(TrailLeak, sleeve) + rl = oos(TrailLag1, sleeve) + agg["clean"].append(rc.get("ret_pct", 0)) + agg["leak"].append(rk.get("ret_pct", 0)) + agg["lag1"].append(rl.get("ret_pct", 0)) + print(f"\n{key}") + print(" " + line("clean", rc)) + print(" " + line("LEAK", rk)) + print(" " + line("LAG1", rl)) + +print("\n" + "-" * 90) +print(f"OOS ret medio clean={np.mean(agg['clean']):.0f}% " + f"LEAK={np.mean(agg['leak']):.0f}% LAG1={np.mean(agg['lag1']):.0f}%") +print(f"LAG1/clean ratio per sleeve: " + f"{[f'{a/ b:.2f}' if b else 'na' for a, b in zip(agg['lag1'], agg['clean'])]}") + +print("\n" + "=" * 90) +print("ESECUZIONE — attriti su OOS k=1.5 (medie sui 6 sleeve)") +print("=" * 90) +scenarios = { + "clean (engine std)": dict(), + "SL slip 5bps": dict(sl_slip_bps=5.0), + "SL slip 10bps": dict(sl_slip_bps=10.0), + "SL gap-through(open)": dict(sl_gap=True), + "SL gap + slip 5bps": dict(sl_gap=True, sl_slip_bps=5.0), + "horizon exit @open[j+1]": dict(horizon_open=True), + "horizon@open + SLslip5": dict(horizon_open=True, sl_slip_bps=5.0), +} +for tag, kw in scenarios.items(): + rets, dds, shs = [], [], [] + for (code, asset), sleeve in DATA.items(): + r = oos(TrailATRKeepTP, sleeve, sim=simulate_exec, **kw) + rets.append(r.get("ret_pct", 0)); dds.append(r.get("dd_pct", 0)); shs.append(r.get("sharpe_t", 0)) + print(f"{tag:<28} OOS ret medio {np.mean(rets):>6.0f}% dd {np.mean(dds):>4.1f} " + f"sh {np.mean(shs):>4.2f} (min ret {min(rets):>5.0f}%)") + +# confronto baseline per contesto +print("\nBaseline (exit fissa) OOS ret medio per riferimento:") +brets = [simulate(ExitPolicy, s, {}, start_ms=OOS_START_MS).get("ret_pct", 0) + for s in DATA.values()] +print(f" base OOS ret medio {np.mean(brets):.0f}%") + + +# ---------------------------------------------------------------- (E) frequenza gap-through SL +print("\n" + "=" * 90) +print("FREQUENZA gap-through allo SL (OOS k=1.5): quanti fill SL aprono OLTRE il livello?") +print("=" * 90) + + +def gap_stats(sleeve, start_ms=OOS_START_MS): + params = {"k": KPICK} + o = sleeve["open"]; h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"] + n = len(c) + ctx = dict(sleeve); TrailATRKeepTP.prepare(ctx, **params) + last_exit = -1 + sl_hits = gaps = 0 + gap_bps = [] + for (i, d, tp0, sl0, mb) in sleeve["signals"]: + if ts[i] < start_ms or i <= last_exit or i + 1 >= n: + continue + entry = c[i] + pol = TrailATRKeepTP(ctx, i, d, entry, tp0, sl0, mb, **params) + horizon = min(int(pol.horizon), EL.HARD_CAP) + j = i + for step in range(1, horizon + 1): + j = i + step + if j >= n: + break + tp, sl, _ = pol.levels(j) + 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: + sl_hits += 1 + # gap-through: l'open e' gia' oltre lo SL (peggio del livello)? + worse = (d == 1 and o[j] < sl) or (d == -1 and o[j] > sl) + if worse: + gaps += 1 + gap_bps.append(abs(o[j] - sl) / sl * 1e4) + break + if hit_tp: + break + last_exit = j + return sl_hits, gaps, (np.mean(gap_bps) if gap_bps else 0.0), (np.median(gap_bps) if gap_bps else 0.0) + + +tot_h = tot_g = 0 +for (code, asset), sleeve in DATA.items(): + sh_, g_, m_, md_ = gap_stats(sleeve) + tot_h += sh_; tot_g += g_ + print(f" {code.split('_')[0]} {asset}: SL hits {sh_:>3} gap-through {g_:>3} " + f"({100*g_/max(sh_,1):>4.0f}%) gap medio {m_:>5.1f}bps mediano {md_:>5.1f}bps") +print(f"\n TOTALE: {tot_g}/{tot_h} SL hit sono gap-through = {100*tot_g/max(tot_h,1):.0f}%") + + +# ------------------------------------------------- (F) gap pessimista vs baseline per sleeve +print("\n" + "=" * 90) +print("VERDETTO ESEGUIBILITA': EXIT-02 con SL gap-through vs BASELINE (exit fissa), OOS, per sleeve") +print("=" * 90) +print(f"{'sleeve':<10}{'base ret':>10}{'base dd':>9}{'base sh':>9} " + f"{'trail gap ret':>14}{'gap dd':>8}{'gap sh':>8} verdetto") +n_better = 0 +for (code, asset), sleeve in DATA.items(): + key = f"{code.split('_')[0]} {asset}" + b = simulate(ExitPolicy, sleeve, {}, start_ms=OOS_START_MS) + g = oos(TrailATRKeepTP, sleeve, sim=simulate_exec, sl_gap=True, sl_slip_bps=5.0) + better = g.get("sharpe_t", 0) >= b.get("sharpe_t", 0) + dd_better = g.get("dd_pct", 99) <= b.get("dd_pct", 99) + n_better += better + verdict = ("Sharpe+DD>" if better and dd_better else + "DD> ret<" if dd_better and not better else "PEGGIO") + print(f"{key:<10}{b.get('ret_pct',0):>9.0f}%{b.get('dd_pct',0):>8.1f}{b.get('sharpe_t',0):>9.2f} " + f"{g.get('ret_pct',0):>13.0f}%{g.get('dd_pct',0):>7.1f}{g.get('sharpe_t',0):>8.2f} {verdict}") +print(f"\n sleeve con Sharpe trail-gap >= baseline: {n_better}/6") + + +# ----------------------- (G) CONFRONTO EQUO: baseline E trail entrambi con gap-through SL +print("\n" + "=" * 90) +print("CONFRONTO EQUO (gap-through SL su ENTRAMBI) — la tesi 'SL fisso distrugge valore' regge?") +print("=" * 90) +print(f"{'sleeve':<10}{'BASE-gap ret':>13}{'dd':>6}{'sh':>6} {'TRAIL-gap ret':>14}{'dd':>6}{'sh':>6} verdetto") +trail_wins = 0 +agg_b = []; agg_t = [] +for (code, asset), sleeve in DATA.items(): + key = f"{code.split('_')[0]} {asset}" + b = oos(ExitPolicy, sleeve, sim=simulate_exec, sl_gap=True, sl_slip_bps=5.0) + t = oos(TrailATRKeepTP, sleeve, sim=simulate_exec, sl_gap=True, sl_slip_bps=5.0) + agg_b.append(b.get("sharpe_t", 0)); agg_t.append(t.get("sharpe_t", 0)) + win = t.get("sharpe_t", 0) >= b.get("sharpe_t", 0) + trail_wins += win + print(f"{key:<10}{b.get('ret_pct',0):>12.0f}%{b.get('dd_pct',0):>6.1f}{b.get('sharpe_t',0):>6.2f} " + f"{t.get('ret_pct',0):>13.0f}%{t.get('dd_pct',0):>6.1f}{t.get('sharpe_t',0):>6.2f} " + f"{'TRAIL>=' if win else 'BASE>'}") +print(f"\n con ENTRAMBI gap-through: trail Sharpe >= baseline su {trail_wins}/6 sleeve") +print(f" Sharpe medio BASE-gap={np.mean(agg_b):.2f} TRAIL-gap={np.mean(agg_t):.2f}") diff --git a/scripts/analysis/exit_policies/verify_02_trail_atr_keep_tp_stress.py b/scripts/analysis/exit_policies/verify_02_trail_atr_keep_tp_stress.py new file mode 100644 index 0000000..42ebca1 --- /dev/null +++ b/scripts/analysis/exit_policies/verify_02_trail_atr_keep_tp_stress.py @@ -0,0 +1,217 @@ +"""STRESS verifier for EXIT-02 trail_atr_keep_tp (train-pick k=1.5). + +Adversarial lens = STRESS. Hypothesis to try to REFUTE the survivor: + (1) Fee 2x (FEE_RT=0.002): the policy raises turnover (avg_bars 9->2.5) => + it should be disproportionately hurt by doubling fees. + (2) Bear/crash subperiod 2021-01..2022-12 (2021-05-19 crash, LUNA, FTX): + does the DD/tail of the policy survive? compare worst trade + 5 worst. + (3) Adverse slippage on the POLICY exits (+20bps against position on exit + price): the trail exits more often near wicks -> does edge survive? + (4) Turnover / capital-churn: the policy turns capital ~3.6x faster. Quantify + how many distinct trades each takes and the compounding effect. + +We monkeypatch exit_lab.FEE_RT and re-implement a thin per-trade collector that +mirrors exit_lab.simulate EXACTLY (same SL-before-TP, same fills, same compounding) +so we can extract per-trade rets/exit prices for tail analysis and slippage. +""" +import sys +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import exit_lab # noqa: E402 +from exit_lab import ExitPolicy, load_sleeves, OOS_START_MS, HARD_CAP # noqa: E402 + +# import the survivor policy +import importlib.util +spec = importlib.util.spec_from_file_location( + "p02", str(Path(__file__).resolve().parent / "02_trail_atr_keep_tp.py")) +p02 = importlib.util.module_from_spec(spec) +spec.loader.exec_module(p02) +TrailATRKeepTP = p02.TrailATRKeepTP + +import pandas as pd +BEAR_START = int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6) +BEAR_END = int(pd.Timestamp("2023-01-01", tz="UTC").value // 1e6) + + +def simulate_detailed(policy_cls, sleeve, params=None, start_ms=None, end_ms=None, + exit_slip_bps=0.0): + """Mirror of exit_lab.simulate but returns per-trade detail and supports an + adverse slippage applied to every fill price (against the position).""" + params = params or {} + h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"] + n = len(c) + ctx = dict(sleeve) + policy_cls.prepare(ctx, **params) + fee = exit_lab.FEE_RT * exit_lab.LEV + LEV, POS = exit_lab.LEV, exit_lab.POS + slip = exit_slip_bps * 1e-4 + capital = peak = 1000.0 + max_dd = 0.0 + last_exit = -1 + rets = [] + tdetail = [] + + for (i, d, tp0, sl0, mb) in sleeve["signals"]: + if start_ms is not None and ts[i] < start_ms: + continue + if end_ms is not None and ts[i] >= end_ms: + continue + if i <= last_exit or i + 1 >= n: + continue + entry = c[i] + pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params) + horizon = min(int(pol.horizon), HARD_CAP) + fills = [] + remaining = 1.0 + j = i + for step in range(1, horizon + 1): + j = i + step + if j >= n: + j = n - 1 + fills.append((remaining, c[j])); remaining = 0.0 + break + tp, sl, tpfrac = pol.levels(j) + 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: + fills.append((remaining, sl)); remaining = 0.0 + break + if hit_tp: + f = min(max(tpfrac, 0.0), 1.0) * remaining + if f > 0: + fills.append((f, tp)); remaining -= f + if remaining <= 1e-9: + break + pol.on_partial(j, tp, remaining) + if pol.after_bar(j): + fills.append((remaining, c[j])); remaining = 0.0 + break + if step == horizon: + fills.append((remaining, c[j])); remaining = 0.0 + if remaining > 1e-9: + fills.append((remaining, c[j])) + + # adverse slippage: every exit fill price moves AGAINST the position + # long (d=1) sells lower -> p*(1-slip); short (d=-1) buys back higher -> p*(1+slip) + adj_fills = [(f, p * (1.0 - d * slip)) for f, p in fills] + ret = sum(f * (p - entry) for f, p in adj_fills) / 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 + rets.append(ret) + tdetail.append({"i": i, "j": j, "d": d, "ret": ret, "bars": j - i, "ts": int(ts[i])}) + + if not rets: + return {} + r = np.array(rets) + return { + "ret_pct": (capital / 1000.0 - 1) * 100, + "dd_pct": max_dd * 100, + "trades": len(r), + "win_pct": (r > 0).mean() * 100, + "avg_ret_bps": r.mean() * 1e4, + "sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0, + "avg_bars": np.mean([t["bars"] for t in tdetail]), + "detail": tdetail, + } + + +def run_grid(data, fee_rt, slip_bps, start_ms, end_ms, label): + orig = exit_lab.FEE_RT + exit_lab.FEE_RT = fee_rt + print(f"\n===== {label} (FEE_RT={fee_rt}, slip={slip_bps}bps, " + f"start={start_ms}, end={end_ms}) =====") + agg = {} + for (code, asset), sleeve in data.items(): + key = f"{code.split('_')[0]} {asset}" + base = simulate_detailed(ExitPolicy, sleeve, {}, start_ms, end_ms, slip_bps) + pol = simulate_detailed(TrailATRKeepTP, sleeve, {"k": 1.5}, start_ms, end_ms, slip_bps) + if not base or not pol: + continue + agg[key] = (base, pol) + print(f"{key:<10} BASE ret{base['ret_pct']:>8.0f}% dd{base['dd_pct']:>5.1f} " + f"sh{base['sharpe_t']:>5.2f} n{base['trades']:>4} bars{base['avg_bars']:>4.1f} " + f"| POL ret{pol['ret_pct']:>8.0f}% dd{pol['dd_pct']:>5.1f} " + f"sh{pol['sharpe_t']:>5.2f} n{pol['trades']:>4} bars{pol['avg_bars']:>4.1f}") + exit_lab.FEE_RT = orig + return agg + + +def policy_better(agg, metric="sharpe_t"): + """count sleeves where policy >= base on metric (and ret not collapsed).""" + n_better_sh = n_better_dd = n_ret_ok = 0 + for key, (base, pol) in agg.items(): + if pol["sharpe_t"] >= base["sharpe_t"]: + n_better_sh += 1 + if pol["dd_pct"] <= base["dd_pct"]: + n_better_dd += 1 + if pol["ret_pct"] >= base["ret_pct"] * 0.5 or pol["ret_pct"] >= base["ret_pct"]: + n_ret_ok += 1 + return n_better_sh, n_better_dd, n_ret_ok, len(agg) + + +def main(): + data = load_sleeves() + + # ---- LENS 1: fee 2x on full OOS + a_base_oos = run_grid(data, 0.001, 0.0, OOS_START_MS, None, "L0 OOS baseline fee (sanity)") + a_fee2_oos = run_grid(data, 0.002, 0.0, OOS_START_MS, None, "L1 OOS FEE 2x") + + # ---- LENS 3: adverse slippage 20bps on exits (OOS, normal fee) + a_slip_oos = run_grid(data, 0.001, 20.0, OOS_START_MS, None, "L3 OOS +20bps adverse exit slippage") + + # ---- LENS 1+3 combined (worst case): fee 2x AND slippage + a_both = run_grid(data, 0.002, 20.0, OOS_START_MS, None, "L1+3 OOS fee2x + 20bps slip") + + # ---- LENS 2: bear/crash subperiod 2021-2022 + a_bear = run_grid(data, 0.001, 0.0, BEAR_START, BEAR_END, "L2 BEAR 2021-2022") + + # ---- LENS 2 tail: worst trade + 5 worst, base vs policy, on bear window + print("\n===== L2 TAIL: worst trades in 2021-2022 (base vs policy) =====") + for (code, asset), sleeve in data.items(): + key = f"{code.split('_')[0]} {asset}" + base = simulate_detailed(ExitPolicy, sleeve, {}, BEAR_START, BEAR_END) + pol = simulate_detailed(TrailATRKeepTP, sleeve, {"k": 1.5}, BEAR_START, BEAR_END) + if not base or not pol: + continue + bw = sorted([t["ret"] for t in base["detail"]])[:5] + pw = sorted([t["ret"] for t in pol["detail"]])[:5] + print(f"{key:<10} base 5-worst(bps) {[f'{x*1e4:.0f}' for x in bw]} " + f"| pol 5-worst(bps) {[f'{x*1e4:.0f}' for x in pw]}") + print(f"{'':<10} base worst {bw[0]*1e4:.0f}bps pol worst {pw[0]*1e4:.0f}bps " + f"base n={base['trades']} pol n={pol['trades']}") + + # ---- LENS 4: turnover / capital churn quantification (OOS) + print("\n===== L4 TURNOVER / capital churn (OOS) =====") + for (code, asset), sleeve in data.items(): + key = f"{code.split('_')[0]} {asset}" + base = simulate_detailed(ExitPolicy, sleeve, {}, OOS_START_MS, None) + pol = simulate_detailed(TrailATRKeepTP, sleeve, {"k": 1.5}, OOS_START_MS, None) + if not base or not pol: + continue + # bars in market total, fraction of time deployed + n = len(sleeve["close"]) + base_bars = sum(t["bars"] for t in base["detail"]) + pol_bars = sum(t["bars"] for t in pol["detail"]) + churn = (pol["trades"] / max(base["trades"], 1)) + print(f"{key:<10} trades base{base['trades']:>4} pol{pol['trades']:>4} " + f"(x{churn:.2f}) | bars-in-mkt base{base_bars:>5} pol{pol_bars:>5} " + f"| avg_bars base{base['avg_bars']:.1f} pol{pol['avg_bars']:.1f} " + f"| win% base{base['win_pct']:.0f} pol{pol['win_pct']:.0f}") + + # ---- VERDICT helpers + print("\n===== VERDICT TALLIES =====") + for lbl, agg in [("OOS baseline-fee", a_base_oos), ("OOS fee2x", a_fee2_oos), + ("OOS slip20", a_slip_oos), ("OOS fee2x+slip", a_both), + ("BEAR 2021-22", a_bear)]: + nsh, ndd, nret, tot = policy_better(agg) + print(f"{lbl:<20} policy>=base: sharpe {nsh}/{tot} dd-better {ndd}/{tot} " + f"ret>=50%base {nret}/{tot}") + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/exit_policies/verify_16_leakage.py b/scripts/analysis/exit_policies/verify_16_leakage.py new file mode 100644 index 0000000..0a83e23 --- /dev/null +++ b/scripts/analysis/exit_policies/verify_16_leakage.py @@ -0,0 +1,211 @@ +"""Verifica avversariale LEAKAGE/ESEGUIBILITA' per EXIT-16 close_confirm_sl. + +Tre attacchi: + A) CONTRATTO: dump statico di cosa legge la policy (close[j], atr[j]) e prova + che nessun indice > j entra nella decisione. Replica esatta del numero + headline (MR02 BTC/ETH OOS) per ancorare. + B) LAG: variante con UN bar di ritardo in piu' sugli input causali della + soglia (atr14[j-1] e confronto su close[j-1] invece di close[j]). Se l'edge + collassa -> appeso al timing perfetto. La decisione resta eseguibile + (close[j-1] noto a j-1), ma sposta il momento dello stop di un bar. + C) ESEGUIBILITA' LIVE: il worker esce al POLL successivo, non al close[j] + esatto. Stima del costo eseguendo l'uscita a open[j+1] invece di close[j]. + +Esegui: cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \ + scripts/analysis/exit_policies/verify_16_leakage.py +""" +import sys +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent)) # scripts/analysis +sys.path.insert(0, str(HERE.parents[2])) # project root + +import exit_lab # noqa: E402 +from exit_lab import (ExitPolicy, load_sleeves, simulate, OOS_START_MS) # noqa: E402 + +# import the survivor policy directly from its file +import importlib.util # noqa: E402 +spec = importlib.util.spec_from_file_location("p16", HERE / "16_close_confirm_sl.py") +p16 = importlib.util.module_from_spec(spec) +spec.loader.exec_module(p16) +CloseConfirmSl = p16.CloseConfirmSl + +BUF = 0.5 # train-pick buffer + + +# --------------------------------------------------------------- B) LAG variant +class CloseConfirmSlLag(ExitPolicy): + """Identica a EXIT-16 ma con 1 bar di ritardo sugli input della soglia: + decisione su close[j-1] e atr[j-1] (eseguibile gia' a j-1). Se l'edge + dipendeva dal close[j] esatto del bar di sfondamento, qui collassa.""" + name = "close_confirm_sl_lag" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.buffer = float(params.get("buffer", 0.0)) + self.close = ctx["close"] + self.atr = ctx["atr14"] + + def levels(self, j): + return self.tp0, None, 1.0 + + def after_bar(self, j): + jj = j - 1 + if jj <= self.i: + return False + a = self.atr[jj] + if not np.isfinite(a): + a = 0.0 + cj = self.close[jj] + if self.d == 1: + return cj < self.sl0 - self.buffer * a + return cj > self.sl0 + self.buffer * a + + +# ----------------------------------------- C) execution-delay (open[j+1]) variant +def simulate_open_next(sleeve, params, start_ms=None, end_ms=None): + """Come exit_lab.simulate ma quando la policy esce sul CLOSE (after_bar o + horizon) il FILL avviene a open[j+1] (poll successivo), non a close[j]. + I TP/SL intrabar restano al livello (limit). Stima il costo del ritardo + di un poll per un'exit market al prossimo bar.""" + h = sleeve["high"]; l = sleeve["low"]; c = sleeve["close"] + o = sleeve["open"]; ts = sleeve["ts_ms"] + n = len(c) + ctx = dict(sleeve) + CloseConfirmSl.prepare(ctx, **params) + fee = exit_lab.FEE_RT * exit_lab.LEV + POS = exit_lab.POS; LEV = exit_lab.LEV + capital = peak = 1000.0 + max_dd = 0.0 + last_exit = -1 + trades = wins = 0 + bars_tot = 0 + rets = [] + for (i, d, tp0, sl0, mb) in sleeve["signals"]: + if start_ms is not None and ts[i] < start_ms: + continue + if end_ms is not None and ts[i] >= end_ms: + continue + if i <= last_exit or i + 1 >= n: + continue + entry = c[i] + pol = CloseConfirmSl(ctx, i, d, entry, tp0, sl0, mb, **params) + horizon = min(int(pol.horizon), exit_lab.HARD_CAP) + fills = [] + remaining = 1.0 + j = i + for step in range(1, horizon + 1): + j = i + step + if j >= n: + j = n - 1 + fills.append((remaining, c[j])); remaining = 0.0 + break + tp, sl, tpfrac = pol.levels(j) + 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: + fills.append((remaining, sl)); remaining = 0.0 + break + if hit_tp: + f = min(max(tpfrac, 0.0), 1.0) * remaining + if f > 0: + fills.append((f, tp)); remaining -= f + if remaining <= 1e-9: + break + pol.on_partial(j, tp, remaining) + if pol.after_bar(j): + # EXECUTION DELAY: fill al prossimo open invece di close[j] + px = o[j + 1] if j + 1 < n else c[j] + fills.append((remaining, px)); remaining = 0.0 + break + if step == horizon: + px = o[j + 1] if j + 1 < n else c[j] + fills.append((remaining, px)); remaining = 0.0 + if remaining > 1e-9: + fills.append((remaining, c[j])) + ret = sum(f * (p - entry) for f, p in fills) / 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 + bars_tot += j - i + rets.append(ret) + if trades == 0: + return {} + r = np.array(rets) + return {"ret_pct": (capital / 1000.0 - 1) * 100, "dd_pct": max_dd * 100, + "trades": trades, "win_pct": wins / trades * 100, + "sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0, + "avg_bars": bars_tot / trades} + + +def fmt(r): + if not r: + return "(no trades)" + return (f"ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} " + f"n{r['trades']:>4} bars{r['avg_bars']:>5.1f}") + + +def main(): + data = load_sleeves() + params = {"buffer": BUF} + keys = list(data.keys()) + + # ---------------------------------------- A) contratto / ancoraggio headline + print("=" * 96) + print("A) ANCORAGGIO (OOS) base vs EXIT-16(buf=0.5) vs LAG(+1 bar) vs OPEN[j+1] delay") + print("=" * 96) + survive_base = survive_lag = survive_delay = 0 + agg = {} + for key in keys: + sl = data[key] + b_oos = simulate(ExitPolicy, sl, {}, start_ms=OOS_START_MS) + s_oos = simulate(CloseConfirmSl, sl, params, start_ms=OOS_START_MS) + lag_oos = simulate(CloseConfirmSlLag, sl, params, start_ms=OOS_START_MS) + del_oos = simulate_open_next(sl, params, start_ms=OOS_START_MS) + name = f"{key[0].split('_')[0]} {key[1]}" + print(f"\n{name}") + print(f" base {fmt(b_oos)}") + print(f" EXIT16 {fmt(s_oos)}") + print(f" LAG+1 {fmt(lag_oos)}") + print(f" DELAY {fmt(del_oos)}") + # survivorship: EXIT16 sharpe >= base sharpe? + if s_oos and b_oos and s_oos["sharpe_t"] >= b_oos["sharpe_t"]: + survive_base += 1 + if lag_oos and b_oos and lag_oos["sharpe_t"] >= b_oos["sharpe_t"]: + survive_lag += 1 + if del_oos and b_oos and del_oos["sharpe_t"] >= b_oos["sharpe_t"]: + survive_delay += 1 + agg[name] = dict(base=b_oos, exit16=s_oos, lag=lag_oos, delay=del_oos) + + print("\n" + "=" * 96) + print(f"GATE OOS (sharpe >= base): EXIT16 {survive_base}/6 | LAG+1 {survive_lag}/6 " + f"| DELAY(open[j+1]) {survive_delay}/6") + + # ---------------------------------------- quantify lag/delay damage on headline + print("\nDanno relativo su sharpe OOS (EXIT16 = 100%):") + for name, a in agg.items(): + s = a["exit16"]["sharpe_t"] if a["exit16"] else 0 + lg = a["lag"]["sharpe_t"] if a["lag"] else 0 + dl = a["delay"]["sharpe_t"] if a["delay"] else 0 + ls = f"{100*lg/s:5.0f}%" if s else " n/a" + ds = f"{100*dl/s:5.0f}%" if s else " n/a" + print(f" {name:<10} sh{s:5.2f} LAG->{ls} DELAY->{ds}") + + # ---------------------------------------- B) per-trade audit of decision indices + print("\n" + "=" * 96) + print("B) AUDIT INDICI: la decisione after_bar(j) legge close[j], atr[j]. " + "Verifico\n che simulate() chiami after_bar SOLO con j = i+step (mai > j corrente).") + # static guarantee from code; demonstrate atr[j] is causal (rolling mean to j) + sl = data[keys[0]] + print(f" atr14[k] = rolling(14).mean(TR) -> usa TR[k-13..k], tutti chiusi a k. OK") + print(f" close[j] noto al close del bar j. Nessun indice > j nella decisione. OK") + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/exit_policies/verify_16_overfit.py b/scripts/analysis/exit_policies/verify_16_overfit.py new file mode 100644 index 0000000..869cfef --- /dev/null +++ b/scripts/analysis/exit_policies/verify_16_overfit.py @@ -0,0 +1,198 @@ +"""VERIFY EXIT-16 close_confirm_sl — lente OVERFIT/ROBUSTEZZA (avversariale). + +Ipotesi nulla: il risultato e' un artefatto (overfit di cella / di regime / di +dipendenza dal loss-guard Hurst gia' applicato in cache). Tre test: + +(1) JITTER PARAMETRI: buffer fuori griglia {0.4, 0.6, 0.75, 1.0} + ponte verso la + base con SL fisso a 3x/4x ATR (no_sl come limite). Il plateau tiene? +(2) STABILITA' TEMPORALE: train 2018-20 vs 21-22; OOS 2023-11/2025-01 vs + 2025-01/2026-05. Il miglioramento e' in OGNI finestra o concentrato? +(3) DIPENDENZA HURST (decisivo): rigenero i segnali con hurst_max=None (NESSUN + loss-guard, NON tocco la cache) e ripeto base-vs-policy. Se senza il guard la + policy crolla, funziona SOLO grazie al guard -> condizione di validita'. +""" +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +import exit_lab # noqa: E402 +from exit_lab import ExitPolicy, simulate, OOS_START_MS, _atr14 # noqa: E402 + +import importlib.util +spec = importlib.util.spec_from_file_location( + "cc16", str(Path(__file__).resolve().parent / "16_close_confirm_sl.py")) +cc16 = importlib.util.module_from_spec(spec) +spec.loader.exec_module(cc16) +CloseConfirmSl = cc16.CloseConfirmSl + +from src.data.downloader import load_data # noqa: E402 +from src.live.strategy_loader import load_strategy # noqa: E402 + +CODES = ["MR01_bollinger_fade", "MR02_donchian_fade", "MR07_return_reversal"] +ASSETS = ("BTC", "ETH") + + +# ---- policy ponte: SL fisso a multiplo di ATR (no_sl come limite) ---- +class WideSlPolicy(ExitPolicy): + """SL intrabar spostato a k*ATR oltre sl0 (ponte tra base e no-sl).""" + name = "wide_sl" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + self.k = float(params.get("k_atr", 2.0)) + self.atr = ctx["atr14"] + + def levels(self, j: int): + a = self.atr[j - 1] if np.isfinite(self.atr[j - 1]) else 0.0 + # sl0 e' sotto (long) / sopra (short) l'entry; allarga di k*atr + sl = self.sl0 - self.k * a if self.d == 1 else self.sl0 + self.k * a + return self.tp0, sl, 1.0 + + +def sub(cls, sleeve, g, s, e): + return simulate(cls, sleeve, g, start_ms=s, end_ms=e) + + +def fmt(r): + if not r: + return " n/a" + return (f"ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} " + f"n{r['trades']:>4} bars{r['avg_bars']:>5.1f}") + + +def build_signals(hurst_max): + """Rigenera sleeve in memoria con hurst_max dato (None = no guard). NON tocca cache.""" + out = {} + params = dict(trend_max=3.0, ema_long=200, hurst_max=hurst_max, min_tp_frac=0.0015) + for code in CODES: + strat = load_strategy(code) + for asset in ASSETS: + df = load_data(asset, "1h") + ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + sigs = strat.generate_signals(df, ts, **params) + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + c = df["close"].values.astype(float) + out[(code, asset)] = { + "signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]), + float(s.metadata["sl"]), int(s.metadata["max_bars"])) + for s in sigs], + "open": df["open"].values.astype(float), + "high": h, "low": l, "close": c, + "ts_ms": df["timestamp"].values.astype(np.int64), + "atr14": _atr14(h, l, c), + } + return out + + +def main(): + data = exit_lab.load_sleeves() + keys = list(data.keys()) + + # ===== TEST 1: JITTER PARAMETRI ===== + print("=" * 100) + print("TEST 1 — JITTER buffer fuori griglia + ponte WIDE-SL (OOS, dopo 2023-11)") + print("=" * 100) + jit_buffers = [0.4, 0.6, 0.75, 1.0] + all_pos = True + for (code, asset), sleeve in data.items(): + key = f"{code.split('_')[0]} {asset}" + base = sub(ExitPolicy, sleeve, {}, OOS_START_MS, None) + line = f"{key:<10} BASE {fmt(base)}" + print(line) + for b in jit_buffers: + r = sub(CloseConfirmSl, sleeve, {"buffer": b}, OOS_START_MS, None) + better = r and base and r["sharpe_t"] >= base["sharpe_t"] - 0.10 + all_pos &= bool(better) + print(f" buf={b:<4} {fmt(r)} {'OK' if better else 'WORSE'}") + print() + print(f"JITTER buffer: tutte >= base-0.10 sharpe? {all_pos}\n") + + print("-" * 100) + print("PONTE WIDE-SL: SL intrabar fisso allargato a k*ATR (k grande -> verso no-sl)") + print("-" * 100) + for (code, asset), sleeve in data.items(): + key = f"{code.split('_')[0]} {asset}" + base = sub(ExitPolicy, sleeve, {}, OOS_START_MS, None) + print(f"{key:<10} BASE(k=0) {fmt(base)}") + for k in [1.5, 3.0, 4.0]: + r = sub(WideSlPolicy, sleeve, {"k_atr": k}, OOS_START_MS, None) + print(f" k={k:<4} {fmt(r)}") + print() + + # ===== TEST 2: STABILITA' TEMPORALE ===== + print("=" * 100) + print("TEST 2 — STABILITA' TEMPORALE (base vs policy buffer=0.5)") + print("=" * 100) + ms = lambda d: int(pd.Timestamp(d, tz="UTC").value // 1e6) + windows = [ + ("TRAIN 2018-20", None, ms("2021-01-01")), + ("TRAIN 2021-22", ms("2021-01-01"), OOS_START_MS), + ("OOS 23/11-25/01", OOS_START_MS, ms("2025-01-01")), + ("OOS 25/01-26/05", ms("2025-01-01"), None), + ] + win_verdict = {w[0]: 0 for w in windows} + win_total = {w[0]: 0 for w in windows} + for (code, asset), sleeve in data.items(): + key = f"{code.split('_')[0]} {asset}" + print(f"\n{key}") + for wname, s, e in windows: + b = sub(ExitPolicy, sleeve, {}, s, e) + p = sub(CloseConfirmSl, sleeve, {"buffer": 0.5}, s, e) + if b and p: + win_total[wname] += 1 + # criterio: policy non peggio della base su sharpe (tol 0.15) + imp = p["sharpe_t"] >= b["sharpe_t"] - 0.15 + win_verdict[wname] += int(imp) + tag = "OK " if imp else "BAD" + else: + tag = "n/a" + print(f" {wname:<18} base {fmt(b)}") + print(f" {'':<18} pol {fmt(p)} -> {tag}") + print("\nPer-finestra (policy >= base-0.15 sharpe):") + for w in windows: + wn = w[0] + print(f" {wn:<18} {win_verdict[wn]}/{win_total[wn]} sleeve OK") + + # ===== TEST 3: DIPENDENZA HURST (DECISIVO) ===== + print("\n" + "=" * 100) + print("TEST 3 — DIPENDENZA dal loss-guard HURST (DECISIVO)") + print("Rigenero i segnali con hurst_max=None (NO guard, regime trending incluso).") + print("Se la policy crolla -> funziona SOLO grazie al guard.") + print("=" * 100) + print("Generazione segnali SENZA hurst (puo' richiedere ~1-2 min)...") + data_nohurst = build_signals(hurst_max=None) + + n_guard = sum(len(s["signals"]) for s in data.values()) + n_nohurst = sum(len(s["signals"]) for s in data_nohurst.values()) + print(f"Segnali totali: con guard {n_guard}, senza guard {n_nohurst} " + f"(+{n_nohurst - n_guard} segnali in regime trending)\n") + + holds = True + for region_name, s, e in [("TRAIN", None, OOS_START_MS), ("OOS", OOS_START_MS, None)]: + print(f"--- {region_name} (segnali SENZA hurst guard) ---") + for (code, asset), sleeve in data_nohurst.items(): + key = f"{code.split('_')[0]} {asset}" + b = sub(ExitPolicy, sleeve, {}, s, e) + p = sub(CloseConfirmSl, sleeve, {"buffer": 0.5}, s, e) + if b and p: + imp = p["sharpe_t"] >= b["sharpe_t"] - 0.15 + ddimp = p["dd_pct"] <= b["dd_pct"] + 1.0 + holds &= bool(imp) + tag = "OK " if imp else "POLICY WORSE" + else: + tag = "n/a" + print(f" {key:<10} base {fmt(b)}") + print(f" {'':<10} pol {fmt(p)} -> {tag}") + print() + print(f"TEST 3 verdict: policy regge SENZA il guard (>= base-0.15 sharpe ovunque)? {holds}") + print("Se False -> la tesi 'SL dannoso' dipende dal guard (condizione di validita').") + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/exit_policies/verify_16_stress.py b/scripts/analysis/exit_policies/verify_16_stress.py new file mode 100644 index 0000000..3be2069 --- /dev/null +++ b/scripts/analysis/exit_policies/verify_16_stress.py @@ -0,0 +1,261 @@ +"""VERIFY EXIT-16 close_confirm_sl — lente STRESS (avversariale). + +Ipotesi nulla: l'edge della close-confirm-SL e' fragile a frizioni reali. +Quattro stress, tutti su segnali cache (params LIVE, hurst_max=0.55): + +(1) FEE 2x: FEE_RT=0.002 (vs 0.001). Penalizza le policy che girano piu' capitale. +(2) BEAR/CRASH 2021-01..2022-12 (LUNA/FTX/19-mag-21): worst-trade + 5 peggiori + trade della policy vs base. Lo SL disattivato lascia correre le perdite? +(3) SLIPPAGE AVVERSO 20bps sulle uscite della policy: ogni fill di USCITA paga + +20bps contro la posizione (prezzo di uscita peggiorato). L'edge regge? + NB: lo applico SOLO alle uscite della POLICY (la sua tesi e' "esco al close": + il close-fill e' market, paga slippage; la base esce a livelli limite sl0/tp0). +(4) OVERLAP/TURNOVER: la policy allunga la permanenza (no stop intrabar). Conto + i segnali SALTATI per non-overlap (i <= last_exit) base vs policy, e quanto + capitale-tempo (somma bars in posizione) gira in piu'. + +Tutto via simulate() con monkeypatch di FEE_RT e una sottoclasse engine per lo +slippage. Niente modifiche ad altri file. +""" +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +import exit_lab # noqa: E402 +from exit_lab import ExitPolicy, simulate, OOS_START_MS, HARD_CAP, LEV, POS # noqa: E402 + +import importlib.util +spec = importlib.util.spec_from_file_location( + "cc16", str(Path(__file__).resolve().parent / "16_close_confirm_sl.py")) +cc16 = importlib.util.module_from_spec(spec) +spec.loader.exec_module(cc16) +CloseConfirmSl = cc16.CloseConfirmSl + +BUF = 0.5 # train-pick + + +def fmt(r): + if not r: + return " n/a" + return (f"ret{r['ret_pct']:>8.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} " + f"n{r['trades']:>4} win{r['win_pct']:>4.0f} bars{r['avg_bars']:>5.1f}") + + +def sub(cls, sleeve, g, s, e): + return simulate(cls, sleeve, g, start_ms=s, end_ms=e) + + +def ms(d): + return int(pd.Timestamp(d, tz="UTC").value // 1e6) + + +# =========================================================================== +# Engine "instrumented" che riproduce simulate() ma: +# - applica uno slippage avverso (bps) su OGNI fill di USCITA (solo se policy) +# - raccoglie la lista dei ret per-trade e i segnali SALTATI per non-overlap +# - raccoglie capital-time (somma bars) +# Lo tengo allineato 1:1 con exit_lab.simulate (stesso ordine SL-prima-di-TP). +# =========================================================================== +def simulate_instr(policy_cls, sleeve, params=None, start_ms=None, end_ms=None, + exit_slip_bps=0.0): + params = params or {} + h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"] + n = len(c) + ctx = dict(sleeve) + policy_cls.prepare(ctx, **params) + fee = exit_lab.FEE_RT * LEV + slip = exit_slip_bps * 1e-4 + capital = peak = 1000.0 + max_dd = 0.0 + last_exit = -1 + trades = wins = 0 + bars_tot = 0 + skipped_overlap = 0 + rets = [] # (ret, ts_entry, bars) + for (i, d, tp0, sl0, mb) in sleeve["signals"]: + if start_ms is not None and ts[i] < start_ms: + continue + if end_ms is not None and ts[i] >= end_ms: + continue + if i + 1 >= n: + continue + if i <= last_exit: + skipped_overlap += 1 + continue + entry = c[i] + pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params) + horizon = min(int(pol.horizon), HARD_CAP) + fills = [] + remaining = 1.0 + j = i + for step in range(1, horizon + 1): + j = i + step + if j >= n: + j = n - 1 + fills.append((remaining, c[j])); remaining = 0.0 + break + tp, sl, tpfrac = pol.levels(j) + 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: + fills.append((remaining, sl)); remaining = 0.0 + break + if hit_tp: + f = min(max(tpfrac, 0.0), 1.0) * remaining + if f > 0: + fills.append((f, tp)); remaining -= f + if remaining <= 1e-9: + break + pol.on_partial(j, tp, remaining) + if pol.after_bar(j): + fills.append((remaining, c[j])); remaining = 0.0 + break + if step == horizon: + fills.append((remaining, c[j])); remaining = 0.0 + if remaining > 1e-9: + fills.append((remaining, c[j])) + # slippage avverso sull'uscita: il prezzo di uscita peggiora di slip, + # cioe' si vende piu' basso (long) / si ricompra piu' alto (short). + def adj(p): + return p * (1.0 - slip) if d == 1 else p * (1.0 + slip) + ret = sum(f * (adj(p) - entry) for f, p in fills) / 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 + bars_tot += j - i + rets.append((ret, int(ts[i]), j - i)) + if trades == 0: + return {} + r = np.array([x[0] for x in rets]) + return { + "ret_pct": (capital / 1000.0 - 1) * 100, + "dd_pct": max_dd * 100, + "trades": trades, + "win_pct": wins / trades * 100, + "sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0, + "avg_bars": bars_tot / trades, + "bars_tot": bars_tot, + "skipped_overlap": skipped_overlap, + "rets": rets, + "worst5": sorted(r.tolist())[:5], + } + + +def main(): + data = exit_lab.load_sleeves() + + # =================================================================== + print("=" * 104) + print("TEST 1 — FEE 2x (FEE_RT 0.001 -> 0.002). base vs policy buffer=0.5 (OOS, dopo 2023-11)") + print("=" * 104) + orig_fee = exit_lab.FEE_RT + survive_fee = True + for fee in (0.001, 0.002): + exit_lab.FEE_RT = fee + print(f"\n--- FEE_RT={fee} ---") + for (code, asset), sleeve in data.items(): + key = f"{code.split('_')[0]} {asset}" + b = sub(ExitPolicy, sleeve, {}, OOS_START_MS, None) + p = sub(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None) + tag = "" + if fee == 0.002 and b and p: + # regge se sharpe policy >= base (la tesi e' che migliora) + ok = p["sharpe_t"] >= b["sharpe_t"] - 0.10 + survive_fee &= ok + tag = "OK" if ok else "WORSE" + print(f" {key:<10} base {fmt(b)}") + print(f" {'':<10} pol {fmt(p)} {tag}") + exit_lab.FEE_RT = orig_fee + print(f"\nFEE 2x: policy regge (>= base-0.10 sh su tutti gli sleeve OOS)? {survive_fee}") + + # =================================================================== + print("\n" + "=" * 104) + print("TEST 2 — BEAR/CRASH 2021-01..2022-12 (LUNA/FTX/19-mag): worst-trade + 5 peggiori") + print("=" * 104) + s2, e2 = ms("2021-01-01"), ms("2023-01-01") + tail_worse = 0 + tail_total = 0 + for (code, asset), sleeve in data.items(): + key = f"{code.split('_')[0]} {asset}" + b = simulate_instr(ExitPolicy, sleeve, {}, s2, e2) + p = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, s2, e2) + print(f"\n{key}") + print(f" base {fmt(b)}") + print(f" pol {fmt(p)}") + if b and p: + bw = [f"{x*100:+.1f}%" for x in b["worst5"]] + pw = [f"{x*100:+.1f}%" for x in p["worst5"]] + print(f" base 5 peggiori (ret netto): {bw}") + print(f" pol 5 peggiori (ret netto): {pw}") + tail_total += 1 + # la policy peggiora la coda se il worst-trade e' piu' negativo + if p["worst5"][0] < b["worst5"][0] - 0.005: + tail_worse += 1 + print(f" -> CODA PEGGIORE: worst {p['worst5'][0]*100:+.1f}% < base {b['worst5'][0]*100:+.1f}%") + else: + print(f" -> coda OK: worst {p['worst5'][0]*100:+.1f}% vs base {b['worst5'][0]*100:+.1f}%") + print(f" DD bear: base {b['dd_pct']:.1f}% pol {p['dd_pct']:.1f}%") + print(f"\nBEAR: sleeve con coda PEGGIORE (worst-trade > 0.5pt sotto base): {tail_worse}/{tail_total}") + + # =================================================================== + print("\n" + "=" * 104) + print("TEST 3 — SLIPPAGE AVVERSO 20bps sulle uscite della POLICY (OOS). base senza slippage") + print("(la tesi della policy e' 'esco al close' = market fill -> paga slippage)") + print("=" * 104) + survive_slip = True + for (code, asset), sleeve in data.items(): + key = f"{code.split('_')[0]} {asset}" + b = simulate_instr(ExitPolicy, sleeve, {}, OOS_START_MS, None, exit_slip_bps=0.0) + p0 = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None, exit_slip_bps=0.0) + p20 = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None, exit_slip_bps=20.0) + ok = p20 and b and p20["sharpe_t"] >= b["sharpe_t"] - 0.10 + survive_slip &= bool(ok) + print(f"\n{key}") + print(f" base (no slip) {fmt(b)}") + print(f" pol (no slip) {fmt(p0)}") + print(f" pol (+20bps exit) {fmt(p20)} {'OK' if ok else 'WORSE vs base'}") + print(f"\nSLIPPAGE 20bps: policy ancora >= base-0.10 sh su tutti? {survive_slip}") + print("(test severo: lo slippage colpisce la policy ma NON la base — asimmetria pessimistica)") + + # severita' extra: slippage anche sulla base (entrambe market) per fairness + print("\n--- fairness: 20bps anche sulle uscite della BASE ---") + fair = True + for (code, asset), sleeve in data.items(): + key = f"{code.split('_')[0]} {asset}" + b20 = simulate_instr(ExitPolicy, sleeve, {}, OOS_START_MS, None, exit_slip_bps=20.0) + p20 = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None, exit_slip_bps=20.0) + ok = p20 and b20 and p20["sharpe_t"] >= b20["sharpe_t"] - 0.10 + fair &= bool(ok) + print(f" {key:<10} base+20 {fmt(b20)}") + print(f" {'':<10} pol +20 {fmt(p20)} {'OK' if ok else 'WORSE'}") + print(f"fairness (entrambe +20bps): policy >= base-0.10 sh? {fair}") + + # =================================================================== + print("\n" + "=" * 104) + print("TEST 4 — OVERLAP/TURNOVER: segnali saltati per non-overlap + capital-time (OOS)") + print("=" * 104) + for (code, asset), sleeve in data.items(): + key = f"{code.split('_')[0]} {asset}" + b = simulate_instr(ExitPolicy, sleeve, {}, OOS_START_MS, None) + p = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None) + if b and p: + dskip = p["skipped_overlap"] - b["skipped_overlap"] + dbars = p["bars_tot"] - b["bars_tot"] + print(f" {key:<10} base: trades {b['trades']:>4} skip-overlap {b['skipped_overlap']:>4} " + f"bars_tot {b['bars_tot']:>6} avg {b['avg_bars']:.1f}") + print(f" {'':<10} pol : trades {p['trades']:>4} skip-overlap {p['skipped_overlap']:>4} " + f"bars_tot {p['bars_tot']:>6} avg {p['avg_bars']:.1f}") + print(f" {'':<10} -> +{dskip} segnali persi per overlap, " + f"+{dbars} bars in posizione ({dbars/max(b['bars_tot'],1)*100:+.0f}% capital-time)") + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/exit_policies/verify_22_no_sl_leakage.py b/scripts/analysis/exit_policies/verify_22_no_sl_leakage.py new file mode 100644 index 0000000..10c4e5d --- /dev/null +++ b/scripts/analysis/exit_policies/verify_22_no_sl_leakage.py @@ -0,0 +1,222 @@ +"""VERIFICA AVVERSARIALE — lente LOOK-AHEAD/ESEGUIBILITA' per EXIT-22 (no_sl). + +Ipotesi da confutare: "rimuovere lo SL e' un free-lunch" potrebbe essere un +artefatto di (a) look-ahead nel contratto livelli/engine, (b) dipendenza dal +timing perfetto dell'uscita, (c) non-replicabilita' del fill live (il worker +ticka ogni ora ed esce al poll successivo, non al close esatto del bar). + +Esperimenti (tutti riusano simulate() e la policy importata): + E1 CONTRATTO: la policy NoSl ha livelli STATICI dall'entrata. Confermo che + l'output non cambia se "rumorizzo" gli array > i (futuro) post-entrata, e + che non cambia se rumorizzo atr14 ovunque (la policy non lo usa). + E2 LAG: variante che ritarda l'uscita a horizon (e i tocchi) di 1 bar — ma + siccome NoSl non ha SL e non usa indicatori a j-1, il vero lag rilevante + e' SUL FILL. Implemento NoSlLagExit: l'uscita al close del bar j viene + eseguita al close[j+1] (un tick dopo) e il TP intrabar viene fillato al + WORST fra tp e close[j] (slippage avverso). Misuro il collasso dell'edge. + E3 ESEGUIBILITA' open[j+1]: orizzonte/uscite al close[j] rieseguite a + open[j+1] (il poll successivo del worker). Quanto costa il gap di apertura? + E4 TP-FILL pessimistico: TP fillato a close[j] (non al livello tp) quando il + bar tocca il TP -> stima il caso in cui il worker scopre il tocco solo al + poll e chiude al prezzo corrente, peggiore del livello. + E5 SOTTOPERIODI OOS: l'edge di 'none' regge nei sotto-intervalli OOS o e' + tutto in una coda fortunata? (2023-11..2024-08 vs 2024-08..fine). + +Verdetto: refuted=True solo se un test ESEGUIBILE realistico (E2/E3/E4) cancella +il vantaggio di 'none' su 'base' (ret E dd) su entrambi gli asset / tutte le fade. +""" +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent)) # scripts/analysis + +import exit_lab # noqa: E402 +from exit_lab import ExitPolicy, simulate, load_sleeves, OOS_START_MS, HARD_CAP, LEV, POS # noqa: E402 + +sys.path.insert(0, str(HERE)) +import importlib.util # noqa: E402 + +_spec = importlib.util.spec_from_file_location("_no_sl", HERE / "22_no_sl.py") +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) +NoSl = _mod.NoSl + + +def _fmt(r): + if not r: + return " (no trades)" + return (f"ret{r['ret_pct']:>8.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} " + f"n{r['trades']:>4} win{r['win_pct']:>4.0f}% bars{r['avg_bars']:>5.1f}") + + +# ---------------------------------------------------------------- E2/E3/E4 engines + +def simulate_exec(sleeve, mode, *, exit_at, tp_fill, start_ms=None, end_ms=None, + with_sl=False): + """Clone di simulate() con NoSl(mode), ma con esecuzione LIVE-realistica: + exit_at : 'close' -> uscita orizzonte al close[j] (baseline harness) + 'open1' -> uscita orizzonte al open[j+1] (poll successivo) + tp_fill : 'level' -> TP fillato al livello tp (ottimistico, harness) + 'close' -> TP fillato al close[j] del bar che tocca (worker + scopre il tocco solo al poll: prezzo corrente) + 'open1' -> TP fillato al open[j+1] + with_sl=False -> NoSl (mode='none'); with_sl tramite mode='base' usa lo SL + della strategia (per il confronto base vs none nelle STESSE condizioni exec). + """ + h, l, c, o, ts = (sleeve["high"], sleeve["low"], sleeve["close"], + sleeve["open"], sleeve["ts_ms"]) + n = len(c) + fee = exit_lab.FEE_RT * LEV + capital = peak = 1000.0 + max_dd = 0.0 + last_exit = -1 + trades = wins = bars_tot = 0 + rets = [] + + for (i, d, tp0, sl0, mb) in sleeve["signals"]: + if start_ms is not None and ts[i] < start_ms: + continue + if end_ms is not None and ts[i] >= end_ms: + continue + if i <= last_exit or i + 1 >= n: + continue + entry = c[i] + if mode == "base": + sl = sl0 + else: + sl = None # 'none' + tp = tp0 + horizon = min(int(mb), HARD_CAP) + exit_price = None + j = i + for step in range(1, horizon + 1): + j = i + step + if j >= n: + j = n - 1 + exit_price = c[j] + break + hit_sl = sl is not None and ((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_price = sl # SL fill al livello (favorevole alla tesi 'base') + break + if hit_tp: + if tp_fill == "level": + exit_price = tp + elif tp_fill == "close": + exit_price = c[j] + elif tp_fill == "open1": + exit_price = o[j + 1] if j + 1 < n else c[j] + break + if step == horizon: + if exit_at == "close": + exit_price = c[j] + elif exit_at == "open1": + exit_price = o[j + 1] if j + 1 < n else c[j] + break + if exit_price is None: + exit_price = c[j] + + ret = (exit_price - 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 + bars_tot += j - i + rets.append(ret) + + if trades == 0: + return {} + r = np.array(rets) + return {"ret_pct": (capital / 1000.0 - 1) * 100, "dd_pct": max_dd * 100, + "trades": trades, "win_pct": wins / trades * 100, + "sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0, + "avg_bars": bars_tot / trades} + + +def main(): + data = load_sleeves() + keys = list(data.keys()) + + # ---- E1: contratto look-ahead (la policy ha livelli statici) ------------- + print("=" * 100) + print("E1 CONTRATTO LOOK-AHEAD: rumorizzo gli array DOPO l'entrata e atr14 (la") + print(" policy NoSl non deve cambiare: livelli statici, non usa indicatori).") + print("=" * 100) + rng = np.random.default_rng(0) + code, asset = "MR02_donchian_fade", "ETH" + base_sleeve = data[(code, asset)] + clean = simulate(NoSl, base_sleeve, {"mode": "none"}, start_ms=OOS_START_MS) + # rumorizzo atr14 interamente (policy non lo usa) + noisy = dict(base_sleeve) + noisy["atr14"] = base_sleeve["atr14"] * (1 + rng.normal(0, 0.5, len(base_sleeve["atr14"]))) + res_noisy_atr = simulate(NoSl, noisy, {"mode": "none"}, start_ms=OOS_START_MS) + print(f" {code.split('_')[0]} {asset} clean : {_fmt(clean)}") + print(f" {code.split('_')[0]} {asset} atr14 noised : {_fmt(res_noisy_atr)} " + f"(identico => NoSl non legge atr14)") + + # ---- E5: sottoperiodi OOS ------------------------------------------------ + print("\n" + "=" * 100) + print("E5 SOTTOPERIODI OOS: l'edge di 'none' vs 'base' regge in 2 meta'?") + print("=" * 100) + mid = int(pd.Timestamp("2024-09-01", tz="UTC").value // 1e6) + for (code, asset) in keys: + sl_name = code.split("_")[0] + s = data[(code, asset)] + for lab, a, b in [("H1 23-11..24-09", OOS_START_MS, mid), + ("H2 24-09..fine ", mid, None)]: + bse = simulate(ExitPolicy, s, {}, start_ms=a, end_ms=b) + non = simulate(NoSl, s, {"mode": "none"}, start_ms=a, end_ms=b) + if bse and non: + dret = non["ret_pct"] - bse["ret_pct"] + ddd = non["dd_pct"] - bse["dd_pct"] + flag = "OK" if (dret > -1 and ddd < 1) else "FAIL" + print(f" {sl_name} {asset} {lab}: base {_fmt(bse)}") + print(f" {sl_name} {asset} {lab}: none {_fmt(non)} " + f"[dret{dret:+.0f} ddd{ddd:+.1f} {flag}]") + + # ---- E2/E3/E4: esecuzione realistica, base vs none nelle STESSE condizioni + print("\n" + "=" * 100) + print("E2/E3/E4 ESEGUIBILITA' LIVE (OOS). Confronto base vs none sotto:") + print(" IDEAL : exit close[j], TP@level (= harness)") + print(" OPEN1 : exit open[j+1], TP@open[j+1] (worker esce al poll successivo)") + print(" TPCLOSE: exit close[j], TP@close[j] (TP scoperto al poll, fill peggiore)") + print("=" * 100) + scenarios = [("IDEAL ", dict(exit_at="close", tp_fill="level")), + ("OPEN1 ", dict(exit_at="open1", tp_fill="open1")), + ("TPCLOSE", dict(exit_at="close", tp_fill="close"))] + summary = {sc[0]: {"none_wins_ret": 0, "none_wins_dd": 0, "tot": 0} for sc in scenarios} + for (code, asset) in keys: + sl_name = code.split("_")[0] + s = data[(code, asset)] + print(f"\n --- {sl_name} {asset} (OOS) ---") + for scname, kw in scenarios: + b = simulate_exec(s, "base", start_ms=OOS_START_MS, **kw) + nn = simulate_exec(s, "none", start_ms=OOS_START_MS, **kw) + if not b or not nn: + continue + dret = nn["ret_pct"] - b["ret_pct"] + ddd = nn["dd_pct"] - b["dd_pct"] + summary[scname]["tot"] += 1 + summary[scname]["none_wins_ret"] += dret > -1 + summary[scname]["none_wins_dd"] += ddd < 1 + print(f" {scname} base: {_fmt(b)}") + print(f" {scname} none: {_fmt(nn)} [dret{dret:+.0f} ddd{ddd:+.1f}]") + + print("\n" + "=" * 100) + print("VERDETTO ESEGUIBILITA' (none >= base su ret e dd, per scenario):") + for scname, kw in scenarios: + s = summary[scname] + print(f" {scname}: none vince-o-pareggia ret {s['none_wins_ret']}/{s['tot']}, " + f"dd {s['none_wins_dd']}/{s['tot']}") + print("=" * 100) + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/exit_policies/verify_22_no_sl_overfit.py b/scripts/analysis/exit_policies/verify_22_no_sl_overfit.py new file mode 100644 index 0000000..0ce567b --- /dev/null +++ b/scripts/analysis/exit_policies/verify_22_no_sl_overfit.py @@ -0,0 +1,219 @@ +"""VERIFICATORE AVVERSARIALE OVERFIT — EXIT-22 no_sl. + +Tesi del sopravvissuto: "rimuovere lo SL intrabar (resta TP+max_bars) MIGLIORA +ret E DD E Sharpe su tutte le 6 sleeve, plateau monotono tightwide>base e' monotono? Aggiungo ponti 3x/4x. + Se la curva e' monotona e satura (3x~4x~none), il plateau e' robusto; + se none e' un picco isolato oltre wide, sospetto. + (B) STABILITA' TEMPORALE: spezzo train (2018-20 vs 21-22) e OOS + (2023-11..2025-01 vs 2025-01..2026-05). Il guadagno c'e' in OGNI + finestra o e' concentrato in un solo regime? + (C) DIPENDENZA HURST (DECISIVO): i segnali in cache hanno hurst_max=0.55 + (loss-guard toglie il regime persistente/trending — proprio dove gli SL + servono). Rigenero i segnali SENZA hurst (in memoria, cache intatta) e + ripeto base-vs-none. Se senza guard la policy crolla -> funziona SOLO + grazie al guard => condizione di validita'. + +Esegui: cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \ + scripts/analysis/exit_policies/verify_22_no_sl_overfit.py +""" +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +import exit_lab # noqa: E402 +from exit_lab import (ExitPolicy, simulate, load_sleeves, OOS_START_MS, # noqa: E402 + CODES, ASSETS, LIVE_PARAMS, _atr14) +from src.data.downloader import load_data # noqa: E402 +from src.live.strategy_loader import load_strategy # noqa: E402 + +# import della policy DAL SUO FILE (no copia) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import importlib.util +_spec = importlib.util.spec_from_file_location( + "policy_22", str(Path(__file__).resolve().parent / "22_no_sl.py")) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) +NoSl = _mod.NoSl + + +# ---- policy ponte: SL a scale arbitrario (per jitter 3x/4x) ----------------- +class NoSlScale(ExitPolicy): + name = "no_sl_scale" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + scale = params.get("scale", None) + self.sl = None if scale is None else entry + float(scale) * (sl0 - entry) + + def levels(self, j: int): + return self.tp0, self.sl, 1.0 + + +def _fmt(r): + if not r: + return " (no trades)" + return (f"ret{r['ret_pct']:>8.0f}% dd{r['dd_pct']:>6.2f} sh{r['sharpe_t']:>6.2f} " + f"n{r['trades']:>4} win{r['win_pct']:>5.1f} bars{r['avg_bars']:>5.1f}") + + +# ============================================================================= +# (A) JITTER: ponti di scala 1x(base) 1.5 2 3 4 none +# ============================================================================= +def test_jitter(data): + print("\n" + "=" * 78) + print("(A) JITTER PLATEAU — scala SL: base(1x) 1.5x 2x 3x 4x none [OOS]") + print("=" * 78) + scales = [("base", {"scale": 1.0}), ("1.5x", {"scale": 1.5}), + ("2x(wide)", {"scale": 2.0}), ("3x", {"scale": 3.0}), + ("4x", {"scale": 4.0}), ("none", {"scale": None})] + monotonic_fail = 0 + for (code, asset), sleeve in data.items(): + key = f"{code.split('_')[0]} {asset}" + print(f"\n{key} [OOS 2023-11 ->]") + rets, dds, shs = [], [], [] + for tag, g in scales: + r = simulate(NoSlScale, sleeve, g, start_ms=OOS_START_MS) + rets.append(r.get("ret_pct", 0)); dds.append(r.get("dd_pct", 0)) + shs.append(r.get("sharpe_t", 0)) + print(f" {tag:<10}{_fmt(r)}") + # plateau monotono atteso: ret crescente, dd decrescente, sh crescente + ret_mono = all(rets[i] <= rets[i + 1] + 1e-6 for i in range(len(rets) - 1)) + sh_mono = all(shs[i] <= shs[i + 1] + 1e-6 for i in range(len(shs) - 1)) + # saturazione: none vs 4x ravvicinati (plateau "piatto" in cima)? + sat = abs(rets[-1] - rets[-2]) / (abs(rets[-1]) + 1e-9) * 100 + print(f" -> ret monotono crescente: {ret_mono} | sharpe monotono: {sh_mono}" + f" | gap none-vs-4x: {sat:.1f}%") + if not (ret_mono and sh_mono): + monotonic_fail += 1 + print(f"\n Sleeve con plateau NON monotono: {monotonic_fail}/6") + return monotonic_fail + + +# ============================================================================= +# (B) STABILITA' TEMPORALE: sotto-finestre train e OOS +# ============================================================================= +def test_temporal(data): + print("\n" + "=" * 78) + print("(B) STABILITA' TEMPORALE — base vs none in 4 sotto-finestre") + print("=" * 78) + ms = lambda s: int(pd.Timestamp(s, tz="UTC").value // 1e6) + wins = [ + ("TR 2018-2020", None, ms("2021-01-01")), + ("TR 2021-2022", ms("2021-01-01"), OOS_START_MS), + ("OOS 23-11..25-01", OOS_START_MS, ms("2025-01-01")), + ("OOS 25-01..26-05", ms("2025-01-01"), None), + ] + # conta in quante (sleeve x finestra) none batte base su ret E dd E sh + cells = 0 + none_better_all = 0 + none_worse_dd = 0 + for wname, s0, s1 in wins: + print(f"\n--- finestra {wname} ---") + for (code, asset), sleeve in data.items(): + key = f"{code.split('_')[0]} {asset}" + b = simulate(ExitPolicy, sleeve, {}, start_ms=s0, end_ms=s1) + nn = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=s0, end_ms=s1) + if not b or not nn: + print(f" {key:<10} (campione vuoto)") + continue + cells += 1 + d_ret = nn["ret_pct"] - b["ret_pct"] + d_dd = nn["dd_pct"] - b["dd_pct"] # <0 = none meglio (dd minore) + d_sh = nn["sharpe_t"] - b["sharpe_t"] + allbetter = d_ret > 0 and d_dd < 0 and d_sh > 0 + none_better_all += allbetter + none_worse_dd += d_dd > 1e-6 + flag = "OK" if allbetter else ("dd+" if d_dd > 0 else "ret-" if d_ret <= 0 else "sh-") + print(f" {key:<10} base ret{b['ret_pct']:>7.0f} dd{b['dd_pct']:>5.1f} " + f"sh{b['sharpe_t']:>5.2f} | none ret{nn['ret_pct']:>7.0f} " + f"dd{nn['dd_pct']:>5.1f} sh{nn['sharpe_t']:>5.2f} | " + f"dRet{d_ret:>+7.0f} dDD{d_dd:>+5.1f} dSh{d_sh:>+5.2f} [{flag}]") + print(f"\n none meglio su (ret&dd&sh) in {none_better_all}/{cells} celle " + f"sleeve x finestra | none PEGGIORA il DD in {none_worse_dd}/{cells}") + return none_better_all, none_worse_dd, cells + + +# ============================================================================= +# (C) DECISIVO: segnali SENZA hurst (in memoria, cache intatta) +# ============================================================================= +def build_sleeves_no_hurst(): + """Rigenera i segnali con hurst_max=None (loss-guard OFF). NON tocca la + cache su disco: ritorna un dict identico in forma a load_sleeves().""" + params = dict(LIVE_PARAMS) + params["hurst_max"] = None + out = {} + for code in CODES: + strat = load_strategy(code) + for asset in ASSETS: + df = load_data(asset, "1h") + ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + sigs = strat.generate_signals(df, ts, **params) + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + c = df["close"].values.astype(float) + out[(code, asset)] = { + "signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]), + float(s.metadata["sl"]), int(s.metadata["max_bars"])) + for s in sigs], + "open": df["open"].values.astype(float), + "high": h, "low": l, "close": c, + "ts_ms": df["timestamp"].values.astype(np.int64), + "atr14": _atr14(h, l, c), + } + return out + + +def test_hurst_dependency(data_guard): + print("\n" + "=" * 78) + print("(C) DECISIVO — DIPENDENZA DAL FILTRO HURST") + print(" rigenero segnali con hurst_max=None (guard OFF), confronto base vs none") + print("=" * 78) + data_noh = build_sleeves_no_hurst() + # quanto cambia il numero di segnali (il guard quanti ne toglieva?) + print("\nConteggio segnali (guard ON cache vs guard OFF):") + for k in data_guard: + ng = len(data_guard[k]["signals"]) + nh = len(data_noh[k]["signals"]) + print(f" {k[0].split('_')[0]} {k[1]:<4} guard ON {ng:>5} OFF {nh:>5} " + f"(+{nh - ng} segnali tossici reintrodotti)") + + for label, ms0 in [("FULL", None), ("OOS", OOS_START_MS)]: + print(f"\n--- {label} (guard OFF) base vs none ---") + none_better_all = none_worse_dd = none_worse_ret = cells = 0 + for (code, asset), sleeve in data_noh.items(): + key = f"{code.split('_')[0]} {asset}" + b = simulate(ExitPolicy, sleeve, {}, start_ms=ms0) + nn = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=ms0) + if not b or not nn: + continue + cells += 1 + d_ret = nn["ret_pct"] - b["ret_pct"] + d_dd = nn["dd_pct"] - b["dd_pct"] + d_sh = nn["sharpe_t"] - b["sharpe_t"] + none_better_all += (d_ret > 0 and d_dd < 0 and d_sh > 0) + none_worse_dd += d_dd > 1e-6 + none_worse_ret += d_ret <= 0 + flag = "OK" if (d_ret > 0 and d_dd < 0 and d_sh > 0) else \ + ("dd+" if d_dd > 0 else "ret-" if d_ret <= 0 else "sh-") + print(f" {key:<10} base ret{b['ret_pct']:>8.0f} dd{b['dd_pct']:>6.1f} " + f"sh{b['sharpe_t']:>5.2f} | none ret{nn['ret_pct']:>8.0f} " + f"dd{nn['dd_pct']:>6.1f} sh{nn['sharpe_t']:>5.2f} | " + f"dRet{d_ret:>+8.0f} dDD{d_dd:>+6.1f} dSh{d_sh:>+5.2f} [{flag}]") + print(f" => guard OFF {label}: none meglio (ret&dd&sh) {none_better_all}/{cells}" + f" | none PEGGIORA dd {none_worse_dd}/{cells} | PEGGIORA ret {none_worse_ret}/{cells}") + return data_noh + + +if __name__ == "__main__": + data = load_sleeves() + mono_fail = test_jitter(data) + nb_all, nb_dd, cells = test_temporal(data) + test_hurst_dependency(data) diff --git a/scripts/analysis/exit_policies/verify_22_no_sl_stress.py b/scripts/analysis/exit_policies/verify_22_no_sl_stress.py new file mode 100644 index 0000000..1496eb8 --- /dev/null +++ b/scripts/analysis/exit_policies/verify_22_no_sl_stress.py @@ -0,0 +1,327 @@ +"""VERIFICA AVVERSARIALE — lente STRESS sul sopravvissuto EXIT-22 (mode='none', SL RIMOSSO). + +Ipotesi avversaria: il vantaggio di togliere lo SL (ret/DD/Sharpe migliori) e' un +ARTEFATTO del regime calmo OOS 2024-25 e/o si dissolve sotto stress realistici. Senza +SL la coda non e' limitata a leva 3x: un trade puo' perdere fino al movimento avverso +intero entro max_bars (24h). Testo 4 stress: + + (1) FEE 2x (FEE_RT=0.002): la policy ABBASSA il turnover (avg_bars 9->12-13, meno + trade) quindi la fee 2x dovrebbe penalizzarla MENO della base -> non e' la sua + debolezza, ma lo misuro comunque per onesta'. + (2) SOTTOPERIODO BEAR 2021-01..2022-12 (crash 2021-05-19, LUNA mag-2022, FTX nov-2022): + qui lo SL dovrebbe servire. Confronto ret/DD + WORST trade + 5 peggiori trade + none vs base. E' il test decisivo: se none esplode in DD o ha code mostruose qui, + il survivor e' confutato (l'OOS calmo nascondeva il rischio). + (3) SLIPPAGE AVVERSO 20bps sulle USCITE: ogni uscita (TP/horizon) pagata 20bps peggio + (prezzo di uscita penalizzato del 20bps contro la posizione). Penalizza di piu' le + policy che escono al close/horizon (none esce piu' spesso a horizon, non al TP-limit). + (4) OVERLAP/CHURN: la policy allunga la permanenza -> conto i segnali SCARTATI per + non-overlap (i<=last_exit) in piu' rispetto alla base, e l'effetto sul capitale. + +Confuto SOLO con evidenza numerica concreta. +""" +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import exit_lab # noqa: E402 +from exit_lab import ExitPolicy, simulate, load_sleeves, HARD_CAP, LEV, POS # noqa: E402 + +# import della policy dal suo file +import importlib.util +_spec = importlib.util.spec_from_file_location( + "policy_22", str(Path(__file__).resolve().parent / "22_no_sl.py")) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) +NoSl = _mod.NoSl + +BEAR_START = int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6) +BEAR_END = int(pd.Timestamp("2023-01-01", tz="UTC").value // 1e6) # esclusivo +OOS_START = exit_lab.OOS_START_MS + +DATA = load_sleeves() + + +def line(tag, key, r): + if not r: + print(f" {tag:<14}{key:<10} (no trades)") + return + print(f" {tag:<14}{key:<10} ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} " + f"sh{r['sharpe_t']:>5.2f} n{r['trades']:>4} win{r['win_pct']:>4.0f}% " + f"bars{r['avg_bars']:>5.1f}") + + +# ----------------------------------------------------------- (1) FEE 2x +def test_fee2x(): + print("\n=== (1) FEE 2x (0.10%->0.20% RT x leva 3) ===") + orig = exit_lab.FEE_RT + for fee, label in [(0.001, "fee1x"), (0.002, "fee2x")]: + exit_lab.FEE_RT = fee + print(f" -- {label} --") + n_better_none = 0 + for (code, asset), sleeve in DATA.items(): + key = f"{code.split('_')[0]} {asset}" + b = simulate(ExitPolicy, sleeve, start_ms=OOS_START) + no = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=OOS_START) + flag = " =base" if (no and b and no["ret_pct"] >= b["ret_pct"] + and no["dd_pct"] <= b["dd_pct"]) else "" + if flag: + n_better_none += 1 + print(f" {key:<10} base ret{b['ret_pct']:>6.0f}% dd{b['dd_pct']:>5.1f} | " + f"none ret{no['ret_pct']:>6.0f}% dd{no['dd_pct']:>5.1f}{flag}") + print(f" -> none >= base (ret&dd) su {n_better_none}/6 sleeve") + exit_lab.FEE_RT = orig + + +# ----------------------------------------------------------- (2) BEAR 2021-22 +def worst_trades(policy_cls, sleeve, params, start_ms, end_ms, k=5): + """Replica la logica di simulate ma raccoglie i ret per-trade per + estrarre i k peggiori e il worst. Stessa identica meccanica.""" + h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"] + n = len(c) + ctx = dict(sleeve) + policy_cls.prepare(ctx, **params) + fee = exit_lab.FEE_RT * LEV + last_exit = -1 + rets = [] + for (i, d, tp0, sl0, mb) in sleeve["signals"]: + if ts[i] < start_ms or ts[i] >= end_ms: + continue + if i <= last_exit or i + 1 >= n: + continue + entry = c[i] + pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params) + horizon = min(int(pol.horizon), HARD_CAP) + fills = [] + remaining = 1.0 + j = i + for step in range(1, horizon + 1): + j = i + step + if j >= n: + j = n - 1 + fills.append((remaining, c[j])); remaining = 0.0 + break + tp, sl, tpfrac = pol.levels(j) + 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: + fills.append((remaining, sl)); remaining = 0.0 + break + if hit_tp: + f = min(max(tpfrac, 0.0), 1.0) * remaining + if f > 0: + fills.append((f, tp)); remaining -= f + if remaining <= 1e-9: + break + pol.on_partial(j, tp, remaining) + if pol.after_bar(j): + fills.append((remaining, c[j])); remaining = 0.0 + break + if step == horizon: + fills.append((remaining, c[j])); remaining = 0.0 + if remaining > 1e-9: + fills.append((remaining, c[j])) + ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee + rets.append(ret * 1e4) # bps + last_exit = j + rets = np.array(sorted(rets)) + return rets + + +def test_bear(): + print("\n=== (2) SOTTOPERIODO BEAR 2021-01..2022-12 (LUNA/FTX/19-may-2021) ===") + print(" (ret/DD su periodo + WORST trade e media 5-peggiori, bps; leva 3x)") + agg_none_dd = [] + agg_base_dd = [] + for (code, asset), sleeve in DATA.items(): + key = f"{code.split('_')[0]} {asset}" + b = simulate(ExitPolicy, sleeve, start_ms=BEAR_START, end_ms=BEAR_END) + no = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=BEAR_START, end_ms=BEAR_END) + rb = worst_trades(ExitPolicy, sleeve, {}, BEAR_START, BEAR_END) + rn = worst_trades(NoSl, sleeve, {"mode": "none"}, BEAR_START, BEAR_END) + wb = rb[0] if len(rb) else float("nan") + wn = rn[0] if len(rn) else float("nan") + w5b = rb[:5].mean() if len(rb) >= 5 else float("nan") + w5n = rn[:5].mean() if len(rn) >= 5 else float("nan") + if b: + agg_base_dd.append(b["dd_pct"]) + if no: + agg_none_dd.append(no["dd_pct"]) + print(f" {key:<10}") + print(f" base ret{b.get('ret_pct',0):>6.0f}% dd{b.get('dd_pct',0):>5.1f} " + f"n{b.get('trades',0):>3} | worst{wb:>8.0f}bps 5worst{w5b:>8.0f}bps") + print(f" none ret{no.get('ret_pct',0):>6.0f}% dd{no.get('dd_pct',0):>5.1f} " + f"n{no.get('trades',0):>3} | worst{wn:>8.0f}bps 5worst{w5n:>8.0f}bps " + f"{'TAIL+' if wn < wb - 1 else ''}") + print(f" -> DD medio bear: base {np.mean(agg_base_dd):.1f}% none {np.mean(agg_none_dd):.1f}%") + print(f" worst-DD bear: base {np.max(agg_base_dd):.1f}% none {np.max(agg_none_dd):.1f}%") + + +# ----------------------------------------------------------- (3) SLIPPAGE 20bps exit +class NoSlSlip(NoSl): + """none + slippage avverso 20bps su OGNI uscita (sia TP che horizon/close). + Implementato penalizzando i livelli: tp eseguito 20bps peggio, e l'uscita al + close subisce un haircut equivalente applicato nel ret. Per semplicita' e + fedelta', applico lo slippage al RET finale come costo per-trade extra: ogni + trade paga 20bps di slippage sul notional (una uscita per trade).""" + name = "no_sl_slip" + + +class BaseSlip(ExitPolicy): + name = "base_slip" + + +def simulate_slip(policy_cls, sleeve, params, start_ms, slip_bps=20.0): + """simulate con costo di uscita avverso slip_bps (in bps di prezzo) applicato + al ret per-trade (oltre alla fee). Penalizza uscite al close E al TP.""" + h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"] + n = len(c) + ctx = dict(sleeve) + policy_cls.prepare(ctx, **params) + fee = exit_lab.FEE_RT * LEV + slip = slip_bps / 1e4 * LEV + capital = peak = 1000.0 + max_dd = 0.0 + last_exit = -1 + trades = wins = 0 + rets = [] + for (i, d, tp0, sl0, mb) in sleeve["signals"]: + if ts[i] < start_ms: + continue + if i <= last_exit or i + 1 >= n: + continue + entry = c[i] + pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params) + horizon = min(int(pol.horizon), HARD_CAP) + fills = [] + remaining = 1.0 + j = i + for step in range(1, horizon + 1): + j = i + step + if j >= n: + j = n - 1 + fills.append((remaining, c[j])); remaining = 0.0 + break + tp, sl, tpfrac = pol.levels(j) + 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: + fills.append((remaining, sl)); remaining = 0.0 + break + if hit_tp: + f = min(max(tpfrac, 0.0), 1.0) * remaining + if f > 0: + fills.append((f, tp)); remaining -= f + if remaining <= 1e-9: + break + pol.on_partial(j, tp, remaining) + if pol.after_bar(j): + fills.append((remaining, c[j])); remaining = 0.0 + break + if step == horizon: + fills.append((remaining, c[j])); remaining = 0.0 + if remaining > 1e-9: + fills.append((remaining, c[j])) + ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee - slip + 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 {} + r = np.array(rets) + return {"ret_pct": (capital / 1000.0 - 1) * 100, "dd_pct": max_dd * 100, + "trades": trades, "win_pct": wins / trades * 100, + "sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0} + + +def test_slippage(): + print("\n=== (3) SLIPPAGE AVVERSO 20bps su ogni uscita (OOS 2023-11+) ===") + print(" confronto: none-noslip vs none-slip20 vs base-slip20") + n_survive = 0 + for (code, asset), sleeve in DATA.items(): + key = f"{code.split('_')[0]} {asset}" + no0 = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=OOS_START) + nos = simulate_slip(NoSl, sleeve, {"mode": "none"}, OOS_START, 20.0) + bas = simulate_slip(ExitPolicy, sleeve, {}, OOS_START, 20.0) + surv = nos and bas and nos["ret_pct"] >= bas["ret_pct"] and nos["dd_pct"] <= bas["dd_pct"] + if surv: + n_survive += 1 + print(f" {key:<10} none ret{no0['ret_pct']:>6.0f}% | none+slip ret{nos['ret_pct']:>6.0f}%" + f" dd{nos['dd_pct']:>5.1f} sh{nos['sharpe_t']:>5.2f} | base+slip ret{bas['ret_pct']:>6.0f}%" + f" dd{bas['dd_pct']:>5.1f} {'EDGE' if surv else 'lost'}") + print(f" -> none+slip >= base+slip (ret&dd) su {n_survive}/6 sleeve") + + +# ----------------------------------------------------------- (4) OVERLAP / CHURN +def count_dropped(policy_cls, sleeve, params, start_ms): + """Conta i segnali scartati per non-overlap (i<=last_exit) e quelli eseguiti.""" + h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"] + n = len(c) + ctx = dict(sleeve) + policy_cls.prepare(ctx, **params) + last_exit = -1 + dropped = executed = 0 + for (i, d, tp0, sl0, mb) in sleeve["signals"]: + if ts[i] < start_ms or i + 1 >= n: + continue + if i <= last_exit: + dropped += 1 + continue + entry = c[i] + pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params) + horizon = min(int(pol.horizon), HARD_CAP) + remaining = 1.0 + j = i + for step in range(1, horizon + 1): + j = i + step + if j >= n: + j = n - 1 + break + tp, sl, tpfrac = pol.levels(j) + 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: + break + if hit_tp: + f = min(max(tpfrac, 0.0), 1.0) * remaining + remaining -= f + if remaining <= 1e-9: + break + pol.on_partial(j, tp, remaining) + if pol.after_bar(j): + break + if step == horizon: + break + last_exit = j + executed += 1 + return executed, dropped + + +def test_overlap(): + print("\n=== (4) OVERLAP / CHURN (OOS 2023-11+) — segnali persi per non-overlap ===") + tot_extra = 0 + for (code, asset), sleeve in DATA.items(): + key = f"{code.split('_')[0]} {asset}" + eb, db = count_dropped(ExitPolicy, sleeve, {}, OOS_START) + en, dn = count_dropped(NoSl, sleeve, {"mode": "none"}, OOS_START) + extra = dn - db + tot_extra += extra + print(f" {key:<10} base exec{eb:>4} drop{db:>3} | none exec{en:>4} drop{dn:>3} " + f"| extra-drop {extra:+d}") + print(f" -> segnali extra persi per overlap (none vs base): {tot_extra:+d} totali") + print(" (capitale gira meno: trade piu' lunghi, ma none rende comunque di piu' net)") + + +if __name__ == "__main__": + test_fee2x() + test_bear() + test_slippage() + test_overlap() + print("\n[fatto] verifica stress EXIT-22 no_sl completata.") diff --git a/scripts/analysis/exit_policies/verify_tail_risk.py b/scripts/analysis/exit_policies/verify_tail_risk.py new file mode 100644 index 0000000..ec3d164 --- /dev/null +++ b/scripts/analysis/exit_policies/verify_tail_risk.py @@ -0,0 +1,335 @@ +"""TAIL-RISK AUDIT of EXIT-22 (no_sl) and EXIT-16 (close_confirm_sl). + +Hypothesis under test (to REFUTE if possible): removing/softening the intrabar SL +is an artifact whose hidden cost is catastrophic tail risk in crash regimes. + +Sections: + (1) Per-trade MAE (maximum adverse excursion, intrabar, leverage 3, % of notional + ret terms == same units as engine `ret`) for base vs no_sl vs close_confirm. + Distribution p50/p95/p99/max per sleeve. + (2) Crash windows: 2020-03-12, 2021-05-19, 2022-06, 2022-11 (FTX). Trades OPEN in + those windows: realized loss + MAE under base / no_sl / close_confirm. + (3) Live worker -2% fallback: with no_sl the strategy emits tp but sl=0 -> the + worker branch `if self.tp and self.sl` is False, falls to `elif self.max_bars` + (fade have max_bars) -> PURE horizon exit, the -2% `else` branch NEVER fires. + So no_sl LIVE has NO stop at all. We simulate what a -2% price stop WOULD have + capped, to quantify the protection that is in fact ABSENT. + (4) Disaster SL at 3x / 4x the base SL distance, intrabar: does it keep almost all + the no_sl gain while cutting the tail? + +Run: cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \ + scripts/analysis/exit_policies/verify_tail_risk.py +""" +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import exit_lab as EL # noqa: E402 +from exit_lab import ExitPolicy, simulate, load_sleeves, LEV, OOS_START_MS # noqa: E402 + +DATA = load_sleeves() +SLEEVES = list(DATA.items()) + + +# --------------------------------------------------------------------------- helpers + +def _replay_trades(policy_cls, sleeve, params=None, start_ms=None, end_ms=None): + """Re-run the engine logic but COLLECT per-trade detail incl. MAE. + + MAE = worst adverse excursion (in `ret` units == leverage*frac move on notional) + measured on the bars the trade is actually OPEN, from entry bar+1 up to and + INCLUDING the exit bar (the exit bar's wick counts: it's where SL would trigger). + """ + params = params or {} + h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"] + n = len(c) + ctx = dict(sleeve) + policy_cls.prepare(ctx, **params) + fee = EL.FEE_RT * LEV + last_exit = -1 + out = [] + for (i, d, tp0, sl0, mb) in sleeve["signals"]: + if start_ms is not None and ts[i] < start_ms: + continue + if end_ms is not None and ts[i] >= end_ms: + continue + if i <= last_exit or i + 1 >= n: + continue + entry = c[i] + pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params) + horizon = min(int(pol.horizon), EL.HARD_CAP) + fills = [] + remaining = 1.0 + j = i + worst = 0.0 # most negative excursion in ret units + for step in range(1, horizon + 1): + j = i + step + if j >= n: + j = n - 1 + fills.append((remaining, c[j])); remaining = 0.0 + break + # adverse excursion of THIS bar (before any exit): worst intrabar price + adverse = l[j] if d == 1 else h[j] + exc = (adverse - entry) / entry * d * LEV + worst = min(worst, exc) + tp, sl, tpfrac = pol.levels(j) + 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: + fills.append((remaining, sl)); remaining = 0.0 + break + if hit_tp: + f = min(max(tpfrac, 0.0), 1.0) * remaining + if f > 0: + fills.append((f, tp)); remaining -= f + if remaining <= 1e-9: + break + pol.on_partial(j, tp, remaining) + if pol.after_bar(j): + fills.append((remaining, c[j])); remaining = 0.0 + break + if step == horizon: + fills.append((remaining, c[j])); remaining = 0.0 + if remaining > 1e-9: + fills.append((remaining, c[j])) + ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee + last_exit = j + out.append({ + "i": i, "j": j, "d": d, "entry": entry, + "ts_entry": ts[i], "ts_exit": ts[j], + "ret": ret, "mae": worst, "bars": j - i, + }) + return out + + +def _pct(arr, q): + return np.percentile(arr, q) if len(arr) else float("nan") + + +# --------------------------------------------------------------------------- (1) MAE dist + +def section1(): + print("=" * 100) + print("(1) MAE DISTRIBUTION per sleeve (ret units = leverage*move on notional; " + "fee NOT included). Negative = adverse.") + print(" MAE is the worst intrabar excursion BEFORE exit. For base, SL caps it; " + "for no_sl/close_confirm it can run.") + print("=" * 100) + policies = _load_policies() + hdr = f"{'sleeve':<11}{'policy':<16}{'n':>5}{'p50':>9}{'p95':>9}{'p99':>9}{'max':>9}{'realP99':>10}{'realMax':>10}" + for (code, asset), sleeve in SLEEVES: + key = f"{code.split('_')[0]} {asset}" + print(f"\n--- {key}") + print(hdr) + for pname, (cls, prm) in policies.items(): + tr = _replay_trades(cls, sleeve, prm) + mae = np.array([t["mae"] for t in tr]) * 100 # to % + rets = np.array([t["ret"] for t in tr]) * 100 + print(f"{'':<11}{pname:<16}{len(tr):>5}" + f"{_pct(mae,50):>9.2f}{_pct(mae,5):>9.2f}{_pct(mae,1):>9.2f}{mae.min():>9.2f}" + f"{_pct(rets,1):>10.2f}{rets.min():>10.2f}") + + +def _load_policies(): + """Return {name: (cls, params)} for base, no_sl, close_confirm(buf0).""" + p22 = Path(__file__).resolve().parents[0] / "22_no_sl.py" + p16 = Path(__file__).resolve().parents[0] / "16_close_confirm_sl.py" + import importlib.util + + def _load(path, attr): + spec = importlib.util.spec_from_file_location(path.stem, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return getattr(mod, attr) + + NoSl = _load(p22, "NoSl") + CloseConfirm = _load(p16, "CloseConfirmSl") + return { + "base": (ExitPolicy, {}), + "no_sl": (NoSl, {"mode": "none"}), + "close_confirm0": (CloseConfirm, {"buffer": 0.0}), + } + + +# --------------------------------------------------------------------------- (2) crashes + +CRASHES = { + "2020-03-12 covid": ("2020-03-10", "2020-03-16"), + "2021-05-19 leverage": ("2021-05-17", "2021-05-23"), + "2022-06 3AC/Luna": ("2022-06-10", "2022-06-20"), + "2022-11 FTX": ("2022-11-07", "2022-11-12"), +} + + +def _ms(s): + return int(pd.Timestamp(s, tz="UTC").value // 1e6) + + +def section2(): + print("\n" + "=" * 100) + print("(2) CRASH WINDOWS — trades OPEN (entry inside window) per policy: realized " + "loss (ret%) and MAE%.") + print("=" * 100) + policies = _load_policies() + for label, (a, b) in CRASHES.items(): + lo, hi = _ms(a), _ms(b) + print(f"\n### {label} [{a} .. {b}]") + any_trade = False + for (code, asset), sleeve in SLEEVES: + key = f"{code.split('_')[0]} {asset}" + for pname, (cls, prm) in policies.items(): + tr = _replay_trades(cls, sleeve, prm) + win = [t for t in tr if lo <= t["ts_entry"] <= hi] + if not win: + continue + any_trade = True + rets = np.array([t["ret"] for t in win]) * 100 + mae = np.array([t["mae"] for t in win]) * 100 + worst = min(win, key=lambda t: t["ret"]) + print(f" {key:<11}{pname:<16}n{len(win):>3} " + f"sumRet{rets.sum():>8.2f}% worstRet{rets.min():>8.2f}% " + f"worstMAE{mae.min():>8.2f}% avgBars{np.mean([t['bars'] for t in win]):>5.1f}") + if not any_trade: + print(" (no trades opened in window across sleeves)") + + +# --------------------------------------------------------------------------- (3) -2% fallback + +def section3(): + print("\n" + "=" * 100) + print("(3) LIVE -2% FALLBACK on no_sl. NOTE: with no_sl the worker has NO stop at " + "all (branch analysis in module docstring).") + print(" Below = what a -2% PRICE stop (==-6% ret at lev3) WOULD cap if it WERE " + "wired. Compares no_sl ret vs a synthetic no_sl+2%stop.") + print("=" * 100) + NoSl = _load_policies()["no_sl"][0] + stop_ret = -0.02 * LEV # -2% price move * leverage = -6% on notional in ret units + hdr = f"{'sleeve':<11}{'no_sl ret%':>12}{'+2%stop ret%':>14}{'Δret pp':>10}{'n capped':>10}{'worst no_sl':>13}{'worst +2%':>11}" + print(hdr) + for (code, asset), sleeve in SLEEVES: + key = f"{code.split('_')[0]} {asset}" + tr = _replay_trades(NoSl, sleeve, {"mode": "none"}) + # synthetic: if MAE breaches stop_ret, realize at stop_ret (approx; ignores + # that price may recover — that's the point: a -2% stop locks the loss). + base_rets = np.array([t["ret"] for t in tr]) + capped = [] + n_cap = 0 + fee = EL.FEE_RT * LEV + for t in tr: + if t["mae"] <= stop_ret: + capped.append(stop_ret - fee) # stopped at -2% price, pay fee + n_cap += 1 + else: + capped.append(t["ret"]) + capped = np.array(capped) + + def _compound(rets): + cap = 1000.0 + for r in rets: + cap = max(cap + cap * EL.POS * r, 10.0) + return (cap / 1000.0 - 1) * 100 + + r_nosl = _compound(base_rets) + r_stop = _compound(capped) + print(f"{key:<11}{r_nosl:>12.0f}{r_stop:>14.0f}{r_stop - r_nosl:>10.1f}" + f"{n_cap:>10}{base_rets.min()*100:>13.2f}{capped.min()*100:>11.2f}") + + +# --------------------------------------------------------------------------- (4) disaster SL + +class DisasterSl(ExitPolicy): + """no_sl behaviour + a FAR intrabar disaster stop at `mult` x base SL distance.""" + name = "disaster_sl" + + def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params): + super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params) + mult = float(params.get("mult", 3.0)) + self.sl = entry + mult * (sl0 - entry) + + def levels(self, j: int): + return self.tp0, self.sl, 1.0 + + +def _full_metrics(cls, sleeve, prm): + full = simulate(cls, sleeve, prm) + oos = simulate(cls, sleeve, prm, start_ms=OOS_START_MS) + return full, oos + + +def section4(): + print("\n" + "=" * 100) + print("(4) DISASTER SL — no_sl + far intrabar stop at 3x / 4x base SL distance. " + "Keep the gain, cut the tail?") + print("=" * 100) + NoSl = _load_policies()["no_sl"][0] + hdr = (f"{'sleeve':<11}{'policy':<13}" + f"{'FULLret%':>10}{'FULLdd%':>9}{'FULLsh':>8}" + f"{'OOSret%':>9}{'OOSdd%':>8}{'OOSsh':>7}{'worstMAE%':>11}{'nStop':>7}") + print(hdr) + for (code, asset), sleeve in SLEEVES: + key = f"{code.split('_')[0]} {asset}" + rows = [ + ("base", ExitPolicy, {}), + ("no_sl", NoSl, {"mode": "none"}), + ("disaster3x", DisasterSl, {"mult": 3.0}), + ("disaster4x", DisasterSl, {"mult": 4.0}), + ] + print(f"--- {key}") + for pname, cls, prm in rows: + full, oos = _full_metrics(cls, sleeve, prm) + tr = _replay_trades(cls, sleeve, prm) + mae = np.array([t["mae"] for t in tr]) * 100 + # count trades that hit the disaster stop (ret near the stop level) + n_stop = 0 + if pname.startswith("disaster"): + mult = prm["mult"] + for t, raw in zip(tr, sleeve["signals"]): + pass + # simpler: a stop hit shows up as a large negative ret roughly = stop level + n_stop = int(np.sum(mae <= -2.0 * mult * LEV / LEV * 0)) # placeholder + print(f"{'':<11}{pname:<13}" + f"{full.get('ret_pct',0):>10.0f}{full.get('dd_pct',0):>9.2f}{full.get('sharpe_t',0):>8.2f}" + f"{oos.get('ret_pct',0):>9.0f}{oos.get('dd_pct',0):>8.2f}{oos.get('sharpe_t',0):>7.2f}" + f"{mae.min():>11.2f}{'':>7}") + + +# --------------------------------------------------------------------------- aggregate + +def section5_aggregate(): + """Equal-weight aggregate across the 6 sleeves: does no_sl's tail blow up the + PORTFOLIO worst-trade vs disaster3x? Sum of per-sleeve compounded won't aggregate + DD honestly, so we report the WORST single-trade ret across all sleeves and the + 99th pct of the pooled trade distribution.""" + print("\n" + "=" * 100) + print("(5) POOLED TRADE DISTRIBUTION across all 6 sleeves (the real tail metric).") + print("=" * 100) + NoSl = _load_policies()["no_sl"][0] + cfgs = [ + ("base", ExitPolicy, {}), + ("no_sl", NoSl, {"mode": "none"}), + ("close_confirm0", _load_policies()["close_confirm0"][0], {"buffer": 0.0}), + ("disaster3x", DisasterSl, {"mult": 3.0}), + ("disaster4x", DisasterSl, {"mult": 4.0}), + ] + print(f"{'policy':<16}{'n':>6}{'retP1%':>9}{'retMin%':>9}{'maeP1%':>9}{'maeMin%':>9}{'<-15%cnt':>10}") + for pname, cls, prm in cfgs: + allret, allmae = [], [] + for (code, asset), sleeve in SLEEVES: + tr = _replay_trades(cls, sleeve, prm) + allret += [t["ret"] * 100 for t in tr] + allmae += [t["mae"] * 100 for t in tr] + ar = np.array(allret); am = np.array(allmae) + n_bad = int(np.sum(ar < -15.0)) + print(f"{pname:<16}{len(ar):>6}{_pct(ar,1):>9.2f}{ar.min():>9.2f}" + f"{_pct(am,1):>9.2f}{am.min():>9.2f}{n_bad:>10}") + + +if __name__ == "__main__": + section1() + section2() + section3() + section4() + section5_aggregate()