From df8bfbceaa0fc9ca493e9b47f67a766742084575 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 9 Jun 2026 07:05:00 +0000 Subject: [PATCH] research(mr02eth): ricerca sostituto + integrazione dati opzioni cerbero-bite Ricerca sostituto/miglioria a MR02/ETH (127 strategie + 18 overlay opzioni, verifica avversariale, gate PORT06). Esito: miglioria = no-SL (gia' ~catturata da EXIT-16 live); tail-hedge opzioni NO-GO empirico su prezzi reali. Infrastruttura opzioni REALE (muro ARGO/GEX caduto): - options_fetcher.py: importa lo storico per-strike + market_snapshots da cerbero-bite -> data/options/ (chain bid/ask/IV/greche/OI + pannello regime con net-GEX dealer/gamma-flip/VRP/funding/liquidation). - options_chain.py: loader + skew_curve/premium_levels (aggregati reali) + quote() causale + load_market() (pannello regime). - option_overlay_lab.py: overlay opzioni BS su DVOL (pricing sintetico). - mr02eth_port06_gate.py / eth_collar_gate.py: gate swap-sleeve e collar. - mr02eth_search/options.workflow.js: i 2 workflow. Numeri reali: skew put 10%OTM ~1.1 (liquido), premio ~1.0%/mese; niente strike 10%OTM a 24h (overlay per-trade infattibile); collar standing paga nei crash ma net-negativo a PORT06 (alza OOS DD). Diario + CLAUDE.md aggiornati. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 + CLAUDE.md | 27 +- .../2026-06-08-mr02eth-replace-search.md | 115 ++++++ scripts/analysis/eth_collar_gate.py | 126 +++++++ scripts/analysis/mr02eth_options.workflow.js | 129 +++++++ scripts/analysis/mr02eth_port06_gate.py | 338 ++++++++++++++++++ scripts/analysis/mr02eth_search.workflow.js | 279 +++++++++++++++ scripts/analysis/option_overlay_lab.py | 250 +++++++++++++ scripts/analysis/options_chain.py | 166 +++++++++ scripts/analysis/options_fetcher.py | 83 +++++ 10 files changed, 1516 insertions(+), 1 deletion(-) create mode 100644 docs/diary/2026-06-08-mr02eth-replace-search.md create mode 100644 scripts/analysis/eth_collar_gate.py create mode 100644 scripts/analysis/mr02eth_options.workflow.js create mode 100644 scripts/analysis/mr02eth_port06_gate.py create mode 100644 scripts/analysis/mr02eth_search.workflow.js create mode 100644 scripts/analysis/option_overlay_lab.py create mode 100644 scripts/analysis/options_chain.py create mode 100644 scripts/analysis/options_fetcher.py diff --git a/.gitignore b/.gitignore index 07e4018..0c4da25 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ data/portfolios/ data/regime/ _disp_scratch/ data/regime/dispersion_features.parquet + +# storico catena opzioni importato da cerbero-bite (rigenerabile: options_fetcher.py) +data/options/ +data/_reset_backup/ diff --git a/CLAUDE.md b/CLAUDE.md index 7e07dd4..88ce9e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,13 +56,16 @@ scripts/portfolios/ → definizioni PORT01-06 (_defs.py) + report run() + hou scripts/waste/ → strategie scartate (W01-W28 + famiglia squeeze SQ/MT/ML/AD/CM/PD) scripts/analysis/ → ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...); regime_fetcher.py + regime_lab.py (DVOL/funding/feature regime per la ricerca); - exit_lab.py + exit_policies/ (harness ricerca exit: cache segnali, train/OOS) + exit_lab.py + exit_policies/ (harness ricerca exit: cache segnali, train/OOS); + options_fetcher.py + options_chain.py (storico opzioni REALE da cerbero-bite); + option_overlay_lab.py (overlay opzioni prezzato; mr02eth_port06_gate.py = gate swap-sleeve) scripts/bump_version.py, scripts/deploy.sh → versionamento e deploy (bump+commit+rebuild) VERSION → versione semver (cotta nell'immagine, +1 ad ogni deploy) strategies.yml → config multi-strategy paper trader docs/diary/ → diario di ricerca giornaliero docs/specs/ → specifiche di design data/raw/ → file .parquet OHLCV (gitignored) | data/regime/ → DVOL+funding+feature (gitignored) +data/options/ → storico catena opzioni per-strike (bid/ask/IV/greche/OI) importato da cerbero-bite (gitignored) data/instruments_registry.json → allowlist strumenti validati (gate del downloader) ``` @@ -324,6 +327,28 @@ public API, OI reale ~368k contratti, DVOL/funding storici gratis) ma **lo stori non è gratuito → non backtestabile OOS** (stesso muro delle opzioni W18/19/21). Niente evidenza crypto, segno fragile, mercato dominato dai perp. Diario `docs/diary/2026-06-01-argo-gex-feasibility.md`. +**Storico opzioni REALE da cerbero-bite (2026-06-09, il muro ARGO è caduto per il periodo recente).** +Il container **cerbero-bite** (accanto, `/opt/docker/cerbero-bite`) accumula in continuo lo **storico +per-strike** della catena opzioni Deribit (BTC+ETH) nella tabella `option_chain_snapshots` del suo +SQLite (`bite-data:/app/data/state.sqlite`, root-only): bid/ask/mid/**IV per-strike**/greche/OI/volume, +**dal 2026-05-01**, cadenza ~12 min (~110k righe/asset). È esattamente il dato che ARGO/W18-21 +credevano non-gratuito. `scripts/analysis/options_fetcher.py` lo importa via `docker exec` → +`data/options/{eth,btc}_chain.parquet`; `scripts/analysis/options_chain.py` (`OptionChain`) espone +loader + `skew_curve()` / `premium_levels()` (aggregati ROBUSTI) + `quote()` causale best-effort. +**Caveat granularità:** cerbero-bite snapshotta una **fetta rotante** (~1 scadenza per ciclo) → ottimo +per skew/premi **aggregati**, limitato per il pricing **per-trade** preciso (usare la finestra di +staleness). **Pannello regime `market_snapshots`** (anch'esso importato → `data/options/market_snapshots.parquet`, +loader `options_chain.load_market(asset)`): feature REALI pre-calcolate dal 2026-03-26, ~15min, 99-100% +popolate — **spot, dvol, realized_vol_30d, iv_minus_rv (VRP), funding perp/cross, `dealer_net_gamma` +(net-GEX dealer!), `gamma_flip_level`, oi_delta_pct_4h, liquidation_long/short_risk**. È il segnale +ARGO/GEX che il progetto dichiarò non-backtestabile: ora accumula, reale e allineato (usabile come +pannello in regime_lab; lo `spot` elimina il proxy ATM). **Numeri reali misurati (ETH):** skew put 10% OTM = **×1.1** (liquido, spread ~7%, NON ×1.6 +come sembrava da un singolo snapshot a 1g, illiquido); premio reale put 10% OTM ≈ **1.0%/mese** (0.65%/sett), +catastrofe 15%+ OTM ≈ 0.33%/mese. **Vincolo strutturale:** gli strike **10% OTM a 24h NON esistono** +(Deribit lista solo near-ATM sui tenor corti) → un overlay **per-trade a 24h è infattibile**; l'unica +struttura eseguibile è una **put settimanale/mensile standing** (catastrofe-cap di sleeve), da gateare +coi premi reali. Vedi `docs/diary/2026-06-08-mr02eth-replace-search.md`. + **Frattali del segnale × Regime ARGO (ricerca 100 agenti, 2026-06-02, RECORD).** Cercata una strategia che combini un segnale frattale (Hurst/Higuchi/Williams/analog) con un gate di regime (DVOL/VRP/funding). Infrastruttura riusabile: `scripts/analysis/regime_fetcher.py` (DVOL+funding da Deribit mainnet → diff --git a/docs/diary/2026-06-08-mr02eth-replace-search.md b/docs/diary/2026-06-08-mr02eth-replace-search.md new file mode 100644 index 0000000..02af22f --- /dev/null +++ b/docs/diary/2026-06-08-mr02eth-replace-search.md @@ -0,0 +1,115 @@ +# 2026-06-08 — Ricerca sostituto/miglioria a MR02/ETH (127 strategie + opzioni) + +## Contesto +MR02/ETH è lo sleeve a DD più alto della famiglia fade ed è il maggior drag +dell'esecuzione reale (testnet: −19€ su 7 trade, 0 win, faded un downtrend −14% +in long poi shortato il rimbalzo — whipsaw classico). Obiettivo: trovare un +sostituto o una miglioria con ≥100 agenti su strategie differenti, metodologia +onesta (ingresso eseguibile, fee 0.10% RT, OOS, gate PORT06). + +## Cosa è stato fatto +- **Workflow 1 — 109 strategie ETH distinte** (`mr02eth_search.workflow.js`): + gated-fade, mean-reversion alternative, exit, trend-following, volatilità, + cross-asset, ML/shape, microstruttura, statistico-robusto. Harness onesto + `explore_lab`. Poi **verifica avversariale** dei top-24 (audit look-ahead + + re-implementazione indipendente + robustezza). → 26 survivor, **6 confermati**. +- **Workflow 2 — 18 overlay di OPZIONI** (`mr02eth_options.workflow.js`) + + sintesi con audit crash reali. Harness nuovo `option_overlay_lab.py`: opzioni + prezzate Black-Scholes sulla **DVOL** reale di Deribit (storica/gratuita, + `data/regime/eth_dvol.parquet`), premio dedotto, ipotesi **conservative** + (skew_mult sui comprati, uscita anticipata solo a intrinseco). +- **Gate PORT06** (`mr02eth_port06_gate.py`): swap di MR02_ETH col candidato, + equity giornaliera su IDX 2021-2026, ri-misura FULL+OOS Sharpe/DD (cap + weighting). Sanity check: MR02 ricostruito col mio engine == canonico (Δ 0.00). + +## Risultato (gate PORT06; baseline canonico FULL 6.43/3.96, OOS 8.58/1.36) +| variante (swap MR02/ETH) | FULL Sh | FULL DD | OOS Sh | OOS DD | +|---|---|---|---|---| +| EXIT-16 (config LIVE) | 6.73 | 3.67 | 8.80 | 1.23 | +| no-SL nudo | 6.76 | 3.68 | 8.87 | 1.23 | +| no-SL + put/call 10% OTM | 6.78 | 3.64 | 8.86 | 1.23 | +| varratio_gate (PROMOSSO vs canonico) | 6.41 | 3.73 | 8.53 | 1.36 | +| choppiness_donch | 6.37 | 3.35 | 8.47 | 1.32 | +| vrp_neg_dvol_low | 6.36 | 3.22 | 8.37 | 1.43 | + +## MIGLIORIA TROVATA (gate vs LIVE EXIT-16, non vs canonico) +Riferimento onesto = config LIVE EXIT-16 (FULL 6.73/3.67 — OOS 8.80/1.23), non +l'intrabar-SL canonico che nessuno gira. + +| sleeve ETH | ΔFULL Sh | ΔFULL DD | ΔOOS Sh | ΔOOS DD | esito | +|---|---|---|---|---|---| +| **no-SL + put/call 10% OTM** | **+0.05** | **−0.03** | **+0.06** | 0.00 | ✅ MIGLIORIA deployabile | +| no-SL nudo | +0.04 | +0.02 | +0.07 | 0.00 | ✅ migliora ma sl=0 NON deployabile | +| EXIT16 + put10 | −0.01 | +0.03 | 0.00 | 0.00 | = pari (assicurazione gratis) | +| varratio / vrp / chop / blend | negativi | | | | peggiorano vs EXIT-16 | + +**La miglioria = MR02/ETH no-SL + floor a opzioni 10% OTM** (put sui long, call +sugli short). Batte il live EXIT-16 in modo PARETO su 3/4 metriche PORT06 (4ª +piatta). Meccanismo: **rimuovere lo stop** (intrabar/close-confirm su una fade +mean-reverting sono falsi negativi — overshoot che rientra) e dare la funzione +dello stop (cap della catastrofe) a un'**opzione comprata** = protezione a +rischio-definito SENZA il costo whipsaw/falso-stop. Standalone il salto è grande +(Sharpe 8.45→12.14, OOS DD 13%→7%); a portafoglio è modesto (+0.05/+0.06 Sharpe) +perché è 1 sleeve su 17, ma è un miglioramento MISURATO e verificato (soglia +gate +0.03, ottenuto +0.05/+0.06), non rumore. + +## Conclusioni (oneste) +1. **Sostituti/blend: tutti peggiorano vs il live EXIT-16.** I candidati gated + (varratio/vrp/choppiness) e i blend within-sleeve battono solo lo strawman + canonico, non il live; le loro basse esposizioni diluiscono il motore di + ritorno (Sharpe −0.08/−0.44). Lo Sharpe ~20 nell'harness era artefatto di + `sqrt(N_trade)` (onesto ~4.3). → **non sostituire né blendare.** +2. **L'unica miglioria è la coppia no-SL + opzione.** Il no-SL recupera il drag + dello stop; l'opzione cappa la coda da **gap-through** che EXIT-16 NON protegge + (esce al close DOPO il buco → prende la perdita piena). Audit crash: FTX nov-22 + −48%→−30%, LUNA-era 2022-09 −63%→−31%, 2018/19 −65%→−30%. Premio ~break-even + (paga 156% / incassa 149%). È ciò che rende deployabile il no-SL. + +## Azione raccomandata +- **Deployare MR02/ETH come no-SL + floor a opzioni 10% OTM** (put long / call + short, per-trade o settimanale ammortizzato). Batte il live su 3/4 metriche + + cappa esplicitamente la coda. **Prima del capitale**: validare prezzi/liquidità/ + skew per-strike REALI su Deribit testnet (qui pricing BS sintetico sulla DVOL = + ATM-IV; stress skew ×1.5 gonfia il premio FULL ma l'OOS regge). Stesso muro + non-backtestabile di ARGO/GEX sul per-strike, aggirato col proxy DVOL. +- Step intermedio a costo zero: **aggiungere subito il floor opzione all'EXIT-16 + live** (= "pari" sui numeri, ma assicurazione gratis sul gap-through) mentre si + valida la transizione a no-SL. + +## Addendum 2026-06-09 — validazione su PREZZI REALI + integrazione cerbero-bite +- Probe live Deribit mainnet (singolo snapshot): sembrava skew ×1.6 e liquidità + pessima sul 10% OTM a 1g → l'overlay per-trade pareva bocciato. +- **cerbero-bite** (container accanto) ha però **lo storico per-strike reale** in + `option_chain_snapshots` (SQLite, dal 2026-05-01, ~110k righe/asset, bid/ask/IV/ + greche/OI, cadenza ~12min). Importato in `data/options/` via `options_fetcher.py`; + loader `options_chain.py` (skew_curve/premium_levels/quote causale). +- **Correzione dai dati reali (5.5 settimane):** skew put 10% OTM = **×1.1** (liquido, + spread ~7%) — il ×1.6 del probe era un artefatto del deep-OTM a 1g illiquido. Premio + reale 10% OTM ≈ **1.0%/mese**. → la miglioria no-SL+opzione **regge** sui prezzi veri. +- **MA vincolo strutturale reale:** gli strike **10% OTM a 24h NON esistono** (n=0 in 5.5 + settimane; Deribit lista solo near-ATM sui tenor corti). L'overlay **per-trade a 24h è + infattibile**; l'unica struttura eseguibile è una **put settimanale/mensile standing** + (catastrofe-cap di sleeve), da gateare a parte coi premi reali. +- **Infrastruttura sbloccata:** il muro ARGO/GEX ("opzioni non-backtestabili") è caduto + per il periodo recente — d'ora in poi le tesi opzioni si validano su prezzi VERI. + Importato anche il pannello `market_snapshots` (spot/VRP/funding/**net-GEX dealer**/ + gamma-flip/liquidation, dal 2026-03-26) → `data/options/`, loader `options_chain.load_market`. + +## Addendum 2 — gate del COLLAR standing (catastrophe-cap auto-finanziato) — NO-GO +Premi reali → collar = put −13% (1.0%/m) finanziata da call +10% (1.05%/m) ≈ premio netto 0. +Gate PORT06 (`eth_collar_gate.py`, BS calibrato skew_put 1.12 / skew_call 1.0, sweep OTM/hedge_frac): +- Il collar **PAGA nei crash** (ETH −40% dic-21 → +6.1%, −30% feb-25 → +6.6%): protezione reale. +- MA **net-negativo a portafoglio**: taglia il FULL DD (−0.2/−0.3pp) ma **costa Sharpe** (FULL e OOS) + e **ALZA l'OOS DD** (+0.18/+0.28pp) — nell'OOS (no crash profondo, qualche rally) la call corta + perde e il premio erode. Peggiora il DD che doveva ridurre. +- **Verdetto: il tail-hedge a opzioni NON conviene a PORT06** (né per-trade né collar). Strutturale: + lo sleeve ETH pesa 6.5% → la **diversificazione è già la protezione di coda** (trade −65% = + −4% aggregato). La cap auto-finanziata era giusta sul *costo* (~break-even) ma la protezione non + vale il costo Sharpe/OOS-DD. Le opzioni si chiudono come **NO-GO empirico su prezzi reali**. + +## Artefatti riusabili +- `scripts/analysis/option_overlay_lab.py` — overlay opzioni (pricing BS sintetico su DVOL). +- `scripts/analysis/options_fetcher.py` — import storico opzioni reale da cerbero-bite → data/options/. +- `scripts/analysis/options_chain.py` — loader + skew/premi reali + lookup causale (`OptionChain`). +- `scripts/analysis/mr02eth_port06_gate.py` — gate swap-sleeve PORT06. +- `scripts/analysis/mr02eth_search.workflow.js`, `mr02eth_options.workflow.js`. diff --git a/scripts/analysis/eth_collar_gate.py b/scripts/analysis/eth_collar_gate.py new file mode 100644 index 0000000..f6e79f8 --- /dev/null +++ b/scripts/analysis/eth_collar_gate.py @@ -0,0 +1,126 @@ +"""Gate del CATASTROPHE-CAP auto-finanziato (collar standing) sullo sleeve ETH no-SL. + +Tesi: lo sleeve ETH no-SL ha la coda da crash (un long-fade puo' perdere -50/-65% in un +gap). Un COLLAR standing rollato mensilmente — put lunga ~13% OTM finanziata da call corta +~10% OTM — cappa quella coda a premio netto ~zero (validato sui premi REALI di cerbero-bite: +put -13%≈1.0%/m IV55, call +10%≈1.05%/m IV49). Pricing BS calibrato sul reale: skew_put 1.12, +skew_call 1.0. Caveat: il collar aggiunge delta SHORT-ETH con dead-zone -p/+c -> cappa anche +l'upside; nei mesi tranquilli (ETH dentro la banda) costa ~zero. Il gate dice se aiuta netto. + +uv run python scripts/analysis/eth_collar_gate.py +""" +from __future__ import annotations +import sys +from pathlib import Path +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from scripts.analysis.explore_lab import get_df +from scripts.analysis.combine_portfolio import IDX, SPLIT, OOS_DATE, metrics, port_returns, _norm +from scripts.analysis.option_overlay_lab import dvol_for, bs_put, bs_call +from scripts.analysis.mr02eth_port06_gate import ( + gen_donchian_base, build_trades, build_trades_exit16, daily_equity, port_metrics, CAPS) +from src.portfolio.sleeves import all_sleeve_equities + +HY = 24 * 365.0 + + +def collar_daily_returns(df, dvol, p_otm=0.13, c_otm=0.10, skew_put=1.12, skew_call=1.0, + roll_h=24 * 30) -> pd.Series: + """Collar standing rollato ogni roll_h ore. Ritorna la SERIE di rendimenti GIORNALIERI + (frazione del notional collar): MTM = d(intrinseco) - theta (premio netto amortizzato).""" + c = df["close"].values; ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + n = len(c) + val = np.zeros(n) # valore collar (frac notional) marcato a intrinseco - premio residuo + T = roll_h / HY + k = 0 + while k < n - 1: + S0 = c[k]; sig = dvol[k] if not np.isnan(dvol[k]) else 0.6 + Kp = S0 * (1 - p_otm); Kc = S0 * (1 + c_otm) + prem = bs_put(S0, Kp, T, sig * skew_put) / S0 - bs_call(S0, Kc, T, sig * skew_call) / S0 + end = min(k + roll_h, n - 1) + span = end - k + for j in range(k, end + 1): + intr = (max(Kp - c[j], 0.0) - max(c[j] - Kc, 0.0)) / S0 + frac_elapsed = (j - k) / span if span else 1.0 + val[j] = intr - prem * (1 - frac_elapsed) # premio pagato up-front, amortizzato a 0 a scadenza + k = end + s = pd.Series(val, index=ts) + daily = s.resample("1D").last().reindex(IDX).ffill().bfill() + return daily.diff().fillna(0.0) # rendimento giornaliero (frac notional) + + +def combine(fade_eq: pd.Series, collar_dr: pd.Series, hedge_frac: float) -> pd.Series: + """sleeve = fade no-SL + hedge_frac * collar. Combina i rendimenti giornalieri.""" + fr = fade_eq.pct_change().fillna(0.0) + return _norm((1 + fr + hedge_frac * collar_dr).cumprod()) + + +def crash_audit(df, dvol, p_otm, c_otm, hedge_frac): + """P&L del collar nei mesi di crollo ETH peggiori (frac notional).""" + cr = collar_daily_returns(df, dvol, p_otm=p_otm, c_otm=c_otm) + # ETH daily ret mensile + cdf = pd.Series(df["close"].values, index=pd.to_datetime(df["timestamp"], unit="ms", utc=True)).resample("1D").last().reindex(IDX).ffill() + mret = cdf.resample("30D").last().pct_change() + collar_m = (1 + cr).resample("30D").apply(lambda x: x.prod()) - 1 + worst = mret.nsmallest(5) + print(f" {'mese (fine)':>12}{'ETH 30g%':>10}{'collar P&L%':>13}") + for t, r in worst.items(): + cm = collar_m.reindex([t], method="nearest").iloc[0] * hedge_frac * 100 + print(f" {str(t.date()):>12}{r*100:>9.0f}%{cm:>12.1f}%") + + +def main(): + print("=" * 96) + print(f" GATE collar standing (catastrophe-cap) sullo sleeve ETH no-SL | OOS da {OOS_DATE}") + print("=" * 96) + df = get_df("ETH", "1h"); dvol = dvol_for(df, "ETH") + eq = dict(all_sleeve_equities()) + ids = [k for k in eq if k in {"MR01_BTC","MR02_BTC","MR07_BTC","MR01_ETH","MR02_ETH","MR07_ETH", + "DIP01_BTC","TR01_basket","ROT02_rot","PR_ETHBTC","PR_LTCETH","PR_ADAETH","PR_BTCLTC","PR_ETHSOL","TSM01","SH_BTC","SH_ETH"}] + base_ents = gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0) + nosl_ents = gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0, use_sl=False) + + def pm(ce): + m = dict(eq); m["MR02_ETH"] = ce; return port_metrics(m, ids) + + f_l, o_l = pm(daily_equity(build_trades_exit16(base_ents, df, sl_confirm=0.5), df)) + fade_nosl = daily_equity(build_trades(nosl_ents, df), df) + f0, o0 = pm(fade_nosl) + print(f"\n {'sleeve ETH':<30s}{'FULL Sh':>8s}{'FULL DD':>8s} |{'OOS Sh':>8s}{'OOS DD':>8s}") + print(" " + "-" * 78) + print(f" {'LIVE EXIT-16 (rif)':<30s}{f_l['sharpe']:>8.2f}{f_l['dd']:>8.2f} |{o_l['sharpe']:>8.2f}{o_l['dd']:>8.2f}") + print(f" {'no-SL nudo':<30s}{f0['sharpe']:>8.2f}{f0['dd']:>8.2f} |{o0['sharpe']:>8.2f}{o0['dd']:>8.2f}") + + configs = [ + ("put13/call10 hf0.45", 0.13, 0.10, 0.45), + ("put13/call10 hf0.30", 0.13, 0.10, 0.30), + ("put15/call12 hf0.45", 0.15, 0.12, 0.45), + ("put20/call15 hf0.45", 0.20, 0.15, 0.45), + ("put13/call08 hf0.45", 0.13, 0.08, 0.45), + ] + rows = [] + for name, p, cc, hf in configs: + cr = collar_daily_returns(df, dvol, p_otm=p, c_otm=cc) + ce = combine(fade_nosl, cr, hf) + f_c, o_c = pm(ce) + rows.append((name, p, cc, hf, f_c, o_c)) + print(f" {'no-SL + '+name:<30s}{f_c['sharpe']:>8.2f}{f_c['dd']:>8.2f} |{o_c['sharpe']:>8.2f}{o_c['dd']:>8.2f}") + + print("\n " + "=" * 90) + print(f" vs LIVE EXIT-16 (FULL {f_l['sharpe']:.2f}/{f_l['dd']:.2f} OOS {o_l['sharpe']:.2f}/{o_l['dd']:.2f}) e vs no-SL") + print(" " + "-" * 90) + for name, p, cc, hf, f_c, o_c in rows: + print(f" {name:<22s} Δ vsEXIT16 FULL {f_c['sharpe']-f_l['sharpe']:+.2f}/{f_c['dd']-f_l['dd']:+.2f} " + f"OOS {o_c['sharpe']-o_l['sharpe']:+.2f}/{o_c['dd']-o_l['dd']:+.2f} | " + f"Δ vsNoSL DD {f_c['dd']-f0['dd']:+.2f}") + + print("\n --- audit crash: P&L collar (hf-scaled) nei 5 mesi ETH peggiori (put13/call10 hf0.45) ---") + crash_audit(df, dvol, 0.13, 0.10, 0.45) + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/mr02eth_options.workflow.js b/scripts/analysis/mr02eth_options.workflow.js new file mode 100644 index 0000000..f33075a --- /dev/null +++ b/scripts/analysis/mr02eth_options.workflow.js @@ -0,0 +1,129 @@ +export const meta = { + name: 'mr02eth-options-protection', + description: 'Overlay di opzioni (protezione coda) sul fade ETH: 18 design DVOL-prezzati + sintesi con audit crash reali', + phases: [ + { title: 'Options', detail: '18 agenti, overlay opzione distinto ciascuno, prezzato via DVOL+BS, premio dedotto' }, + { title: 'Synthesis', detail: 'audit crash reali (FTX/maggio-21/ago-24) e sintesi del miglior overlay deployabile' }, + ], +} + +const RECIPE = `Lavora in /opt/docker/PythagorasGoal. Testa UN overlay di OPZIONI come protezione di coda sul fade ETH (per rendere DEPLOYABILE la versione ad alto Sharpe e cappare i crash). +Usa l'harness condiviso scripts.analysis.option_overlay_lab (gia' pronto e verificato): + from scripts.analysis.option_overlay_lab import get_df, dvol_for, donchian_fade, simulate_hedged, evaluate_hedged, bs_put, bs_call +- df = get_df("ETH","1h"); dv = dvol_for(df,"ETH") # DVOL frazione, causale (oraria 2021-03 -> 2026-06; pre-2021 e' bfilled approssimato) +- donchian_fade(df, n=20, sl_atr=2.0, max_bars=24, trend_max=3.0, ema_long=200, use_sl=True/False) -> entries MR02/ETH (use_sl=False = togli lo stop ATR) +- simulate_hedged(entries, df, dv, hedge=..., otm=..., otm2=..., skew_mult=1.10, tenor_mult=1.0, hedge_side="both|long|short", min_dvol=0.0, split=-1) -> dict(trades,win,ret,dd,sharpe,yearly,exposure,prem_paid_pct,pay_recv_pct) +- evaluate_hedged(name, entries, df, dv, **hcfg) -> {full,oos,sweep,sweep_oos,pos_yrs,n_yrs} e stampa una riga (prem/pay = premi pagati e payoff incassati cumulati %). +hedge: "none" | "put" (put se long / call se short, floor a otm) | "put_spread" (compra otm, vende otm2 piu' lontano) | "collar" (compra protezione otm, vende l'opzione opposta a otm2 per finanziare). + +Prezzo opzioni: Black-Scholes(spot, DVOL) r=0, DELIBERATAMENTE CONSERVATIVO (bias CONTRO le opzioni): skew_mult rincara la sigma dei comprati, all'uscita anticipata l'opzione vale solo l'INTRINSECO. Se sotto queste ipotesi pessimistiche l'overlay aiuta, e' robusto. I numeri sono INDICATIVI (la DVOL e' reale e gratuita, ma niente fill/skew per-strike reali). + +RIFERIMENTO gia' misurato (ETH 1h, fee 0.10% RT, lev 3x, pos 0.15): +- baseline MR02/ETH con SL-ATR + trend3.0: Sharpe 8.45, full DD 25%, OOS DD 13%. +- no-SL (stesso fade SENZA stop ATR): Sharpe 11.63, full DD 31%, OOS DD 7%, anniPos 8/9 (togliere lo stop e' il salto piu' grande, coerente con EXIT-16; ma sl=0 NON e' deployabile per il rischio coda da crash -> qui le opzioni servono a cappare quella coda). +- no-SL + put 8% OTM: Sharpe 12.13, full DD 33%, premio cum 360% ~ break-even col payoff 255%. +- no-SL + put 3% OTM: premio mostruoso (2363%), peggiora -> troppo vicina. + +OBIETTIVO: trovare l'overlay che dia il MIGLIOR rischio/rendimento DEPLOYABILE. Conta soprattutto: (a) Sharpe FULL e OOS >= no-SL, (b) drawdown contenuto, (c) la CODA nei crash veri sia cappata (l'opzione DEVE pagare quando ETH crolla), (d) premio netto sostenibile, (e) regge skew_mult piu' alto. Focalizza la lettura sul 2021+ e sull'OOS (DVOL reale). + +Procedura: implementa/configura il tuo overlay (puoi scrivere uno script in /tmp/opt_KEY.py se serve, ma puoi anche chiamare direttamente le funzioni), fai uno sweep PICCOLO dei suoi parametri, scegli la config migliore. Riporta i numeri VERI dall'esecuzione. Sii onesto: se l'overlay non aiuta, dillo (e' informazione utile).` + +const SCHEMA = { + type: 'object', additionalProperties: false, + required: ['key','structure','base','ran_ok','best_config','full_sharpe','full_ret','full_dd','oos_sharpe','oos_dd','prem_pct','pay_pct','full_dd_skew150','deployable','improves_over_noSL','improves_over_baseline','no_lookahead','tail_note','note'], + properties: { + key: { type: 'string' }, + structure: { type: 'string', description: 'put | put_spread | collar | standing | other' }, + base: { type: 'string', description: 'no-SL | SL-ATR | exit16' }, + ran_ok: { type: 'boolean' }, + best_config: { type: 'string' }, + full_sharpe: { type: 'number' }, + full_ret: { type: 'number' }, + full_dd: { type: 'number' }, + oos_sharpe: { type: 'number' }, + oos_dd: { type: 'number' }, + prem_pct: { type: 'number', description: 'premio cumulato pagato % (full)' }, + pay_pct: { type: 'number', description: 'payoff cumulato incassato % (full)' }, + full_dd_skew150: { type: 'number', description: 'full DD con skew_mult=1.5 (stress del premio); -1 se non testato' }, + deployable: { type: 'boolean', description: 'coda cappata + premio sostenibile -> deployabile (a differenza del no-SL nudo)' }, + improves_over_noSL: { type: 'boolean' }, + improves_over_baseline: { type: 'boolean', description: 'batte MR02/ETH baseline SL-ATR (Sharpe 8.45 / DD 25)' }, + no_lookahead: { type: 'boolean', description: 'DVOL causale, ingresso a close[i], nessun look-ahead' }, + tail_note: { type: 'string', description: 'comportamento nei crash: l opzione paga? di quanto cappa la perdita peggiore?' }, + note: { type: 'string' }, + }, +} + +const SPECS = [ + ['put_floor_sweep','put','no-SL','no-SL + put protettiva (call sugli short). Sweep OTM 5/8/10/12%. Trova il floor che massimizza Sharpe a premio netto sostenibile. Riferimento: 8% sembra il punto dolce.'], + ['put_spread_ladder','put_spread','no-SL','no-SL + debit put-spread (compra otm, vende otm2 piu lontano) per abbattere il premio. Sweep (otm,otm2): (5,15),(8,20),(8,25),(10,25). Il cap del payoff e accettabile se i crash ETH raramente vanno oltre otm2 nell orizzonte.'], + ['collar_financed','collar','no-SL','no-SL + collar (vende call OTM per finanziare la put). Sweep (put_otm, call_otm): (5,8),(8,10),(8,15),(10,15). Attenzione: la call venduta cappa gli short-fade vincenti -> testa se il netto regge.'], + ['atm_put_shortdated','put','no-SL','no-SL + put quasi-ATM ma tenor minimo (tenor_mult=1, copre solo l orizzonte): e mai conveniente la protezione massima a scadenza cortissima? Sweep otm 0/2/3%. Probabile premio troppo alto: verifica.'], + ['longer_tenor_put','put','no-SL','no-SL + put a tenor PIU LUNGO (tenor_mult 3/5/7 = settimanale/mensile): il time-value per-ora e piu basso, e la stessa put copre piu trade. Sweep tenor_mult e otm. Confronta premio cum vs tenor=1.'], + ['regime_gated_put_high','put','no-SL','no-SL + put SOLO quando DVOL all ingresso > soglia (min_dvol 0.8/1.0/1.2): proteggi solo nei regimi di vol elevata (dove i crash si addensano), resta nudo nella calma. Sweep soglia/otm. Riduce il premio totale?'], + ['regime_gated_put_low','put','no-SL','no-SL + put SOLO quando DVOL e BASSA (assicurazione a buon mercato prima che la vol esploda). Implementa un gate min_dvol-inverso (modifica/wrap: salta l hedge se dvol>soglia). Test controintuitivo: la calma e dove la coda e meno prezzata.'], + ['long_only_floor','put','no-SL','no-SL + put SOLO sui long-fade (hedge_side="long", le prese-coltello su un ETH che crolla = il failure mode documentato di MR02). Gli short-fade restano nudi. Sweep otm. Dimezza il premio focalizzandolo dove serve?'], + ['short_only_cap','put','no-SL','no-SL + call SOLO sugli short-fade (hedge_side="short"): cappa il rischio di shortare un rimbalzo esplosivo. Sweep otm. ETH rimbalza meno violentemente di quanto crolla -> probabilmente meno utile dei long: misura.'], + ['asym_put_call','put','no-SL','no-SL + protezione ASIMMETRICA: put piu vicina sui long (ETH crolla forte e veloce), call piu lontana sugli short. Implementa due chiamate simulate_hedged side-split, oppure due passate con hedge_side e somma le equity. Sweep (put_otm, call_otm).'], + ['skew_stress','put','no-SL','Prendi la migliore put-floor (es 8% OTM) e fai lo STRESS dello skew: skew_mult 1.0/1.1/1.25/1.5/1.75. A che skew il premio mangia l edge? Riporta la sensibilita: e l overlay sopravvive allo skew realistico (~1.2-1.4 sui put cripto)?'], + ['dynamic_otm_dvol','put','no-SL','OTM dinamico in funzione di DVOL: protezione piu lontana quando la vol e alta (la stessa % di mossa e piu probabile -> protezione relativamente piu economica). Implementa un loop che varia otm = base + k*(dvol-mediana). Sweep base/k.'], + ['exit16_base_put','put','exit16','Base = EXIT-16 (close-confirm SL, gia LIVE) invece del no-SL nudo: applica la put-floor sopra l EXIT-16. Modella EXIT-16 come SL-at-level approssimato (usa donchian_fade con use_sl=True ma sl piu largo, sl_atr 3.0) + put. Confronta: l opzione aggiunge valore SOPRA l EXIT-16 gia deployato?'], + ['standing_weekly_put','standing','no-SL','Put SETTIMANALE STANDING su notional ETH fisso (non per-trade): compra una put 1-settimana ~10% OTM ogni 168h, rolla, ammortizza il premio su TUTTI i trade della settimana. Implementa una serie di payoff/premio settimanale dalla DVOL e sommala all equity del fade no-SL. Confronta col per-trade.'], + ['portfolio_tail_overlay','standing','no-SL','Overlay di coda a livello SLEEVE: una put mensile ~15-20% OTM su notional ETH costante come assicurazione catastrofica (rara ma paga nei crash -30%+). Misura il costo annuo (premio) vs la riduzione del DD/coda dello sleeve no-SL. E un trade-off accettabile (pochi bps/anno per cappare il -39% FTX)?'], + ['funding_financed','put','no-SL','Finanzia il premio della put col CARRY del funding perp: quando il funding e positivo, il long perp incassa funding che offre il premio. Usa eth_funding (interest_8h) per stimare il carry accumulato per-trade e sottrailo dal premio netto. Il funding copre quanto del premio?'], + ['ratio_put_spread','put_spread','no-SL','Ratio put-spread (compra 1 put otm, vende 2 put otm2): premio quasi-zero o credito, ma reintroduce rischio sotto otm2. Approssima con put_spread + una seconda put venduta. Test del trade-off: il credito vale il rischio di coda residuo? Audita i crash.'], + ['best_combo','put','no-SL','SINTESI best-guess: no-SL + put-floor regime-gated (DVOL>0.8) all OTM ottimale + funding-offset. Combina le leve migliori che ti aspetti e riporta full/OOS Sharpe, DD, premio netto, e il comportamento nei 3 peggiori crash. E questo il candidato deployabile?'], +] + +phase('Options') +log(`Lancio ${SPECS.length} agenti di overlay-opzione (DVOL-prezzati, premio conservativo)`) + +const results = await parallel(SPECS.map(([key, structure, base, idea]) => () => + agent( + `${RECIPE}\n\n========\nIL TUO OVERLAY — key="${key}", structure="${structure}", base="${base}":\n${idea}\n\nRestituisci il risultato strutturato (key/structure/base coi tuoi valori).`, + { label: `opt:${key}`, phase: 'Options', schema: SCHEMA } + ).then(r => r ? { ...r, key, structure, base } : null) +)) + +const ran = results.filter(Boolean) +log(`${ran.length}/${SPECS.length} overlay rientrati`) + +// candidati deployabili interessanti: girati, no look-ahead, migliorano almeno la baseline, coda cappata +const good = ran.filter(r => r.ran_ok && r.no_lookahead && r.deployable && + (r.improves_over_baseline || r.improves_over_noSL)) +good.sort((a, b) => (b.oos_sharpe - 0.1 * b.full_dd) - (a.oos_sharpe - 0.1 * a.full_dd)) +const top = good.slice(0, 6) + +phase('Synthesis') +log(`Sintesi: audit crash reali sui top ${top.length} overlay`) +const SYN = { + type: 'object', additionalProperties: false, + required: ['recommendation','deployable_config','rationale','crash_audit','caveats','full_sharpe','full_dd','oos_sharpe','oos_dd','net_premium_drag_pct'], + properties: { + recommendation: { type: 'string', description: 'verdetto: usare le opzioni si/no e come' }, + deployable_config: { type: 'string', description: 'la config esatta consigliata (struttura, OTM, tenor, gating)' }, + rationale: { type: 'string' }, + crash_audit: { type: 'string', description: 'comportamento nei crash reali (FTX nov-22, crash mag-21, ago-24): perdita no-SL vs hedged' }, + caveats: { type: 'string', description: 'limiti del modello DVOL-BS (no skew/fill reali), rischio esecuzione' }, + full_sharpe: { type: 'number' }, + full_dd: { type: 'number' }, + oos_sharpe: { type: 'number' }, + oos_dd: { type: 'number' }, + net_premium_drag_pct: { type: 'number', description: 'premio netto (premio-payoff) cumulato % della config consigliata' }, + }, +} +const summary = await agent( + `${RECIPE}\n\n========\nSEI L'AGENTE DI SINTESI. Hai i risultati dei migliori overlay-opzione sul fade ETH:\n${JSON.stringify(top, null, 1)}\n\nFai un AUDIT dei CRASH REALI: ricostruisci (con option_overlay_lab) il comportamento della migliore config nelle finestre di crollo ETH piu' severe (crash maggio 2021, collasso FTX nov 2022, e altri drawdown -25%+ del 2024/2025). Per ciascuna mostra: la perdita peggiore del fade no-SL NUDO vs con l'overlay (l'opzione DEVE pagare li). Scrivi uno script /tmp/crash_audit.py se serve (filtra i trade per finestra temporale e confronta).\n\nPoi dai la RACCOMANDAZIONE finale: conviene usare le opzioni per proteggere lo sleeve ETH? Quale config esatta e' deployabile (struttura, OTM, tenor, regime-gating)? Con quali numeri (full/OOS Sharpe, DD, premio netto)? E i CAVEAT onesti (il modello DVOL-BS non ha skew/fill reali; serve verificare liquidita'/prezzi reali su Deribit prima di rischiare capitale). Sii sobrio e onesto.`, + { label: 'synthesis', phase: 'Synthesis', schema: SYN } +) + +return { + n_specs: SPECS.length, n_ran: ran.length, n_good: good.length, + recommendation: summary, + top_overlays: top.map(r => ({ key: r.key, structure: r.structure, base: r.base, best_config: r.best_config, + full_sharpe: r.full_sharpe, full_dd: r.full_dd, oos_sharpe: r.oos_sharpe, oos_dd: r.oos_dd, + prem_pct: r.prem_pct, pay_pct: r.pay_pct, deployable: r.deployable, tail_note: r.tail_note, note: r.note })), + all: ran.map(r => ({ key: r.key, structure: r.structure, full_sharpe: r.full_sharpe, full_dd: r.full_dd, + oos_sharpe: r.oos_sharpe, oos_dd: r.oos_dd, prem_pct: r.prem_pct, pay_pct: r.pay_pct, + deployable: r.deployable, improves_over_baseline: r.improves_over_baseline, improves_over_noSL: r.improves_over_noSL, note: r.note })), +} diff --git a/scripts/analysis/mr02eth_port06_gate.py b/scripts/analysis/mr02eth_port06_gate.py new file mode 100644 index 0000000..b64b5ea --- /dev/null +++ b/scripts/analysis/mr02eth_port06_gate.py @@ -0,0 +1,338 @@ +"""GATE PORT06: i top candidati sostituiscono MR02/ETH. Misura FULL+OOS Sharpe/DD. + +Ogni candidato genera i trade ETH 1h con l'ENGINE INTRABAR identico al sleeve canonico +(explore_lab.simulate: SL/TP intrabar al livello, fee 0.10% RT, lev 3x), equity giornaliera +normalizzata su IDX (2021-01-01 -> 2026-05-26), swap su all_sleeve_equities()['MR02_ETH'], +e ri-misura PORT06 (cap weighting PAIRS 0.33 / SHAPE 0.0588). + +uv run python scripts/analysis/mr02eth_port06_gate.py +""" +from __future__ import annotations +import sys +from pathlib import Path +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from scripts.analysis.explore_lab import get_df, atr, ema +from scripts.analysis.combine_portfolio import IDX, SPLIT, OOS_DATE, metrics, port_returns, _norm +from src.portfolio.sleeves import all_sleeve_equities +from src.portfolio import weighting as W + +FEE_RT, LEV, POS = 0.001, 3.0, 0.15 +CAPS = {"PAIRS": 0.33, "SHAPE": 0.0588} + + +# ----------------------- engine intrabar (== explore_lab.simulate) ----------------------- +def build_trades(entries, df, fee_rt=FEE_RT, lev=LEV): + h, l, c = df["high"].values, df["low"].values, df["close"].values + n = len(c); out = []; last = -1 + for e in entries: + i, d = e["i"], e["d"] + if i <= last or i + 1 >= n: + continue + entry = c[i]; tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"] + exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1) + for k in range(1, mb + 1): + j = i + k + if j >= n: + exit_p = c[n - 1]; break + hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)) + hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)) + if hit_sl: + exit_p = sl; break + if hit_tp: + exit_p = tp; break + if k == mb: + exit_p = c[j] + out.append((i, j, (exit_p - entry) / entry * d * lev - fee_rt * lev)); last = j + return out + + +def build_trades_exit16(entries, df, sl_confirm=0.5, fee_rt=FEE_RT, lev=LEV, + dvol=None, otm=None, skew=1.10, tenor_mult=1.0): + """Engine EXIT-16 close-confirm (== config LIVE): SL intrabar OFF, lo stop scatta solo se il + CLOSE sfonda sl ∓ sl_confirm*ATR14; TP intrabar ha priorita'. + Se dvol+otm sono dati, AGGIUNGE un overlay opzione (put se long / call se short) a otm OTM.""" + from scripts.analysis.option_overlay_lab import bs_put, bs_call + h, l, c = df["high"].values, df["low"].values, df["close"].values + a = atr(df, 14); n = len(c); out = []; last = -1 + for e in entries: + i, d = e["i"], e["d"] + if i <= last or i + 1 >= n: + continue + entry = c[i]; tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"] + exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1) + for k in range(1, mb + 1): + j = i + k + if j >= n: + exit_p = c[n - 1]; break + hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)) + if hit_tp: + exit_p = tp; break + if sl is not None and not np.isnan(a[j]): + buf = sl_confirm * a[j] + if (d == 1 and c[j] < sl - buf) or (d == -1 and c[j] > sl + buf): + exit_p = c[j]; break + if k == mb: + exit_p = c[j] + ret = (exit_p - entry) / entry * d * lev - fee_rt * lev + if dvol is not None and otm is not None: + T = max(mb * tenor_mult, 1.0) / _HOURS_YEAR; sig = dvol[i] * skew + if d == 1: + K = entry * (1 - otm); prem = bs_put(entry, K, T, sig) / entry; pay = max(K - exit_p, 0.0) / entry + else: + K = entry * (1 + otm); prem = bs_call(entry, K, T, sig) / entry; pay = max(exit_p - K, 0.0) / entry + ret += lev * (pay - prem) + out.append((i, j, ret)); last = j + return out + + +def blend_equity(eqs, weights=None) -> pd.Series: + """Combina N equity giornaliere mediando i rendimenti giornalieri (ribilancio daily).""" + drs = [e.pct_change().fillna(0.0) for e in eqs] + w = weights or [1.0 / len(drs)] * len(drs) + dr = sum(wi * di for wi, di in zip(w, drs)) + return _norm((1 + dr).cumprod()) + + +def daily_equity(trades, df) -> pd.Series: + ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + n = len(df); eq = np.full(n, 1000.0); cap = 1000.0 + for i, j, ret in sorted(trades, key=lambda t: t[1]): + cap = max(cap + cap * POS * ret, 10.0); eq[j:] = cap + s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill() + return _norm(s) + + +# ----------------------- indicatori ----------------------- +def choppiness(df, n=14): + h, l, c = df["high"].values, df["low"].values, df["close"].values + pc = np.roll(c, 1); pc[0] = c[0] + tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc))) + str_ = pd.Series(tr).rolling(n).sum().values + hh = pd.Series(h).rolling(n).max().values + ll = pd.Series(l).rolling(n).min().values + rng = hh - ll + with np.errstate(divide="ignore", invalid="ignore"): + ci = 100.0 * np.log10(str_ / rng) / np.log10(n) + return ci + + +def var_ratio(close, k=30, win=100): + r1 = pd.Series(close).pct_change() + rk = pd.Series(close).pct_change(k) + v1 = r1.rolling(win).var().values + vk = rk.rolling(win).var().values + with np.errstate(divide="ignore", invalid="ignore"): + vr = vk / (k * v1) + return vr + + +def donchian_levels(df, n): + hh = pd.Series(df["high"].values).rolling(n).max().shift(1).values + ll = pd.Series(df["low"].values).rolling(n).min().shift(1).values + return hh, ll + + +# ----------------------- generatori di segnale (candidati) ----------------------- +def gen_donchian_base(df, n=20, sl_atr=2.0, max_bars=24, trend_max=None, ema_long=200, gate=None, use_sl=True): + """gate(i)->bool: True = consenti il segnale alla barra i. None = sempre. use_sl=False -> sl=None (no-SL).""" + h, l, c = df["high"].values, df["low"].values, df["close"].values + a = atr(df, 14); hh, ll = donchian_levels(df, n); em = ema(c, ema_long) + ents = [] + for i in range(max(n, ema_long, 14) + 1, len(c)): + if np.isnan(hh[i]) or np.isnan(ll[i]) or np.isnan(a[i]) or a[i] <= 0: + continue + if trend_max is not None and not np.isnan(em[i]) and abs(c[i] - em[i]) / a[i] > trend_max: + continue + if gate is not None and not gate(i): + continue + tp = (hh[i] + ll[i]) / 2.0 + if c[i] < ll[i] and c[i - 1] >= ll[i - 1]: + ents.append({"i": i, "d": 1, "tp": tp, "sl": (c[i] - sl_atr * a[i]) if use_sl else None, "max_bars": max_bars}) + elif c[i] > hh[i] and c[i - 1] <= hh[i - 1]: + ents.append({"i": i, "d": -1, "tp": tp, "sl": (c[i] + sl_atr * a[i]) if use_sl else None, "max_bars": max_bars}) + return ents + + +# ----------------------- engine intrabar + overlay opzione (per i candidati no-SL) ----------------------- +_HOURS_YEAR = 24 * 365.0 + + +def build_trades_hedged(entries, df, dvol, otm=0.10, skew=1.10, tenor_mult=1.0, fee_rt=FEE_RT, lev=LEV): + from scripts.analysis.option_overlay_lab import bs_put, bs_call + h, l, c = df["high"].values, df["low"].values, df["close"].values + n = len(c); out = []; last = -1 + for e in entries: + i, d = e["i"], e["d"] + if i <= last or i + 1 >= n: + continue + entry = c[i]; tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"] + exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1) + for k in range(1, mb + 1): + j = i + k + if j >= n: + exit_p = c[n - 1]; break + hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)) + hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)) + if hit_sl: + exit_p = sl; break + if hit_tp: + exit_p = tp; break + if k == mb: + exit_p = c[j] + base = (exit_p - entry) / entry * d * lev - fee_rt * lev + T = max(mb * tenor_mult, 1.0) / _HOURS_YEAR; sig = dvol[i]; sigb = sig * skew + if d == 1: + K = entry * (1.0 - otm); prem = bs_put(entry, K, T, sigb) / entry; pay = max(K - exit_p, 0.0) / entry + else: + K = entry * (1.0 + otm); prem = bs_call(entry, K, T, sigb) / entry; pay = max(exit_p - K, 0.0) / entry + out.append((i, j, base + lev * (pay - prem))); last = j + return out + + +def cand_choppiness_gate_fade(df): + ci = choppiness(df, 14) + return gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=None, + gate=lambda i: not np.isnan(ci[i]) and ci[i] >= 50.0) + + +def cand_choppiness_donchian(df): + ci = choppiness(df, 14) + return gen_donchian_base(df, n=14, sl_atr=2.0, trend_max=3.0, + gate=lambda i: not np.isnan(ci[i]) and ci[i] > 61.8) + + +def cand_varratio_gate_fade(df): + vr = var_ratio(df["close"].values, k=30, win=100) + return gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0, + gate=lambda i: not np.isnan(vr[i]) and vr[i] < 0.95) + + +def cand_baseline_recon(df): + """MR02/ETH canonico ricostruito col MIO engine (sanity check vs all_sleeve_equities).""" + return gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0) + + +def cand_vrp_neg_dvol_low(df): + from scripts.analysis.regime_lab import load, regime_features + rdf = load("ETH", "1h") + R = regime_features(rdf) + # allinea per indice posizionale (regime_lab.load parte da get_df, stesso ordinamento) + vrp = R["vrp"]; dvp = R["dvol_pct"] + m = min(len(df), len(vrp)) + def gate(i): + if i >= m: + return False + return (not np.isnan(vrp[i]) and vrp[i] < 0) and (not np.isnan(dvp[i]) and dvp[i] < 0.60) + return gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=None, gate=gate) + + +# ----------------------- gate PORT06 ----------------------- +def port_metrics(members, ids): + w = W.weight_vector("cap", ids, None, caps=CAPS) + drp = port_returns({i: members[i] for i in ids}, w) + return metrics(drp), metrics(drp, lo=SPLIT) + + +def main(): + print("=" * 104) + print(f" GATE PORT06 — sostituire MR02/ETH | finestra {IDX[0].date()}..{IDX[-1].date()} | OOS da {OOS_DATE}") + print("=" * 104) + eq = dict(all_sleeve_equities()) + ids = [k for k in eq if k in { + "MR01_BTC","MR02_BTC","MR07_BTC","MR01_ETH","MR02_ETH","MR07_ETH", + "DIP01_BTC","TR01_basket","ROT02_rot", + "PR_ETHBTC","PR_LTCETH","PR_ADAETH","PR_BTCLTC","PR_ETHSOL","TSM01","SH_BTC","SH_ETH"}] + print(f" sleeve PORT06: {len(ids)} | MR02_ETH presente: {'MR02_ETH' in ids}") + + f_b, o_b = port_metrics(eq, ids) + print(f"\n {'variante':<22s}{'FULL Sh':>8s}{'FULL DD':>8s}{'FULL CAGR':>10s} |{'OOS Sh':>8s}{'OOS DD':>8s}{'OOS CAGR':>9s}") + print(" " + "-" * 100) + print(f" {'BASELINE (canonico)':<22s}{f_b['sharpe']:>8.2f}{f_b['dd']:>8.2f}{f_b['cagr']:>9.0f}% |{o_b['sharpe']:>8.2f}{o_b['dd']:>8.2f}{o_b['cagr']:>8.0f}%") + + df = get_df("ETH", "1h") + from scripts.analysis.option_overlay_lab import dvol_for + dvol = dvol_for(df, "ETH") + # candidati: (nome, builder) dove builder(df)->trades + def b_signal(fn): + return lambda df: build_trades(fn(df), df) + base_ents = gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0) + def b_exit16(df): + return build_trades_exit16(base_ents, df, sl_confirm=0.5) + def b_exit16_put10(df): + return build_trades_exit16(base_ents, df, sl_confirm=0.5, dvol=dvol, otm=0.10) + def b_noSL(df): + return build_trades(gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0, use_sl=False), df) + def b_noSL_put10(df): + ents = gen_donchian_base(df, n=20, sl_atr=2.0, trend_max=3.0, use_sl=False) + return build_trades_hedged(ents, df, dvol, otm=0.10) + cands = { + "MR02recon(sanity)": b_signal(cand_baseline_recon), + "varratio_gate": b_signal(cand_varratio_gate_fade), + "choppiness_donch": b_signal(cand_choppiness_donchian), + "vrp_neg_dvol_low": b_signal(cand_vrp_neg_dvol_low), + "EXIT16(live)": b_exit16, + "EXIT16+put10%OTM": b_exit16_put10, + "noSL_raw": b_noSL, + "noSL_put10%OTM": b_noSL_put10, + } + rows = [] + equ = {} # salva le equity per i blend + for name, fn in cands.items(): + try: + tr = fn(df) + ce = daily_equity(tr, df) + equ[name] = ce + m2 = dict(eq); m2["MR02_ETH"] = ce + f_c, o_c = port_metrics(m2, ids) + rows.append((name, len(tr), f_c, o_c)) + print(f" {name:<22s}{f_c['sharpe']:>8.2f}{f_c['dd']:>8.2f}{f_c['cagr']:>9.0f}% |{o_c['sharpe']:>8.2f}{o_c['dd']:>8.2f}{o_c['cagr']:>8.0f}%" + f" ({len(tr)} trade)") + except Exception as ex: + print(f" {name:<22s} ERRORE: {ex}") + + # ---- BLEND within-sleeve: riempi lo sleeve ETH con EXIT-16 + un candidato decorrelato ---- + print(" " + "-" * 100 + "\n BLEND within-sleeve (lo sleeve ETH = mix di 2 strategie, peso PORT06 invariato):") + blends = { + "50/50 EXIT16+varratio": (["EXIT16(live)", "varratio_gate"], [0.5, 0.5]), + "50/50 EXIT16+chopDonch": (["EXIT16(live)", "choppiness_donch"], [0.5, 0.5]), + "50/50 EXIT16+vrp": (["EXIT16(live)", "vrp_neg_dvol_low"], [0.5, 0.5]), + "70/30 EXIT16+vrp": (["EXIT16(live)", "vrp_neg_dvol_low"], [0.7, 0.3]), + "50/50 EXIT16put+vrp": (["EXIT16+put10%OTM", "vrp_neg_dvol_low"], [0.5, 0.5]), + "tri EXIT16put+vrp+chop": (["EXIT16+put10%OTM", "vrp_neg_dvol_low", "choppiness_donch"], [0.5, 0.25, 0.25]), + } + for name, (keys, wts) in blends.items(): + try: + be = blend_equity([equ[k] for k in keys], wts) + m2 = dict(eq); m2["MR02_ETH"] = be + f_c, o_c = port_metrics(m2, ids) + rows.append((name, -1, f_c, o_c)) + print(f" {name:<22s}{f_c['sharpe']:>8.2f}{f_c['dd']:>8.2f}{f_c['cagr']:>9.0f}% |{o_c['sharpe']:>8.2f}{o_c['dd']:>8.2f}{o_c['cagr']:>8.0f}%") + except Exception as ex: + print(f" {name:<22s} ERRORE: {ex}") + + # riferimento ONESTO = EXIT-16 (config LIVE), non il canonico intrabar-SL + ex = next((r for r in rows if r[0] == "EXIT16(live)"), None) + f_l, o_l = (ex[2], ex[3]) if ex else (f_b, o_b) + print("\n " + "=" * 100) + print(f" GATE vs LIVE EXIT-16 (FULL {f_l['sharpe']:.2f}/{f_l['dd']:.2f} OOS {o_l['sharpe']:.2f}/{o_l['dd']:.2f}):") + print(" MIGLIORIA = nessuna metrica peggiora oltre il rumore E almeno una migliora (Sharpe +>=0.03 o DD -)") + print(" " + "-" * 100) + for name, ntr, f_c, o_c in rows: + if name.startswith("MR02recon") or name == "EXIT16(live)": + continue + dfs, dfd = f_c['sharpe'] - f_l['sharpe'], f_c['dd'] - f_l['dd'] + dos, dod = o_c['sharpe'] - o_l['sharpe'], o_c['dd'] - o_l['dd'] + no_worse = dfs >= -0.03 and dos >= -0.03 and dfd <= 0.05 and dod <= 0.03 + better = dfs >= 0.03 or dos >= 0.03 or dfd <= -0.03 or dod <= -0.03 + ok = no_worse and better + print(f" {name:<22s} ΔFULL Sh {dfs:+.2f} DD {dfd:+.2f} | ΔOOS Sh {dos:+.2f} DD {dod:+.2f} -> " + f"{'>>> MIGLIORIA' if ok else ('= pari' if no_worse else 'peggiora')}") + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/mr02eth_search.workflow.js b/scripts/analysis/mr02eth_search.workflow.js new file mode 100644 index 0000000..b87597f --- /dev/null +++ b/scripts/analysis/mr02eth_search.workflow.js @@ -0,0 +1,279 @@ +export const meta = { + name: 'mr02eth-replace-search', + description: 'Cerca sostituto/miglioria a MR02/ETH: 112 strategie distinte su harness onesto + verifica avversariale', + phases: [ + { title: 'Generate', detail: '112 agenti, una strategia ETH distinta ciascuno, backtest onesto fee-aware OOS' }, + { title: 'Verify', detail: 'verifica avversariale dei survivor (look-ahead audit + re-run + robustezza)' }, + ], +} + +// ---------------------------------------------------------------- baseline & recipe +const BASELINE = `BASELINE DA BATTERE (MR02/ETH sullo STESSO engine explore_lab, ETH 1h, fee 0.10% RT, lev 3x, pos 0.15): +- canonico (Donchian n=20, sl 2*ATR, max_bars 24, TP a centro canale): full Sharpe 7.49, OOS Sharpe 10.94, full DD 42%, OOS DD 18%, exposure 31%, 2392 trade, fee0.2% positivo. +- trend-filtrato (come sopra + salta se |close-EMA200|/ATR(14) > 3.0): full Sharpe 8.45, OOS Sharpe 11.96, full DD 25%, OOS DD 13%, exposure 16%, 1148 trade, fee0.2% positivo. +Il PROBLEMA di questo sleeve e' il DD/whipsaw (fada il trend al ribasso, poi shorta il rimbalzo). Un buon SOSTITUTO deve: battere OOS Sharpe ~12 E/O tagliare il full DD sotto ~20%, restando OOS positivo, sopravvivere a fee 0.20% RT, con la maggior parte degli anni 2021+ positivi. DD piu' basso a Sharpe comparabile e' GIA' valore. Anche un buon DIVERSIFICATORE (Sharpe decente + decorrelato, esposizione diversa) e' utile.` + +const RECIPE = `Lavora in /opt/docker/PythagorasGoal. Testa UNA idea di strategia come candidata a SOSTITUIRE/MIGLIORARE MR02/ETH. +Usa l'harness ONESTO condiviso (no look-ahead, fee-aware, OOS). Scrivi UNO script self-contained in /tmp/cand_KEY.py (KEY = la tua key, univoca) ed eseguilo: + cd /opt/docker/PythagorasGoal && uv run python /tmp/cand_KEY.py + +API (import from scripts.analysis.explore_lab): +- get_df(asset, tf) -> df [open,high,low,close,volume,timestamp,dt]. Usa asset="ETH", tf="1h" (puoi testare anche tf="4h"). +- atr(df,n=14)->np.array ; ema(x,n)->np.array ; rsi(close,n=14)->np.array +- simulate(entries, df, fee_rt=0.001, lev=3.0, pos=0.15, split=-1) -> dict(trades,win,ret,dd,sharpe,yearly,exposure) +- evaluate(name, entries, df, fees=(0,0.0005,0.001,0.002)) -> dict(full,oos,sweep,sweep_oos,pos_yrs,n_yrs) e stampa una riga. +- robust(res)->bool. +Se la tua idea usa DVOL/funding/Hurst/fractali, puoi importare da scripts.analysis.regime_lab: load(asset,tf), regime_features(df), frac_features(df), load_features(asset,tf) (cache; vedi il file se serve). Se non sono disponibili, ripiega su feature calcolate dalle sole OHLCV. Se la tua idea e' shape/kNN, vedi scripts.analysis.shape_lab (analog_entries, analog_signals). +entries = list di {"i":int, "d":+1/-1, "max_bars":int, "tp":float|None, "sl":float|None}. L'ingresso esegue a close[i]. TP/SL sono intrabar al livello (SL valutato PRIMA del TP). split>0 nel simulate = solo entries con i>=split (OOS). + +ANTI-LOOK-AHEAD (non negoziabile): direzione e prezzo alla barra i usano SOLO dati [0..i] (close[i] incluso). Mai usare high/low/close della barra i+ per decidere ingresso/direzione. Canali/rolling che devono escludere la barra corrente vanno .shift(1). Prezzo d'ingresso = close[i]. La famiglia squeeze e' stata scartata proprio per questo errore: NON ripeterlo. + +${BASELINE} + +Procedura: implementa l'idea, fai uno sweep PICCOLO (2-4 config di parametri), scegli la MIGLIORE per OOS Sharpe a parita' di: sopravvive a fee 0.20% RT e full DD non peggiore della baseline. Verifica l'assenza di look-ahead (perturba le barre future e controlla che gli entries non cambino, oppure argomenta perche' non c'e'). + +Restituisci SOLO il risultato strutturato della tua MIGLIORE config. Sii ONESTO: se non batte la baseline, riporta i numeri VERI (peggiori) — un risultato negativo e' informazione utile. NON inventare numeri: devono venire dall'esecuzione reale dello script.` + +const SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['key','family','implemented','ran_ok','best_config','trades','exposure','full_sharpe','full_ret','full_dd','oos_sharpe','oos_ret','oos_dd','fee02_full','fee02_oos','pos_yrs','n_yrs','no_lookahead','beats_baseline','verdict','note'], + properties: { + key: { type: 'string' }, + family: { type: 'string' }, + implemented: { type: 'boolean' }, + ran_ok: { type: 'boolean', description: 'lo script ha girato senza errori e i numeri vengono dall esecuzione reale' }, + best_config: { type: 'string', description: 'parametri della config migliore' }, + trades: { type: 'integer' }, + exposure: { type: 'number', description: '% barre in posizione (full)' }, + full_sharpe: { type: 'number' }, + full_ret: { type: 'number', description: '% ritorno full' }, + full_dd: { type: 'number', description: '% max drawdown full' }, + oos_sharpe: { type: 'number' }, + oos_ret: { type: 'number' }, + oos_dd: { type: 'number' }, + fee02_full: { type: 'number', description: 'ritorno % full a fee 0.20% RT' }, + fee02_oos: { type: 'number', description: 'ritorno % OOS a fee 0.20% RT' }, + pos_yrs: { type: 'integer', description: 'anni positivi' }, + n_yrs: { type: 'integer' }, + no_lookahead: { type: 'boolean' }, + beats_baseline: { type: 'boolean', description: 'batte la baseline MR02/ETH trend-filtrata (OOS Sharpe ~12 e/o DD<20%)' }, + verdict: { type: 'string', enum: ['substitute','improvement','diversifier','reject'] }, + note: { type: 'string', description: '1-2 frasi: perche funziona o perche no, e l angolo distintivo' }, + }, +} + +// ---------------------------------------------------------------- 112 idee distinte +// [key, family, idea] +const SPECS = [ + // A. fade gated da regime (il fix del whipsaw) + ['adx_gate_fade','gated-fade','Donchian/Bollinger fade su ETH ma SALTA quando ADX(14) e alto (regime trending). Calcola ADX causale; entra in fade solo se ADXsoglia (range). Sweep finestra/soglia.'], + ['rsq_gate_fade','gated-fade','Fade gated dall R^2 di una regressione lineare rolling sul log-prezzo: alto R^2 = trend pulito (salta), basso = range (fada). Sweep finestra/soglia.'], + ['bbw_gate_fade','gated-fade','Bollinger fade gated dalla band-width (BBW): fada solo quando BBW in regime medio/alto ma non esplosivo. Sweep soglie BBW percentile.'], + ['atr_pct_gate_fade','gated-fade','Donchian fade ma salta quando ATR-percentile rolling e estremo (>p80): la coda di vol e dove il whipsaw uccide. Sweep percentile.'], + ['htf_align_fade','gated-fade','Fade ETH 1h ma NON contro il trend 4h: salta i long-fade se EMA2090, exit su RSI2 che torna a 50 o max_bars. Sweep soglie e max_bars su ETH 1h.'], + ['rsi14_bands','mr-alt','RSI(14) revert con bande: long RSI<30, short RSI>70, TP al ritorno a 50 (proxy prezzo), SL ATR. Sweep.'], + ['pctb_fade','mr-alt','Bollinger %B fade: long quando %B<0, short quando %B>1, TP a media, SL ATR. Sweep finestra/sigma/max_bars. Confronta con MR01.'], + ['zscore_price_fade','mr-alt','Z-score del prezzo vs SMA(n): entra quando |z|>soglia contro il movimento, TP a z=0 (SMA), SL ATR. Sweep n/soglia. Versione simmetrica del DIP.'], + ['zscore_ret_fade','mr-alt','Z-score del rendimento di barra (MR07-like) ma ri-tunato per ETH: |z|>soglia su finestra n, exit in multipli ATR. Sweep n/soglia/exit.'], + ['vwap_revert','mr-alt','Reversion a VWAP rolling (volume-weighted): entra quando il prezzo devia >k*sigma dal VWAP, TP al VWAP. Sweep finestra/k.'], + ['keltner_fade_eth','mr-alt','Keltner channel fade ri-tunato per ETH (EMA +/- m*ATR): fada la rottura della banda Keltner, TP a EMA. Sweep m/finestra. MR03 era debole su BTC ma ETH puo differire.'], + ['cci_revert','mr-alt','CCI(n) extreme revert: long CCI<-100, short CCI>+100, exit su CCI->0 o max_bars. Sweep.'], + ['williamsr_revert','mr-alt','Williams %R revert: long %R<-80, short %R>-20, TP a media canale, SL ATR. Sweep.'], + ['stoch_revert','mr-alt','Stochastic %K/%D extreme revert su ETH: long quando %K<20 e cross-up, short %K>80 cross-down. Sweep finestre.'], + ['connors_rsi','mr-alt','Connors RSI (RSI3 del prezzo + RSI2 della streak + percentrank del rendimento) extreme revert. Sweep soglie.'], + ['anchored_vwap_revert','mr-alt','Reversion a VWAP ancorato all ultimo swing-low/high (Williams): entra quando il prezzo si allontana >k*ATR dall AVWAP, TP all AVWAP.'], + ['median_z_fade','mr-alt','Z robusto: (close - rolling median) / rolling MAD. Fada |z|>soglia, TP a mediana. Piu robusto agli outlier della SMA-z. Sweep.'], + ['pctrank_fade','mr-alt','Percent-rank del close nella finestra rolling: long se rank<0.05, short se rank>0.95, TP al rank 0.5 (proxy). Sweep finestra.'], + ['double_bollinger','mr-alt','Doppia Bollinger: entra fade alla banda esterna (2.5-3 sigma), esce alla banda interna (1 sigma) invece che alla media. Sweep sigma esterna/interna.'], + ['fade_to_ema','mr-alt','Donchian/Bollinger fade ma TP all EMA(n) invece che SMA/centro-canale (target dinamico che segue il trend). Sweep n EMA.'], + ['nbar_lowhigh_rsi','mr-alt','Entra long su nuovo minimo a N barre CON RSI<35 (doppia conferma di esaurimento), short speculare. TP a media, SL ATR. Sweep N.'], + ['overshoot_swing_fade','mr-alt','Fada quando il prezzo supera lo swing-high/low precedente (Williams) di k*ATR e poi la barra chiude rientrando: false-breakout fade. Sweep k.'], + + // C. exit migliori sulla Donchian fade (stesso ingresso) + ['tight_sl_confirm','exit','Donchian fade con SL piu stretto (1.0/1.5 ATR) ma close-confirm (esci solo se il close sfonda SL-0.5ATR). Replica EXIT-16. Sweep buffer.'], + ['fast_tp_partial','exit','Donchian fade con TP a frazione del canale (0.25/0.5 width) invece che al centro: incassa prima il rimbalzo, riduce il tempo a rischio. Sweep frazione.'], + ['short_timestop','exit','Donchian fade con time-stop corto (max_bars 6/8/12 invece di 24): esce prima che il whipsaw maturi. Sweep max_bars.'], + ['atr_trailing_exit','exit','Donchian fade con uscita a ATR-trailing dopo l ingresso (trail il massimo favorevole di k*ATR). Sweep k. (la ricerca dice che il trailing spesso fallisce: verifica onestamente).'], + ['breakeven_stop','exit','Donchian fade: dopo che il trade va in utile di +x*ATR, sposta lo stop a breakeven. Sweep x.'], + ['rsi_exit','exit','Donchian fade con exit quando RSI torna oltre 50 (mean ripristinata) invece del TP fisso. Sweep.'], + ['return_to_ema_exit','exit','Donchian fade con exit quando il prezzo ri-tocca l EMA(50) (ritorno alla media mobile) o max_bars. Sweep EMA.'], + ['volscaled_maxbars','exit','Donchian fade con max_bars scalato sulla vol: meno barre in alta-vol, piu in bassa-vol. Sweep mapping.'], + ['chandelier_exit','exit','Donchian fade con Chandelier exit (max favorevole - k*ATR). Sweep k.'], + ['tp_opposite_band','exit','Donchian fade con TP alla banda OPPOSTA (reversione completa del canale) invece del centro. Piu ambizioso, meno frequente. Sweep.'], + + // D. trend-following su ETH (prende cio che la fade perde) + ['ema_cross_4h','trend','EMA20/EMA100 cross su ETH 4h, long quando EMA20>EMA100, flat altrimenti (TR01-style su ETH solo). Niente fade. Valuta come diversificatore direzionale.'], + ['donchian_breakout','trend','Donchian BREAKOUT trend (opposto del fade): long su rottura del massimo a N barre, stop sotto il minimo. La ricerca dice che i breakout crypto rientrano: verifica ONESTAMENTE se su ETH regge netto fee.'], + ['macd_trend','trend','MACD(12,26,9) trend-following su ETH 1h/4h: long su cross rialzista sopra zero, exit su cross opposto. Sweep TF.'], + ['dual_ma_atr','trend','Dual-MA (fast/slow) con filtro ATR sul gap: entra trend solo se separazione MA > k*ATR (evita whipsaw delle MA). Sweep.'], + ['supertrend','trend','Supertrend (ATR band) su ETH: segui il flip. Sweep multiplier/periodo.'], + ['momentum_sign','trend','Momentum semplice: long se rendimento a H barre > 0 (12h/24h/48h), tieni H barre. Time-series momentum su ETH. Sweep H.'], + ['tsmom_eth','trend','TSMOM multi-orizzonte (3/6/12 mesi sul daily resample di ETH) con risk-off se sotto SMA100. Solo ETH. Valuta come diversificatore.'], + ['breakout_vol_confirm','trend','Channel breakout con conferma di volume (rottura valida solo se volume>media*k). Sweep.'], + ['heikin_trend','trend','Heikin-Ashi trend: long su serie di candele HA verdi, exit su prima rossa. Costruisci HA causale. Sweep.'], + ['triple_screen','trend','Triple-screen: trend 4h (EMA) definisce il bias, ingresso 1h su pullback nella direzione del trend (no contro-trend). Sweep.'], + + // E. volatilita + ['squeeze_expansion','vol','Bollinger-squeeze -> espansione, ma ingresso ONESTO: entra solo DOPO che la barra di espansione e CHIUSA, nella sua direzione, a close[i] (non anticipare). Verifica se l edge sparisce come per la famiglia squeeze scartata.'], + ['atr_breakout','vol','ATR breakout: entra quando |close-close[-1]|>k*ATR nella direzione del movimento (continuazione) OPPURE contro (reversione) — testa entrambe e riporta quale regge. Sweep k.'], + ['volofvol_revert','vol','Mean-reversion della vol-of-vol: quando la realized-vol fa uno spike estremo (z alto), fada il prezzo (gli spike di vol = capitolazione che rimbalza). Sweep.'], + ['or_breakout_daily','vol','Opening-range breakout: definisci il range delle prime k ore UTC del giorno, entra sulla rottura con SL all altro lato. Sweep k. Trade ETH 1h.'], + ['rv_regime_switch','vol','Switch fade/trend in base al regime di realized-vol: fade in bassa-vol (range), trend-follow in alta-vol. Una strategia adattiva. Sweep soglia.'], + ['keltner_squeeze','vol','Keltner-inside-Bollinger squeeze: rileva la compressione, poi entra sulla direzione della prima rottura confermata (close oltre la banda). Onesto. Sweep.'], + + // F. cross-asset / relative + ['btc_leadlag','cross','BTC lead-lag: il rendimento di BTC alla barra i predice ETH alla barra i+1. Entra ETH a close[i] nella direzione del segnale BTC (solo dati fino a i). Sweep soglia/horizon.'], + ['ethbtc_ratio_revert','cross','Reversione del ratio ETH/BTC: quando il log-ratio devia >z*sigma dalla sua media, prendi posizione DIREZIONALE su ETH (long ETH se ratio depresso). Sweep n/z.'], + ['eth_beta_residual','cross','Residuo beta: regredisci ETH su BTC (beta rolling causale), fada il residuo (ETH ricco/povero vs BTC). Posizione direzionale ETH. Sweep finestra.'], + ['btc_trend_eth_fade','cross','Fade ETH SOLO quando BTC e in range (gia simile a btc_regime_fade ma qui il gate e la pendenza EMA di BTC + la sua DVOL). Combina due gate BTC.'], + ['eth_vs_basket_mom','cross','Cross-sectional: ETH momentum relativo vs basket (BTC,BNB,SOL,XRP). Long ETH se sovra-performa il basket di recente (continuazione) o sotto-performa (reversione) — testa entrambe.'], + ['funding_spread','cross','Spread di funding ETH-BTC (regime_lab): quando il funding di ETH e estremo vs BTC, prendi posizione di reversione su ETH. Sweep.'], + + // G. ML / shape + ['sh01_logit_eth','ml','LogisticRegression stile SH01 su ETH: 17 feature di forma (body/shadow, rendimenti, pendenza, curvatura, pos max/min, RSI, estensione) su W24, predice segno a H12, walk-forward causale, entra se proba>0.58. Riusa scripts.analysis.shape_ml_research se possibile. ETH-specifico.'], + ['analog_knn_eth','ml','Analog kNN sulla forma (shape_lab.analog_entries) su ETH: W/H/K sweep stretto, agree>0.6. Causale (KDTree ribuilt). Valuta come diversificatore.'], + ['gbm_features_eth','ml','GradientBoostingClassifier walk-forward su feature ingegnerizzate (RSI, ATR-norm returns, distance-from-EMA, vol, Donchian-pos) per predire il segno a H barre. Train solo sul passato. Sweep H/threshold.'], + ['logit_regime_mom','ml','LogReg walk-forward su feature di regime+momentum (DVOL, funding, ER, rendimenti multi-h) per predire direzione ETH a H. Da regime_lab. Sweep.'], + ['rf_direction','ml','RandomForest walk-forward direzione ETH su feature tecniche. Train espandente. Sweep n_estimators/H/threshold. Onesto (no leakage temporale).'], + ['ridge_return_sign','ml','Ridge regression walk-forward che prevede il rendimento a H barre; entra sul segno se |pred|>soglia. Feature: lag returns, RSI, ATR, distance-EMA. Sweep.'], + + // H. microstruttura / calendario + ['intraday_seasonality','micro','Stagionalita oraria: trova le ore UTC con edge di reversione/continuazione su ETH (split-half per evitare overfit), trada solo quelle. Onesto su OOS.'], + ['gap_fade','micro','Gap fade: quando una barra apre/chiude con un gap >k*ATR vs la precedente, fada il gap (reversione). Sweep k.'], + ['hourofday_fade','micro','Fade Donchian condizionato all ora del giorno: attiva la fade solo nelle ore storicamente mean-reverting. Verifica OOS (rischio overfit alto: sii severo).'], + ['dayofweek','micro','Effetto giorno-della-settimana su ETH: posizioni condizionate al weekday (es. weekend illiquido). Severo su OOS (probabile rumore).'], + ['funding_settlement','micro','Timing settlement funding (00/08/16 UTC): fade del movimento attorno al settlement (over-reaction pre/post funding). Sweep finestra.'], + ['large_candle_reversal','micro','Reversione dopo candela estesa: se |close-open|>k*ATR (barra-shock), entra contro alla chiusura della barra successiva. Sweep k.'], + ['three_bar_reversal','micro','Pattern 3-barre: 3 chiusure consecutive nello stesso verso poi entra contro (esaurimento). Sweep n-barre/soglia.'], + ['consecutive_exhaustion','micro','N candele rosse consecutive -> long (e speculare). Conta le streak causalmente, entra all N-esima. Sweep N. TP a media, SL ATR.'], + + // I. combinazioni / ensemble + ['donchian_rsi_confirm','combo','Donchian fade CON conferma RSI (long solo se RSI<40 oltre alla rottura del canale): doppia conferma per ridurre i falsi segnali nei trend.'], + ['bb_donchian_confluence','combo','Confluenza Bollinger + Donchian: fada solo quando ENTRAMBE le bande sono rotte simultaneamente (segnale piu forte, meno frequente, meno whipsaw).'], + ['volspike_fade','combo','Fade solo su spike di volume (volume>media*k): la capitolazione su volume rimbalza meglio del drift silenzioso. Sweep k.'], + ['funding_tailwind_fade','combo','Donchian fade con vento di funding: fada gli short (entra long) solo se funding positivo (i long pagano = pressione di unwind ribassista esaurita) e viceversa. Da regime_lab.'], + ['regime_switch_classifier','combo','Classificatore di regime (trend vs range via ADX/ER/Hurst) che decide SE fadare o trend-followare ad ogni barra: una sola strategia adattiva. Sweep soglie del classificatore.'], + ['vote_ensemble_mr','combo','Ensemble a voto di 3 segnali MR indipendenti (Bollinger, Donchian, z-return): entra solo se >=2 concordano sulla direzione. Riduce i falsi.'], + ['adaptive_donchian','combo','Donchian a lookback ADATTIVO: n scalato sulla vol (canale piu lungo in alta-vol, piu corto in bassa-vol). Sweep mapping vol->n.'], + + // J. statistico-robusto + ['ou_halflife','stat','Ornstein-Uhlenbeck: stima half-life e media di reversione (rolling, causale), entra quando la deviazione dalla media OU supera z*sigma, exit a half-life. Sweep.'], + ['kalman_meanrev','stat','Kalman filter sul prezzo (livello+trend) come media dinamica; fada la deviazione dal livello stimato. Causale (filtro one-pass). Sweep Q/R.'], + ['quantile_band_fade','stat','Bande a quantili rolling (5/95 percentile su finestra n) invece di sigma: fada la rottura del quantile, TP al quantile 50. Piu robusto a code fat. Sweep n/quantili.'], + ['theilsen_residual','stat','Detrend Theil-Sen rolling (pendenza robusta), fada il residuo quando estremo. Sweep finestra.'], + ['hampel_outlier_fade','stat','Hampel filter: identifica gli outlier (|x-median|>k*MAD) e fada l outlier (reversione). Sweep k/finestra.'], + ['rollreg_residual_z','stat','Regressione lineare rolling (prezzo~tempo), z del residuo; fada |z|>soglia, TP a residuo 0. Sweep finestra/soglia.'], + ['dominant_cycle_revert','stat','Stima del ciclo dominante (autocorrelazione/zero-cross rolling), entra in reversione in fase con il ciclo. Causale. Sweep.'], + + // K. ulteriori gate/varianti per arrivare a 112 + ['choppiness_donchian','gated-fade','Choppiness Index + Donchian: fada solo se CI>61.8 (range conclamato). Variante severa di choppiness_gate_fade con soglia fissa Fibonacci. Sweep secondario su n.'], + ['er_bollinger','gated-fade','Efficiency-Ratio gate su Bollinger fade (non Donchian): fada la banda solo se ER|2|), fada il prezzo nella direzione dell unwind atteso. Da regime_lab. Sweep.'], + ['range_day_fade','gated-fade','Filtro range-day: fada solo se il range della sessione corrente (rolling 24h) < media*soglia (giornata compressa = reversione affidabile). Sweep.'], + ['mtf_donchian','combo','Donchian multi-timeframe: ingresso su rottura del canale 1h ma con canale di riferimento 4h (TP al centro del canale 4h). Sweep n1/n4.'], + ['volweighted_fade','combo','Fade pesata dal volume: ingresso solo se il volume della barra di segnale e nel quartile alto (conferma di partecipazione). Sweep.'], + ['false_break_fade','mr-alt','Fade del falso breakout: il prezzo rompe il canale Donchian ma la barra SUCCESSIVA chiude di nuovo dentro -> entra contro la rottura a quella chiusura. Onesto. Sweep n.'], + ['band_walk_exhaustion','mr-alt','Esaurimento del band-walk: dopo k chiusure consecutive fuori dalla banda Bollinger (band walking), fada il rientro alla prima chiusura dentro. Sweep k/sigma.'], + ['pivot_revert','stat','Reversione ai pivot floor classici (P, R1/R2, S1/S2 calcolati sul giorno precedente): fada il tocco di R2/S2 verso P. Causale (pivot da ieri). Sweep.'], + ['fractal_swing_revert','stat','Reversione agli swing di Williams (frattali a 5 barre confermati con ritardo): fada il tocco di un nuovo frattale verso la media. Causale (conferma ritardata). Sweep.'], + ['lrc_fade','stat','Linear Regression Channel: canale di regressione rolling +/- 2 std del residuo; fada il tocco dei bordi verso la linea centrale. Sweep finestra.'], +] + +// ---------------------------------------------------------------- esecuzione +phase('Generate') +log(`Lancio ${SPECS.length} agenti, una strategia ETH distinta ciascuno (harness onesto fee-aware OOS)`) + +const results = await parallel(SPECS.map(([key, family, idea]) => () => + agent( + `${RECIPE}\n\n========\nLA TUA IDEA — key="${key}", family="${family}":\n${idea}\n\nUsa /tmp/cand_${key}.py come file. Restituisci il risultato strutturato (campi key e family valorizzati con i tuoi).`, + { label: `gen:${key}`, phase: 'Generate', schema: SCHEMA } + ).then(r => r ? { ...r, key, family } : null) +)) + +const ran = results.filter(Boolean) +log(`${ran.length}/${SPECS.length} agenti rientrati. Filtro i survivor per la verifica avversariale.`) + +// survivor: ha girato davvero, e o batte la baseline o e' chiaramente interessante +// (OOS Sharpe alto, oppure DD ben piu basso a Sharpe decente), e regge la fee 0.2%. +const survivors = ran.filter(r => + r.ran_ok && r.implemented && r.no_lookahead && + r.full_ret > 0 && r.oos_ret > 0 && r.fee02_oos > 0 && r.fee02_full > 0 && + ( + r.beats_baseline || + r.oos_sharpe >= 11.0 || + (r.full_dd <= 20.0 && r.oos_sharpe >= 8.0) || + (r.full_dd <= 12.0 && r.oos_sharpe >= 5.0) // diversificatore a basso DD + ) +) + +// ordina per una metrica composita: premia OOS Sharpe e penalizza DD +const score = r => r.oos_sharpe - 0.10 * r.full_dd +survivors.sort((a, b) => score(b) - score(a)) +const toVerify = survivors.slice(0, 24) // cap la verifica ai 24 migliori +log(`${survivors.length} survivor; verifico avversarialmente i top ${toVerify.length}`) + +phase('Verify') +const VSCHEMA = { + type: 'object', additionalProperties: false, + required: ['key','confirmed','reproduced','lookahead_found','overfit_risk','full_sharpe','full_dd','oos_sharpe','oos_dd','beats_mr02eth','note'], + properties: { + key: { type: 'string' }, + confirmed: { type: 'boolean', description: 'edge reale, eseguibile, fee-robusto, dopo audit indipendente' }, + reproduced: { type: 'boolean', description: 'ho ri-eseguito e ho ottenuto numeri coerenti (entro ~15%)' }, + lookahead_found: { type: 'boolean' }, + overfit_risk: { type: 'string', enum: ['low','med','high'] }, + full_sharpe: { type: 'number' }, + full_dd: { type: 'number' }, + oos_sharpe: { type: 'number' }, + oos_dd: { type: 'number' }, + beats_mr02eth: { type: 'boolean', description: 'batte davvero MR02/ETH (OOS Sharpe>=~12 a DD<=25, o DD<20 a Sharpe comparabile)' }, + note: { type: 'string' }, + }, +} + +const verified = await parallel(toVerify.map(r => () => + agent( + `Verifica AVVERSARIALE di una strategia candidata a sostituire MR02/ETH. Lavora in /opt/docker/PythagorasGoal con l'harness onesto scripts.analysis.explore_lab (get_df, simulate, evaluate, robust, atr, ema, rsi). Default: il tuo compito e' SMENTIRE.\n\n${BASELINE}\n\nCandidata key="${r.key}" (family ${r.family}).\nConfig dichiarata: ${r.best_config}\nNumeri dichiarati: full Sharpe ${r.full_sharpe}, OOS Sharpe ${r.oos_sharpe}, full DD ${r.full_dd}%, OOS DD ${r.oos_dd}%, trade ${r.trades}, exposure ${r.exposure}%, fee0.2% full ${r.fee02_full}, fee0.2% OOS ${r.fee02_oos}.\nDescrizione idea: la trovi rifacendola da zero.\n\nRI-IMPLEMENTA tu stesso la strategia (in /tmp/verify_${r.key}.py) sulla SUA config dichiarata, eseguila sull'harness, e controlla: (1) LOOK-AHEAD — l'ingresso/direzione usa solo dati fino a close[i]? Perturba le barre future e verifica che gli entries non cambino. (2) RIPRODUCIBILITA — i numeri tornano entro ~15%? (3) ROBUSTEZZA/OVERFIT — regge a fee 0.20% RT, a piccole variazioni dei parametri (plateau, non picco isolato), ed e' positiva nella maggior parte degli anni 2021+? (4) batte DAVVERO MR02/ETH (OOS Sharpe e/o DD)?\n\nSe non riesci a riprodurre o trovi look-ahead, confirmed=false. Riporta i TUOI numeri ri-eseguiti, non quelli dichiarati. Sii severo: meglio bocciare un falso positivo che promuovere un artefatto.`, + { label: `verify:${r.key}`, phase: 'Verify', schema: VSCHEMA } + ).then(v => v ? { ...v, key: r.key, family: r.family, gen: r } : null) +)) + +const vok = verified.filter(Boolean) +const confirmed = vok.filter(v => v.confirmed && !v.lookahead_found && v.beats_mr02eth) +confirmed.sort((a, b) => (b.oos_sharpe - 0.1 * b.full_dd) - (a.oos_sharpe - 0.1 * a.full_dd)) + +log(`Verifica completata: ${confirmed.length} confermati che battono MR02/ETH`) + +return { + n_specs: SPECS.length, + n_ran: ran.length, + n_survivors: survivors.length, + n_verified: vok.length, + n_confirmed: confirmed.length, + confirmed, + all_survivors: survivors.map(s => ({ key: s.key, family: s.family, best_config: s.best_config, + full_sharpe: s.full_sharpe, full_dd: s.full_dd, oos_sharpe: s.oos_sharpe, oos_dd: s.oos_dd, + exposure: s.exposure, trades: s.trades, fee02_oos: s.fee02_oos, verdict: s.verdict, note: s.note })), + verified: vok.map(v => ({ key: v.key, confirmed: v.confirmed, reproduced: v.reproduced, + lookahead_found: v.lookahead_found, overfit_risk: v.overfit_risk, beats_mr02eth: v.beats_mr02eth, + full_sharpe: v.full_sharpe, full_dd: v.full_dd, oos_sharpe: v.oos_sharpe, oos_dd: v.oos_dd, note: v.note })), +} diff --git a/scripts/analysis/option_overlay_lab.py b/scripts/analysis/option_overlay_lab.py new file mode 100644 index 0000000..2053a57 --- /dev/null +++ b/scripts/analysis/option_overlay_lab.py @@ -0,0 +1,250 @@ +"""Harness ONESTO per overlay di OPZIONI (protezione) sulle strategie ETH. + +Vincolo noto (analisi ARGO/GEX 2026-06-01): lo storico per-strike dell'OI/prezzi +opzioni NON e' gratuito -> non backtestabile. MA la DVOL di Deribit (indice di vol +implicita ETH, annualizzato %) e' storica e gratuita in data/regime/eth_dvol.parquet +(oraria 2021-03 -> 2026-06). Quindi prezziamo le opzioni SINTETICAMENTE con +Black-Scholes(spot, DVOL) -> backtest del COSTO dell'overlay (premio) vs il payoff. + +Approssimazione dichiarata (e DELIBERATAMENTE conservativa, bias CONTRO le opzioni): + - niente skew: i put reali costano piu' dell'ATM-IV -> applichiamo `skew_mult>=1` + alla sigma dei put (rincara il premio). + - all'uscita anticipata l'opzione vale solo l'INTRINSECO (nessun rebate di time- + value) -> sotto-stima il beneficio dell'hedge. + - niente costi di fill/liquidita' delle opzioni oltre lo skew_mult. +Se sotto queste ipotesi pessimistiche l'overlay MIGLIORA il rischio/rendimento, +l'edge e' robusto. I numeri sono INDICATIVI, non eseguibili come i perp. + +Convenzione coerente con explore_lab: ret per-notional, lev 3x, pos 0.15, fee 0.10% RT. +L'opzione copre lo STESSO notional della posizione (lev*pos*cap), quindi il suo P&L +come frazione entra in ret' = ret_base + lev*(payoff - premio)/entry. +""" +from __future__ import annotations + +import math +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from scripts.analysis.explore_lab import get_df, atr, ema, rsi, FEE_RT, LEV, POS, OOS_FRAC # noqa + +HOURS_YEAR = 24 * 365.0 + + +# --------------------------- Black-Scholes (r=0) --------------------------- +def _ncdf(x: float) -> float: + return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0))) + + +def bs_put(S: float, K: float, T: float, sigma: float) -> float: + """Prezzo put europea, r=0. T in anni, sigma annualizzata (frazione).""" + if T <= 0 or sigma <= 0 or S <= 0 or K <= 0: + return max(K - S, 0.0) + sd = sigma * math.sqrt(T) + d1 = (math.log(S / K) + 0.5 * sigma * sigma * T) / sd + d2 = d1 - sd + return K * _ncdf(-d2) - S * _ncdf(-d1) + + +def bs_call(S: float, K: float, T: float, sigma: float) -> float: + if T <= 0 or sigma <= 0 or S <= 0 or K <= 0: + return max(S - K, 0.0) + sd = sigma * math.sqrt(T) + d1 = (math.log(S / K) + 0.5 * sigma * sigma * T) / sd + d2 = d1 - sd + return S * _ncdf(d1) - K * _ncdf(d2) + + +# --------------------------- DVOL allineata (causale) --------------------------- +def dvol_for(df: pd.DataFrame, asset: str = "ETH") -> np.ndarray: + """DVOL (annualizzata, FRAZIONE es. 0.70) allineata causalmente ai bar di df. + merge_asof backward: ogni bar riceve l'ultima DVOL con ts <= bar. NaN -> ffill, + eventuale buco iniziale (pre-2021-03) -> bfill della prima disponibile.""" + dv = pd.read_parquet(PROJECT_ROOT / "data" / "regime" / f"{asset.lower()}_dvol.parquet") + dv = dv[["timestamp", "dvol"]].sort_values("timestamp") + base = pd.DataFrame({"timestamp": df["timestamp"].values}).sort_values("timestamp") + m = pd.merge_asof(base, dv, on="timestamp", direction="backward") + s = (m["dvol"].values.astype(float)) / 100.0 # da % a frazione + s = pd.Series(s).ffill().bfill().values + return s + + +# --------------------------- engine con overlay opzione --------------------------- +def simulate_hedged(entries: list[dict], df: pd.DataFrame, dvol: np.ndarray | None = None, + fee_rt: float = FEE_RT, lev: float = LEV, pos: float = POS, split: int = -1, + hedge: str = "none", otm: float = 0.05, otm2: float = 0.15, + skew_mult: float = 1.10, tenor_mult: float = 1.0, + hedge_side: str = "both", min_dvol: float = 0.0) -> dict: + """Come explore_lab.simulate ma con un overlay di opzione per-trade. + + hedge: + "none" -> nessun overlay (identico a explore_lab.simulate) + "put" -> compra protezione direzionale: put se long, call se short (floor a otm) + "put_spread"-> debit spread: long protezione a otm, short a otm2 (piu' lontano) -> piu' economico, cap del payoff + "collar" -> protezione a otm finanziata vendendo l'opzione opposta OTM a otm2 (riduce/azzera il premio, cappa il guadagno) + otm: moneyness della protezione (frazione, es 0.05 = 5% OTM) + otm2: secondo strike (per put_spread / collar) + skew_mult: rincaro della sigma sull'opzione COMPRATA (conservativo) + tenor_mult: scadenza = max_bars*tenor_mult ore (>=1: copre almeno l'orizzonte) + hedge_side: "both" | "long" (solo i long-fade, le prese-coltello) | "short" + min_dvol: copri solo se DVOL all'ingresso >= soglia (frazione) -> hedge selettivo + """ + h, l, c = df["high"].values, df["low"].values, df["close"].values + n = len(c) + ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + if dvol is None: + dvol = np.full(n, 0.7) + cap = peak = 1000.0 + max_dd = 0.0 + fee = fee_rt * lev + trades = wins = 0 + last_exit = -1 + bars_in = 0 + yearly: dict[int, float] = {} + rets: list[float] = [] + prem_paid = 0.0 + pay_recv = 0.0 + + for e in entries: + i, d = e["i"], e["d"] + if i <= last_exit or i + 1 >= n or i < split: + continue + entry = c[i] + tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"] + exit_p = c[min(i + mb, n - 1)] + j = min(i + mb, n - 1) + for k in range(1, mb + 1): + j = i + k + if j >= n: + exit_p = c[n - 1]; break + hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)) + hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)) + if hit_sl: + exit_p = sl; break + if hit_tp: + exit_p = tp; break + if k == mb: + exit_p = c[j] + + base_ret = (exit_p - entry) / entry * d * lev - fee + + # ---- overlay opzione ---- + opt_ret = 0.0 + do_hedge = hedge != "none" and ( + hedge_side == "both" + or (hedge_side == "long" and d == 1) + or (hedge_side == "short" and d == -1) + ) and dvol[i] >= min_dvol + if do_hedge: + T = max(mb * tenor_mult, 1.0) / HOURS_YEAR + sig = dvol[i] + sig_buy = sig * skew_mult + if d == 1: # posizione long -> rischio sotto -> PUT (floor) + Kp = entry * (1.0 - otm) + prem = bs_put(entry, Kp, T, sig_buy) / entry + payoff = max(Kp - exit_p, 0.0) / entry + if hedge == "put_spread": + Kp2 = entry * (1.0 - otm2) + prem -= bs_put(entry, Kp2, T, sig) / entry # vendo la coda piu' lontana + payoff -= max(Kp2 - exit_p, 0.0) / entry + elif hedge == "collar": + Kc = entry * (1.0 + otm2) # vendo call OTM per finanziare + prem -= bs_call(entry, Kc, T, sig) / entry + payoff -= max(exit_p - Kc, 0.0) / entry + else: # posizione short -> rischio sopra -> CALL (cap) + Kc = entry * (1.0 + otm) + prem = bs_call(entry, Kc, T, sig_buy) / entry + payoff = max(exit_p - Kc, 0.0) / entry + if hedge == "put_spread": + Kc2 = entry * (1.0 + otm2) + prem -= bs_call(entry, Kc2, T, sig) / entry + payoff -= max(exit_p - Kc2, 0.0) / entry + elif hedge == "collar": + Kp = entry * (1.0 - otm2) + prem -= bs_put(entry, Kp, T, sig) / entry + payoff -= max(Kp - exit_p, 0.0) / entry + opt_ret = lev * (payoff - prem) + prem_paid += lev * prem + pay_recv += lev * payoff + + ret = base_ret + opt_ret + 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; bars_in += (j - i) + last_exit = j + rets.append(ret * pos) + yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100 + + 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, + "ret": (cap / 1000 - 1) * 100, "dd": max_dd * 100, "sharpe": sharpe, + "yearly": yearly, "exposure": bars_in / n * 100 if n else 0.0, + "prem_paid_pct": prem_paid * 100, "pay_recv_pct": pay_recv * 100, + } + + +def evaluate_hedged(name: str, entries: list[dict], df: pd.DataFrame, dvol: np.ndarray, + fees=(0.0, 0.001, 0.002), **hcfg) -> dict: + """FULL + OOS + sweep fee per una config di overlay. Stampa una riga.""" + split = int(len(df) * (1 - OOS_FRAC)) + full = simulate_hedged(entries, df, dvol, **hcfg) + oos = simulate_hedged(entries, df, dvol, split=split, **hcfg) + sweep = {f: simulate_hedged(entries, df, dvol, fee_rt=f, **hcfg)["ret"] for f in fees} + sweep_oos = {f: simulate_hedged(entries, df, dvol, fee_rt=f, split=split, **hcfg)["ret"] for f in fees} + yrs = full["yearly"]; pos_yrs = sum(1 for v in yrs.values() if v > 0) + print(f" {name:<26s} trd={full['trades']:>5d} win={full['win']:>4.1f}% " + f"FULL={full['ret']:>+8.0f}% OOS={oos['ret']:>+7.0f}% DD={full['dd']:>4.0f}% " + f"oDD={oos['dd']:>4.0f}% Shrp={full['sharpe']:>4.2f} exp={full['exposure']:>4.1f}% " + f"prem={full['prem_paid_pct']:>5.0f}% pay={full['pay_recv_pct']:>5.0f}% " + f"anniPos={pos_yrs}/{len(yrs)} | fee0.2%: FULL={sweep[0.002]:>+7.0f} OOS={sweep_oos[0.002]:>+6.0f}") + return {"full": full, "oos": oos, "sweep": sweep, "sweep_oos": sweep_oos, + "pos_yrs": pos_yrs, "n_yrs": len(yrs)} + + +# --------------------------- baseline: Donchian fade ETH --------------------------- +def donchian_fade(df, n=20, sl_atr=2.0, max_bars=24, trend_max=3.0, ema_long=200, use_sl=True): + """Le entries della MR02/ETH (per testarci sopra gli overlay).""" + h, l, c = df["high"].values, df["low"].values, df["close"].values + a = atr(df, 14) + hh = pd.Series(h).rolling(n).max().shift(1).values + ll = pd.Series(l).rolling(n).min().shift(1).values + em = ema(c, ema_long) + ents = [] + for i in range(max(n, ema_long, 14) + 1, len(c)): + if np.isnan(hh[i]) or np.isnan(ll[i]) or np.isnan(a[i]): + continue + if trend_max is not None and not np.isnan(em[i]) and a[i] > 0: + if abs(c[i] - em[i]) / a[i] > trend_max: + continue + tp = (hh[i] + ll[i]) / 2.0 + if c[i] < ll[i] and c[i - 1] >= ll[i - 1]: + ents.append({"i": i, "d": 1, "tp": tp, "sl": (c[i] - sl_atr * a[i]) if use_sl else None, "max_bars": max_bars}) + elif c[i] > hh[i] and c[i - 1] <= hh[i - 1]: + ents.append({"i": i, "d": -1, "tp": tp, "sl": (c[i] + sl_atr * a[i]) if use_sl else None, "max_bars": max_bars}) + return ents + + +if __name__ == "__main__": + df = get_df("ETH", "1h") + dv = dvol_for(df, "ETH") + print(f"ETH 1h {len(df)} bar | DVOL media {np.nanmean(dv)*100:.0f}% (min {np.nanmin(dv)*100:.0f} max {np.nanmax(dv)*100:.0f})") + ents = donchian_fade(df) # MR02/ETH trend-filtrato, con SL ATR + ents_nosl = donchian_fade(df, use_sl=False) # senza SL (per testare l'opzione COME stop) + print("\n=== riferimento: MR02/ETH baseline vs overlay opzione (premio dedotto, conservativo) ===") + evaluate_hedged("baseline SL-ATR (no opt)", ents, df, dv, hedge="none") + evaluate_hedged("no-SL (no opt)", ents_nosl, df, dv, hedge="none") + print(" -- opzione COME floor al posto dello stop (no-SL + put/call OTM) --") + for otm in (0.03, 0.05, 0.08): + evaluate_hedged(f"no-SL +put {int(otm*100)}%OTM", ents_nosl, df, dv, hedge="put", otm=otm, hedge_side="both") + print(" -- put-spread / collar (piu' economici) sul no-SL --") + evaluate_hedged("no-SL +put_spread 5/15", ents_nosl, df, dv, hedge="put_spread", otm=0.05, otm2=0.15) + evaluate_hedged("no-SL +collar 5/10", ents_nosl, df, dv, hedge="collar", otm=0.05, otm2=0.10) + print(" -- hedge SELETTIVO: solo long-fade (prese-coltello) in alta DVOL --") + evaluate_hedged("SL +put long-only DVOL>0.8", ents, df, dv, hedge="put", otm=0.05, hedge_side="long", min_dvol=0.8) diff --git a/scripts/analysis/options_chain.py b/scripts/analysis/options_chain.py new file mode 100644 index 0000000..ad0557d --- /dev/null +++ b/scripts/analysis/options_chain.py @@ -0,0 +1,166 @@ +"""Loader + lookup PREZZI REALI della catena opzioni (da cerbero-bite, data/options/). + +Fonte: scripts/analysis/options_fetcher.py importa lo storico per-strike reale di +cerbero-bite (bid/ask/mid/IV/greche/OI/vol, BTC+ETH, dal 2026-05-01, cadenza ~12min) +in data/options/{eth,btc}_chain.parquet. + +NB granularita': cerbero-bite snapshotta una FETTA ROTANTE della catena (~1 scadenza +per volta). Quindi: + - gli AGGREGATI (curva di skew, livelli di premio per moneyness/tenor) sono ROBUSTI + e sono l'uso principale -> skew_curve(), premium_levels(); + - il lookup per-trade quote() e' BEST-EFFORT con una finestra di staleness + (default 48h) e ritorna il flag staleness_h: un backtest per-trade preciso e' + limitato dalla sparsita', usalo con cautela. + +Uso: + from scripts.analysis.options_chain import OptionChain + oc = OptionChain("ETH") + oc.premium_levels() # tabella premi reali per moneyness x tenor + q = oc.quote(ts_ms, spot=1700, otm=0.10, opt_type="P", min_tenor_d=5, max_tenor_d=14) + # q = dict(ask_pct, iv, atm_iv, skew, spread_pct, oi, delta, strike, tenor_d, staleness_h) +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +OPT_DIR = PROJECT_ROOT / "data" / "options" + +_NUM = ["strike", "bid", "ask", "mid", "iv", "delta", "gamma", "theta", "vega"] + + +def load_chain(asset: str) -> pd.DataFrame: + df = pd.read_parquet(OPT_DIR / f"{asset.lower()}_chain.parquet") + for c in _NUM: + df[c] = pd.to_numeric(df[c], errors="coerce") + for c in ("open_interest", "volume_24h"): + df[c] = pd.to_numeric(df[c], errors="coerce") + df["ts"] = pd.to_datetime(df["timestamp"], utc=True) + df["exp"] = pd.to_datetime(df["expiry"], utc=True) + df["ts_ms"] = (df["ts"].astype("int64") // 10**6) + df["tenor_d"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0 + return df.sort_values("ts_ms").reset_index(drop=True) + + +_MKT_NUM = ["spot", "dvol", "realized_vol_30d", "iv_minus_rv", "funding_perp_annualized", + "funding_cross_annualized", "dealer_net_gamma", "gamma_flip_level", + "oi_delta_pct_4h", "macro_days_to_event"] + + +def load_market(asset: str | None = None) -> pd.DataFrame: + """Pannello regime REALE pre-calcolato da cerbero-bite (market_snapshots, dal 2026-03-26, + ~15min): spot, dvol, realized_vol_30d, iv_minus_rv (VRP), funding perp/cross, + dealer_net_gamma (net-GEX), gamma_flip_level, oi_delta_pct_4h, liquidation_long/short_risk. + asset=None -> tutti. Restituisce ordinato per ts con colonne ts (UTC) e ts_ms.""" + df = pd.read_parquet(OPT_DIR / "market_snapshots.parquet") + if asset is not None: + df = df[df["asset"] == asset].copy() + for c in _MKT_NUM: + if c in df.columns: + df[c] = pd.to_numeric(df[c], errors="coerce") + df["ts"] = pd.to_datetime(df["timestamp"], utc=True, format="ISO8601") + df["ts_ms"] = df["ts"].astype("int64") // 10**6 + return df.sort_values("ts_ms").reset_index(drop=True) + + +class OptionChain: + def __init__(self, asset: str): + self.asset = asset + self.df = load_chain(asset) + self._ts = self.df["ts_ms"].values + + # ---------------- aggregati robusti ---------------- + def _spot_proxy(self) -> pd.Series: + """spot ~ strike del put con delta piu' vicino a -0.5, per snapshot.""" + puts = self.df[self.df["option_type"] == "P"].copy() + puts["d_atm"] = (puts["delta"] + 0.5).abs() + atm = puts.sort_values("d_atm").groupby("timestamp").first() + return atm["strike"] + + def skew_curve(self, opt_type: str = "P") -> pd.DataFrame: + """IV mediana / IV-ATM per banda di moneyness (strike/spot).""" + spot = self._spot_proxy() + d = self.df[self.df["option_type"] == opt_type].copy() + d["spot"] = d["timestamp"].map(spot) + d = d.dropna(subset=["spot", "iv"]) + d["m"] = d["strike"] / d["spot"] + atm_iv = d.assign(da=(d["delta"].abs() - 0.5).abs()).sort_values("da").groupby("timestamp")["iv"].first() + d["atm_iv"] = d["timestamp"].map(atm_iv) + d["skew"] = d["iv"] / d["atm_iv"] + bins = [0.7, 0.85, 0.9, 0.95, 1.0, 1.05, 1.1, 1.15, 1.3] + d["band"] = pd.cut(d["m"], bins) + g = d.groupby("band", observed=True).agg( + n=("skew", "size"), iv_med=("iv", "median"), skew_med=("skew", "median"), + spread_med=("ask", lambda s: float("nan"))) + # spread% mediano + d["spread_pct"] = (d["ask"] - d["bid"]) / ((d["ask"] + d["bid"]) / 2) * 100 + g["spread_med%"] = d.groupby("band", observed=True)["spread_pct"].median() + g["oi_med"] = d.groupby("band", observed=True)["open_interest"].median() + return g.drop(columns=["spread_med"]) + + def premium_levels(self, opt_type: str = "P") -> pd.DataFrame: + """premio reale mediano (ask, %spot) per banda moneyness x tenor.""" + spot = self._spot_proxy() + d = self.df[self.df["option_type"] == opt_type].copy() + d["spot"] = d["timestamp"].map(spot) + d = d.dropna(subset=["spot", "ask"]) + d["m"] = d["strike"] / d["spot"] + d["ask_pct"] = d["ask"] * 100.0 # ask quotato in coin -> %spot = ask*100 + d["tenor_b"] = pd.cut(d["tenor_d"], [0, 3.5, 14, 45, 400], + labels=["1-3d", "4-14d", "15-45d", ">45d"]) + d["m_b"] = pd.cut(d["m"], [0.7, 0.85, 0.9, 0.95, 1.05, 1.15, 1.3], + labels=["<-10%", "-10%", "-7%", "ATM", "+7%", "+10%"]) + g = d.groupby(["m_b", "tenor_b"], observed=True).agg( + n=("ask_pct", "size"), prem_pct=("ask_pct", "median"), + iv=("iv", "median"), oi=("open_interest", "median")) + return g + + # ---------------- lookup per-trade (best effort) ---------------- + def quote(self, ts_ms: int, spot: float, otm: float = 0.10, opt_type: str = "P", + min_tenor_d: float = 5.0, max_tenor_d: float = 14.0, + max_staleness_h: float = 48.0) -> dict | None: + """Quote REALE piu' fresca <= ts_ms per la protezione a `otm` OTM e tenor nel range. + put: strike target = spot*(1-otm); call: spot*(1+otm). None se nulla nella finestra.""" + lo = ts_ms - int(max_staleness_h * 3600 * 1000) + i1 = np.searchsorted(self._ts, ts_ms, "right") + i0 = np.searchsorted(self._ts, lo, "left") + if i1 <= i0: + return None + w = self.df.iloc[i0:i1] + w = w[w["option_type"] == opt_type] + w = w[(w["tenor_d"] >= min_tenor_d) & (w["tenor_d"] <= max_tenor_d)] + if w.empty: + return None + # ultima quota per strumento, poi strike piu' vicino al target + w = w.sort_values("ts_ms").groupby("instrument_name").tail(1) + target = spot * (1 - otm) if opt_type == "P" else spot * (1 + otm) + row = w.iloc[(w["strike"] - target).abs().argmin()] + atm = w.iloc[(w["delta"].abs() - 0.5).abs().argmin()] + ask, bid = float(row["ask"]), float(row["bid"]) + return dict( + ask_pct=ask * 100.0, iv=float(row["iv"]), atm_iv=float(atm["iv"]), + skew=float(row["iv"]) / float(atm["iv"]) if atm["iv"] else float("nan"), + spread_pct=(ask - bid) / ((ask + bid) / 2) * 100 if (ask + bid) > 0 else float("nan"), + oi=int(row["open_interest"] or 0), delta=float(row["delta"]), + strike=float(row["strike"]), tenor_d=float(row["tenor_d"]), + staleness_h=(ts_ms - int(row["ts_ms"])) / 3600000.0, + instrument=row["instrument_name"]) + + +if __name__ == "__main__": + for asset in ("ETH", "BTC"): + oc = OptionChain(asset) + print(f"\n===== {asset} — {len(oc.df)} righe, {oc.df['ts'].min().date()} -> {oc.df['ts'].max().date()} =====") + print("\n--- curva di skew (put, IV mediana / IV-ATM) ---") + print(oc.skew_curve("P").to_string()) + print("\n--- premi reali mediani (ask %spot) per moneyness x tenor ---") + print(oc.premium_levels("P").to_string()) + # quote demo: ultimo ts disponibile, 10% OTM put settimanale + ts = int(oc.df["ts_ms"].iloc[-1]); sp = float(oc._spot_proxy().iloc[-1]) + q = oc.quote(ts, spot=sp, otm=0.10, opt_type="P", min_tenor_d=5, max_tenor_d=14) + print(f"\n--- quote demo: put 10%OTM settimanale ~spot {sp:.0f} ---\n {q}") diff --git a/scripts/analysis/options_fetcher.py b/scripts/analysis/options_fetcher.py new file mode 100644 index 0000000..3d53ccf --- /dev/null +++ b/scripts/analysis/options_fetcher.py @@ -0,0 +1,83 @@ +"""Importa lo storico per-strike delle opzioni da cerbero-bite -> data/options/. + +cerbero-bite (container Docker accanto) accumula in continuo gli snapshot della +catena opzioni Deribit (BTC+ETH) nella tabella `option_chain_snapshots` del suo +SQLite (`/app/data/state.sqlite`, volume `cerbero-bite_bite-data`, root-only). +Questo e' lo storico per-strike REALE (bid/ask/mid/IV/greche/OI/volume) che il +progetto credeva non-backtestabile (muro ARGO/GEX): qui esiste ed e' gratis. + +Il volume non e' leggibile direttamente (root), quindi esportiamo via `docker +exec` (il container ha pandas+pyarrow) e copiamo i parquet in data/options/. +Analogo a regime_fetcher.py -> data/regime/. Rigenera con: + + uv run python scripts/analysis/options_fetcher.py + +NB: snapshot da 2026-05-01 in poi (cerbero-bite e' partito allora), cadenza ~12min. +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +OUT = PROJECT_ROOT / "data" / "options" +BITE_COMPOSE = "/opt/docker/cerbero-bite/docker-compose.yml" +BITE_SERVICE = "cerbero-bite" + +_EXPORT = r''' +import sqlite3, pandas as pd +con = sqlite3.connect("file:/app/data/state.sqlite?mode=ro", uri=True) +for asset in ("ETH", "BTC"): + df = pd.read_sql_query( + "SELECT * FROM option_chain_snapshots WHERE asset=?", con, params=(asset,)) + df.to_parquet(f"/tmp/{asset.lower()}_chain.parquet") + print(f"{asset} {len(df)}") +try: + dv = pd.read_sql_query("SELECT * FROM dvol_history", con) + dv.to_parquet("/tmp/dvol_history.parquet"); print(f"DVOL {len(dv)}") +except Exception as e: + print("dvol skip", e) +# market_snapshots: pannello regime allineato (spot, dvol, VRP, funding, GEX dealer, +# gamma_flip, liquidation risk, oi_delta) -- feature reali pre-calcolate. +try: + ms = pd.read_sql_query("SELECT * FROM market_snapshots", con) + ms.to_parquet("/tmp/market_snapshots.parquet"); print(f"MARKET {len(ms)}") +except Exception as e: + print("market skip", e) +''' + + +def _container_id() -> str: + out = subprocess.run(["docker", "compose", "-f", BITE_COMPOSE, "ps", "-q", BITE_SERVICE], + capture_output=True, text=True, check=True) + cid = out.stdout.strip().splitlines()[0] + if not cid: + raise RuntimeError("container cerbero-bite non trovato (e' su?)") + return cid + + +def fetch() -> None: + OUT.mkdir(parents=True, exist_ok=True) + print("export dalla SQLite di cerbero-bite (docker exec)...") + r = subprocess.run(["docker", "compose", "-f", BITE_COMPOSE, "exec", "-T", BITE_SERVICE, + "python", "-c", _EXPORT], capture_output=True, text=True) + print(r.stdout.strip()) + if r.returncode != 0: + print(r.stderr[-2000:], file=sys.stderr) + raise SystemExit("export fallito") + cid = _container_id() + for fn in ("eth_chain.parquet", "btc_chain.parquet", "dvol_history.parquet", + "market_snapshots.parquet"): + dst = OUT / fn + cp = subprocess.run(["docker", "cp", f"{cid}:/tmp/{fn}", str(dst)], + capture_output=True, text=True) + if cp.returncode == 0: + print(f" -> {dst.relative_to(PROJECT_ROOT)} ({dst.stat().st_size // 1024} KB)") + else: + print(f" (skip {fn}: {cp.stderr.strip()})") + print("OK. Loader: scripts/analysis/options_chain.py") + + +if __name__ == "__main__": + fetch()