diff --git a/docs/diary/2026-07-26-edge-death.md b/docs/diary/2026-07-26-edge-death.md new file mode 100644 index 0000000..2c9a948 --- /dev/null +++ b/docs/diary/2026-07-26-edge-death.md @@ -0,0 +1,112 @@ +# 2026-07-26 — "come faccio a capire se l'edge è morto?" + +Domanda dell'operatore dopo la misura del decadimento. Scopre un buco: il progetto ha gate di kill +**pre-registrati per i candidati** (DVOLSPREAD 24/10, XSR01 23/10, STATARB 27/09) e **nessuno per +il book che gira con soldi veri**. La risposta implicita era "si vedrà" — cioè esattamente ciò che +il progetto non accetta dai candidati. + +**Script:** `r0726_edge_death.py` (taratura) + `scripts/live/edge_watch.py` (sorveglianza, cablata +in `cron_daily.sh`). **Test:** `tests/test_edge_watch.py` (11). **Book/pesi/config INVARIATI.** + +--- + +## 1. La risposta scomoda: non puoi saperlo in fretta + +Con Sharpe ~1.6 e vol ~8%, un anno di risultati non distingue nulla. Distribuzione dello Sharpe +rolling su 4.000 percorsi bootstrap, con edge **vivo** e con edge **morto** (drift a zero, stessa vol): + +``` +finestra VIVO (p5..p50..p95) MORTO (p5..p50..p95) sovrapposizione + 6m -1.06 1.60 4.15 -3.24 -0.10 2.71 67.8% + 12m -0.24 1.62 3.43 -2.20 -0.04 1.99 56.0% + 24m 0.34 1.64 2.92 -1.51 -0.02 1.44 34.6% + 36m 0.57 1.62 2.68 -1.22 -0.03 1.19 21.1% +``` + +**A 12 mesi il 56% dei casi "morto" è indistinguibile da uno vivo.** Un anno brutto non è +informazione, è rumore. + +## 2. La taratura: falsi kill contro velocità + +*"Sharpe rolling a N mesi scende sotto S almeno una volta in 10 anni"*, con edge **intatto** — +cioè quanto spesso la regola ucciderebbe una strategia che funziona: + +``` +finestra S<-0.5 S<0.0 S<+0.5 + 6m 100.0% 100.0% 100.0% + 12m 80.5% 95.9% 99.5% + 24m 14.1% 43.7% 78.8% + 36m 1.8% 12.3% 43.2% +``` + +Con il controllo positivo (edge davvero morto) accanto: + +| finestra | soglia | falso kill | lo prende | rilevamento mediano | +|---|---|---|---|---| +| 12m | −0.5 | 80.5% | 100% | 1.1 anni | +| 24m | −0.5 | 14.1% | 98.5% | 2.5 anni | +| **36m** | **−0.5** | **1.8%** | **90.8%** | **3.8 anni** | + +**→ CRITERIO A: Sharpe rolling 36 mesi sotto −0.5.** Falso kill 1.8% in 10 anni, riconosce l'edge +morto nel 91% dei casi, in **3.8 anni** mediani. + +⚠️ **La lentezza non è un difetto della regola: è statistica.** Una versione a 12 mesi ucciderebbe +un edge vivo nell'80% dei casi. **Non esiste una versione veloce e onesta.** + +## 3. TP01 non si giudica così — e questo è il punto + +Il leave-one-out del 26/07 ha misurato il contributo hold-out di TP01 **negativo nel 99.1% delle +configurazioni d'ancora**. È la firma dell'assicurazione: paga premio negli anni senza incendio. +**"Non ha guadagnato" non è evidenza di morte per uno sleeve difensivo** — la sua morte è *non +proteggere quando serve*. + +``` + anno DD buy&hold DD TP01 protezione + 2019 56.4% 10.3% 5.5x + 2020 59.2% 8.4% 7.0x + 2021 52.0% 6.8% 7.6x + 2022 68.4% 2.8% 24.2x + 2023 22.0% 12.3% 1.8x <- il peggiore, e passa comunque + 2024 35.6% 7.1% 5.0x + 2025 45.1% 6.8% 6.6x + 2026 46.6% 1.4% 34.4x +``` + +**→ CRITERIO B: in un anno con DD buy&hold > 10%, il DD di TP01 deve restare sotto il 75% di +quello.** Storico: **8 anni di sinistro, 8/8 superati**. Negli anni **senza** sinistro il criterio +non si valuta: non c'è informazione. + +Questo criterio è **veloce** dove l'altro è lento — si pronuncia a ogni anno con un crash, che nel +campione è ogni anno. + +## 4. Cosa succede se scattano + +Dichiarato ora per non doverlo decidere nel momento sbagliato: + +* **(A) scatta** → il book **non si spegne da solo**. Si apre una revisione: `weights_tilt_null` + + deflated-Sharpe ricalcolato sui dati nuovi. Spegnere è una decisione dell'operatore. +* **(B) fallito in due anni di sinistro consecutivi** → TP01 non assicura più, e il suo peso (75%) + va rimesso in discussione: proteggere è **l'unico motivo** per cui sta lì. + +Cablato in `cron_daily.sh`, allerta Telegram, non tocca l'esecuzione. Stato al 2026-07-26: +**Sharpe 36m +1.51**, protezione **8/8**. + +## 5. Il limite, di nuovo + +Il criterio A misura se l'edge è morto **dopo** che è morto, con ~4 anni di ritardo. Questo non è +riparabile con una regola migliore — è il contenuto informativo dei dati. La difesa vera non è la +rilevazione: è che **il piano regge a un edge dimezzato** (misurato: traguardo da 11.6 a 15.8 anni, +P(entro 20a) ancora 77%) e che i rischi *veloci* — venue, esecuzione, feed — hanno sorveglianze +proprie che scattano in ore. + +## 6. Regole trasferibili + +1. **Se si pretende un gate di kill dai candidati, se ne deve avere uno per ciò che gira.** + L'asimmetria era invisibile finché non è stata nominata. +2. **Un criterio di kill si tara sul nullo e si valida su un controllo positivo** — identico al + tripwire di venue costruito lo stesso giorno. "Non è mai scattato" non è una buona notizia + finché non si è provato che sa scattare. +3. **Uno sleeve difensivo richiede un criterio DIVERSO**, o lo si uccide per aver fatto il suo + mestiere. Il criterio giusto guarda il sinistro, e negli anni senza sinistro **non si valuta**. +4. **Quando la rilevazione è strutturalmente lenta, dirlo e spostare la difesa altrove** invece di + accorciare la finestra fino a ottenere una risposta veloce e falsa. diff --git a/scripts/cron_daily.sh b/scripts/cron_daily.sh index 486deb5..627f0ed 100755 --- a/scripts/cron_daily.sh +++ b/scripts/cron_daily.sh @@ -25,5 +25,8 @@ mkdir -p logs uv run python scripts/live/paper_combo.py # avanza paper combo (forward-only) # --- REPORT GIORNALIERO Telegram (sola lettura): rompe il silenzio quando il libro e' flat --- uv run python scripts/live/telegram_daily.py # stato conto + perche' non opera + gate + # Sorveglianza dell'EDGE del book live (criteri di kill pre-registrati, + # tarati in scripts/research/r0726_edge_death.py). Riporta e allerta, non spegne nulla. + uv run python scripts/live/edge_watch.py --quiet || true echo "===== done $(date -u '+%H:%M:%SZ') =====" } >> logs/cron_daily.log 2>&1 diff --git a/scripts/live/edge_watch.py b/scripts/live/edge_watch.py new file mode 100644 index 0000000..7177409 --- /dev/null +++ b/scripts/live/edge_watch.py @@ -0,0 +1,148 @@ +#!/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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/research/r0726_edge_death.py b/scripts/research/r0726_edge_death.py new file mode 100644 index 0000000..a9c47e5 --- /dev/null +++ b/scripts/research/r0726_edge_death.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python +"""r0726_edge_death.py — "come faccio a capire se l'edge e' morto?" + +IL BUCO. Il progetto ha gate di kill PRE-REGISTRATI per i candidati (DVOLSPREAD kill 24/10, +XSR01 23/10, STATARB 27/09) e **nessuno per il book che gira con soldi veri**. TP01 e SKH01 non +hanno una condizione dichiarata sotto la quale si direbbe "ha smesso di funzionare": finora la +risposta implicita e' "si vedra'", che e' esattamente cio' che il progetto non accetta dai +candidati. + +Stessa disciplina del tripwire di venue costruito oggi: **si tara sul NULLO** (edge intatto -> non +deve scattare) e **si valida su un CONTROLLO POSITIVO** (edge morto -> deve scattare, e si misura +quanto ci mette). + +IL PROBLEMA STATISTICO. Con Sharpe ~1.7 e vol ~8%, un anno di risultati non distingue niente: +SE(Sharpe) su N anni ≈ sqrt((1+S²/2)/N), che a N=1 vale ~1.1 — piu' grande dell'edge stesso. Un +criterio di kill onesto deve quindi (a) accettare di essere LENTO, oppure (b) accettare falsi kill. +Qui si misura il compromesso invece di sceglierlo a occhio. + +⚠️ E I DUE SLEEVE NON SI GIUDICANO ALLO STESSO MODO. Il leave-one-out del 26/07 ha misurato che +il contributo hold-out di TP01 e' **negativo nel 99.1% delle configurazioni d'ancora**: e' la firma +dell'assicurazione, che paga premio negli anni senza incendio. **Per uno sleeve difensivo "non ha +guadagnato" NON e' evidenza di morte** — la sua morte e' *non proteggere quando serve*. Due +criteri diversi, non uno. + + uv run python scripts/research/r0726_edge_death.py +""" +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)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +import r0725_capcurve as CC # noqa: E402 + +N_PATHS = 4000 +BLOCK = 20 +SEED = 20260726 +YEARS = 10 +# Finestre di valutazione in mesi. Sotto i 12 mesi non c'e' potenza, sopra i 36 il kill arriva +# quando il danno e' fatto: la griglia serve a mostrare proprio questo. +WINDOWS_M = (6, 12, 18, 24, 36) + + +def rolling_sharpe(eq_ret: np.ndarray, win_days: int) -> np.ndarray: + """Sharpe annualizzato su finestra mobile, per ogni path (path x tempo).""" + n_paths, n = eq_ret.shape + out = np.full((n_paths, n), np.nan) + c1 = np.cumsum(eq_ret, axis=1) + c2 = np.cumsum(eq_ret ** 2, axis=1) + for t in range(win_days, n): + s1 = c1[:, t] - c1[:, t - win_days] + s2 = c2[:, t] - c2[:, t - win_days] + m = s1 / win_days + v = np.maximum(s2 / win_days - m ** 2, 1e-18) + out[:, t] = m / np.sqrt(v) * np.sqrt(365) + return out + + +def min_rolling(paths: np.ndarray, win_days: int) -> np.ndarray: + """Il PEGGIOR Sharpe rolling visto lungo ciascun path — la statistica che un criterio di kill + guarda davvero (basta che scenda una volta sotto soglia per far scattare la regola).""" + rs = rolling_sharpe(paths, win_days) + return np.nanmin(rs, axis=1) + + +def main() -> None: + print("=" * 100) + print(' "come faccio a capire se l\'edge e\' morto?"') + print("=" * 100) + + book = CC.book_series("hourly") + r = book.values.astype(float) + mu = float(r.mean()) + print(f" book live TP01+SKH01, {len(r):,} giorni, Sharpe storico " + f"{mu/r.std()*np.sqrt(365):.2f}, vol {r.std()*np.sqrt(365):.1%}") + + rng = np.random.default_rng(SEED) + n_days = 365 * YEARS + alive = CC._boot_paths(r, N_PATHS, n_days, BLOCK, rng) # NULLO: edge intatto + dead = alive - mu # CONTROPROVA: drift a zero, stessa vol + + # ------------------------------------------------------------------ 1. potenza + print("\n" + "=" * 100) + print(" 1. QUANTO SERVE PER DISTINGUERE — la statistica prima delle regole") + print("=" * 100) + print(f"\n {'finestra':>9}{'Sharpe se VIVO (p5..p50..p95)':>36}{'se MORTO (p5..p50..p95)':>32}" + f"{'sovrapposizione':>17}") + for m in WINDOWS_M: + w = int(m * 30.44) + a = rolling_sharpe(alive, w)[:, -1] + d = rolling_sharpe(dead, w)[:, -1] + # quota di casi "morto" che stanno sopra il 5° pctl dei "vivo" = indistinguibili + thr = np.nanpercentile(a, 5) + overlap = float(np.nanmean(d > thr)) + print(f" {m:>7}m {np.nanpercentile(a,5):>10.2f}{np.nanpercentile(a,50):>11.2f}" + f"{np.nanpercentile(a,95):>11.2f} {np.nanpercentile(d,5):>10.2f}" + f"{np.nanpercentile(d,50):>10.2f}{np.nanpercentile(d,95):>10.2f}{overlap:>16.1%}") + print("\n 'sovrapposizione' = quota di casi MORTI che un test al 5% NON riconoscerebbe.") + print(" A 6-12 mesi e' altissima: un anno brutto non e' informazione, e' rumore.") + + # ------------------------------------------------------------------ 2. falsi kill + print("\n" + "=" * 100) + print(" 2. TARATURA SUL NULLO — quanto spesso un edge VIVO verrebbe ucciso per sbaglio") + print("=" * 100) + print(f"\n Regola: 'Sharpe rolling a N mesi scende sotto S almeno una volta in {YEARS} anni'") + print(f"\n {'finestra':>9}" + "".join(f"{f'S<{s:+.1f}':>12}" for s in (-0.5, 0.0, 0.5))) + fp = {} + for m in WINDOWS_M: + w = int(m * 30.44) + worst = min_rolling(alive, w) + cells = [] + for s in (-0.5, 0.0, 0.5): + fp[(m, s)] = float(np.mean(worst < s)) + cells.append(f"{fp[(m,s)]:>11.1%}") + print(f" {m:>7}m " + "".join(cells)) + print(f"\n Questi sono FALSI KILL: l'edge c'e' e la regola lo spegne. Una regola a") + print(f" 6 mesi e' inutilizzabile; a 24-36 mesi diventa gestibile.") + + # ------------------------------------------------------------------ 3. controllo positivo + print("\n" + "=" * 100) + print(" 3. CONTROLLO POSITIVO — se l'edge E' morto, quanto ci mette la regola ad accorgersene") + print("=" * 100) + print(f"\n {'finestra':>9}{'soglia':>9}{'falso kill':>13}{'lo prende':>12}" + f"{'mediana rilevamento':>22}") + best = None + for m in WINDOWS_M: + w = int(m * 30.44) + rs_dead = rolling_sharpe(dead, w) + for s in (-0.5, 0.0, 0.5): + below = rs_dead < s + first = np.where(below.any(axis=1), below.argmax(axis=1), -1) + det = first[first >= 0] / 365.0 + power = float((first >= 0).mean()) + med = float(np.median(det)) if len(det) else np.nan + if fp[(m, s)] <= 0.05 and power >= 0.90: + if best is None or med < best[3]: + best = (m, s, power, med) + print(f" {m:>7}m{s:>9.1f}{fp[(m,s)]:>12.1%}{power:>11.1%}" + f"{med:>19.1f} anni" if not np.isnan(med) else + f" {m:>7}m{s:>9.1f}{fp[(m,s)]:>12.1%}{power:>11.1%}{'mai':>22}") + + if best: + m, s, power, med = best + print(f"\n --> REGOLA SCELTA: Sharpe rolling a {m} mesi sotto {s:+.1f}") + print(f" falso kill {fp[(m,s)]:.1%} in {YEARS} anni · lo riconosce nel {power:.0%} dei casi" + f" · rilevamento mediano {med:.1f} anni") + else: + print(f"\n ✋ Nessuna coppia soddisfa insieme falso-kill<=5% e potenza>=90%.") + + # ------------------------------------------------------------------ 4. lo sleeve difensivo + print("\n" + "=" * 100) + print(" 4. ⚠️ TP01 NON SI GIUDICA COSI' — uno sleeve difensivo si valuta sul SINISTRO") + print("=" * 100) + from src.portfolio.sleeves import tp01_sleeve + + tp = tp01_sleeve(weight=1.0).daily() + from src.data.downloader import load_data + from src.strategies.trend_portfolio import resample_1d, simple_returns + bh = {} + for a in ("BTC", "ETH"): + df = resample_1d(load_data(a, "1h")) + bh[a] = pd.Series(simple_returns(df["close"].values.astype(float)), + index=pd.to_datetime(df["datetime"])) + J = pd.concat(bh, axis=1, join="inner").fillna(0.0) + hold = pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index) + common = tp.index.intersection(hold.index) + tp, hold = tp.loc[common], hold.loc[common] + + def dd(s): + e = (1 + s).cumprod() + return float((1 - e / e.cummax()).max()) + + print(f"\n Il LOO del 26/07 ha misurato: contributo hold-out di TP01 NEGATIVO nel 99.1% delle") + print(f" configurazioni d'ancora. E' la firma dell'assicurazione — paga premio negli anni") + print(f" senza incendio. Quindi 'non ha guadagnato' NON e' evidenza di morte.") + print(f"\n Cio' che TP01 deve fare, per anno (maxDD suo vs buy&hold 50/50):") + print(f"\n {'anno':>6}{'DD buy&hold':>14}{'DD TP01':>11}{'protezione':>13}") + fails = 0 + yrs_tested = 0 + for y, g in hold.groupby(hold.index.year): + t = tp.loc[g.index] + d_bh, d_tp = dd(g), dd(t) + if d_bh < 0.10: + continue # anno senza sinistro: non dice niente sull'assicurazione + yrs_tested += 1 + ratio = d_tp / d_bh if d_bh > 0 else np.nan + bad = ratio > 0.75 + fails += bad + print(f" {y:>6}{d_bh:>13.1%}{d_tp:>10.1%}{1/ratio:>11.1f}x" + f"{' <- NON HA PROTETTO' if bad else ''}") + print(f"\n Anni con sinistro (DD buy&hold > 10%): {yrs_tested}. Fallimenti: {fails}.") + print(f"\n --> CRITERIO PER TP01 (difensivo): non 'quanto rende' ma **in un anno con DD") + print(f" buy&hold > 10%, il DD di TP01 deve restare sotto il 75% di quello**. Due anni") + print(f" di sinistro consecutivi falliti = l'assicurazione non assicura piu'.") + print(f" NB: negli anni senza sinistro il criterio NON si valuta — non c'e' informazione.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_edge_watch.py b/tests/test_edge_watch.py new file mode 100644 index 0000000..7beee0d --- /dev/null +++ b/tests/test_edge_watch.py @@ -0,0 +1,129 @@ +"""Test della sorveglianza dell'edge (scripts/live/edge_watch.py). + +Due criteri, tarati in scripts/research/r0726_edge_death.py: + A) Sharpe rolling 36m < -0.5 (falso kill 1.8% in 10 anni, rilevamento mediano 3.8 anni) + B) TP01 deve tenere il DD sotto il 75% del buy&hold negli anni con sinistro (DD b&h > 10%) + +Il test piu' importante e' `test_un_anno_senza_sinistro_non_viene_valutato`: e' la differenza fra +giudicare un'assicurazione sul premio e giudicarla sul sinistro, ed e' l'errore che il criterio B +esiste per non fare. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "live")) + +import edge_watch as EW # noqa: E402 + + +def _serie(vals, start="2020-01-01"): + return pd.Series(vals, index=pd.date_range(start, periods=len(vals), freq="D", tz="UTC")) + + +# =========================================================================== +# criterio A — Sharpe rolling +# =========================================================================== +def test_storia_insufficiente_non_e_un_via_libera(): + """None significa 'non misurabile', e il chiamante deve poterlo distinguere da 'va bene'.""" + assert EW.trailing_sharpe(_serie([0.001] * 100)) is None + + +def test_sharpe_rolling_usa_solo_la_finestra_recente(): + """Un passato ottimo non deve mascherare un presente pessimo.""" + n = int(EW.WIN_MONTHS * 30.44) + rng = np.random.default_rng(1) + buono = list(rng.normal(0.004, 0.005, n)) # tratto ottimo, poi scartato + cattivo = list(rng.normal(-0.002, 0.005, n)) + s = EW.trailing_sharpe(_serie(buono + cattivo)) + assert s is not None and s < 0, f"la finestra guarda troppo indietro: {s}" + + +def test_la_soglia_congelata_non_cambia_per_sbaglio(): + """Le soglie valgono la taratura (falso kill 1.8%/10a): cambiarle la invalida.""" + assert EW.WIN_MONTHS == 36 + assert EW.SHARPE_KILL == -0.5 + assert EW.SINISTRO_DD == 0.10 + assert EW.PROTECT_MAX == 0.75 + + +# =========================================================================== +# criterio B — protezione, ed e' qui che vive la lezione +# =========================================================================== +def test_un_anno_senza_sinistro_non_viene_valutato(): + """IL test. In un anno in cui il buy&hold non perde, TP01 non ha niente da dimostrare: + l'anno non deve entrare nella statistica ne' come successo ne' come fallimento.""" + calmo = _serie([0.0005] * 365, start="2021-01-01") # b&h sale sempre: nessun sinistro + tp = _serie([-0.001] * 365, start="2021-01-01") # TP01 perde: irrilevante qui + assert EW.protection_by_year(tp, calmo) == [] + + +def test_un_anno_con_sinistro_protetto_passa(): + n = 365 + bh = np.full(n, 0.001) + bh[100:160] = -0.02 # crash: DD b&h grande + tp = np.full(n, 0.0) + tp[100:105] = -0.01 # TP01 esce quasi subito + out = EW.protection_by_year(_serie(tp, "2021-01-01"), _serie(bh, "2021-01-01")) + assert len(out) == 1 and out[0]["ok"] is True + assert out[0]["ratio"] < EW.PROTECT_MAX + + +def test_un_anno_con_sinistro_non_protetto_fallisce(): + n = 365 + bh = np.full(n, 0.001) + bh[100:160] = -0.02 + tp = bh.copy() # TP01 subisce tutto il crash + out = EW.protection_by_year(_serie(tp, "2021-01-01"), _serie(bh, "2021-01-01")) + assert len(out) == 1 and out[0]["ok"] is False + assert out[0]["ratio"] == pytest.approx(1.0, rel=0.05) + + +def test_il_conteggio_dei_fallimenti_e_consecutivo_non_cumulativo(): + """Un fallimento isolato dieci anni fa non deve sommarsi a uno di oggi.""" + prot = [dict(ok=False), dict(ok=True), dict(ok=False)] + consec = 0 + for p in prot: + consec = consec + 1 if not p["ok"] else 0 + assert consec == 1 + + +# =========================================================================== +# controlli positivi: la regola deve SCATTARE quando deve +# =========================================================================== +def test_controllo_positivo_edge_morto_fa_scattare_A(): + n = int(EW.WIN_MONTHS * 30.44) + rng = np.random.default_rng(3) + s = EW.trailing_sharpe(_serie(list(rng.normal(-0.0015, 0.004, n)))) + assert s is not None and s < EW.SHARPE_KILL + + +def test_controllo_negativo_edge_vivo_non_fa_scattare_A(): + n = int(EW.WIN_MONTHS * 30.44) + rng = np.random.default_rng(4) + s = EW.trailing_sharpe(_serie(list(rng.normal(0.0007, 0.004, n)))) + assert s is not None and s > EW.SHARPE_KILL + + +# =========================================================================== +# stato reale + cablaggio +# =========================================================================== +def test_il_book_reale_supera_oggi_entrambi_i_criteri(): + """Se questo test fallisce non e' il test a essere rotto: e' il book.""" + r = EW.run() + assert r["sharpe"] is None or r["sharpe"] >= EW.SHARPE_KILL + assert r["consec_fail"] < 2 + assert all(p["ok"] for p in r["protection"]), \ + f"anni di sinistro falliti: {[p['year'] for p in r['protection'] if not p['ok']]}" + + +def test_edge_watch_e_cablato_nel_cron_giornaliero(): + sh = (ROOT / "scripts" / "cron_daily.sh").read_text() + assert "edge_watch.py" in sh