#!/usr/bin/env python """edge_watch.py — sorveglianza dell'EDGE del book live. Riporta e allerta; non tocca nulla. IL BUCO CHE CHIUDE. Il progetto ha gate di kill pre-registrati per i CANDIDATI (DVOLSPREAD 24/10, XSR01 23/10, STATARB 27/09) e non ne aveva **nessuno per il book che gira con soldi veri**. DUE CRITERI, PERCHE' I DUE SLEEVE NON SI GIUDICANO ALLO STESSO MODO (tarati in `scripts/research/r0726_edge_death.py`, 4.000 percorsi bootstrap): A) RITORNO (book nel suo insieme) — **Sharpe rolling a 36 mesi sotto −0.5**. Tarato sul nullo: falso kill **1.8% in 10 anni** con edge intatto. Controllo positivo: se l'edge e' davvero morto lo riconosce nel **91%** dei casi, in **3.8 anni** mediani. ⚠️ La lentezza NON e' un difetto della regola, e' statistica: a 12 mesi la stessa regola ucciderebbe un edge VIVO nell'80% dei casi. Non esiste una versione veloce e onesta. B) PROTEZIONE (TP01, che e' DIFENSIVO) — in un anno con **drawdown buy&hold > 10%**, il drawdown di TP01 deve restare **sotto il 75%** di quello. Storico: 8 anni con sinistro, **8/8 superati** (protezione da 1.8x a 34.4x). ⚠️ Serve un criterio separato perche' il leave-one-out del 26/07 ha misurato il contributo hold-out di TP01 **negativo nel 99.1%** delle configurazioni d'ancora: e' la firma dell'assicurazione, che paga premio negli anni senza incendio. **"Non ha guadagnato" non e' evidenza di morte per uno sleeve difensivo** — la sua morte e' non proteggere nel sinistro. Negli anni SENZA sinistro il criterio non si valuta: non c'e' informazione. Regola di kill dichiarata ORA per non doverla decidere nel momento sbagliato: * (A) scatta -> il book NON si spegne da solo: si apre una revisione con `weights_tilt_null` e si ricalcola il deflated-Sharpe sui dati nuovi. Spegnere e' una decisione dell'operatore. * (B) fallito in **due anni di sinistro consecutivi** -> TP01 non assicura piu', e il suo peso va rimesso in discussione (e' l'unico motivo per cui e' nel book al 75%). uv run python scripts/live/edge_watch.py # report uv run python scripts/live/edge_watch.py --quiet # solo se qualcosa scatta (cron) """ from __future__ import annotations import sys from pathlib import Path import numpy as np import pandas as pd ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from src.live.notifier import notify # noqa: E402 from src.portfolio.portfolio import combine_outer # noqa: E402 from src.portfolio.sleeves import deribit_book_sleeves, tp01_sleeve # noqa: E402 # --- soglie CONGELATE (cambiarle invalida la taratura di r0726_edge_death.py) --- WIN_MONTHS = 36 SHARPE_KILL = -0.5 SINISTRO_DD = 0.10 # un anno "conta" come sinistro se il buy&hold perde piu' di questo PROTECT_MAX = 0.75 # DD di TP01 ammesso, in frazione del DD buy&hold def book_daily() -> pd.Series: sl = deribit_book_sleeves() return combine_outer({s.name: s.daily() for s in sl}, {s.name: s.weight for s in sl}) def buyhold_daily() -> pd.Series: from src.data.downloader import load_data from src.strategies.trend_portfolio import resample_1d, simple_returns cols = {} for a in ("BTC", "ETH"): df = resample_1d(load_data(a, "1h")) cols[a] = pd.Series(simple_returns(df["close"].values.astype(float)), index=pd.to_datetime(df["datetime"])) J = pd.concat(cols, axis=1, join="inner").fillna(0.0) return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index) def maxdd(s: pd.Series) -> float: e = (1.0 + s).cumprod() return float((1.0 - e / e.cummax()).max()) def trailing_sharpe(s: pd.Series, months: int = WIN_MONTHS) -> float | None: """Sharpe annualizzato sulla finestra piu' recente. None se la storia non basta — e 'non basta' NON e' 'va bene': si riporta a parte.""" w = int(months * 30.44) v = s.dropna().values[-w:] if len(v) < w or v.std() <= 0: return None return float(v.mean() / v.std() * np.sqrt(365)) def protection_by_year(tp: pd.Series, bh: pd.Series) -> list[dict]: """Per ogni anno con SINISTRO, il rapporto di protezione. Gli anni senza sinistro sono esclusi: non contengono informazione sull'assicurazione.""" common = tp.index.intersection(bh.index) tp, bh = tp.loc[common], bh.loc[common] out = [] for y, g in bh.groupby(bh.index.year): d_bh = maxdd(g) if d_bh <= SINISTRO_DD: continue d_tp = maxdd(tp.loc[g.index]) out.append(dict(year=int(y), dd_bh=d_bh, dd_tp=d_tp, ratio=d_tp / d_bh, ok=(d_tp / d_bh) <= PROTECT_MAX)) return out def run() -> dict: book = book_daily() sh = trailing_sharpe(book) prot = protection_by_year(tp01_sleeve(weight=1.0).daily(), buyhold_daily()) # due anni di SINISTRO CONSECUTIVI falliti (consecutivi fra gli anni con sinistro) consec = 0 for p in prot: consec = consec + 1 if not p["ok"] else 0 alerts = [] if sh is not None and sh < SHARPE_KILL: alerts.append(f"criterio A: Sharpe {WIN_MONTHS}m = {sh:+.2f} < {SHARPE_KILL:+.1f}") if consec >= 2: alerts.append(f"criterio B: TP01 non ha protetto in {consec} anni di sinistro consecutivi") return dict(sharpe=sh, protection=prot, consec_fail=consec, alerts=alerts) def main() -> int: r = run() if "--quiet" not in sys.argv: print("=" * 78) print(" EDGE WATCH — il book live sta ancora funzionando?") print("=" * 78) s = r["sharpe"] print(f"\n A) RITORNO — Sharpe rolling {WIN_MONTHS}m: " f"{f'{s:+.2f}' if s is not None else 'storia insufficiente'}" f" (kill sotto {SHARPE_KILL:+.1f}; falso kill 1.8%/10a, rilevamento ~3.8a)") print(f"\n B) PROTEZIONE — TP01 negli anni con sinistro (DD buy&hold > {SINISTRO_DD:.0%}):") print(f"\n {'anno':>6}{'DD b&h':>10}{'DD TP01':>10}{'protez.':>10}{'esito':>8}") for p in r["protection"]: print(f" {p['year']:>6}{p['dd_bh']:>9.1%}{p['dd_tp']:>9.1%}" f"{1/p['ratio']:>9.1f}x{' OK' if p['ok'] else ' FAIL':>8}") n_ok = sum(p["ok"] for p in r["protection"]) print(f"\n {n_ok}/{len(r['protection'])} anni di sinistro superati · " f"fallimenti consecutivi in corso: {r['consec_fail']}") if r["alerts"]: notify("🚨 EDGE WATCH — criterio di kill scattato sul book LIVE", {f"alert {i+1}": a for i, a in enumerate(r["alerts"])}) for a in r["alerts"]: print(f" ALERT: {a}") return 2 return 0 USO = """uso: edge_watch.py [--quiet] edge_watch.py — sorveglianza dell'EDGE del book live. Riporta e allerta; non tocca nulla. uv run python scripts/live/edge_watch.py # report uv run python scripts/live/edge_watch.py --quiet # solo se qualcosa scatta (cron) Dettaglio nel docstring in testa al file.""" if __name__ == "__main__": from src.live.cli import valida valida("edge_watch.py", USO, flag=("--quiet",), con_valore=()) raise SystemExit(main())