diff --git a/CLAUDE.md b/CLAUDE.md index 89d5edc..85c52f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -329,7 +329,8 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale - Un `Portfolio` è un oggetto di prima classe (`src/portfolio/`) con definizione (sleeve + schema pesi) e due facce sulla **STESSA** definizione: `.backtest()` (riusa il builder unico di `sleeves.py` → parità esatta con `report_families`) e live (`PortfolioRunner`: capitale pool condiviso, sizing per peso, ribilancio giornaliero, ledger aggregato in `data/portfolios/{code}/`). - **Schemi peso:** `equal` (default), `cap` (tetto per famiglia, es. pairs 33% — config raccomandata), `inverse_vol`, `cluster_rp` (equal fra cluster naturali poi inverse-vol intra-cluster), `manual`. Definiti in `weighting.py`; la chiave cap è la famiglia (PAIRS/FADE/HONEST/SHAPE/TSM). -- **Default `portfolios.yml`:** PORT06 (master+shape), `weighting=cap pairs 0.33`, leva 2x, ribilancio 1D. Backtest PORT06 canonico (dati al 2026-05-28): FULL Sharpe 6.47 DD 4.10% / OOS Sharpe 8.82 DD 1.30%; **con EXIT-16 close-confirm (config live attuale): FULL 7.84 / 2.60%, OOS 10.06 / 1.15%** (i vecchi 6.07/8.19 erano pre-loss-guard/pre-refresh dati). +- **Default `portfolios.yml`:** PORT06 (master+shape), `weighting=cap pairs 0.33 + shape 0.0588`, leva 2x, ribilancio 1D. Backtest PORT06 canonico (dati al 2026-05-28, pre-cap-shape): FULL Sharpe 6.47 DD 4.10% / OOS Sharpe 8.82 DD 1.30%; **con EXIT-16 close-confirm (config live attuale): FULL 7.84 / 2.60%, OOS 10.06 / 1.15%** (i vecchi 6.07/8.19 erano pre-loss-guard/pre-refresh dati). Col cap SHAPE (2026-06-05): FULL 6.43 / 3.96%, OOS 8.58 / 1.36% — assicurazione sulla coda SH01, vedi sotto. +- **SH01 SENZA STOP-LOSS — by design, CONFERMATO da ricerca (2026-06-05).** Dopo il crash ETH (−15.6% su un trade SH01 live), ricerca multi-agente con harness dedicato `scripts/analysis/sh01_exit_lab.py` (cache segnali walk-forward, engine con **fill gap-aware** `worse(livello, open)`, parity esatta con explore_lab, protocollo train≤2023-11-01/OOS): **11 famiglie di stop testate (ATR intrabar/close-confirm, %, chandelier, breakeven, giveback, loser-timestop, disaster-cap close+intrabar, swing, vol-regime), 0 sopravvissute** al gate (ETH migliorato senza degradare BTC, train E oos, plateau). Pattern: ogni stop stretto abbastanza da toccare la coda ETH rompe BTC; ogni stop largo non arriva alla coda; nei crash il fill è al gap, non al livello (lo stop "protettivo" PEGGIORA la coda OOS). Mitigazione adottata: **cap famiglia SHAPE a 0.0588 in PORT06** (≈ dimezzata; costo OOS Sharpe −0.24, FULL DD −0.14pp) — la prossima coda impatta il conto per metà. NON impostare mai `sl`/`sl_confirm_atr` su SH_BTC/SH_ETH. Direzione futura: liquidity-gate sull'entry (skip dopo feed flat). Diario `docs/diary/2026-06-05-sh01-sl-research.md`. - **Data layer Cerbero v2:** `get_historical_v2` unificato + `get_instruments` (naming robusto) + `get_ticker_batch`. Trading su Deribit. - **SCOPE LIVE (fase 2 completata):** il runner esegue TUTTI gli sleeve di PORT06. Worker: single `StrategyWorker` (fade MR01/02/07, DIP01, **e SH01**), `PairsWorker` (PR01 2 gambe), e i multi-asset dedicati `BasketTrendWorker` (TR01 4h), `RotationWorker` (ROT02 1d), `TsmomWorker` (TSM01 1d). Il runner fetcha 1h da Cerbero v2 e **resampla a 4h/1d** (lookback dimensionato sui daily: TSM01 usa 252g). Validazione: runner pool/ribilancio/ledger == backtest (`validate_portfolio_runner.py`, identico); worker multi-asset == reference (`validate_honest_workers.py`: TSM01 esatto, ROT02 +1303% canonico, TR01 stesso ordine — differenza di convenzione capitale-unico vs media-equity). - **FIX SH01 wiring (2026-06-01).** SH01 gira come **`StrategyWorker` NORMALE** (NON il vecchio `MLWorkerWrapper` di `multi_runner`, che usava il `SignalEngine` **squeeze SCARTATO**: apriva senza metadata ed usciva a `hold_bars=3`, ignorando del tutto SH01_shape_ml). `SH01_shape_ml.generate_signals` fa il walk-forward (retraining) internamente ad ogni tick ed emette `metadata.max_bars=12` → exit a orizzonte via `StrategyWorker.tick`. Serve ≥4000 barre 1h (`_ML_LOOKBACK_DAYS=365`). Vedi `docs/diary/2026-06-01-sh01-wiring-squeeze-bug.md`. diff --git a/docs/diary/2026-06-05-sh01-sl-research.md b/docs/diary/2026-06-05-sh01-sl-research.md new file mode 100644 index 0000000..9dda1c4 --- /dev/null +++ b/docs/diary/2026-06-05-sh01-sl-research.md @@ -0,0 +1,99 @@ +# Diario — 2026-06-05 — SL su SH01: ricerca multi-agente (verdetto: NO-SL, cap del peso) + +## Innesco + +Crash ETH del 2026-06-05 (1742→1546, −11%, con feed testnet FLAT ~2h e gap 1655→1600): +SH01 ETH si è preso la coda intera, **−15.6% in un trade** (long 1727.8 → time_limit 1594.35, +leva 2x) — il singolo trade peggiore del portafoglio live. SH01 non ha TP/SL per design +(exit solo a orizzonte H=12). Domanda: esiste uno SL che taglia le code senza distruggere +l'edge (win ~50%, edge tutto nell'asimmetria dei winner)? + +## Numeri storici per anno (config live W24 H12 th0.58, netto fee 0.10% RT, leva 3x) + +Somma per-trade in pp (leva 3x); equity-level ≈ ×0.15. + +| Anno | BTC | ETH | +|---|---:|---:| +| 2018 | −65.8 | +73.7 | +| 2019 | +87.7 | −19.3 | +| 2020 | +194.0 | **−293.1** | +| 2021 | +301.3 | +67.4 | +| 2022 | +64.3 | +79.1 | +| 2023 | +17.4 | +20.6 | +| 2024 | +110.0 | +108.1 | +| 2025 | +76.8 | +539.7 | +| 2026 | +59.0 | −29.5 | + +BTC = motore robusto (8/9 anni+). ETH = fragile (DD 61%, win 48.8%): la coda non +protetta è un problema STRUTTURALE di ETH, non solo dell'episodio live. + +## Infrastruttura (riusabile) + +`scripts/analysis/sh01_exit_lab.py` — harness onesto stile exit-lab: +- cache segnali walk-forward (`data/cache/sh01_exit_lab.pkl`, 7248 BTC + 7386 ETH entries); +- engine intrabar con **fill GAP-AWARE**: stop fillato a `worse(livello, open[j])`, non + "al livello" — chiude il bias PRO-stop-stretti documentato il 2026-06-04 (54% di fill + ottimisti) e modella il caso reale del crash (gap 1655→1600 senza scambi intermedi); +- modalità stop `intrabar` e `close` (close-confirm stile EXIT-16), `after_bar` per + policy discrezionali, stress `lag_close_exit` (poll in ritardo); +- parity ESATTA con `explore_lab.simulate` (BTC +218.50%/1531 trade, ETH +80.13%/1257); +- protocollo: TRAIN fino 2023-11-01 (selezione SOLO lì, plateau ≥3 celle), OOS una volta. + +Baseline (orizzonte puro): BTC TRAIN +127%/dd23/shrp2.09/worst−5.5 | OOS +41%/8/2.18/−3.1. +ETH TRAIN **−26%/dd61/shrp−0.16/worst−14.9** | OOS +143%/7/**3.60**/−4.6. + +## Esito: 11 famiglie testate, 0 sopravvissute + +Policy in `scripts/analysis/sh01_exit_policies/` (01-10 dagli agenti + 11 dal +completeness-probe della sintesi): + +| # | Famiglia | Verdetto | +|---|---|---| +| 01 | ATR fisso intrabar (k 1–5) | NO: 0/7 celle migliorative sul train | +| 02 | ATR fisso close-confirm (k 1–5) | NO: k=4 migliora ETH train (shrp −0.16→+0.62, DD 61→41, worst −8.4) MA ETH-OOS peggiora (shrp 3.60→2.32, worst −6.6) | +| 03 | % fisso (1–5%, 2 modi) | NO: BTC↓, ETH-OOS ret 52% del baseline | +| 04 | Chandelier trailing | NO: cella isolata; ETH-OOS tutto peggio (4ª bocciatura della famiglia trailing) | +| 05 | Breakeven ratchet | NO: 0 celle migliorative | +| 06 | Giveback (profit-protection) | NO: dormiente sulle code (worst invariato), taglia winner BTC | +| 07 | Loser time-stop (m,x) | NO: ETH-OOS shrp↓ DD↑, BTC↓ | +| 08 | Disaster-cap LARGO close-confirm (k 3–6) | NO: k=4 plateau sul train ma ETH-OOS DD 7→11-15%, ret 59-74%; FABBRICA short da stop a −44/−54% | +| 09 | Swing strutturale | NO: 0/18 celle | +| 10 | Stop condizionale vol-regime | NO: il migliore dei falliti (ETH-OOS shrp 3.28 vs 3.60) ma BTC train <95% | +| 11 | Disaster-cap LARGO **intrabar** (k 5–12) | NO: k=10 cella isolata; BTC-OOS shrp 2.18→**0.92**, worst −3.1→−11.4 (il fill gap-aware rende lo stop più tossico del no-stop) | + +**Pattern (5ª conferma della lezione exit-lab):** ogni stop abbastanza stretto da toccare +la coda ETH intacca il motore BTC; ogni stop abbastanza largo da risparmiare BTC non +arriva mai alla coda ETH. Per un segnale win~50% con edge nell'asimmetria dei winner, +OGNI SL taglia i winner-in-drawdown insieme ai loser. In più il fill gap-aware mostra +che proprio nei crash (quando lo stop servirebbe) il fill è al gap, non al livello: +lo stop intrabar largo PEGGIORA la coda OOS di BTC invece di proteggerla. + +## Decisione + +- **NO SL su SH01** (né `sl` né `sl_confirm_atr` per SH_BTC/SH_ETH: corretto E sicuro — + il fallback −2% del worker si applica solo se `sl` è presente). +- **La leva giusta è il PESO**: cap della famiglia SHAPE nel weighting di PORT06 + (`weighting.py` supporta già `caps` per famiglia, come PAIRS 0.33). Dimezzare la quota + SHAPE ≈ dimezza l'impatto della prossima coda −15% sul conto, a costo ~nullo di Sharpe + (SH01 è un diversificatore, corr +0.08 col MASTER). +- **APPLICATO E DEPLOYATO (stesso giorno):** `caps={"PAIRS": 0.33, "SHAPE": 0.0588}` in + `_defs.py` (SHAPE 11.8%→5.9%, ≈ mezzo sleeve equal; SH_BTC=SH_ETH=0.0294, somma pesi 1.0, + verificato). Quantificato sul backtest PORT06: + + | Config | FULL Sharpe | FULL DD | CAGR | OOS Sharpe | OOS DD | + |---|---:|---:|---:|---:|---:| + | precedente (SHAPE 11.8%) | 6.47 | 4.10% | 61.2% | 8.82 | 1.30% | + | **cap SHAPE 5.9% (scelto)** | 6.43 | 3.96% | 62.1% | 8.58 | 1.36% | + | SHAPE rimosso | 6.30 | 3.88% | 63.0% | 8.26 | 1.41% | + + Il costo (−0.24 OOS Sharpe) è il premio dell'assicurazione su una coda che il backtest + daily NON modella (gap di feed, flat testnet). Rimuovere SHAPE costa troppo (8.26). + +## Direzioni future (fuori scope SL, annotate dalla sintesi) + +1. **Liquidity/staleness-gate sull'ENTRY** (skip ingressi dopo finestre di feed flat — + il flat 2h ha preceduto il gap del 2026-06-05): la direzione più promettente per il + gap-through specifico, merita uno studio dedicato. +2. Vol-target sizing per-trade su SH01 (sulle fade fallì, su SH01 mai testato). +3. SL solo-ETH: scartato anche concettualmente (il beneficio coda ETH è marginale/nullo + OOS in TUTTE le famiglie — non è un problema di uniformità del meccanismo). diff --git a/scripts/analysis/sh01_exit_lab.py b/scripts/analysis/sh01_exit_lab.py new file mode 100644 index 0000000..700ecaf --- /dev/null +++ b/scripts/analysis/sh01_exit_lab.py @@ -0,0 +1,252 @@ +"""SH01 EXIT LAB — harness onesto e CONDIVISO per la ricerca di STOP-LOSS su SH01. + +SH01 (shape-ML, logit walk-forward W24 H12 th0.58) NON ha TP/SL: esce SOLO a +orizzonte H=12 barre. Live (2026-06-05) si è preso il crash ETH intero: −15.6% +in un trade (long 1727.8 → 1594.35, leva 2x). Domanda di ricerca: esiste uno SL +che taglia le code SENZA distruggere l'edge (che vive nell'asimmetria dei +winner, win-rate ~50%)? + +CONTRATTO ANTI-LOOK-AHEAD (vincolante, verificato da agenti avversari): + - i livelli attivi nel bar j (`levels(..., j)`) possono usare SOLO dati <= j-1 + (il worker live fissa i livelli al close del bar precedente; il bar j li tocca); + - `after_bar(..., j)` decide sul CLOSE del bar j (eseguibile al poll del tick); + - indicatori causali: usare l'indice j-1 (es. ctx["atr14"][j-1]). + +FILL GAP-AWARE (lezione exit-lab 2026-06-04 + crash live 2026-06-05): lo stop +intrabar NON filla "al livello" se il bar apre già oltre → fill = worse(level, +open[j]). Senza questo il backtest ha un bias PRO stop-stretti (54% dei fill +era ottimista). Il crash di oggi (feed flat 2h → gap 1655→1600) è il caso reale. + +PROTOCOLLO ANTI-OVERFIT (vincolante, = exit_lab): + - TRAIN = storico fino al 2023-11-01, OOS = dopo. SELEZIONE parametri SOLO + sul train; OOS guardato una volta per il verdetto. + - gate: miglioramento su ENTRAMBI gli asset (BTC e ETH), train E oos, con + plateau sulla griglia (non una cella isolata). Metrica primaria: Sharpe e + DD; il return non deve crollare (>= ~80% del baseline). + - fee 0.10% RT × leva su tutto il notional. + +Baseline = exit a orizzonte puro (max_bars=H, nessun TP/SL): parità ESATTA con +`explore_lab.simulate` verificata da `parity_check()`. + + uv run python scripts/analysis/sh01_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)) + +LEV, POS, FEE_RT = 3.0, 0.15, 0.001 +OOS_START_MS = int(pd.Timestamp("2023-11-01", tz="UTC").value // 1e6) +ASSETS = ("BTC", "ETH") +CACHE = PROJECT_ROOT / "data" / "cache" / "sh01_exit_lab.pkl" + + +# ----------------------------------------------------------------------------- cache + +def build_cache() -> dict: + """Walk-forward SH01 (lento, ~minuti) → entries cache su disco.""" + from scripts.analysis.explore_lab import get_df # noqa: E402 + from scripts.analysis.shape_ml_research import ml_wf_entries, atr # noqa: E402 + from scripts.strategies.SH01_shape_ml import CONFIG # noqa: E402 + + out = {} + for a in ASSETS: + df = get_df(a, "1h") + ents = ml_wf_entries(df, **CONFIG) + out[a] = { + "entries": [(int(e["i"]), int(e["d"]), int(e["max_bars"])) for e in ents], + "open": df["open"].values.astype(float), + "high": df["high"].values.astype(float), + "low": df["low"].values.astype(float), + "close": df["close"].values.astype(float), + "ts_ms": df["timestamp"].values.astype("int64"), + "atr14": atr(df, 14), + } + print(f" {a}: {len(ents)} entries, {len(df)} bars", flush=True) + CACHE.parent.mkdir(parents=True, exist_ok=True) + with open(CACHE, "wb") as f: + pickle.dump(out, f) + return out + + +def load_sleeves(refresh: bool = False) -> dict: + """{asset: ctx}. ctx = {entries, open, high, low, close, ts_ms, atr14}.""" + if CACHE.exists() and not refresh: + with open(CACHE, "rb") as f: + return pickle.load(f) + return build_cache() + + +# ----------------------------------------------------------------------------- policy + +class ExitPolicy: + """Contratto per le policy di stop su SH01 (solo SL/uscite anticipate: il + TP non esiste e l'exit a orizzonte max_bars resta SEMPRE il bound). + + open_trade(ctx, i, d) -> state : livelli iniziali, SOLO dati <= i + levels(ctx, i, d, j, st) -> (sl, mode) attivi nel bar j, SOLO dati <= j-1. + sl=None → nessuno stop nel bar. mode: "intrabar" (tocco high/low, fill + gap-aware worse(sl, open[j])) o "close" (stop solo se il CLOSE sfonda + sl, uscita al close — stile EXIT-16). + after_bar(ctx, i, d, j, st) -> bool : uscita discrezionale al CLOSE del bar + j (dati <= j). Per giveback/time-stop/regime. + Lo state è un dict mutabile per-trade (trailing ecc.).""" + + name = "base" + + def open_trade(self, ctx: dict, i: int, d: int) -> dict: + return {} + + def levels(self, ctx: dict, i: int, d: int, j: int, st: dict): + return None, "intrabar" + + def after_bar(self, ctx: dict, i: int, d: int, j: int, st: dict) -> bool: + return False + + +# ----------------------------------------------------------------------------- engine + +def simulate(ctx: dict, policy: ExitPolicy, fee_rt: float = FEE_RT, + lev: float = LEV, pos: float = POS, + t_lo: int | None = None, t_hi: int | None = None, + gap_fill: bool = True, lag_close_exit: bool = False) -> dict: + """Engine intrabar con policy di stop. Entries non sovrapposte (come + explore_lab.simulate). t_lo/t_hi: filtro ms-epoch sull'ENTRY (train/oos). + gap_fill: fill stop intrabar a worse(sl, open[j]) — tenere True. + lag_close_exit: stress — le uscite "al close" fillano al close del bar + successivo (poll in ritardo).""" + o, h, l, c = ctx["open"], ctx["high"], ctx["low"], ctx["close"] + ts = ctx["ts_ms"] + n = len(c) + cap = peak = 1000.0 + max_dd = 0.0 + fee = fee_rt * lev + trades = wins = stops = 0 + bars_in = 0 + last_exit = -1 + yearly: dict[int, float] = {} + rets: list[float] = [] + trade_rows: list[dict] = [] + + for (i, d, mb) in ctx["entries"]: + if i <= last_exit or i + 1 >= n: + continue + if t_lo is not None and ts[i] < t_lo: + continue + if t_hi is not None and ts[i] >= t_hi: + continue + entry = c[i] + st = policy.open_trade(ctx, i, d) + exit_p, j, reason = c[min(i + mb, n - 1)], min(i + mb, n - 1), "time" + for k in range(1, mb + 1): + j = i + k + if j >= n: + j, exit_p, reason = n - 1, c[n - 1], "eod" + break + sl, mode = policy.levels(ctx, i, d, j, st) + if sl is not None and mode == "intrabar": + hit = (l[j] <= sl) if d == 1 else (h[j] >= sl) + if hit: + if gap_fill: + exit_p = min(sl, o[j]) if d == 1 else max(sl, o[j]) + else: + exit_p = sl + reason = "stop" + break + if sl is not None and mode == "close": + brk = (c[j] < sl) if d == 1 else (c[j] > sl) + if brk: + jj = min(j + 1, n - 1) if lag_close_exit else j + exit_p, j, reason = c[jj], jj, "stop" + break + if policy.after_bar(ctx, i, d, j, st): + jj = min(j + 1, n - 1) if lag_close_exit else j + exit_p, j, reason = c[jj], jj, "policy" + break + if k == mb: + exit_p, reason = c[j], "time" + ret = (exit_p - entry) / entry * d * lev - fee + cb = cap + cap = max(cb + cb * pos * ret, 10.0) + peak = max(peak, cap) + max_dd = max(max_dd, (peak - cap) / peak) + trades += 1 + wins += ret > 0 + stops += reason == "stop" + bars_in += (j - i) + last_exit = j + rets.append(ret * pos) + yr = pd.Timestamp(ts[i], unit="ms", tz="UTC").year + yearly[yr] = yearly.get(yr, 0.0) + ret * 100 + trade_rows.append({"i": i, "j": j, "d": d, "ret": ret, "reason": reason}) + + sharpe = (float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) + if len(rets) > 1 and np.std(rets) > 0 else 0.0) + return { + "trades": trades, + "win": wins / trades * 100 if trades else 0.0, + "stop_rate": stops / trades * 100 if trades else 0.0, + "ret": (cap / 1000 - 1) * 100, + "dd": max_dd * 100, + "sharpe": sharpe, + "worst": min(rets) * 100 if rets else 0.0, # peggior trade, % equity (ret*pos) + "yearly": yearly, + "_trades": trade_rows, + } + + +def evaluate(policy: ExitPolicy, sleeves: dict | None = None, **kw) -> dict: + """train (fino al 2023-11-01) e oos (dopo) per BTC e ETH. Stampa sintesi.""" + sleeves = sleeves or load_sleeves() + out = {} + for a in ASSETS: + ctx = sleeves[a] + tr = simulate(ctx, policy, t_hi=OOS_START_MS, **kw) + oo = simulate(ctx, policy, t_lo=OOS_START_MS, **kw) + out[a] = {"train": tr, "oos": oo} + print(f" {policy.name:<28s} {a}: " + f"TRAIN ret={tr['ret']:>+7.0f}% dd={tr['dd']:>4.0f}% shrp={tr['sharpe']:>5.2f} " + f"worst={tr['worst']:>+5.1f}% stop={tr['stop_rate']:>4.1f}% | " + f"OOS ret={oo['ret']:>+6.0f}% dd={oo['dd']:>4.0f}% shrp={oo['sharpe']:>5.2f} " + f"worst={oo['worst']:>+5.1f}%", flush=True) + return out + + +# ----------------------------------------------------------------------------- parity + +def parity_check() -> bool: + """Baseline (nessuno stop) == explore_lab.simulate sugli stessi entries.""" + from scripts.analysis.explore_lab import get_df, simulate as ref_sim # noqa: E402 + + sleeves = load_sleeves() + ok = True + for a in ASSETS: + ctx = sleeves[a] + mine = simulate(ctx, ExitPolicy()) + df = get_df(a, "1h") + ents = [{"i": i, "d": d, "max_bars": mb, "tp": None, "sl": None} + for (i, d, mb) in ctx["entries"]] + ref = ref_sim(ents, df) + same = (abs(mine["ret"] - ref["ret"]) < 1e-6 and mine["trades"] == ref["trades"] + and abs(mine["dd"] - ref["dd"]) < 1e-6) + ok &= same + print(f" parity {a}: mine ret={mine['ret']:+.2f}% trades={mine['trades']} " + f"| ref ret={ref['ret']:+.2f}% trades={ref['trades']} -> {'OK' if same else 'MISMATCH'}") + return ok + + +if __name__ == "__main__": + print("build cache (walk-forward SH01, puo' richiedere minuti)...") + load_sleeves(refresh="--refresh" in sys.argv) + print("parity check baseline vs explore_lab.simulate:") + ok = parity_check() + print("baseline train/oos:") + evaluate(ExitPolicy()) + sys.exit(0 if ok else 1) diff --git a/scripts/analysis/sh01_exit_policies/01_atr_fixed_intrabar.py b/scripts/analysis/sh01_exit_policies/01_atr_fixed_intrabar.py new file mode 100644 index 0000000..31940c5 --- /dev/null +++ b/scripts/analysis/sh01_exit_policies/01_atr_fixed_intrabar.py @@ -0,0 +1,205 @@ +"""SH01 EXIT policy 01 — atr_fixed_intrabar. + +SL fisso ad ATR, INTRABAR. In open_trade fissiamo il livello una volta sola: + sl = entry - d * k * ATR14[i] (entry = close[i], ATR14[i] noto a close[i]) +levels() restituisce (sl, "intrabar") costante per tutta la vita del trade. +Il fill è gap-aware (worse(sl, open[j])) nell'engine — realistico sui crash a +gap (es. 2026-06-05: feed flat 2h -> gap ETH 1655->1600). + +ANTI-LOOK-AHEAD: il livello usa SOLO dati <= i (ATR14[i], close[i]); levels usa +quel valore congelato (nessun dato del bar j). OK. + +PROTOCOLLO: grid su k SOLO sul train (t_hi=OOS_START_MS). Plateau >=3 celle +adiacenti migliorative. Poi OOS una volta sulla config scelta + 2 vicine. + + cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/01_atr_fixed_intrabar.py +""" +from __future__ import annotations + +import sys + +sys.path.insert(0, "/opt/docker/PythagorasGoal") + +from scripts.analysis.sh01_exit_lab import ( # noqa: E402 + ExitPolicy, OOS_START_MS, evaluate, load_sleeves, simulate, +) + + +class AtrFixedIntrabar(ExitPolicy): + def __init__(self, k: float): + self.k = float(k) + self.name = f"atr_fixed_intrabar k={k:.1f}" + + def open_trade(self, ctx, i, d): + atr = ctx["atr14"][i] + entry = ctx["close"][i] + # se atr nan/0 (early bars) -> nessuno stop attivo + sl = entry - d * self.k * atr if atr == atr and atr > 0 else None + return {"sl": sl} + + def levels(self, ctx, i, d, j, st): + return st["sl"], "intrabar" + + def after_bar(self, ctx, i, d, j, st): + return False + + +# baseline numbers (exit a orizzonte puro) — dal prompt/harness +BASELINE = { + "BTC": {"train": dict(ret=127, dd=23, sharpe=2.09, worst=-5.5), + "oos": dict(ret=41, dd=8, sharpe=2.18, worst=-3.1)}, + "ETH": {"train": dict(ret=-26, dd=61, sharpe=-0.16, worst=-14.9), + "oos": dict(ret=143, dd=7, sharpe=3.60, worst=-4.6)}, +} + + +def _row(tag, a, r): + print(f" {tag:<10s} {a}: ret={r['ret']:>+7.0f}% dd={r['dd']:>4.0f}% " + f"shrp={r['sharpe']:>5.2f} worst={r['worst']:>+5.1f}% " + f"stop={r['stop_rate']:>4.1f}% trades={r['trades']}") + + +def main(): + sleeves = load_sleeves() + KS = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0] + + print("=" * 78) + print("TRAIN GRID (selezione SOLO sul train, t_hi=OOS_START)") + print("=" * 78) + # baseline train + print(" baseline (orizzonte puro):") + base = evaluate(ExitPolicy(), sleeves=sleeves) + print() + + train = {} # k -> {asset: result} + for k in KS: + pol = AtrFixedIntrabar(k) + row = {} + for a in ("BTC", "ETH"): + ctx = sleeves[a] + row[a] = simulate(ctx, pol, t_hi=OOS_START_MS) + train[k] = row + print(f" k={k:.1f}") + _row("TRAIN", "BTC", row["BTC"]) + _row("TRAIN", "ETH", row["ETH"]) + + print() + print("=" * 78) + print("PLATEAU CHECK (train): per ogni k, ETH sharpe up & dd down & worst up,") + print(" BTC sharpe>=95% & ret>=80% baseline") + print("=" * 78) + b_btc, b_eth = BASELINE["BTC"]["train"], BASELINE["ETH"]["train"] + improving = [] + for k in KS: + bt, et = train[k]["BTC"], train[k]["ETH"] + eth_ok = (et["sharpe"] > b_eth["sharpe"] and et["dd"] < b_eth["dd"] + and et["worst"] > b_eth["worst"]) + btc_ok = (bt["sharpe"] >= 0.95 * b_btc["sharpe"] + and bt["ret"] >= 0.80 * b_btc["ret"]) + ok = eth_ok and btc_ok + if ok: + improving.append(k) + print(f" k={k:.1f} ETH_ok={eth_ok} BTC_ok={btc_ok} -> " + f"{'IMPROVING' if ok else '-'}") + print(f" improving cells (train): {improving}") + + # plateau = >=3 k adiacenti improving + plateau = [] + for idx in range(len(KS)): + run = [] + for j in range(idx, len(KS)): + if KS[j] in improving: + run.append(KS[j]) + else: + break + if len(run) >= 3 and len(run) > len(plateau): + plateau = run + print(f" longest adjacent improving run: {plateau} " + f"(plateau={'YES' if len(plateau) >= 3 else 'NO'})") + + # scelgo centro del plateau (o miglior ETH sharpe fra gli improving) + chosen = None + if len(plateau) >= 3: + chosen = plateau[len(plateau) // 2] + elif improving: + chosen = max(improving, key=lambda k: train[k]["ETH"]["sharpe"]) + + print() + print("=" * 78) + if chosen is None: + print("NESSUNA cella migliorativa sul train -> verdetto NO (niente OOS).") + print("=" * 78) + return {"chosen": None, "plateau": plateau, "improving": improving, + "train": train, "oos": None} + + print(f"CHOSEN k={chosen:.1f} -> OOS (config + 2 vicine), guardato UNA volta") + print("=" * 78) + ci = KS.index(chosen) + neigh = [KS[x] for x in (ci - 1, ci, ci + 1) if 0 <= x < len(KS)] + oos = {} + for k in neigh: + pol = AtrFixedIntrabar(k) + row = {} + for a in ("BTC", "ETH"): + ctx = sleeves[a] + row[a] = { + "train": train[k][a], + "oos": simulate(ctx, pol, t_lo=OOS_START_MS), + } + oos[k] = row + print(f" k={k:.1f}") + _row("TRAIN", "BTC", row["BTC"]["train"]) + _row("OOS", "BTC", row["BTC"]["oos"]) + _row("TRAIN", "ETH", row["ETH"]["train"]) + _row("OOS", "ETH", row["ETH"]["oos"]) + + # gate finale sulla config scelta + print() + print("=" * 78) + print(f"GATE finale (k={chosen:.1f}):") + bt_tr, et_tr = oos[chosen]["BTC"]["train"], oos[chosen]["ETH"]["train"] + bt_oo, et_oo = oos[chosen]["BTC"]["oos"], oos[chosen]["ETH"]["oos"] + Bb_o, Be_o = BASELINE["BTC"]["oos"], BASELINE["ETH"]["oos"] + + # a) ETH: sharpe up & dd down & worst up, train E oos + a_train = (et_tr["sharpe"] > b_eth["sharpe"] and et_tr["dd"] < b_eth["dd"] + and et_tr["worst"] > b_eth["worst"]) + a_oos = (et_oo["sharpe"] > Be_o["sharpe"] and et_oo["dd"] < Be_o["dd"] + and et_oo["worst"] > Be_o["worst"]) + cond_a = a_train and a_oos + # b) BTC sharpe>=95% & ret>=80% baseline (train e oos) + b_tr = (bt_tr["sharpe"] >= 0.95 * b_btc["sharpe"] + and bt_tr["ret"] >= 0.80 * b_btc["ret"]) + b_oo = (bt_oo["sharpe"] >= 0.95 * Bb_o["sharpe"] + and bt_oo["ret"] >= 0.80 * Bb_o["ret"]) + cond_b = b_tr and b_oo + # c) ret ETH oos >= 80% baseline + cond_c = et_oo["ret"] >= 0.80 * Be_o["ret"] + # d) plateau + cond_d = len(plateau) >= 3 + + print(f" a) ETH sharpe up & dd down & worst up (train&oos): {cond_a}") + print(f" train: shrp {et_tr['sharpe']:.2f} vs {b_eth['sharpe']:.2f} | " + f"dd {et_tr['dd']:.0f} vs {b_eth['dd']:.0f} | " + f"worst {et_tr['worst']:.1f} vs {b_eth['worst']:.1f}") + print(f" oos: shrp {et_oo['sharpe']:.2f} vs {Be_o['sharpe']:.2f} | " + f"dd {et_oo['dd']:.0f} vs {Be_o['dd']:.0f} | " + f"worst {et_oo['worst']:.1f} vs {Be_o['worst']:.1f}") + print(f" b) BTC sharpe>=95% & ret>=80% (train&oos): {cond_b}") + print(f" train: shrp {bt_tr['sharpe']:.2f} (>={0.95*b_btc['sharpe']:.2f}) | " + f"ret {bt_tr['ret']:.0f} (>={0.80*b_btc['ret']:.0f})") + print(f" oos: shrp {bt_oo['sharpe']:.2f} (>={0.95*Bb_o['sharpe']:.2f}) | " + f"ret {bt_oo['ret']:.0f} (>={0.80*Bb_o['ret']:.0f})") + print(f" c) ETH oos ret>=80% baseline ({0.80*Be_o['ret']:.0f}): {cond_c} " + f"(ret={et_oo['ret']:.0f})") + print(f" d) plateau: {cond_d} ({plateau})") + passes = cond_a and cond_b and cond_c and cond_d + print(f" PASSES GATE: {passes}") + print("=" * 78) + + return {"chosen": chosen, "plateau": plateau, "improving": improving, + "passes": passes, "oos": oos, "conds": (cond_a, cond_b, cond_c, cond_d)} + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/sh01_exit_policies/02_atr_fixed_close_confirm.py b/scripts/analysis/sh01_exit_policies/02_atr_fixed_close_confirm.py new file mode 100644 index 0000000..1b94bc5 --- /dev/null +++ b/scripts/analysis/sh01_exit_policies/02_atr_fixed_close_confirm.py @@ -0,0 +1,94 @@ +"""SH01 EXIT POLICY 02 — ATR-fixed stop, CLOSE-CONFIRM (stile EXIT-16 delle fade). + +Stesso livello di stop fisso della policy 01 (intrabar): + sl = entry - d * k * ATR14[i] (fissato all'ingresso, dati <= i) +ma `levels` ritorna mode="close" → lo stop scatta SOLO se il CLOSE del bar j +sfonda il livello, con uscita al close (immune ai wick). E' il trasferimento a +SH01 della lezione EXIT-16 sulle fade: l'overshoot che buca lo stop e rientra e' +un falso negativo; aspettare la conferma del CLOSE evita di farsi stoppare dai +wick di un crash che poi rimbalza dentro l'orizzonte. + +Griglia k in {1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0}. + +ANTI-LOOK-AHEAD: sl usa SOLO atr14[i] e c[i] (dati <= i); mode="close" decide +sul close del bar j (dati <= j, eseguibile al poll). Nessun indicatore al bar j. + + cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/02_atr_fixed_close_confirm.py +""" +from __future__ import annotations + +import sys + +sys.path.insert(0, "/opt/docker/PythagorasGoal") + +from scripts.analysis.sh01_exit_lab import ( # noqa: E402 + ExitPolicy, evaluate, load_sleeves, simulate, OOS_START_MS, +) + + +class AtrFixedCloseConfirm(ExitPolicy): + def __init__(self, k: float): + self.k = float(k) + self.name = f"atr_fixed_close k={k:g}" + + def open_trade(self, ctx: dict, i: int, d: int) -> dict: + atr = ctx["atr14"][i] + entry = ctx["close"][i] + sl = entry - d * self.k * atr + return {"sl": float(sl)} + + def levels(self, ctx: dict, i: int, d: int, j: int, st: dict): + return st["sl"], "close" + + +GRID = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0] + + +def _fmt(m): + return (f"ret={m['ret']:>+7.0f}% dd={m['dd']:>4.0f}% shrp={m['sharpe']:>5.2f} " + f"worst={m['worst']:>+5.1f}% stop={m['stop_rate']:>4.1f}% n={m['trades']}") + + +def main(): + sleeves = load_sleeves() + + # baseline (orizzonte puro) per riferimento + base = {} + for a in ("BTC", "ETH"): + ctx = sleeves[a] + base[a] = { + "train": simulate(ctx, ExitPolicy(), t_hi=OOS_START_MS), + "oos": simulate(ctx, ExitPolicy(), t_lo=OOS_START_MS), + } + + print("=" * 110) + print("BASELINE (exit orizzonte puro):") + for a in ("BTC", "ETH"): + print(f" {a} TRAIN {_fmt(base[a]['train'])}") + print(f" {a} OOS {_fmt(base[a]['oos'])}") + + print("=" * 110) + print("GRID — TRAIN ONLY (selezione parametri):") + train_res = {} + for k in GRID: + pol = AtrFixedCloseConfirm(k) + row = {} + for a in ("BTC", "ETH"): + row[a] = simulate(sleeves[a], pol, t_hi=OOS_START_MS) + train_res[k] = row + print(f" k={k:>3g} | BTC {_fmt(row['BTC'])}") + print(f" | ETH {_fmt(row['ETH'])}") + + # verdetto OOS sulla config scelta + vicine (guardato una volta sola) + print("=" * 110) + print("OOS (verdetto, config scelta + vicine):") + for k in GRID: + pol = AtrFixedCloseConfirm(k) + print(f" k={k:>3g} | BTC TRAIN {_fmt(train_res[k]['BTC'])}") + for a in ("BTC", "ETH"): + oo = simulate(sleeves[a], pol, t_lo=OOS_START_MS) + print(f" | {a} OOS {_fmt(oo)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/sh01_exit_policies/03_pct_fixed.py b/scripts/analysis/sh01_exit_policies/03_pct_fixed.py new file mode 100644 index 0000000..4841531 --- /dev/null +++ b/scripts/analysis/sh01_exit_policies/03_pct_fixed.py @@ -0,0 +1,203 @@ +"""SH01 exit policy 03 — pct_fixed. + +SL fisso in PERCENTUALE del prezzo d'ingresso: sl = entry * (1 - d*p). +Griglia p in {0.01, 0.015, 0.02, 0.03, 0.04, 0.05}, modalita' {intrabar, close} +-> 12 celle. Il livello e' FISSO (deciso a open_trade su close[i]) -> nessun +look-ahead nei bar successivi (i livelli usano solo dati <= i). + +Protocollo: grid SOLO sul train; plateau (>=3 celle adiacenti migliorative); +poi OOS una volta per la config scelta + le 2 vicine. + + cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/03_pct_fixed.py +""" +from __future__ import annotations + +import sys + +sys.path.insert(0, "/opt/docker/PythagorasGoal") + +from scripts.analysis.sh01_exit_lab import ( # noqa: E402 + ASSETS, OOS_START_MS, ExitPolicy, load_sleeves, simulate, +) + + +class PctFixed(ExitPolicy): + """SL fisso a una frazione p del prezzo d'ingresso.""" + + def __init__(self, p: float, mode: str = "intrabar"): + self.p = p + self.mode = mode + self.name = f"pct_fixed p={p:.3f} {mode}" + + def open_trade(self, ctx, i, d): + entry = ctx["close"][i] + sl = entry * (1.0 - d * self.p) # long: sotto; short: sopra + return {"sl": sl} + + def levels(self, ctx, i, d, j, st): + return st["sl"], self.mode + + +# ----------------------------------------------------------------------------- grid + +P_GRID = [0.01, 0.015, 0.02, 0.03, 0.04, 0.05] +MODES = ["intrabar", "close"] + + +def _row(m): + return (f"ret={m['ret']:>+7.0f}% dd={m['dd']:>4.0f}% shrp={m['sharpe']:>5.2f} " + f"worst={m['worst']:>+5.1f}% stop={m['stop_rate']:>4.1f}%") + + +def main(): + sleeves = load_sleeves() + + # baseline (no stop) + print("=" * 110) + print("BASELINE (orizzonte puro, no SL) — TRAIN:") + base = {} + for a in ASSETS: + m = simulate(sleeves[a], ExitPolicy(), t_hi=OOS_START_MS) + base[a] = m + print(f" {a}: {_row(m)}") + print() + + # ---------------- grid TRAIN only + print("=" * 110) + print("GRID — TRAIN ONLY (selezione qui):") + train = {} + for mode in MODES: + print(f"\n mode={mode}") + for p in P_GRID: + pol = PctFixed(p, mode) + row = {} + for a in ASSETS: + m = simulate(sleeves[a], pol, t_hi=OOS_START_MS) + row[a] = m + train[(mode, p)] = row + print(f" p={p:.3f} | BTC {_row(row['BTC'])}") + print(f" | ETH {_row(row['ETH'])}") + + # improvement flags vs baseline on TRAIN: ETH gate (sharpe up, dd down, worst less neg) + # + BTC not degraded (sharpe>=0.95x, ret>=0.80x) + print("\n" + "=" * 110) + print("TRAIN improvement check (cell = migliorativa se ETH sharpe^ dd v worst^ AND BTC sharpe>=95% ret>=80%):") + bE, bB = base["ETH"], base["BTC"] + improved = {} + for mode in MODES: + flags = [] + for p in P_GRID: + r = train[(mode, p)] + eth, btc = r["ETH"], r["BTC"] + eth_ok = (eth["sharpe"] > bE["sharpe"] and eth["dd"] < bE["dd"] + and eth["worst"] > bE["worst"]) + btc_ok = (btc["sharpe"] >= 0.95 * bB["sharpe"] + and btc["ret"] >= 0.80 * bB["ret"]) + cell = eth_ok and btc_ok + improved[(mode, p)] = cell + flags.append("Y" if cell else (".|E" if not eth_ok else ".|B")) + print(f" mode={mode:<9s} " + " ".join(f"p={p:.3f}:{f}" for p, f in zip(P_GRID, flags))) + + # plateau detection: >=3 adjacent p's (same mode) all improved + print("\nPLATEAU (>=3 p adiacenti migliorativi nella stessa modalita'):") + plateau_cells = [] + for mode in MODES: + run = [] + runs = [] + for p in P_GRID: + if improved[(mode, p)]: + run.append(p) + else: + if len(run) >= 1: + runs.append(run) + run = [] + if run: + runs.append(run) + for run in runs: + mark = " <-- PLATEAU" if len(run) >= 3 else "" + print(f" mode={mode}: run {run} (len {len(run)}){mark}") + if len(run) >= 3: + plateau_cells.extend((mode, p) for p in run) + + if not plateau_cells: + print("\nNESSUN PLATEAU sul train -> famiglia NON passa. OOS solo informativo.") + else: + print(f"\nplateau cells: {plateau_cells}") + + # ---------------- pick best cell on TRAIN within plateau (or best overall if no plateau) + def score(cell): + r = train[cell] + # ETH train e' il banco di prova (baseline negativo) -> max ETH sharpe, + # tie-break ETH dd minore, poi BTC sharpe. + return (r["ETH"]["sharpe"], -r["ETH"]["dd"], r["BTC"]["sharpe"]) + + pool = plateau_cells if plateau_cells else list(train.keys()) + best = max(pool, key=score) + print(f"\nCHOSEN (train): mode={best[0]} p={best[1]:.3f}") + + # neighbors (same mode, adjacent p) + mode_b, p_b = best + idx = P_GRID.index(p_b) + neigh = [(mode_b, P_GRID[k]) for k in (idx - 1, idx, idx + 1) if 0 <= k < len(P_GRID)] + + # ---------------- OOS verdict (chosen + 2 neighbors) — looked at ONCE + print("\n" + "=" * 110) + print("OOS VERDICT (config scelta + 2 vicine) — guardato UNA volta:") + print("\nBaseline OOS:") + base_oos = {} + for a in ASSETS: + m = simulate(sleeves[a], ExitPolicy(), t_lo=OOS_START_MS) + base_oos[a] = m + print(f" {a}: {_row(m)}") + + chosen_oos = None + for cell in neigh: + pol = PctFixed(cell[1], cell[0]) + tag = " <== CHOSEN" if cell == best else "" + print(f"\n mode={cell[0]} p={cell[1]:.3f}{tag}") + res = {} + for a in ASSETS: + tr = simulate(sleeves[a], pol, t_hi=OOS_START_MS) + oo = simulate(sleeves[a], pol, t_lo=OOS_START_MS) + res[a] = {"train": tr, "oos": oo} + print(f" {a} TRAIN {_row(tr)}") + print(f" {a} OOS {_row(oo)}") + if cell == best: + chosen_oos = res + + # ---------------- gate evaluation on chosen + print("\n" + "=" * 110) + print("GATE (tutte e 4, train E oos):") + r = chosen_oos + bE_o, bB_o = base_oos["ETH"], base_oos["BTC"] + + def g(label, cond): + print(f" [{'PASS' if cond else 'FAIL'}] {label}") + return cond + + # a) ETH: sharpe^ dd v worst^ su train E oos + a_tr = (r["ETH"]["train"]["sharpe"] > bE["sharpe"] + and r["ETH"]["train"]["dd"] < bE["dd"] + and r["ETH"]["train"]["worst"] > bE["worst"]) + a_oo = (r["ETH"]["oos"]["sharpe"] > bE_o["sharpe"] + and r["ETH"]["oos"]["dd"] < bE_o["dd"] + and r["ETH"]["oos"]["worst"] > bE_o["worst"]) + A = g("a) ETH sharpe^ dd v worst^ (train E oos)", a_tr and a_oo) + # b) BTC sharpe>=95% ret>=80% baseline (train E oos) + b_tr = (r["BTC"]["train"]["sharpe"] >= 0.95 * bB["sharpe"] + and r["BTC"]["train"]["ret"] >= 0.80 * bB["ret"]) + b_oo = (r["BTC"]["oos"]["sharpe"] >= 0.95 * bB_o["sharpe"] + and r["BTC"]["oos"]["ret"] >= 0.80 * bB_o["ret"]) + B = g("b) BTC sharpe>=95% ret>=80% (train E oos)", b_tr and b_oo) + # c) ret ETH oos >= 80% baseline + C = g("c) ret ETH oos >= 80% baseline", r["ETH"]["oos"]["ret"] >= 0.80 * bE_o["ret"]) + # d) plateau + D = g("d) plateau confermato", bool(plateau_cells) and best in plateau_cells) + + passes = A and B and C and D + print(f"\n ==> GATE {'PASS' if passes else 'FAIL'}") + return passes + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/sh01_exit_policies/04_chandelier_trail.py b/scripts/analysis/sh01_exit_policies/04_chandelier_trail.py new file mode 100644 index 0000000..ca175ca --- /dev/null +++ b/scripts/analysis/sh01_exit_policies/04_chandelier_trail.py @@ -0,0 +1,238 @@ +"""SH01 EXIT policy 04 — chandelier_trail. + +Trailing chandelier CAUSALE. Lo state tiene il running peak dei CLOSE da i a +j-1; lo stop per il bar j e': + long : sl = peak - k * ATR14[j-1] + short: sl = trough + k * ATR14[j-1] (specchiato) +Il peak/trough viene aggiornato dentro levels() usando SOLO close[j-1] (dato +gia' chiuso quando il worker fissa il livello per il bar j). ATR14[j-1] e' +causale. Griglia k x mode {intrabar, close}. + +ANTI-LOOK-AHEAD: levels(j) usa peak su close[<=j-1] e ATR14[j-1] -> nessun dato +del bar j. open_trade usa solo close[i]/ATR14[i]. OK. + +Profilo SH01: hold a orizzonte (momentum), win ~50%, edge nell'asimmetria dei +winner. Sulle fade la famiglia trailing e' stata SCARTATA (taglia i winner che +vanno in drawdown e poi recuperano) -> qui si testa se su SH01 va diversamente, +pronti a un NO. + +PROTOCOLLO: grid (k x mode) SOLO sul train (t_hi=OOS_START_MS). Plateau >=3 +celle adiacenti migliorative (adiacenza su k, mode fisso). Poi OOS una volta +sulla config scelta + 2 vicine. + + cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/04_chandelier_trail.py +""" +from __future__ import annotations + +import sys + +sys.path.insert(0, "/opt/docker/PythagorasGoal") + +from scripts.analysis.sh01_exit_lab import ( # noqa: E402 + ExitPolicy, OOS_START_MS, evaluate, load_sleeves, simulate, +) + + +class ChandelierTrail(ExitPolicy): + def __init__(self, k: float, mode: str = "intrabar"): + self.k = float(k) + self.mode = mode + self.name = f"chandelier_trail k={k:.1f} {mode}" + + def open_trade(self, ctx, i, d): + # peak/trough inizializzato all'entry (close[i]); atr14[i] noto a close[i]. + entry = ctx["close"][i] + return {"peak": entry, "trough": entry} + + def levels(self, ctx, i, d, j, st): + close = ctx["close"] + atr = ctx["atr14"] + # aggiorna il running peak/trough con close[j-1] (gia' chiuso). j>=i+1 + # sempre nell'engine, quindi j-1>=i e' definito. + cprev = close[j - 1] + if cprev > st["peak"]: + st["peak"] = cprev + if cprev < st["trough"]: + st["trough"] = cprev + a = atr[j - 1] + if not (a == a and a > 0): # nan/0 -> nessuno stop attivo + return None, self.mode + if d == 1: + sl = st["peak"] - self.k * a + else: + sl = st["trough"] + self.k * a + return sl, self.mode + + def after_bar(self, ctx, i, d, j, st): + return False + + +# baseline numbers (exit a orizzonte puro) — dal prompt/harness +BASELINE = { + "BTC": {"train": dict(ret=127, dd=23, sharpe=2.09, worst=-5.5), + "oos": dict(ret=41, dd=8, sharpe=2.18, worst=-3.1)}, + "ETH": {"train": dict(ret=-26, dd=61, sharpe=-0.16, worst=-14.9), + "oos": dict(ret=143, dd=7, sharpe=3.60, worst=-4.6)}, +} + +KS = [2.0, 2.5, 3.0, 4.0, 5.0] +MODES = ["intrabar", "close"] + + +def _row(tag, a, r): + print(f" {tag:<10s} {a}: ret={r['ret']:>+7.0f}% dd={r['dd']:>4.0f}% " + f"shrp={r['sharpe']:>5.2f} worst={r['worst']:>+5.1f}% " + f"stop={r['stop_rate']:>4.1f}% trades={r['trades']}") + + +def _eth_ok(et, b_eth): + return (et["sharpe"] > b_eth["sharpe"] and et["dd"] < b_eth["dd"] + and et["worst"] > b_eth["worst"]) + + +def _btc_ok(bt, b_btc): + return (bt["sharpe"] >= 0.95 * b_btc["sharpe"] + and bt["ret"] >= 0.80 * b_btc["ret"]) + + +def main(): + sleeves = load_sleeves() + b_btc, b_eth = BASELINE["BTC"]["train"], BASELINE["ETH"]["train"] + + print("=" * 78) + print("TRAIN GRID (selezione SOLO sul train, t_hi=OOS_START)") + print("=" * 78) + print(" baseline (orizzonte puro):") + evaluate(ExitPolicy(), sleeves=sleeves) + print() + + # train[(mode,k)] -> {asset: result} + train = {} + for mode in MODES: + print(f" --- mode={mode} ---") + for k in KS: + pol = ChandelierTrail(k, mode) + row = {} + for a in ("BTC", "ETH"): + row[a] = simulate(sleeves[a], pol, t_hi=OOS_START_MS) + train[(mode, k)] = row + print(f" k={k:.1f} ({mode})") + _row("TRAIN", "BTC", row["BTC"]) + _row("TRAIN", "ETH", row["ETH"]) + print() + + print("=" * 78) + print("PLATEAU CHECK (train): ETH sharpe up & dd down & worst up,") + print(" BTC sharpe>=95% & ret>=80% baseline") + print("=" * 78) + improving = {} # mode -> [k...] + for mode in MODES: + imp = [] + for k in KS: + bt, et = train[(mode, k)]["BTC"], train[(mode, k)]["ETH"] + eth_ok = _eth_ok(et, b_eth) + btc_ok = _btc_ok(bt, b_btc) + ok = eth_ok and btc_ok + if ok: + imp.append(k) + print(f" {mode:<9s} k={k:.1f} ETH_ok={eth_ok} BTC_ok={btc_ok} -> " + f"{'IMPROVING' if ok else '-'}") + improving[mode] = imp + print(f" improving cells ({mode}): {imp}") + + # plateau = >=3 k adiacenti improving in QUALCHE mode + best_plateau, best_mode = [], None + for mode in MODES: + imp = improving[mode] + for idx in range(len(KS)): + run = [] + for j in range(idx, len(KS)): + if KS[j] in imp: + run.append(KS[j]) + else: + break + if len(run) >= 3 and len(run) > len(best_plateau): + best_plateau, best_mode = run, mode + print(f" longest adjacent improving run: {best_plateau} (mode={best_mode}) " + f"plateau={'YES' if len(best_plateau) >= 3 else 'NO'}") + + chosen = None + if len(best_plateau) >= 3: + chosen_k = best_plateau[len(best_plateau) // 2] + chosen = (best_mode, chosen_k) + else: + # fallback: miglior ETH sharpe fra tutti gli improving (per diagnosi OOS) + cands = [(m, k) for m in MODES for k in improving[m]] + if cands: + chosen = max(cands, key=lambda mk: train[mk]["ETH"]["sharpe"]) + + print() + print("=" * 78) + if chosen is None: + print("NESSUNA cella migliorativa sul train -> verdetto NO (niente OOS).") + print("=" * 78) + return {"chosen": None, "plateau": best_plateau, "improving": improving, + "passes": False, "train": train} + + c_mode, c_k = chosen + print(f"CHOSEN k={c_k:.1f} mode={c_mode} -> OOS (config + 2 vicine k), 1 volta") + print("=" * 78) + ci = KS.index(c_k) + neigh = [KS[x] for x in (ci - 1, ci, ci + 1) if 0 <= x < len(KS)] + oos = {} + for k in neigh: + pol = ChandelierTrail(k, c_mode) + row = {} + for a in ("BTC", "ETH"): + row[a] = {"train": train[(c_mode, k)][a], + "oos": simulate(sleeves[a], pol, t_lo=OOS_START_MS)} + oos[k] = row + print(f" k={k:.1f} ({c_mode})") + _row("TRAIN", "BTC", row["BTC"]["train"]) + _row("OOS", "BTC", row["BTC"]["oos"]) + _row("TRAIN", "ETH", row["ETH"]["train"]) + _row("OOS", "ETH", row["ETH"]["oos"]) + + print() + print("=" * 78) + print(f"GATE finale (k={c_k:.1f} mode={c_mode}):") + bt_tr, et_tr = oos[c_k]["BTC"]["train"], oos[c_k]["ETH"]["train"] + bt_oo, et_oo = oos[c_k]["BTC"]["oos"], oos[c_k]["ETH"]["oos"] + Bb_o, Be_o = BASELINE["BTC"]["oos"], BASELINE["ETH"]["oos"] + + a_train = _eth_ok(et_tr, b_eth) + a_oos = (et_oo["sharpe"] > Be_o["sharpe"] and et_oo["dd"] < Be_o["dd"] + and et_oo["worst"] > Be_o["worst"]) + cond_a = a_train and a_oos + b_tr = _btc_ok(bt_tr, b_btc) + b_oo = (bt_oo["sharpe"] >= 0.95 * Bb_o["sharpe"] + and bt_oo["ret"] >= 0.80 * Bb_o["ret"]) + cond_b = b_tr and b_oo + cond_c = et_oo["ret"] >= 0.80 * Be_o["ret"] + cond_d = len(best_plateau) >= 3 + + print(f" a) ETH sharpe up & dd down & worst up (train&oos): {cond_a}") + print(f" train: shrp {et_tr['sharpe']:.2f} vs {b_eth['sharpe']:.2f} | " + f"dd {et_tr['dd']:.0f} vs {b_eth['dd']:.0f} | " + f"worst {et_tr['worst']:.1f} vs {b_eth['worst']:.1f}") + print(f" oos: shrp {et_oo['sharpe']:.2f} vs {Be_o['sharpe']:.2f} | " + f"dd {et_oo['dd']:.0f} vs {Be_o['dd']:.0f} | " + f"worst {et_oo['worst']:.1f} vs {Be_o['worst']:.1f}") + print(f" b) BTC sharpe>=95% & ret>=80% (train&oos): {cond_b}") + print(f" train: shrp {bt_tr['sharpe']:.2f} (>={0.95*b_btc['sharpe']:.2f}) | " + f"ret {bt_tr['ret']:.0f} (>={0.80*b_btc['ret']:.0f})") + print(f" oos: shrp {bt_oo['sharpe']:.2f} (>={0.95*Bb_o['sharpe']:.2f}) | " + f"ret {bt_oo['ret']:.0f} (>={0.80*Bb_o['ret']:.0f})") + print(f" c) ETH oos ret>=80% baseline ({0.80*Be_o['ret']:.0f}): {cond_c} " + f"(ret={et_oo['ret']:.0f})") + print(f" d) plateau: {cond_d} ({best_plateau} mode={best_mode})") + passes = cond_a and cond_b and cond_c and cond_d + print(f" PASSES GATE: {passes}") + print("=" * 78) + + return {"chosen": chosen, "plateau": best_plateau, "improving": improving, + "passes": passes, "oos": oos, "conds": (cond_a, cond_b, cond_c, cond_d)} + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/sh01_exit_policies/05_breakeven_ratchet.py b/scripts/analysis/sh01_exit_policies/05_breakeven_ratchet.py new file mode 100644 index 0000000..49dbf9f --- /dev/null +++ b/scripts/analysis/sh01_exit_policies/05_breakeven_ratchet.py @@ -0,0 +1,267 @@ +"""SH01 EXIT policy 05 — breakeven_ratchet. + +Disaster-stop ampio + ratchet a breakeven. Idea: NON tagliare i loser presto +(quello distrugge l'edge, lezione exit-lab sulle fade), ma proteggere SOLO i +trade gia' andati in profitto, alzando lo stop a entry (o entry+b*ATR) una volta +che il move e' partito. Il disaster-stop iniziale (4*ATR14[i]) taglia la coda +estrema (il crash ETH -15.6%) senza interferire coi trade normali. + +Logica: + long : + sl_init = entry - 4 * ATR14[i] + quando close[<=j-1] >= entry + a * ATR14[i] -> sl = entry + b * ATR14[i] + short: specchiato. + RATCHET: una volta alzato lo stop a breakeven, NON riscende (st["armed"]). + +Lo stop iniziale (4*ATR14[i]) e' FISSO sul valore noto a close[i] (open_trade); +il ratchet si arma leggendo close[j-1] (gia' chiuso quando il worker fissa il +livello per il bar j) -> nessun dato del bar j. ATR14[i] e' causale. + +ANTI-LOOK-AHEAD: open_trade usa solo close[i]/ATR14[i]; levels(j) legge solo +close[j-1] per decidere l'arming e ATR14[i] (gia' fissato). OK. + +Griglia: a in {0.5, 1.0, 1.5, 2.0} (soglia di arming in ATR) x b in {0, 0.25} +(dove va lo stop una volta armato: entry o entry+0.25 ATR) x mode {intrabar, +close}. Il disaster-stop 4*ATR e' fisso (la coda da tagliare e' a -15%, ~3 ATR). + +Profilo SH01: hold a orizzonte, win ~50%, edge nell'asimmetria. Il rischio del +breakeven e' di chiudere a 0 i winner che vanno prima in drawdown leggero e poi +recuperano -> pronti a un NO se BTC degrada. + +PROTOCOLLO: grid SOLO sul train (t_hi=OOS_START_MS). Plateau >=3 celle adiacenti +migliorative (adiacenza su a, con b/mode fissi). Poi OOS una volta su config + +2 vicine. + + cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/05_breakeven_ratchet.py +""" +from __future__ import annotations + +import sys + +sys.path.insert(0, "/opt/docker/PythagorasGoal") + +from scripts.analysis.sh01_exit_lab import ( # noqa: E402 + ExitPolicy, OOS_START_MS, evaluate, load_sleeves, simulate, +) + +DISASTER_ATR = 4.0 + + +class BreakevenRatchet(ExitPolicy): + def __init__(self, a: float, b: float = 0.0, mode: str = "intrabar"): + self.a = float(a) + self.b = float(b) + self.mode = mode + self.name = f"be_ratchet a={a:.1f} b={b:.2f} {mode}" + + def open_trade(self, ctx, i, d): + entry = ctx["close"][i] + a14 = ctx["atr14"][i] + if not (a14 == a14 and a14 > 0): + # nessun ATR valido -> nessuno stop (degenera a baseline su quel trade) + return {"entry": entry, "atr": None, "sl_disaster": None, "armed": False} + if d == 1: + sl_dis = entry - DISASTER_ATR * a14 + else: + sl_dis = entry + DISASTER_ATR * a14 + return {"entry": entry, "atr": a14, "sl_disaster": sl_dis, "armed": False} + + def levels(self, ctx, i, d, j, st): + a14 = st["atr"] + if a14 is None: + return None, self.mode + entry = st["entry"] + cprev = ctx["close"][j - 1] # gia' chiuso quando si fissa il livello per j + # arming del ratchet (una volta armato resta armato) + if not st["armed"]: + if d == 1: + if cprev >= entry + self.a * a14: + st["armed"] = True + else: + if cprev <= entry - self.a * a14: + st["armed"] = True + if st["armed"]: + if d == 1: + sl = entry + self.b * a14 + else: + sl = entry - self.b * a14 + else: + sl = st["sl_disaster"] + return sl, self.mode + + def after_bar(self, ctx, i, d, j, st): + return False + + +# baseline numbers (exit a orizzonte puro) — dal prompt/harness +BASELINE = { + "BTC": {"train": dict(ret=127, dd=23, sharpe=2.09, worst=-5.5), + "oos": dict(ret=41, dd=8, sharpe=2.18, worst=-3.1)}, + "ETH": {"train": dict(ret=-26, dd=61, sharpe=-0.16, worst=-14.9), + "oos": dict(ret=143, dd=7, sharpe=3.60, worst=-4.6)}, +} + +A_VALS = [0.5, 1.0, 1.5, 2.0] +B_VALS = [0.0, 0.25] +MODES = ["intrabar", "close"] + + +def _row(tag, a, r): + print(f" {tag:<10s} {a}: ret={r['ret']:>+7.0f}% dd={r['dd']:>4.0f}% " + f"shrp={r['sharpe']:>5.2f} worst={r['worst']:>+5.1f}% " + f"stop={r['stop_rate']:>4.1f}% trades={r['trades']}") + + +def _eth_ok(et, b_eth): + return (et["sharpe"] > b_eth["sharpe"] and et["dd"] < b_eth["dd"] + and et["worst"] > b_eth["worst"]) + + +def _btc_ok(bt, b_btc): + return (bt["sharpe"] >= 0.95 * b_btc["sharpe"] + and bt["ret"] >= 0.80 * b_btc["ret"]) + + +def main(): + sleeves = load_sleeves() + b_btc, b_eth = BASELINE["BTC"]["train"], BASELINE["ETH"]["train"] + + print("=" * 78) + print("TRAIN GRID (selezione SOLO sul train, t_hi=OOS_START)") + print("=" * 78) + print(" baseline (orizzonte puro):") + evaluate(ExitPolicy(), sleeves=sleeves) + print() + + # train[(mode,b,a)] -> {asset: result} + train = {} + for mode in MODES: + for b in B_VALS: + print(f" --- mode={mode} b={b:.2f} ---") + for a in A_VALS: + pol = BreakevenRatchet(a, b, mode) + row = {} + for asset in ("BTC", "ETH"): + row[asset] = simulate(sleeves[asset], pol, t_hi=OOS_START_MS) + train[(mode, b, a)] = row + print(f" a={a:.1f} b={b:.2f} ({mode})") + _row("TRAIN", "BTC", row["BTC"]) + _row("TRAIN", "ETH", row["ETH"]) + print() + + print("=" * 78) + print("PLATEAU CHECK (train): ETH sharpe up & dd down & worst up,") + print(" BTC sharpe>=95% & ret>=80% baseline") + print("=" * 78) + # improving[(mode,b)] -> [a...] + improving = {} + for mode in MODES: + for b in B_VALS: + imp = [] + for a in A_VALS: + bt, et = train[(mode, b, a)]["BTC"], train[(mode, b, a)]["ETH"] + eth_ok = _eth_ok(et, b_eth) + btc_ok = _btc_ok(bt, b_btc) + ok = eth_ok and btc_ok + if ok: + imp.append(a) + print(f" {mode:<9s} b={b:.2f} a={a:.1f} ETH_ok={eth_ok} " + f"BTC_ok={btc_ok} -> {'IMPROVING' if ok else '-'}") + improving[(mode, b)] = imp + print(f" improving cells ({mode}, b={b:.2f}): {imp}") + + # plateau = >=3 a adiacenti improving in QUALCHE (mode,b) + best_plateau, best_key = [], None + for key in improving: + imp = improving[key] + for idx in range(len(A_VALS)): + run = [] + for jj in range(idx, len(A_VALS)): + if A_VALS[jj] in imp: + run.append(A_VALS[jj]) + else: + break + if len(run) >= 3 and len(run) > len(best_plateau): + best_plateau, best_key = run, key + print(f" longest adjacent improving run: {best_plateau} (key={best_key}) " + f"plateau={'YES' if len(best_plateau) >= 3 else 'NO'}") + + chosen = None + if len(best_plateau) >= 3: + chosen_a = best_plateau[len(best_plateau) // 2] + chosen = (best_key[0], best_key[1], chosen_a) + else: + cands = [(m, b, a) for (m, b) in improving for a in improving[(m, b)]] + if cands: + chosen = max(cands, key=lambda mba: train[mba]["ETH"]["sharpe"]) + + print() + print("=" * 78) + if chosen is None: + print("NESSUNA cella migliorativa sul train -> verdetto NO (niente OOS).") + print("=" * 78) + return {"chosen": None, "plateau": best_plateau, "improving": improving, + "passes": False, "train": train} + + c_mode, c_b, c_a = chosen + print(f"CHOSEN a={c_a:.1f} b={c_b:.2f} mode={c_mode} -> OOS (config + 2 vicine a)") + print("=" * 78) + ai = A_VALS.index(c_a) + neigh = [A_VALS[x] for x in (ai - 1, ai, ai + 1) if 0 <= x < len(A_VALS)] + oos = {} + for a in neigh: + pol = BreakevenRatchet(a, c_b, c_mode) + row = {} + for asset in ("BTC", "ETH"): + row[asset] = {"train": train[(c_mode, c_b, a)][asset], + "oos": simulate(sleeves[asset], pol, t_lo=OOS_START_MS)} + oos[a] = row + print(f" a={a:.1f} b={c_b:.2f} ({c_mode})") + _row("TRAIN", "BTC", row["BTC"]["train"]) + _row("OOS", "BTC", row["BTC"]["oos"]) + _row("TRAIN", "ETH", row["ETH"]["train"]) + _row("OOS", "ETH", row["ETH"]["oos"]) + + print() + print("=" * 78) + print(f"GATE finale (a={c_a:.1f} b={c_b:.2f} mode={c_mode}):") + bt_tr, et_tr = oos[c_a]["BTC"]["train"], oos[c_a]["ETH"]["train"] + bt_oo, et_oo = oos[c_a]["BTC"]["oos"], oos[c_a]["ETH"]["oos"] + Bb_o, Be_o = BASELINE["BTC"]["oos"], BASELINE["ETH"]["oos"] + + a_train = _eth_ok(et_tr, b_eth) + a_oos = (et_oo["sharpe"] > Be_o["sharpe"] and et_oo["dd"] < Be_o["dd"] + and et_oo["worst"] > Be_o["worst"]) + cond_a = a_train and a_oos + b_tr = _btc_ok(bt_tr, b_btc) + b_oo = (bt_oo["sharpe"] >= 0.95 * Bb_o["sharpe"] + and bt_oo["ret"] >= 0.80 * Bb_o["ret"]) + cond_b = b_tr and b_oo + cond_c = et_oo["ret"] >= 0.80 * Be_o["ret"] + cond_d = len(best_plateau) >= 3 + + print(f" a) ETH sharpe up & dd down & worst up (train&oos): {cond_a}") + print(f" train: shrp {et_tr['sharpe']:.2f} vs {b_eth['sharpe']:.2f} | " + f"dd {et_tr['dd']:.0f} vs {b_eth['dd']:.0f} | " + f"worst {et_tr['worst']:.1f} vs {b_eth['worst']:.1f}") + print(f" oos: shrp {et_oo['sharpe']:.2f} vs {Be_o['sharpe']:.2f} | " + f"dd {et_oo['dd']:.0f} vs {Be_o['dd']:.0f} | " + f"worst {et_oo['worst']:.1f} vs {Be_o['worst']:.1f}") + print(f" b) BTC sharpe>=95% & ret>=80% (train&oos): {cond_b}") + print(f" train: shrp {bt_tr['sharpe']:.2f} (>={0.95*b_btc['sharpe']:.2f}) | " + f"ret {bt_tr['ret']:.0f} (>={0.80*b_btc['ret']:.0f})") + print(f" oos: shrp {bt_oo['sharpe']:.2f} (>={0.95*Bb_o['sharpe']:.2f}) | " + f"ret {bt_oo['ret']:.0f} (>={0.80*Bb_o['ret']:.0f})") + print(f" c) ETH oos ret>=80% baseline ({0.80*Be_o['ret']:.0f}): {cond_c} " + f"(ret={et_oo['ret']:.0f})") + print(f" d) plateau: {cond_d} ({best_plateau} key={best_key})") + passes = cond_a and cond_b and cond_c and cond_d + print(f" PASSES GATE: {passes}") + print("=" * 78) + + return {"chosen": chosen, "plateau": best_plateau, "improving": improving, + "passes": passes, "oos": oos, "conds": (cond_a, cond_b, cond_c, cond_d)} + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/sh01_exit_policies/06_giveback.py b/scripts/analysis/sh01_exit_policies/06_giveback.py new file mode 100644 index 0000000..44803e8 --- /dev/null +++ b/scripts/analysis/sh01_exit_policies/06_giveback.py @@ -0,0 +1,260 @@ +"""SH01 EXIT policy 06 — giveback (profit-protection). + +Protezione del profitto via after_bar (mode "close" implicito: uscita sempre al +close del bar j). Lo state traccia il PEAK FAVOREVOLE dei close da i (per long il +max close; per short il min close, specchiato). Si esce al close del bar j se: + + giveback = (peak_fav - close[j]) * d >= g * ATR14[j-1] (retrace) + profit_at_peak = (peak_fav - entry) * d >= m * ATR_ref (era in gain) + +cioe' il trade aveva raggiunto un profitto di almeno m*ATR e poi ha ritracciato +di g*ATR dal massimo favorevole. Idea: lascia correre il momentum SH01 finche' +sale, ma protegge il guadagno quando rifiata — senza toccare i trade che non sono +mai andati in profitto (quelli muoiono a orizzonte come nel baseline, cosi' non +si crea un trailing-stop mascherato che taglia i winner-in-drawdown). + +Griglia g in {1.0, 1.5, 2.0, 3.0} x m in {0.5, 1.0}. + +ANTI-LOOK-AHEAD: after_bar(j) decide sul CLOSE del bar j (dato <= j, eseguibile +al poll). Il peak favorevole include close[j] (gia' chiuso quando si decide). +ATR di riferimento: usiamo ATR14[j-1] per la soglia di giveback (causale, come +i livelli) e ATR14[i] per la soglia di profit-at-peak (noto a close[i], cioe' +all'apertura del trade). open_trade usa solo close[i]/ATR14[i]. Nessun dato di +un bar futuro. OK. + +Profilo SH01: hold a orizzonte (momentum), win ~50%, edge nell'asimmetria dei +winner. La famiglia "ride/trailing" sulle fade e' stata SCARTATA; il giveback e' +una variante condizionata-al-profitto, pensata per NON toccare i loser-che- +recuperano. Pronti a un NO se taglia comunque l'edge. + +PROTOCOLLO: grid (g x m) SOLO sul train (t_hi=OOS_START_MS). Plateau >=3 celle +adiacenti migliorative (adiacenza su g, m fisso). Poi OOS una volta sulla config +scelta + 2 vicine. + + cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/06_giveback.py +""" +from __future__ import annotations + +import sys + +sys.path.insert(0, "/opt/docker/PythagorasGoal") + +from scripts.analysis.sh01_exit_lab import ( # noqa: E402 + ExitPolicy, OOS_START_MS, evaluate, load_sleeves, simulate, +) + + +class Giveback(ExitPolicy): + def __init__(self, g: float, m: float): + self.g = float(g) + self.m = float(m) + self.name = f"giveback g={g:.1f} m={m:.1f}" + + def open_trade(self, ctx, i, d): + entry = ctx["close"][i] + a0 = ctx["atr14"][i] + a0 = float(a0) if (a0 == a0 and a0 > 0) else 0.0 + # peak favorevole inizializzato all'entry; atr_ref per il profit-at-peak. + return {"entry": entry, "peak": entry, "atr0": a0} + + def levels(self, ctx, i, d, j, st): + # nessuno stop a livello: il giveback e' tutto in after_bar. + return None, "close" + + def after_bar(self, ctx, i, d, j, st): + close = ctx["close"] + atr = ctx["atr14"] + cj = close[j] + # aggiorna il peak FAVOREVOLE con close[j] (gia' chiuso quando decidiamo). + # per long: max close; per short: min close (= peak favorevole specchiato). + if d == 1: + if cj > st["peak"]: + st["peak"] = cj + else: + if cj < st["peak"]: + st["peak"] = cj + + a_gb = atr[j - 1] + if not (a_gb == a_gb and a_gb > 0): + return False + a_pk = st["atr0"] + if a_pk <= 0: + return False + + # profitto raggiunto al peak favorevole (in direzione del trade). + profit_at_peak = (st["peak"] - st["entry"]) * d + if profit_at_peak < self.m * a_pk: + return False + # ritracciamento dal peak favorevole fino al close corrente. + giveback = (st["peak"] - cj) * d + return giveback >= self.g * a_gb + + +# baseline numbers (exit a orizzonte puro) — dal prompt/harness +BASELINE = { + "BTC": {"train": dict(ret=127, dd=23, sharpe=2.09, worst=-5.5), + "oos": dict(ret=41, dd=8, sharpe=2.18, worst=-3.1)}, + "ETH": {"train": dict(ret=-26, dd=61, sharpe=-0.16, worst=-14.9), + "oos": dict(ret=143, dd=7, sharpe=3.60, worst=-4.6)}, +} + +GS = [1.0, 1.5, 2.0, 3.0] +MS = [0.5, 1.0] + + +def _row(tag, a, r): + print(f" {tag:<10s} {a}: ret={r['ret']:>+7.0f}% dd={r['dd']:>4.0f}% " + f"shrp={r['sharpe']:>5.2f} worst={r['worst']:>+5.1f}% " + f"stop={r['stop_rate']:>4.1f}% trades={r['trades']}") + + +def _eth_ok(et, b_eth): + return (et["sharpe"] > b_eth["sharpe"] and et["dd"] < b_eth["dd"] + and et["worst"] > b_eth["worst"]) + + +def _btc_ok(bt, b_btc): + return (bt["sharpe"] >= 0.95 * b_btc["sharpe"] + and bt["ret"] >= 0.80 * b_btc["ret"]) + + +def main(): + sleeves = load_sleeves() + b_btc, b_eth = BASELINE["BTC"]["train"], BASELINE["ETH"]["train"] + + print("=" * 78) + print("TRAIN GRID (selezione SOLO sul train, t_hi=OOS_START)") + print("=" * 78) + print(" baseline (orizzonte puro):") + evaluate(ExitPolicy(), sleeves=sleeves) + print() + + # train[(m,g)] -> {asset: result} + train = {} + for m in MS: + print(f" --- m={m:.1f} (profit-at-peak threshold) ---") + for g in GS: + pol = Giveback(g, m) + row = {} + for a in ("BTC", "ETH"): + row[a] = simulate(sleeves[a], pol, t_hi=OOS_START_MS) + train[(m, g)] = row + print(f" g={g:.1f} m={m:.1f}") + _row("TRAIN", "BTC", row["BTC"]) + _row("TRAIN", "ETH", row["ETH"]) + print() + + print("=" * 78) + print("PLATEAU CHECK (train): ETH sharpe up & dd down & worst up,") + print(" BTC sharpe>=95% & ret>=80% baseline") + print("=" * 78) + improving = {} # m -> [g...] + for m in MS: + imp = [] + for g in GS: + bt, et = train[(m, g)]["BTC"], train[(m, g)]["ETH"] + eth_ok = _eth_ok(et, b_eth) + btc_ok = _btc_ok(bt, b_btc) + ok = eth_ok and btc_ok + if ok: + imp.append(g) + print(f" m={m:.1f} g={g:.1f} ETH_ok={eth_ok} BTC_ok={btc_ok} -> " + f"{'IMPROVING' if ok else '-'}") + improving[m] = imp + print(f" improving cells (m={m:.1f}): {imp}") + + # plateau = >=3 g adiacenti improving in QUALCHE m + best_plateau, best_m = [], None + for m in MS: + imp = improving[m] + for idx in range(len(GS)): + run = [] + for j in range(idx, len(GS)): + if GS[j] in imp: + run.append(GS[j]) + else: + break + if len(run) >= 3 and len(run) > len(best_plateau): + best_plateau, best_m = run, m + print(f" longest adjacent improving run: {best_plateau} (m={best_m}) " + f"plateau={'YES' if len(best_plateau) >= 3 else 'NO'}") + + chosen = None + if len(best_plateau) >= 3: + chosen_g = best_plateau[len(best_plateau) // 2] + chosen = (best_m, chosen_g) + else: + cands = [(m, g) for m in MS for g in improving[m]] + if cands: + chosen = max(cands, key=lambda mg: train[mg]["ETH"]["sharpe"]) + + print() + print("=" * 78) + if chosen is None: + print("NESSUNA cella migliorativa sul train -> verdetto NO (niente OOS).") + print("=" * 78) + return {"chosen": None, "plateau": best_plateau, "improving": improving, + "passes": False, "train": train} + + c_m, c_g = chosen + print(f"CHOSEN g={c_g:.1f} m={c_m:.1f} -> OOS (config + 2 vicine g), 1 volta") + print("=" * 78) + gi = GS.index(c_g) + neigh = [GS[x] for x in (gi - 1, gi, gi + 1) if 0 <= x < len(GS)] + oos = {} + for g in neigh: + pol = Giveback(g, c_m) + row = {} + for a in ("BTC", "ETH"): + row[a] = {"train": train[(c_m, g)][a], + "oos": simulate(sleeves[a], pol, t_lo=OOS_START_MS)} + oos[g] = row + print(f" g={g:.1f} m={c_m:.1f}") + _row("TRAIN", "BTC", row["BTC"]["train"]) + _row("OOS", "BTC", row["BTC"]["oos"]) + _row("TRAIN", "ETH", row["ETH"]["train"]) + _row("OOS", "ETH", row["ETH"]["oos"]) + + print() + print("=" * 78) + print(f"GATE finale (g={c_g:.1f} m={c_m:.1f}):") + bt_tr, et_tr = oos[c_g]["BTC"]["train"], oos[c_g]["ETH"]["train"] + bt_oo, et_oo = oos[c_g]["BTC"]["oos"], oos[c_g]["ETH"]["oos"] + Bb_o, Be_o = BASELINE["BTC"]["oos"], BASELINE["ETH"]["oos"] + + a_train = _eth_ok(et_tr, b_eth) + a_oos = (et_oo["sharpe"] > Be_o["sharpe"] and et_oo["dd"] < Be_o["dd"] + and et_oo["worst"] > Be_o["worst"]) + cond_a = a_train and a_oos + b_tr = _btc_ok(bt_tr, b_btc) + b_oo = (bt_oo["sharpe"] >= 0.95 * Bb_o["sharpe"] + and bt_oo["ret"] >= 0.80 * Bb_o["ret"]) + cond_b = b_tr and b_oo + cond_c = et_oo["ret"] >= 0.80 * Be_o["ret"] + cond_d = len(best_plateau) >= 3 + + print(f" a) ETH sharpe up & dd down & worst up (train&oos): {cond_a}") + print(f" train: shrp {et_tr['sharpe']:.2f} vs {b_eth['sharpe']:.2f} | " + f"dd {et_tr['dd']:.0f} vs {b_eth['dd']:.0f} | " + f"worst {et_tr['worst']:.1f} vs {b_eth['worst']:.1f}") + print(f" oos: shrp {et_oo['sharpe']:.2f} vs {Be_o['sharpe']:.2f} | " + f"dd {et_oo['dd']:.0f} vs {Be_o['dd']:.0f} | " + f"worst {et_oo['worst']:.1f} vs {Be_o['worst']:.1f}") + print(f" b) BTC sharpe>=95% & ret>=80% (train&oos): {cond_b}") + print(f" train: shrp {bt_tr['sharpe']:.2f} (>={0.95*b_btc['sharpe']:.2f}) | " + f"ret {bt_tr['ret']:.0f} (>={0.80*b_btc['ret']:.0f})") + print(f" oos: shrp {bt_oo['sharpe']:.2f} (>={0.95*Bb_o['sharpe']:.2f}) | " + f"ret {bt_oo['ret']:.0f} (>={0.80*Bb_o['ret']:.0f})") + print(f" c) ETH oos ret>=80% baseline ({0.80*Be_o['ret']:.0f}): {cond_c} " + f"(ret={et_oo['ret']:.0f})") + print(f" d) plateau: {cond_d} ({best_plateau} m={best_m})") + passes = cond_a and cond_b and cond_c and cond_d + print(f" PASSES GATE: {passes}") + print("=" * 78) + + return {"chosen": chosen, "plateau": best_plateau, "improving": improving, + "passes": passes, "oos": oos, "conds": (cond_a, cond_b, cond_c, cond_d)} + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/sh01_exit_policies/07_loser_timestop.py b/scripts/analysis/sh01_exit_policies/07_loser_timestop.py new file mode 100644 index 0000000..c6a8bbf --- /dev/null +++ b/scripts/analysis/sh01_exit_policies/07_loser_timestop.py @@ -0,0 +1,107 @@ +"""SH01 EXIT POLICY 07 — LOSER time-stop condizionale (after_bar). + +Idea: SH01 esce a orizzonte fisso H=12. Se a un check-point intermedio j=i+m il +trade e' GIA' in perdita oltre una soglia, esci subito al close invece di tenere +fino a H. L'ipotesi (lezione exit-lab fade: tagliare i loser presto rischia di +tagliare anche i winner in drawdown temporaneo): su un orizzonte FISSO di 12 bar +forse un loser conclamato a meta' corsa raramente recupera, mentre i winner del +modello partono subito (asimmetria). Il time-stop e' UNA volta sola (al bar m), +non un trailing: non insegue il prezzo, condiziona solo l'uscita a un istante. + +Regola (after_bar): + al bar j == i + m: se (close[j]-entry)/entry * d < -x * ATR14[i] / entry + esci al close del bar j. +Equivalente: directional_move[j] < -x*ATR14[i]. x=0.0 => esci se in QUALSIASI +perdita direzionale al bar m. + +Griglia m in {2, 3, 4, 6} x x in {0.0, 0.5, 1.0}. + +ANTI-LOOK-AHEAD: ATR14[i] e entry=close[i] fissati all'ingresso (dati <= i); +after_bar decide sul close del bar j (dati <= j, eseguibile al poll del tick). +Nessun indicatore al bar j stesso. + + cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/07_loser_timestop.py +""" +from __future__ import annotations + +import sys + +sys.path.insert(0, "/opt/docker/PythagorasGoal") + +from scripts.analysis.sh01_exit_lab import ( # noqa: E402 + ExitPolicy, evaluate, load_sleeves, simulate, OOS_START_MS, +) + + +class LoserTimestop(ExitPolicy): + def __init__(self, m: int, x: float): + self.m = int(m) + self.x = float(x) + self.name = f"loser_timestop m={m} x={x:g}" + + def open_trade(self, ctx: dict, i: int, d: int) -> dict: + return { + "entry": float(ctx["close"][i]), + "atr": float(ctx["atr14"][i]), + } + + def after_bar(self, ctx: dict, i: int, d: int, j: int, st: dict) -> bool: + if j != i + self.m: + return False + move = (ctx["close"][j] - st["entry"]) * d # directional, in price + thresh = -self.x * st["atr"] + return move < thresh + + +GRID_M = [2, 3, 4, 6] +GRID_X = [0.0, 0.5, 1.0] + + +def _fmt(m): + return (f"ret={m['ret']:>+7.0f}% dd={m['dd']:>4.0f}% shrp={m['sharpe']:>5.2f} " + f"worst={m['worst']:>+5.1f}% stop={m['stop_rate']:>4.1f}% n={m['trades']}") + + +def main(): + sleeves = load_sleeves() + + base = {} + for a in ("BTC", "ETH"): + ctx = sleeves[a] + base[a] = { + "train": simulate(ctx, ExitPolicy(), t_hi=OOS_START_MS), + "oos": simulate(ctx, ExitPolicy(), t_lo=OOS_START_MS), + } + + print("=" * 110) + print("BASELINE (exit orizzonte puro):") + for a in ("BTC", "ETH"): + print(f" {a} TRAIN {_fmt(base[a]['train'])}") + print(f" {a} OOS {_fmt(base[a]['oos'])}") + + print("=" * 110) + print("GRID — TRAIN ONLY (selezione parametri): m x rows") + train_res = {} + for mm in GRID_M: + for xx in GRID_X: + pol = LoserTimestop(mm, xx) + row = {} + for a in ("BTC", "ETH"): + row[a] = simulate(sleeves[a], pol, t_hi=OOS_START_MS) + train_res[(mm, xx)] = row + print(f" m={mm} x={xx:g} | BTC {_fmt(row['BTC'])}") + print(f" | ETH {_fmt(row['ETH'])}") + + print("=" * 110) + print("OOS (verdetto, intera griglia per ispezione plateau):") + for mm in GRID_M: + for xx in GRID_X: + pol = LoserTimestop(mm, xx) + line_b = simulate(sleeves["BTC"], pol, t_lo=OOS_START_MS) + line_e = simulate(sleeves["ETH"], pol, t_lo=OOS_START_MS) + print(f" m={mm} x={xx:g} | BTC OOS {_fmt(line_b)}") + print(f" | ETH OOS {_fmt(line_e)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/sh01_exit_policies/08_disaster_wide_close.py b/scripts/analysis/sh01_exit_policies/08_disaster_wide_close.py new file mode 100644 index 0000000..d8f23e0 --- /dev/null +++ b/scripts/analysis/sh01_exit_policies/08_disaster_wide_close.py @@ -0,0 +1,159 @@ +"""SH01 EXIT POLICY 08 — DISASTER-CAP LARGO, close-confirm (minimal intervention). + +Ipotesi: SH01 (exit a orizzonte puro, niente TP/SL) si fa massacrare dalle code +rare (crash ETH 2026-06-05 −15.6% in un trade, ETH 2020). Uno stop LARGO a +k·ATR14[i] (k grande) dovrebbe toccare SOLO quei pochi trade-disastro, lasciando +intatto il resto della distribuzione — e quindi l'edge asimmetrico dei winner. + + sl = entry - d * k * ATR14[i] (fissato all'ingresso, dati <= i) + mode = "close" (stop solo se il CLOSE sfonda, stile EXIT-16) + +Griglia LARGA: k in {3.0, 4.0, 5.0, 6.0}. E' il complemento "wide-only" della +policy 02 (che spazzava anche stop stretti): qui l'intento e' la NON-interferenza. + +Strumentazione extra (richiesta dal mandato): per ogni k riporto + - stop_rate (quanti trade vengono stoppati), + - la DISTRIBUZIONE dei trade tagliati: erano tutti loser? quanti winner uccisi? + Per ogni trade stoppato confronto il suo ret (post-stop, ⇒ negativo) con il + ret che AVREBBE avuto a orizzonte puro (baseline, senza stop) → conto quanti + sarebbero finiti winner (stop "dannoso") vs loser (stop "utile"). + +ANTI-LOOK-AHEAD: sl usa SOLO atr14[i] e c[i] (dati <= i); mode="close" decide sul +close del bar j (dati <= j). Nessun indicatore valutato al bar j. + + cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/08_disaster_wide_close.py +""" +from __future__ import annotations + +import sys + +sys.path.insert(0, "/opt/docker/PythagorasGoal") + +import numpy as np # noqa: E402 + +from scripts.analysis.sh01_exit_lab import ( # noqa: E402 + ExitPolicy, load_sleeves, simulate, OOS_START_MS, FEE_RT, LEV, POS, +) + + +class DisasterWideClose(ExitPolicy): + def __init__(self, k: float): + self.k = float(k) + self.name = f"disaster_wide_close k={k:g}" + + def open_trade(self, ctx: dict, i: int, d: int) -> dict: + atr = ctx["atr14"][i] + entry = ctx["close"][i] + sl = entry - d * self.k * atr + return {"sl": float(sl)} + + def levels(self, ctx: dict, i: int, d: int, j: int, st: dict): + return st["sl"], "close" + + +GRID = [3.0, 4.0, 5.0, 6.0] + + +def _fmt(m): + return (f"ret={m['ret']:>+7.0f}% dd={m['dd']:>4.0f}% shrp={m['sharpe']:>5.2f} " + f"worst={m['worst']:>+5.1f}% stop={m['stop_rate']:>4.1f}% n={m['trades']}") + + +def _baseline_ret_by_entry(ctx, t_lo=None, t_hi=None): + """Mappa entry-i -> ret a orizzonte puro (baseline, nessuno stop), stesso + engine, stesso slice. Serve a classificare i trade stoppati.""" + base = simulate(ctx, ExitPolicy(), t_lo=t_lo, t_hi=t_hi) + return {r["i"]: r["ret"] for r in base["_trades"]} + + +def _stop_breakdown(ctx, policy, t_lo=None, t_hi=None): + """Esegue la policy e analizza SOLO i trade con reason=='stop'. + Ritorna (n_stop, n_winner_killed, n_loser_cut, dettaglio_list).""" + res = simulate(ctx, policy, t_lo=t_lo, t_hi=t_hi) + base_ret = _baseline_ret_by_entry(ctx, t_lo=t_lo, t_hi=t_hi) + killed = cut = 0 + detail = [] + for r in res["_trades"]: + if r["reason"] != "stop": + continue + br = base_ret.get(r["i"]) + would_win = (br is not None and br > 0) + killed += would_win + cut += (not would_win) + detail.append((r["i"], r["d"], r["ret"], br, would_win)) + return res, len(detail), killed, cut, detail + + +def main(): + sleeves = load_sleeves() + + # baseline orizzonte puro + base = {} + for a in ("BTC", "ETH"): + ctx = sleeves[a] + base[a] = { + "train": simulate(ctx, ExitPolicy(), t_hi=OOS_START_MS), + "oos": simulate(ctx, ExitPolicy(), t_lo=OOS_START_MS), + } + + print("=" * 118) + print("BASELINE (exit orizzonte puro):") + for a in ("BTC", "ETH"): + print(f" {a} TRAIN {_fmt(base[a]['train'])}") + print(f" {a} OOS {_fmt(base[a]['oos'])}") + + print("=" * 118) + print("GRID — TRAIN ONLY (selezione parametri):") + train_res = {} + for k in GRID: + pol = DisasterWideClose(k) + row = {} + for a in ("BTC", "ETH"): + row[a] = simulate(sleeves[a], pol, t_hi=OOS_START_MS) + train_res[k] = row + print(f" k={k:>3g} | BTC {_fmt(row['BTC'])}") + print(f" | ETH {_fmt(row['ETH'])}") + + # ---- breakdown dei trade stoppati (TRAIN), per la domanda "minimal intervention" + print("=" * 118) + print("STOP BREAKDOWN — TRAIN (chi viene tagliato? winner uccisi vs loser tagliati):") + for k in GRID: + pol = DisasterWideClose(k) + for a in ("BTC", "ETH"): + ctx = sleeves[a] + res, ns, killed, cut, detail = _stop_breakdown(ctx, pol, t_hi=OOS_START_MS) + print(f" k={k:>3g} {a} TRAIN: stop n={ns:>2d}/{res['trades']} " + f"({res['stop_rate']:.1f}%) -> loser_tagliati={cut} winner_UCCISI={killed}") + for (i, d, ret, br, ww) in detail: + tag = "WINNER-KILLED" if ww else "loser-cut" + brs = f"{br*100:>+6.1f}%" if br is not None else " n/a " + print(f" i={i:>6d} d={d:>+d} stop_ret={ret*100:>+6.1f}% " + f"baseline_ret={brs} [{tag}]") + + # ---- verdetto OOS (config scelta + vicine, guardato una volta) + print("=" * 118) + print("OOS (verdetto):") + for k in GRID: + pol = DisasterWideClose(k) + print(f" k={k:>3g} | BTC TRAIN {_fmt(train_res[k]['BTC'])}") + for a in ("BTC", "ETH"): + oo = simulate(sleeves[a], pol, t_lo=OOS_START_MS) + print(f" | {a} OOS {_fmt(oo)}") + print("=" * 118) + print("STOP BREAKDOWN — OOS:") + for k in GRID: + pol = DisasterWideClose(k) + for a in ("BTC", "ETH"): + ctx = sleeves[a] + res, ns, killed, cut, detail = _stop_breakdown(ctx, pol, t_lo=OOS_START_MS) + print(f" k={k:>3g} {a} OOS : stop n={ns:>2d}/{res['trades']} " + f"({res['stop_rate']:.1f}%) -> loser_tagliati={cut} winner_UCCISI={killed}") + for (i, d, ret, br, ww) in detail: + tag = "WINNER-KILLED" if ww else "loser-cut" + brs = f"{br*100:>+6.1f}%" if br is not None else " n/a " + print(f" i={i:>6d} d={d:>+d} stop_ret={ret*100:>+6.1f}% " + f"baseline_ret={brs} [{tag}]") + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/sh01_exit_policies/09_swing_stop.py b/scripts/analysis/sh01_exit_policies/09_swing_stop.py new file mode 100644 index 0000000..fea2ba3 --- /dev/null +++ b/scripts/analysis/sh01_exit_policies/09_swing_stop.py @@ -0,0 +1,256 @@ +"""SH01 EXIT policy 09 — swing_stop. + +Stop STRUTTURALE sullo swing recente, fissato all'ingresso: + long : sl = min(low[i-N+1 .. i]) - pad * ATR14[i] + short: sl = max(high[i-N+1 .. i]) + pad * ATR14[i] +Specchiato per d=-1. Il livello e' congelato in open_trade (SOLO dati <= i: +low/high della finestra fino a i incluso, ATR14[i] noto a close[i]). levels() +restituisce quel livello costante per tutta la vita del trade -> nessun dato del +bar j -> anti-look-ahead OK. + +Idea: invece di uno stop a distanza fissa (ATR/%), ancora lo stop alla STRUTTURA +del prezzo (minimo/massimo dello swing recente). Un long viene stoppato solo se +rompe il supporto strutturale che lo ha generato; il pad in ATR da' un cuscinetto +sotto il livello per evitare i wick (mode intrabar) o per richiedere conferma sul +close (mode close, stile EXIT-16). + +Griglia: N in {6, 12, 24} x pad in {0.0, 0.25, 0.5} x mode {intrabar, close}. + + cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/09_swing_stop.py +""" +from __future__ import annotations + +import sys + +sys.path.insert(0, "/opt/docker/PythagorasGoal") + +from scripts.analysis.sh01_exit_lab import ( # noqa: E402 + ExitPolicy, OOS_START_MS, evaluate, load_sleeves, simulate, +) + + +class SwingStop(ExitPolicy): + def __init__(self, n: int, pad: float, mode: str): + self.n = int(n) + self.pad = float(pad) + self.mode = str(mode) + self.name = f"swing n={n} pad={pad:.2f} {mode}" + + def open_trade(self, ctx, i, d): + lo, hi = ctx["low"], ctx["high"] + atr = ctx["atr14"][i] + lo0 = max(0, i - self.n + 1) + if atr != atr or atr <= 0: # nan/0 (early bars) -> nessuno stop + return {"sl": None} + if d == 1: + swing = float(lo[lo0:i + 1].min()) + sl = swing - self.pad * atr + else: + swing = float(hi[lo0:i + 1].max()) + sl = swing + self.pad * atr + return {"sl": sl} + + def levels(self, ctx, i, d, j, st): + return st["sl"], self.mode + + def after_bar(self, ctx, i, d, j, st): + return False + + +# baseline numbers (exit a orizzonte puro) — dal prompt/harness +BASELINE = { + "BTC": {"train": dict(ret=127, dd=23, sharpe=2.09, worst=-5.5), + "oos": dict(ret=41, dd=8, sharpe=2.18, worst=-3.1)}, + "ETH": {"train": dict(ret=-26, dd=61, sharpe=-0.16, worst=-14.9), + "oos": dict(ret=143, dd=7, sharpe=3.60, worst=-4.6)}, +} + +NS = [6, 12, 24] +PADS = [0.0, 0.25, 0.5] +MODES = ["intrabar", "close"] + + +def _row(tag, a, r): + print(f" {tag:<10s} {a}: ret={r['ret']:>+7.0f}% dd={r['dd']:>4.0f}% " + f"shrp={r['sharpe']:>5.2f} worst={r['worst']:>+5.1f}% " + f"stop={r['stop_rate']:>4.1f}% trades={r['trades']}") + + +def _eth_ok(et, b_eth): + return (et["sharpe"] > b_eth["sharpe"] and et["dd"] < b_eth["dd"] + and et["worst"] > b_eth["worst"]) + + +def _btc_ok(bt, b_btc): + return (bt["sharpe"] >= 0.95 * b_btc["sharpe"] + and bt["ret"] >= 0.80 * b_btc["ret"]) + + +def main(): + sleeves = load_sleeves() + b_btc, b_eth = BASELINE["BTC"]["train"], BASELINE["ETH"]["train"] + + print("=" * 78) + print("TRAIN GRID (selezione SOLO sul train, t_hi=OOS_START)") + print("=" * 78) + print(" baseline (orizzonte puro):") + evaluate(ExitPolicy(), sleeves=sleeves) + print() + + # train: (mode, n, pad) -> {asset: result} + train = {} + for mode in MODES: + print(f" --- mode={mode} ---") + for n in NS: + for pad in PADS: + pol = SwingStop(n, pad, mode) + row = {} + for a in ("BTC", "ETH"): + row[a] = simulate(sleeves[a], pol, t_hi=OOS_START_MS) + train[(mode, n, pad)] = row + print(f" n={n:<2d} pad={pad:.2f}") + _row("TRAIN", "BTC", row["BTC"]) + _row("TRAIN", "ETH", row["ETH"]) + print() + + print("=" * 78) + print("PLATEAU CHECK (train): per ogni cella, ETH(shrp up & dd down & worst up)") + print(" & BTC(shrp>=95% & ret>=80% baseline)") + print("=" * 78) + improving = [] + grid_imp = {} # (mode,n,pad) -> bool + for mode in MODES: + for n in NS: + for pad in PADS: + bt, et = train[(mode, n, pad)]["BTC"], train[(mode, n, pad)]["ETH"] + ok = _eth_ok(et, b_eth) and _btc_ok(bt, b_btc) + grid_imp[(mode, n, pad)] = ok + if ok: + improving.append((mode, n, pad)) + print(f" {mode:<8s} n={n:<2d} pad={pad:.2f} " + f"ETH_ok={_eth_ok(et, b_eth)!s:<5} BTC_ok={_btc_ok(bt, b_btc)!s:<5} " + f"-> {'IMPROVING' if ok else '-'}") + print(f" improving cells (train): {len(improving)}/{len(train)} -> {improving}") + + # PLATEAU = adiacenza nella griglia N x pad (stesso mode). Adiacenti = vicini + # nelle liste NS/PADS. Cerco il blocco contiguo piu' grande di celle improving. + def adjacent_block_size(mode): + cells = [(NS.index(n), PADS.index(p)) + for (m, n, p) in improving if m == mode] + cells_set = set(cells) + best = [] + for start in cells: + # BFS sul reticolo 4-connesso + seen, stack = set(), [start] + while stack: + cur = stack.pop() + if cur in seen: + continue + seen.add(cur) + ci, cj = cur + for di, dj in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nb = (ci + di, cj + dj) + if nb in cells_set and nb not in seen: + stack.append(nb) + if len(seen) > len(best): + best = list(seen) + return best + + plateau_cells = [] + plateau_mode = None + for mode in MODES: + blk = adjacent_block_size(mode) + if len(blk) > len(plateau_cells): + plateau_cells = blk + plateau_mode = mode + plateau_ok = len(plateau_cells) >= 3 + if plateau_mode is not None: + readable = [(plateau_mode, NS[i], PADS[j]) for (i, j) in plateau_cells] + else: + readable = [] + print(f" largest adjacent improving block: {len(plateau_cells)} cells " + f"mode={plateau_mode} -> {readable} (plateau={'YES' if plateau_ok else 'NO'})") + + # scelta: centro del plateau (miglior ETH sharpe fra le celle del blocco), + # altrimenti miglior ETH sharpe fra gli improving. + chosen = None + if plateau_ok: + chosen = max(readable, key=lambda c: train[c]["ETH"]["sharpe"]) + elif improving: + chosen = max(improving, key=lambda c: train[c]["ETH"]["sharpe"]) + + print() + print("=" * 78) + if chosen is None: + print("NESSUNA cella migliorativa sul train -> verdetto NO (niente OOS).") + print("=" * 78) + return {"chosen": None, "plateau": readable, "improving": improving, + "passes": False} + + print(f"CHOSEN {chosen} -> OOS (config + vicine), guardato UNA volta") + print("=" * 78) + mode, n, pad = chosen + # vicine: stesso mode, pad +-1 step e n +-1 step (se esistono e improving o no) + ni, pi = NS.index(n), PADS.index(pad) + neigh = set([chosen]) + for di, dj in ((0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)): + a, b = ni + di, pi + dj + if 0 <= a < len(NS) and 0 <= b < len(PADS): + neigh.add((mode, NS[a], PADS[b])) + oos = {} + for c in sorted(neigh, key=lambda c: (c[1], c[2])): + m, nn, pp = c + pol = SwingStop(nn, pp, m) + row = {} + for a in ("BTC", "ETH"): + row[a] = {"train": train[c][a], + "oos": simulate(sleeves[a], pol, t_lo=OOS_START_MS)} + oos[c] = row + print(f" {m} n={nn} pad={pp:.2f}") + _row("TRAIN", "BTC", row["BTC"]["train"]) + _row("OOS", "BTC", row["BTC"]["oos"]) + _row("TRAIN", "ETH", row["ETH"]["train"]) + _row("OOS", "ETH", row["ETH"]["oos"]) + + print() + print("=" * 78) + print(f"GATE finale ({chosen}):") + bt_tr, et_tr = oos[chosen]["BTC"]["train"], oos[chosen]["ETH"]["train"] + bt_oo, et_oo = oos[chosen]["BTC"]["oos"], oos[chosen]["ETH"]["oos"] + Bb_o, Be_o = BASELINE["BTC"]["oos"], BASELINE["ETH"]["oos"] + + a_train = _eth_ok(et_tr, b_eth) + a_oos = (et_oo["sharpe"] > Be_o["sharpe"] and et_oo["dd"] < Be_o["dd"] + and et_oo["worst"] > Be_o["worst"]) + cond_a = a_train and a_oos + cond_b = _btc_ok(bt_tr, b_btc) and (bt_oo["sharpe"] >= 0.95 * Bb_o["sharpe"] + and bt_oo["ret"] >= 0.80 * Bb_o["ret"]) + cond_c = et_oo["ret"] >= 0.80 * Be_o["ret"] + cond_d = plateau_ok + + print(f" a) ETH sharpe up & dd down & worst up (train&oos): {cond_a}") + print(f" train: shrp {et_tr['sharpe']:.2f} vs {b_eth['sharpe']:.2f} | " + f"dd {et_tr['dd']:.0f} vs {b_eth['dd']:.0f} | " + f"worst {et_tr['worst']:.1f} vs {b_eth['worst']:.1f}") + print(f" oos: shrp {et_oo['sharpe']:.2f} vs {Be_o['sharpe']:.2f} | " + f"dd {et_oo['dd']:.0f} vs {Be_o['dd']:.0f} | " + f"worst {et_oo['worst']:.1f} vs {Be_o['worst']:.1f}") + print(f" b) BTC sharpe>=95% & ret>=80% (train&oos): {cond_b}") + print(f" train: shrp {bt_tr['sharpe']:.2f} (>={0.95*b_btc['sharpe']:.2f}) | " + f"ret {bt_tr['ret']:.0f} (>={0.80*b_btc['ret']:.0f})") + print(f" oos: shrp {bt_oo['sharpe']:.2f} (>={0.95*Bb_o['sharpe']:.2f}) | " + f"ret {bt_oo['ret']:.0f} (>={0.80*Bb_o['ret']:.0f})") + print(f" c) ETH oos ret>=80% baseline ({0.80*Be_o['ret']:.0f}): {cond_c} " + f"(ret={et_oo['ret']:.0f})") + print(f" d) plateau: {cond_d} ({len(plateau_cells)} cells)") + passes = cond_a and cond_b and cond_c and cond_d + print(f" PASSES GATE: {passes}") + print("=" * 78) + + return {"chosen": chosen, "plateau": readable, "improving": improving, + "passes": passes, "oos": oos, + "conds": (cond_a, cond_b, cond_c, cond_d)} + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/sh01_exit_policies/10_vol_regime_stop.py b/scripts/analysis/sh01_exit_policies/10_vol_regime_stop.py new file mode 100644 index 0000000..fe6d1e8 --- /dev/null +++ b/scripts/analysis/sh01_exit_policies/10_vol_regime_stop.py @@ -0,0 +1,157 @@ +"""SH01 EXIT POLICY 10 — vol_regime_stop. + +Stop CONDIZIONALE al regime di volatilita': lo SL esiste solo quando la vol +sta esplodendo. Razionale: il danno (2020 ETH, crash live 2026-06-05) avviene +in vol-expansion; quando la vol e' normale lo SL taglierebbe winner in +drawdown temporaneo (l'edge SH01 e' nell'asimmetria, win ~50%). + +Regime causale: vr[j] = ATR14[j] / SMA100(ATR14)[j]. Nel bar j si guarda +vr[j-1] (dati <= j-1). Se vr[j-1] > r -> SL = entry - d*k1*ATR14[i] +(ATR all'entry = dati <= i). Altrimenti nessuno stop. + + r in {1.2, 1.5} + k1 in {1.5, 2.0, 3.0} + mode in {intrabar, close} + +ANTI-LOOK-AHEAD: + - vr e' un array precomputato module-level (SMA100 causale, no centering). + - levels(j) usa vr[j-1] e atr14[i] (entry), entrambi <= j-1. + - mode "close": stop solo se il CLOSE sfonda (stile EXIT-16). +""" +from __future__ import annotations + +import sys + +sys.path.insert(0, "/opt/docker/PythagorasGoal") + +import numpy as np + +from scripts.analysis.sh01_exit_lab import ( + ExitPolicy, evaluate, load_sleeves, simulate, OOS_START_MS, +) + +_VR_CACHE: dict[int, np.ndarray] = {} + + +def _vol_ratio(atr14: np.ndarray, win: int = 100) -> np.ndarray: + """vr[j] = atr14[j] / SMA(atr14, win)[j], causale. NaN dove non definito.""" + key = id(atr14) + if key in _VR_CACHE: + return _VR_CACHE[key] + a = np.asarray(atr14, dtype=float) + n = len(a) + sma = np.full(n, np.nan) + # rolling mean causale (include il bar corrente j: e' OK perche' in levels + # consumiamo vr[j-1], cioe' dati fino a j-1). + csum = np.nancumsum(np.where(np.isnan(a), 0.0, a)) + cnt = np.cumsum(~np.isnan(a)) + for j in range(n): + lo = j - win + 1 + if lo < 0: + continue + s = csum[j] - (csum[lo - 1] if lo > 0 else 0.0) + k = cnt[j] - (cnt[lo - 1] if lo > 0 else 0) + if k > 0: + sma[j] = s / k + vr = np.where((sma > 0) & ~np.isnan(a), a / sma, np.nan) + _VR_CACHE[key] = vr + return vr + + +class VolRegimeStop(ExitPolicy): + def __init__(self, r: float, k1: float, mode: str): + self.r = float(r) + self.k1 = float(k1) + self.mode = mode + self.name = f"volreg r{r} k{k1} {mode[:3]}" + + def open_trade(self, ctx: dict, i: int, d: int) -> dict: + atr_i = ctx["atr14"][i] + if not np.isfinite(atr_i) or atr_i <= 0: + atr_i = 0.0 + return {"vr": _vol_ratio(ctx["atr14"]), "atr_i": float(atr_i)} + + def levels(self, ctx: dict, i: int, d: int, j: int, st: dict): + if j - 1 < 0: + return None, self.mode + vr_prev = st["vr"][j - 1] + if not np.isfinite(vr_prev) or vr_prev <= self.r: + return None, self.mode # regime calmo -> nessuno stop + atr_i = st["atr_i"] + if atr_i <= 0: + return None, self.mode + entry = ctx["close"][i] + sl = entry - d * self.k1 * atr_i + return sl, self.mode + + +# ----------------------------------------------------------------------------- grid + +def _fmt(m: dict) -> str: + return (f"ret={m['ret']:>+7.0f}% dd={m['dd']:>4.0f}% shrp={m['sharpe']:>5.2f} " + f"worst={m['worst']:>+5.1f}% stop={m['stop_rate']:>4.1f}%") + + +def main(): + sleeves = load_sleeves() + + # baseline + print("=== BASELINE (orizzonte puro) ===") + base = {} + for a in ("BTC", "ETH"): + ctx = sleeves[a] + tr = simulate(ctx, ExitPolicy(), t_hi=OOS_START_MS) + oo = simulate(ctx, ExitPolicy(), t_lo=OOS_START_MS) + base[a] = {"train": tr, "oos": oo} + print(f" {a} TRAIN {_fmt(tr)}") + print(f" {a} OOS {_fmt(oo)}") + + rs = [1.2, 1.5] + ks = [1.5, 2.0, 3.0] + modes = ["intrabar", "close"] + + print("\n=== GRID (TRAIN only) ===") + grid = {} + for mode in modes: + print(f"\n--- mode={mode} ---") + for r in rs: + for k1 in ks: + pol = VolRegimeStop(r, k1, mode) + btc = simulate(sleeves["BTC"], pol, t_hi=OOS_START_MS) + eth = simulate(sleeves["ETH"], pol, t_hi=OOS_START_MS) + grid[(mode, r, k1)] = (btc, eth) + print(f" r={r} k1={k1}: BTC {_fmt(btc)} | ETH {_fmt(eth)}") + + # plateau check sul train: cella migliorativa se + # ETH sharpe up & dd down & worst less-neg, BTC sharpe>=95% & ret>=80% + bB_tr = base["BTC"]["train"]; bE_tr = base["ETH"]["train"] + print("\n=== TRAIN improvement map (cell = ETH sh^ dd_v worst^ AND BTC ok) ===") + improved = {} + for key, (btc, eth) in grid.items(): + eth_ok = (eth["sharpe"] > bE_tr["sharpe"] and eth["dd"] < bE_tr["dd"] + and eth["worst"] > bE_tr["worst"]) + btc_ok = (btc["sharpe"] >= 0.95 * bB_tr["sharpe"] + and btc["ret"] >= 0.80 * bB_tr["ret"]) + improved[key] = eth_ok and btc_ok + flag = "YES" if improved[key] else " . " + print(f" {key}: {flag} (ethSh {eth['sharpe']:+.2f} vs {bE_tr['sharpe']:+.2f}, " + f"ethDD {eth['dd']:.0f} vs {bE_tr['dd']:.0f}, ethW {eth['worst']:+.1f} vs {bE_tr['worst']:+.1f}, " + f"btcSh {btc['sharpe']:.2f} btcRet {btc['ret']:+.0f})") + + n_imp = sum(improved.values()) + print(f"\nTRAIN improving cells: {n_imp}/{len(grid)}") + + # OOS verdict on improving cells (guardato UNA volta) + print("\n=== OOS verdict (improving train cells) ===") + for key, ok in improved.items(): + if not ok: + continue + mode, r, k1 = key + pol = VolRegimeStop(r, k1, mode) + btc = simulate(sleeves["BTC"], pol, t_lo=OOS_START_MS) + eth = simulate(sleeves["ETH"], pol, t_lo=OOS_START_MS) + print(f" {key}: BTC {_fmt(btc)} | ETH {_fmt(eth)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/sh01_exit_policies/11_disaster_wide_intrabar.py b/scripts/analysis/sh01_exit_policies/11_disaster_wide_intrabar.py new file mode 100644 index 0000000..8a4f588 --- /dev/null +++ b/scripts/analysis/sh01_exit_policies/11_disaster_wide_intrabar.py @@ -0,0 +1,105 @@ +"""SH01 EXIT policy 11 — disaster_wide_intrabar (COMPLETENESS PROBE). + +Le 10 policy precedenti hanno tutte fallito. Diagnosi ricorrente: + - close-confirm (02,08) ALLARGA la coda su momentum-continuation (caso live + ETH 2026-06-05): il close corre oltre il livello. + - intrabar fisso (01) cappa AL livello (worst limitato) ma degrada BTC anche a k=5. + +QUESTA probe chiude il buco: intrabar cap MOLTO LARGO (k=6..12), gap-aware, +il cui UNICO scopo e' tagliare la coda catastrofica (la -14.9% ETH / il crash +live -15.6%) SENZA mai toccare i normali pullback. E' la domanda diretta: +"esiste un k cosi' largo che NON tocca BTC ma cappa la coda ETH?". + +Anti-look-ahead: sl = entry - d*k*ATR14[i], congelato in open_trade (dati<=i); +levels restituisce il livello costante, fill gap-aware nell'engine. mode=intrabar +cappa AL livello (a differenza del close-confirm che lascia correre il close). + + cd /opt/docker/PythagorasGoal && uv run python scripts/analysis/sh01_exit_policies/11_disaster_wide_intrabar.py +""" +from __future__ import annotations + +import sys + +sys.path.insert(0, "/opt/docker/PythagorasGoal") + +from scripts.analysis.sh01_exit_lab import ( # noqa: E402 + ExitPolicy, OOS_START_MS, load_sleeves, simulate, +) + + +class DisasterWideIntrabar(ExitPolicy): + def __init__(self, k: float): + self.k = float(k) + self.name = f"disaster_wide_intrabar k={k:.1f}" + + def open_trade(self, ctx, i, d): + atr = ctx["atr14"][i] + entry = ctx["close"][i] + sl = entry - d * self.k * atr if atr == atr and atr > 0 else None + return {"sl": sl} + + def levels(self, ctx, i, d, j, st): + return st["sl"], "intrabar" + + +BASELINE = { + "BTC": {"train": dict(ret=127, dd=23, sharpe=2.09, worst=-5.5), + "oos": dict(ret=41, dd=8, sharpe=2.18, worst=-3.1)}, + "ETH": {"train": dict(ret=-26, dd=61, sharpe=-0.16, worst=-14.9), + "oos": dict(ret=143, dd=7, sharpe=3.60, worst=-4.6)}, +} + + +def _row(tag, a, r): + print(f" {tag:<7s} {a}: ret={r['ret']:>+7.0f}% dd={r['dd']:>4.0f}% " + f"shrp={r['sharpe']:>5.2f} worst={r['worst']:>+6.1f}% " + f"stop={r['stop_rate']:>4.1f}% trades={r['trades']}") + + +def main(): + sleeves = load_sleeves() + KS = [5.0, 6.0, 7.0, 8.0, 10.0, 12.0] + + print("=" * 78) + print("TRAIN GRID (intrabar cap LARGO, fill gap-aware)") + print("=" * 78) + train = {} + for k in KS: + pol = DisasterWideIntrabar(k) + row = {a: simulate(sleeves[a], pol, t_hi=OOS_START_MS) for a in ("BTC", "ETH")} + train[k] = row + print(f" k={k:.1f}") + _row("TRAIN", "BTC", row["BTC"]) + _row("TRAIN", "ETH", row["ETH"]) + + b_btc, b_eth = BASELINE["BTC"]["train"], BASELINE["ETH"]["train"] + improving = [] + for k in KS: + bt, et = train[k]["BTC"], train[k]["ETH"] + eth_ok = (et["sharpe"] > b_eth["sharpe"] and et["dd"] < b_eth["dd"] + and et["worst"] > b_eth["worst"]) + btc_ok = (bt["sharpe"] >= 0.95 * b_btc["sharpe"] + and bt["ret"] >= 0.80 * b_btc["ret"]) + if eth_ok and btc_ok: + improving.append(k) + print(f" k={k:.1f} ETH_ok={eth_ok} BTC_ok={btc_ok} " + f"(BTC shrp={bt['sharpe']:.2f} ret={bt['ret']:.0f} | " + f"ETH shrp={et['sharpe']:.2f} dd={et['dd']:.0f} worst={et['worst']:.1f})") + print(f"\n improving cells (train): {improving}") + + if not improving: + print(" -> NESSUNA cella migliorativa: NO pulito, OOS non guardato.") + return + + # plateau >=3 adiacenti? poi OOS + print("\n Plateau candidate -> OOS verdetto:") + for k in improving: + oos = {a: simulate(sleeves[a], DisasterWideIntrabar(k), t_lo=OOS_START_MS) + for a in ("BTC", "ETH")} + print(f" k={k:.1f}") + _row("OOS", "BTC", oos["BTC"]) + _row("OOS", "ETH", oos["ETH"]) + + +if __name__ == "__main__": + main() diff --git a/scripts/portfolios/_defs.py b/scripts/portfolios/_defs.py index 2583677..30c0308 100644 --- a/scripts/portfolios/_defs.py +++ b/scripts/portfolios/_defs.py @@ -66,6 +66,11 @@ PORTFOLIOS = { weighting="cap", caps={"PAIRS": 0.33}), "PORT05": Portfolio("PORT05", "Master esteso", FADE + HONEST + PAIRS + TSM, weighting="cap", caps={"PAIRS": 0.33}), + # SHAPE cappata a ~mezzo sleeve equal (2026-06-05): SH01 non ha SL per design e la + # ricerca multi-agente (sh01_exit_lab, 11 famiglie di stop, 0 sopravvissute) dimostra + # che NESSUNO stop taglia la coda ETH senza rompere l'edge -> si dimezza l'esposizione + # (costo backtest ~0: FULL 6.47->6.43, OOS 8.82->8.58, FULL DD 4.10->3.96). Vedi + # docs/diary/2026-06-05-sh01-sl-research.md. "PORT06": Portfolio("PORT06", "Master + shape", FADE + HONEST + PAIRS + TSM + SHAPE, - weighting="cap", caps={"PAIRS": 0.33}, leverage=2.0), + weighting="cap", caps={"PAIRS": 0.33, "SHAPE": 0.0588}, leverage=2.0), } diff --git a/tests/portfolio/test_definitions.py b/tests/portfolio/test_definitions.py index fa7d2a6..1ebb9ef 100644 --- a/tests/portfolio/test_definitions.py +++ b/tests/portfolio/test_definitions.py @@ -10,7 +10,9 @@ def test_port06_is_master_shape_cap(): sids = set(p.sleeve_ids) assert {"SH_BTC", "SH_ETH", "TSM01", "PR_ETHBTC"} <= sids assert len(sids) == 17 - assert p.weighting == "cap" and p.caps == {"PAIRS": 0.33} + # SHAPE cappata a 0.0588 (2026-06-05): SH01 senza SL by-design, esposizione dimezzata + # (ricerca sh01_exit_lab: 11 famiglie di stop, 0 sopravvissute) + assert p.weighting == "cap" and p.caps == {"PAIRS": 0.33, "SHAPE": 0.0588} def test_default_leverage_sober():