diff --git a/docs/research/RESULTS-0822.md b/docs/research/RESULTS-0822.md index e512fef..ebe226d 100644 --- a/docs/research/RESULTS-0822.md +++ b/docs/research/RESULTS-0822.md @@ -694,9 +694,68 @@ stessi hanno nominato senza poterli eseguire**. Stesso contratto di consegna, st |---|---|---|---| | 21 | XS01-OOS | il vantaggio del PROP-ALLOC poggia sul drift di XS01 misurato sulla sua finestra di scoperta: regge fuori? | _in corso_ | | 22 | MAKER | l'esecuzione passiva e' una fonte di ritorno, al netto del costo di non essere eseguiti? | _in corso_ | -| 23 | BOCPD | un rilevatore di cambio di regime vero batte il miglior lookback COSTANTE? | _in corso_ | +| 23 | BOCPD | un rilevatore di cambio di regime vero batte il miglior lookback COSTANTE? | **SCARTATO** β€” filone chiuso definitivamente | | 24 | CRITICO | cosa NON e' stato misurato, e quale singola misura mancante vale di piu' | _in corso_ | | 25 | BIN-FREQ | il binario di frequenza su SKH01 e' un candidato o un sottoprodotto? | _in corso_ | | 26 | TP01-SINISTRO | quanto pesa TP01 se l'obiettivo include un crash che nel campione non c'e'? | _in corso_ | | 27 | SURFACE-RV | la superficie e' internamente incoerente, e l'incoerenza vale piu' del costo di attraversarla? | _in corso_ | + +--- + +## 23 β€” BOCPD (orizzonte adattivo, il falsificatore eseguito) + +`scripts/research/r0822b_bocpd.py` (123 s). **169 trial dichiarati**, conteggio al rialzo, inclusi i +13 di taratura fatti guardando **solo il numero di change-point**, mai uno Sharpe. + +**Il falsificatore che il 22/08 restava aperto e' stato eseguito con i rilevatori che nominava**: +BOCPD (Adams & MacKay, run-length posterior online, Normale-InverseGamma, hazard costante) e +**optimal partitioning causale** (l'obiettivo che PELT accelera, ricorsione in avanti, mai un +`fit_predict` retrospettivo), piu' `L` scelto per walk-forward sul PnL realizzato. + +πŸ“Œ **E questa volta il test HA AVUTO POTENZA β€” e' il fatto che rende il "no" esaustivo.** +Il vincitore del 22/08 aveva lookback **costante** (stava al bordo il 100% del tempo, corr 1,000): +si era chiusa una famiglia di *proxy*, non il meccanismo. Qui **68/96 celle sono davvero adattive** +(BOCPD 24/24: `L_t` mediano 25-107 g, IQR 28-134 g, **0-4% al tetto**, 7-44 reset/anno). + +| | Sh FULL | Sh HOLD | maxDD | CAGR | corrβ†’TP01 | +|---|---|---|---|---|---| +| (A) adattivo, cella in-sample | 1,618 | 0,627 | 14,48% | 22,05% | 0,757 | +| **(B) miglior COSTANTE** (L=15g triple 12h) | **1,639** | **0,657** | 12,68% | 22,31% | 0,737 | +| (C) walk-forward su PnL | 1,385 | 0,382 | 16,33% | 17,20% | 0,860 | +| TP01 30/90/180 | 1,314 | 0,489 | 14,31% | 16,44% | 1,000 | + +πŸ“Œ **La decomposizione E' il risultato:** adattivo βˆ’ TP01 = **+0,305**; miglior costante βˆ’ TP01 = +**+0,326**; adattivo βˆ’ miglior costante = **βˆ’0,021** β†’ **l'adattivita' spiega il βˆ’7% del vantaggio +su TP01. Il 100% e' "un orizzonte piu' corto", non "adattarlo".** + +**Gate:** `earns_slot_honest=False`; `marginal_vs_tp01` **NEUTRAL** con **corrβ†’TP01 0,861**; +appaiato per anno vs il null giusto (B) **3/8 anni** (mediana βˆ’0,15); **banda d'ancora 12 ancore, +mediana delle differenze appaiate βˆ’0,136, positiva in 0/12**, e la canonica sta al **92Β° pctl** = +fortuna d'ancora a suo favore. `causality_ok` ok, e **troncamento manuale esplicito** su 4 +combinazioni: `max|L(prefisso) βˆ’ L(pieno)| = 0` β†’ la trappola del change-point retrospettivo e' +esclusa **per misura, non per fiducia**. Null del de-levering **non applicabile e dichiarato** +(il candidato NON riduce il maxDD: 14,5% contro 12,7%). + +**Il criterio del falsificatore applicato a tutte le 68 celle adattive: 0/68**, e nessuna delle due +condizioni va vicina β€” corrβ†’TP01 **minimo 0,754** (soglia 0,60), **nessuna** batte il miglior +costante sul FULL (la migliore e' βˆ’0,013), massimo **4/8** anni battuti (richiesti 6). + +⚠️ **Cosa l'agente NON propone, e perche':** il miglior costante batte TP01 di +0,326 β€” ma il salto +di Sharpe fra **L adiacenti** e' **+0,190, cioe' il 58% dell'intero vantaggio**: e' un max-of-24 su +un crinale di rumore, non una curva. **Nessun cambio a TP01, al libro, ai pesi.** + +🚨 **Sottoprodotto che vale fuori dal filone: un deflated-Sharpe calcolato dentro una famiglia +OMOGENEA e' CIECO.** Qui dice **0,998-1,000 PASS**; ricalcolato con l'sr0 **di screen** misurato dal +filone ORTHO (1,572 su 168 trial) dice **0,555 / 0,580 / 0,237 FAIL** β€” TP01 compreso. Il motivo e' +strutturale: `deflated_sharpe` stima il massimo-dal-rumore dalla **varianza** degli Sharpe della +griglia, e una griglia di varianti molto correlate ha varianza piccola β†’ sr0 basso β†’ DSR alto **per +costruzione**. Gonfiare N col padding alza N e non la varianza: **non basta contare i trial di +screen, serve la VARIANZA di screen.** + +πŸ“Œ **Secondo sottoprodotto, un controllo da mettere in ogni famiglia con un parametro clippato: la +PROVA DI IDENTITA' batte la diagnostica.** Invece di dire "questa cella sta al bordo", l'agente +mostra che la sua serie e quella del lookback costante coincidono a **max|Ξ”| = 0,000e+00 su +n=2719** β€” non *somiglia a*, **lo e'**. + +**VERDETTO: SCARTATO. IL FILONE ORIZZONTE-ADATTIVO E' CHIUSO DEFINITIVAMENTE.** diff --git a/scripts/research/r0822b_bin_freq.py b/scripts/research/r0822b_bin_freq.py new file mode 100644 index 0000000..345285f --- /dev/null +++ b/scripts/research/r0822b_bin_freq.py @@ -0,0 +1,825 @@ +#!/usr/bin/env python +"""r0822b_bin_freq.py β€” BIN-FREQ: il BINARIO di frequenza su SKH01 giudicato come un CANDIDATO. + +STORIA DEI FATTI (verificata leggendo il codice, non il riassunto). + 1. `r0822_vol_size.py` ha proposto una SIZE PER-TRADE su SKH01 (cella congelata `VTL tv20 w90` + = clip(0.20/rv90(t-1), 0, 3) fissata all'INGRESSO) come CONTROLLO DEL RISCHIO: + +0,070 ISO dShFULL, 23/23 ancore, 8/8 anni. + 2. `r0822_volsize_skeptic.py` ha CONFERMATO il numero e FALSIFICATO il meccanismo: il guadagno + e' FREQUENZA (+0,051 sul solo filtro di attivita') e non MAGNITUDINE (+0,003); e la forma + minima e' un BINARIO 0/1 (`BIN L>=med`, 194/406 trade scartati) che vale +0,080 FULL (20/23) + = 115% del LEAD, con controllo di segno superato (l'inverso fa -0,159, 0/23). + 3. Lo scettico ha scritto "il candidato giusto non e' la formula ma il binario" β€” e nessuno + l'ha passato per `study_family_honest`. E' un SOTTOPRODOTTO DI UN ATTACCO, mai dichiarato + come famiglia. Questo script lo giudica come si giudica un candidato. + +IPOTESI, REGISTRATA PRIMA DI MISURARE. Mi aspetto che il numero si riproduca (lo scettico e' stato +rigoroso e la sua replica e' bit-exact) e che il candidato NON superi il gate onesto, per due +ragioni dichiarate in anticipo: (a) la soglia e la finestra sono due assi liberi trovati DENTRO +l'esplorazione di un'altra famiglia, quindi la stima di famiglia dovra' essere molto piu' piccola +dell'argmax (precedente: sull'asse w lo scettico stesso stima +0,025 contro +0,080); (b) il +deflated-Sharpe sara' VACUO come lo e' stato sulla famiglia VOL-SIZE β€” su perturbazioni della +STESSA strategia la varianza fra i trial e' quasi nulla, e il DSR e' ~1 per chiunque, baseline +compreso. Se succede lo dichiaro vacuo e uso un null che ha potenza. + +=========================== LA FAMIGLIA, DICHIARATA PRIMA DI GUARDARE ========================= +Un binario di frequenza su SKH01 ha esattamente quattro gradi di liberta'. Li dichiaro tutti, +con i loro valori, PRIMA di misurare, e li conto AL RIALZO: + + soglia (6) quantile ESPANDENTE causale della vol trailing, q in {0.25, 0.35, 0.50, 0.65, + 0.75} + la soglia ASSOLUTA rv <= 20% (== `L>=1` della regola congelata) + finestra (6) w in {45, 60, 90, 120, 180, 270} giorni (sotto 45 lo scettico ha misurato che + la finestra contiene 1-3 barre ATTIVE: campione vuoto, non fragilita') + isteresi (2) h in {0.00, 0.10} (banda +-10% attorno alla soglia, stato persistente causale) + segno (2) +1 tieni la vol BASSA (l'ipotesi) / -1 tieni la vol ALTA (controllo di segno) + timeframe(1) uno solo: la gamba SKH01 vive sulla griglia 230m per costruzione. Dichiarato. + -> 6 x 6 x 2 x 2 = 144 celle + +RIAPRIRE UN PARAMETRO RIAPRE LA SUA FAMIGLIA, e questo binario e' nato dentro l'esplorazione di +un'altra famiglia. Percio' al conto delle 144 si SOMMANO i trial gia' spesi sullo stesso oggetto: +28 (r0822_vol_size) + 43 (r0822_volsize_skeptic) = 71. + -> CONTO ONESTO DICHIARATO: 144 + 71 = 215 trial. +La sensibilita' del verdetto al conteggio e' pubblicata (Β§5), come nel precedente 30/07 dove un +DSR passava a N=8, falliva a N=72 e falliva a N=360. + +============================== LENTE, ANCORE, CONVENZIONI ===================================== +Lente: path CANONICO di SKH01 (fill al livello, entry a chiusura di bin) β€” identica nelle due +braccia di ogni confronto, quindi il Delta e' pulito anche se il livello e' pessimistico verso il +path live. Ancore: **23 offset**, la griglia a priori dell'audit 02/07 (ogni 30m su [0,690)): +NON ridotta, perche' il costo misurato e' ~20 ms per cella per ancora. Statistica: MEDIANA DELLE +DIFFERENZE APPAIATE (`A.anchor_luck_delta`), mai differenza delle mediane. +Confronto sempre ISO-PESO: il binario cambia la vol della gamba e quindi il peso EFFETTIVO di +SKH01 nel libro, e muovere quel peso e' gia' stato misurato e RESPINTO il 26/07. Il null del +de-levering e' DEGENERE su una ri-scalatura (`sh(k*base) === sh(base)`), quindi il test e' +iso-peso sullo Sharpe; `k_iso_dd` resta come diagnostica del DD. +Nessuna riscrittura: `extract / equity_daily / leg_daily / sizes_from_L / book / netted_target` +sono IMPORTATE da r0822_vol_size, e il titolo dello scettico e' riprodotto chiamando la SUA +funzione. Replica bit-exact contro `sleeves._skyhook_returns()` prima di ogni delta (Β§0). + +USO: nice -n 19 timeout 900 uv run python scripts/research/r0822b_bin_freq.py [--every K] + --every 8 -> 3 ancore (pilota). default: tutte e 23. +""" +from __future__ import annotations + +import argparse +import sys +import time +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +for _p in (ROOT, ROOT / "scripts" / "research", ROOT / "scripts" / "research" / "alt"): + sys.path.insert(0, str(_p)) + +import altlib as A # noqa: E402 +import r0702_anchor_skh01 as R # noqa: E402 +import r0702_tp01_offset as TO # noqa: E402 +import r0822_vol_size as V # noqa: E402 (motore: riusato, non riscritto) +import r0822_volsize_skeptic as SK # noqa: E402 (per riprodurre il titolo) +from r0822_sol_leg import k_iso_dd # noqa: E402 +from src.portfolio.portfolio import weights_tilt_null # noqa: E402 +from src.strategies.skyhook import SKH01_V1 # noqa: E402 + +ASSETS = V.ASSETS +OFFSETS_FULL = V.OFFSETS_FULL +sh, vol, maxdd, cagr = V.sh, V.vol, V.maxdd, V.cagr +hold, ins, book = V.hold, V.ins, V.book +CAPITAL, MIN_ORDER = V.CAPITAL, V.MIN_ORDER +W_TP, W_SKH = V.W_TP, V.W_SKH + +# ------------------------------------------------------------------ la famiglia (dichiarata sopra) +QS = (0.25, 0.35, 0.50, 0.65, 0.75) +THRS = tuple([("q", q) for q in QS] + [("abs", 0.20)]) +WS = (45, 60, 90, 120, 180, 270) +HS = (0.00, 0.10) +SIGNS = (+1, -1) +N_INHERITED = 28 + 43 # r0822_vol_size + r0822_volsize_skeptic, sullo stesso oggetto +CANON = ("q", 0.50, 90, 0.00, +1) # la cella dello scettico, dentro la mia famiglia +L = 112 + + +def cname(thr, w, h, sg) -> str: + t = "rv<=20%" if thr[0] == "abs" else f"q{thr[1]:.2f}" + return f"{t} w{w} h{h:.2f} s{'+' if sg > 0 else '-'}" + + +# =============================================================== il binario, causale per costruzione +def _hyst(x: np.ndarray, thr: np.ndarray, h: float, keep_below: bool) -> np.ndarray: + """Stato persistente con banda +-h attorno alla soglia. Causale: al passo t usa solo x_t e + thr_t (entrambi funzione di dati <= t-1) e lo stato al passo t-1. h=0 -> confronto secco.""" + n = len(x) + out = np.ones(n, bool) + st = True + for i in range(n): + xi, ti = x[i], thr[i] + if not (np.isfinite(xi) and np.isfinite(ti)): + st = True # warm-up: si tiene tutto (conv. scettico) + elif keep_below: + st = (xi <= ti * (1.0 + h)) if st else (xi <= ti * (1.0 - h)) + else: + st = (xi >= ti * (1.0 - h)) if st else (xi >= ti * (1.0 + h)) + out[i] = st + return out + + +class KeepCache: + """rv e quantili espandenti calcolati UNA volta per (asset, ancora, w). L'espandente e' la + parte cara; il resto e' un confronto.""" + + def __init__(self) -> None: + self.rv: dict = {} + self.q: dict = {} + + def keep(self, ex: dict, key, thr, w: int, h: float, sg: int) -> pd.Series: + base = ex["base_daily"] + k = (key, w) + if k not in self.rv: + self.rv[k] = V._rv_daily(base, w, active_only=False) # gia' shiftata di 1 giorno + rv = self.rv[k] + if thr[0] == "abs": + t = np.full(len(rv), float(thr[1])) + else: + kq = (key, w, thr[1]) + if kq not in self.q: + self.q[kq] = rv.expanding(min_periods=V.VT_WARM).quantile(thr[1]) + t = self.q[kq].values + keep = _hyst(rv.values.astype(float), np.asarray(t, float), h, keep_below=(sg > 0)) + return pd.Series(keep.astype(float), index=rv.index) + + +KC = KeepCache() + + +def size_bin(thr, w, h, sg): + def f(ex, key): + return V.sizes_from_L(ex, KC.keep(ex, key, thr, w, h, sg)) + return f + + +# =============================================================== motore +def leg_of(EXX: dict, key, fn) -> pd.Series: + return V.leg_daily(EXX, {a: fn(EXX[a], (key, a)) for a in ASSETS}) + + +def iso_pair(TP: pd.Series, leg: pd.Series, leg_base: pd.Series): + """Libro della variante e libro di CONTROLLO ISO-PESO (baseline ri-scalato alla stessa vol di + gamba => stesso peso effettivo di SKH). Convenzione del bersaglio, riusata tale e quale.""" + vb = vol(leg_base) + m = vol(leg) / vb if vb > 0 else 1.0 + return book(TP, leg), book(TP, leg_base * m), m + + +# =============================================================== main +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--every", type=int, default=1) + ap.add_argument("--ndraw", type=int, default=200) + args = ap.parse_args() + offs = OFFSETS_FULL[:: max(1, args.every)] + t0 = time.time() + + print("=" * L) + print(" r0822b BIN-FREQ β€” il BINARIO di frequenza su SKH01, giudicato come un CANDIDATO") + print("=" * L) + print(f" ancore: {len(offs)}/{len(OFFSETS_FULL)} della griglia a priori 02/07 (ogni 30m su " + f"[0,690)). NON ridotta.") + print(" lente: path CANONICO, identica nelle due braccia. Confronto sempre ISO-PESO.") + + # ---------------------------------------------------------- Β§1 famiglia, PRIMA di misurare + print("\n" + "-" * L) + print(" 1. GRIGLIA DICHIARATA β€” scritta prima di guardare i risultati, contata AL RIALZO") + print("-" * L) + cells = [(t, w, h, s) for t in THRS for w in WS for h in HS for s in SIGNS] + print(f" soglia {len(THRS):>3} {['q%.2f' % q for q in QS] + ['rv<=20% (== L>=1)']}") + print(f" finestra {len(WS):>3} {list(WS)} giorni") + print(f" isteresi {len(HS):>3} {list(HS)} (banda +-h attorno alla soglia)") + print(f" segno {len(SIGNS):>3} +1 tieni vol BASSA (ipotesi) / -1 tieni vol ALTA (controllo)") + print(f" timeframe 1 la gamba SKH01 vive su 230m per costruzione") + print(f" -> celle di QUESTA famiglia: {len(cells)}") + print(f" -> trial gia' spesi sullo STESSO oggetto: {N_INHERITED} " + f"(28 r0822_vol_size + 43 r0822_volsize_skeptic)") + print(f" -> CONTO ONESTO DICHIARATO: {len(cells)} + {N_INHERITED} = " + f"{len(cells) + N_INHERITED} trial. La sensibilita' al conteggio e' in Β§5.") + assert len(cells) == 144 + + # ---------------------------------------------------------- estrazione + print("\n estrazione tabelle trade (V2-DD) ...", flush=True) + EX = {o: {a: V.extract(a, o) for a in ASSETS} for o in offs} + ntr = {a: int(np.mean([len(EX[o][a]["i_ent"]) for o in offs])) for a in ASSETS} + print(f" fatto in {time.time()-t0:.0f}s β€” trade medi/ancora: " + + ", ".join(f"{a} {ntr[a]}" for a in ASSETS)) + TP = A.tp01_baseline_daily() + can = offs[0] + + # ---------------------------------------------------------- Β§0 SANITY + print("\n" + "-" * L) + print(" 0. SANITY β€” bit-exact prima di qualunque delta") + print("-" * L) + from src.portfolio.sleeves import _skyhook_returns + base_leg = {o: V.leg_daily(EX[o], {a: V.size_flat(EX[o][a]) for a in ASSETS}) for o in offs} + off = _skyhook_returns() + d0 = float(np.max(np.abs(base_leg[can].values - off.values))) if len(base_leg[can]) == len(off) \ + else float("nan") + print(f" gamba 50/50 off{can} vs sleeves._skyhook_returns(): max|dif| = {d0:.2e} " + f"({len(base_leg[can])} giorni)") + assert d0 < 1e-15, "replica NON bit-exact" + for a in ASSETS: + ref = R.run_asset(a, can)[0] + R._CACHE.clear() + dd = float(np.max(np.abs(V.equity_daily(EX[can][a], V.size_flat(EX[can][a])).values + - ref.values))) + print(f" {a} vs r0702_anchor_skh01.run_asset: max|dif| = {dd:.2e}") + assert dd < 1e-15 + + # ---------------------------------------------------------- Β§2 riproduzione del titolo + print("\n" + "-" * L) + print(" 2. RIPRODUZIONE DEL TITOLO DELLO SCETTICO β€” chiamando la SUA funzione, non la mia") + print(" (pubblicato: BIN L>=med +0,080 FULL 20/23 ; INVERSO -0,159 0/23)") + print("-" * L) + REP = {} + for tag, mode in (("BIN L>=med (scettico)", "gemed"), ("BIN L<1 INVERSO (scettico)", "lt1"), + ("BIN L>=1 (scettico)", "ge1")): + fn = SK.size_bin(SK.FROZEN, mode) + bk, ct = {}, {} + for o in offs: + lg = V.leg_daily(EX[o], {a: fn(EX[o][a]) for a in ASSETS}) + bk[o], ct[o], _ = iso_pair(TP, lg, base_leg[o]) + REP[tag] = (bk, ct) + dF = A.anchor_luck_delta(lambda o: bk[o], lambda o: ct[o], offs, metric=sh) + dH = A.anchor_luck_delta(lambda o: hold(bk[o]), lambda o: hold(ct[o]), offs, metric=sh) + print(f" {tag:<28} ISO dShFULL {dF['median_paired']:+.3f} " + f"({dF['n_positive']}/{len(offs)}) banda [{dF['lo']:+.3f},{dF['hi']:+.3f}] " + f"ISO dShHOLD {dH['median_paired']:+.3f} ({dH['n_positive']}/{len(offs)})") + med_F = float(np.median([sh(book(TP, base_leg[o])) for o in offs])) + med_H = float(np.median([sh(hold(book(TP, base_leg[o]))) for o in offs])) + med_D = float(np.median([maxdd(book(TP, base_leg[o])) for o in offs])) + ban_F = [sh(book(TP, base_leg[o])) for o in offs] + print(f" libro BASE, mediana della banda: ShFULL {med_F:.3f} / ShHOLD {med_H:.3f} / " + f"maxDD {med_D*100:.1f}% [pubblicato 1,626 / 1,037 / 10,4%]") + print(f" banda d'ancora del LIVELLO del libro: ShFULL [{min(ban_F):.3f},{max(ban_F):.3f}] " + f"= ampiezza {max(ban_F)-min(ban_F):.3f} (il termine di paragone di Β§12)") + + # ---------------------------------------------------------- Β§3 causalita' per troncamento + print("\n" + "-" * L) + print(" 3. CAUSALITA' β€” la decisione tenere/scartare ricalcolata TRONCANDO i dati al giorno") + print(" d'ingresso. `A.causality_ok` NON E' GIRATO (vuole un target_fn(df) e qui il") + print(" segnale nasce da 5m->230m: ri-eseguirlo su prefissi costa quanto tutto lo") + print(" script). Sostituito da un troncamento diretto, piu' stringente sul punto esatto.") + print("-" * L) + thr_c, w_c, h_c, s_c = ("q", CANON[1]), CANON[2], CANON[3], CANON[4] + bad = tested = 0 + for a in ASSETS: + ex = EX[can][a] + kfull = KC.keep(ex, (can, a), thr_c, w_c, h_c, s_c) + rv_full = V._rv_daily(ex["base_daily"], w_c, False) + days = list(dict.fromkeys(ex["day_ent"])) + for d in days[::7]: # 1 ingresso su 7, su tutta la storia + if d not in kfull.index: + continue + b_tr = ex["base_daily"][ex["base_daily"].index <= d] + if len(b_tr) < V.VT_WARM + w_c: + continue + rv_t = V._rv_daily(b_tr, w_c, False) + q_t = rv_t.expanding(min_periods=V.VT_WARM).quantile(thr_c[1]) + k_t = _hyst(rv_t.values.astype(float), q_t.values.astype(float), h_c, s_c > 0)[-1] + tested += 1 + if bool(k_t) != bool(kfull.loc[d] > 0.5): + bad += 1 + assert abs(float(rv_t.iloc[-1]) - float(rv_full.loc[d])) < 1e-12 or \ + not np.isfinite(rv_full.loc[d]) + print(f" decisioni ricalcolate su prefisso: {tested} DIVERGENTI: {bad}") + print(" NB l'isteresi ha memoria: troncando si perde lo stato, quindi il test e' esatto") + print(" per h=0 (la cella canonica) e conservativo per h>0.") + assert bad == 0, "il binario NON e' causale" + + # ---------------------------------------------------------- Β§4 tabella di famiglia + print("\n" + "-" * L) + print(" 4. LA FAMIGLIA INTERA β€” ISO dShFULL, mediana delle differenze appaiate su " + f"{len(offs)} ancore") + print("-" * L) + # DUE PASSATE, per la RAM (7 GB sulla VPS, ~3 liberi, e siamo in tanti): la 1a calcola le + # metriche per-ancora di tutte le 144 celle SENZA conservare le serie (144 x 23 x 3 serie da + # 2717 punti sarebbero ~500 MB); la 2a ricalcola e CONSERVA le serie delle sole 3-4 celle che + # servono dopo (canonica / scelta al buio / argmax): 4 x 23 gambe, ~2 s. + BK, CT, LEG, MS = {}, {}, {}, {} + + def eval_cell(thr, w, h, sg, store: bool = False) -> dict: + nm = cname(thr, w, h, sg) + fn = size_bin(thr, w, h, sg) + dF, dH, dI, lF, lI, lH, ms, kp, ddv, ddc, dC_ = ([] for _ in range(11)) + yr: dict = {} + for o in offs: + lg = leg_of(EX[o], o, fn) + b, c, m = iso_pair(TP, lg, base_leg[o]) + dF.append(sh(b) - sh(c)); dH.append(sh(hold(b)) - sh(hold(c))) + dI.append(sh(ins(b)) - sh(ins(c))); dC_.append(cagr(b) - cagr(c)) + lF.append(sh(lg)); lI.append(sh(ins(lg))); lH.append(sh(hold(lg))) + ms.append(m); ddv.append(maxdd(b)); ddc.append(maxdd(c)) + kp.append(float(np.mean(np.concatenate([fn(EX[o][a], (o, a)) for a in ASSETS])))) + for y in {int(t) for t in b.index.year}: + by, cy = b[b.index.year == y], c[c.index.year == y] + if len(by) > 20: + yr.setdefault(y, []).append(sh(by) - sh(cy)) + if store: + BK.setdefault(nm, {})[o] = b + CT.setdefault(nm, {})[o] = c + LEG.setdefault(nm, {})[o] = lg + MS.setdefault(nm, {})[o] = m + a_ = np.asarray(dF, float) + return dict(nm=nm, thr=thr, w=w, h=h, sg=sg, + dF=float(np.median(a_)), nF=int((a_ > 0).sum()), + lo=float(a_.min()), hi=float(a_.max()), arrF=a_, + dH=float(np.median(dH)), nH=int((np.asarray(dH) > 0).sum()), + isoI=float(np.median(dI)), dC=float(np.median(dC_)), + legF=float(np.median(lF)), legI=float(np.median(lI)), + legH=float(np.median(lH)), kept=float(np.median(kp)), + weff=float(np.median([W_SKH * x for x in ms])), + ddv=float(np.median(ddv)), ddc=float(np.median(ddc)), + yr={y: float(np.median(v)) for y, v in yr.items()}) + + RES = {} + for (thr, w, h, sg) in cells: + r = eval_cell(thr, w, h, sg) + RES[r["nm"]] = r + print(f" ({len(cells)} celle x {len(offs)} ancore valutate in {time.time()-t0:.0f}s " + f"dall'avvio, prima passata senza conservare le serie)") + + def matrix(sg: int, h: float) -> None: + print(f"\n ISO dShFULL β€” segno {'+1 (tieni vol BASSA)' if sg>0 else '-1 (CONTROLLO)'}" + f", isteresi h={h:.2f}") + print(" soglia " + "".join(f"{('w%d' % w):>11}" for w in WS)) + for thr in THRS: + t = "rv<=20%" if thr[0] == "abs" else f"q{thr[1]:.2f}" + row = "" + for w in WS: + r = RES[cname(thr, w, h, sg)] + row += f"{r['dF']:>+8.3f}{r['nF']:>3}" + print(f" {t:<10}" + row) + + for sg in SIGNS: + for h in HS: + matrix(sg, h) + print("\n (ogni cella: mediana appaiata e n. di ancore positive su " + f"{len(offs)})") + + POS = [RES[cname(t, w, h, +1)] for t in THRS for w in WS for h in HS] + NEG = [RES[cname(t, w, h, -1)] for t in THRS for w in WS for h in HS] + ALLC = [RES[cname(t, w, h, s)] for t in THRS for w in WS for h in HS for s in SIGNS] + nmcan = cname(("q", CANON[1]), CANON[2], CANON[3], CANON[4]) + argmax = max(ALLC, key=lambda r: r["dF"]) + print(f"\n ARGMAX della famiglia: {cname(argmax['thr'], argmax['w'], argmax['h'], argmax['sg']):<26}" + f" dShFULL {argmax['dF']:+.3f} ({argmax['nF']}/{len(offs)})") + print(f" cella dello scettico: {nmcan:<26} dShFULL {RES[nmcan]['dF']:+.3f} " + f"({RES[nmcan]['nF']}/{len(offs)}) rango " + f"{1 + sum(r['dF'] > RES[nmcan]['dF'] for r in ALLC)}/{len(ALLC)}") + print(f" STIMA ONESTA se la cella si sceglie a caso dentro la famiglia (mediana):") + print(f" tutta la famiglia (144) {float(np.median([r['dF'] for r in ALLC])):+.3f} " + f"positive {sum(r['dF']>0 for r in ALLC)}/{len(ALLC)}") + print(f" solo segno +1 (72) {float(np.median([r['dF'] for r in POS])):+.3f} " + f"positive {sum(r['dF']>0 for r in POS)}/{len(POS)}") + print(f" solo segno -1 (72) {float(np.median([r['dF'] for r in NEG])):+.3f} " + f"positive {sum(r['dF']>0 for r in NEG)}/{len(NEG)} <- controllo di segno") + # seconda passata: conserva le serie delle sole celle che servono dopo + sel_iso0 = max(ALLC, key=lambda r: r["isoI"]) + for r in dict((x["nm"], x) for x in (RES[nmcan], argmax, sel_iso0, + max(ALLC, key=lambda z: z["legI"]))).values(): + eval_cell(r["thr"], r["w"], r["h"], r["sg"], store=True) + dchk = A.anchor_luck_delta(lambda o: BK[nmcan][o], lambda o: CT[nmcan][o], offs, metric=sh) + assert abs(dchk["median_paired"] - RES[nmcan]["dF"]) < 1e-12 and \ + dchk["n_positive"] == RES[nmcan]["nF"], "la statistica appaiata di Β§4 non coincide con A.anchor_luck_delta" + print(f" controllo: la statistica appaiata calcolata in Β§4 coincide con " + f"`A.anchor_luck_delta` ({dchk['median_paired']:+.6f})") + print(f" quota di trade TENUTI, mia cella q0.50 w90: {RES[nmcan]['kept']*100:.1f}% " + f"(mediana fra le ancore)") + fn_can = size_bin(("q", CANON[1]), CANON[2], CANON[3], CANON[4]) + fn_sk = SK.size_bin(SK.FROZEN, "gemed") + ov, k_me, k_sk = [], [], [] + for o in offs: + for a in ASSETS: + x = fn_can(EX[o][a], (o, a)) > 0.5 + y = fn_sk(EX[o][a]) > 0.5 + ov.append(float((x == y).mean())); k_me.append(float(x.mean())); k_sk.append(float(y.mean())) + dleg = float(np.max(np.abs(BK[nmcan][can].values - REP["BIN L>=med (scettico)"][0][can].values))) + print(f" ⚠ la MIA parametrizzazione NON e' bit-identica a quella dello scettico: lui prende") + print(f" la mediana espandente di L = clip(0.20/rv,0,3) DOPO un `.fillna(1.0)` che inietta") + print(f" dei 1.0 nel warm-up; io prendo il quantile espandente di rv, che salta i NaN. Il") + print(f" trasformo e' monotono, quindi le due decisioni coincidono nel " + f"{float(np.mean(ov))*100:.1f}% dei trade") + print(f" (tenuti {float(np.mean(k_me))*100:.1f}% io / {float(np.mean(k_sk))*100:.1f}% lui); " + f"max|dif| fra i due libri = {dleg:.2e}.") + + # ---------------------------------------------------------- Β§5 i tre componenti del gate + print("\n" + "-" * L) + print(" 5. `study_family_honest` β€” NON chiamabile direttamente (e' cablato sui candidati") + print(" DIREZIONALI: factory -> target_fn via candidate_daily, e qui il candidato e' un") + print(" overlay su uno sleeve). Come il 30/07 sul tenore VRP01, uso i suoi TRE") + print(" COMPONENTI REALI: selezione in-sample-only -> deflated_sharpe -> marginal_vs_tp01.") + print("-" * L) + print(" (a) SELEZIONE IN-SAMPLE-ONLY β€” due criteri, entrambi dichiarati, entrambi sulla") + print(" MEDIANA fra le ancore (l'ancora non e' un hold-out, e' una dimensione di luck)") + sel_leg = max(ALLC, key=lambda r: r["legI"]) # come `select_cell_insample` + sel_iso = max(ALLC, key=lambda r: r["isoI"]) # cio' su cui si decide davvero + hop_leg = max(ALLC, key=lambda r: r["legH"]) + hop_iso = max(ALLC, key=lambda r: r["dH"]) + for tag, r in (("Sharpe IS standalone della gamba (come select_cell_insample)", sel_leg), + ("ISO dShFULL IN-SAMPLE del libro (cio' che decide)", sel_iso)): + n = cname(r["thr"], r["w"], r["h"], r["sg"]) + print(f" scelta al buio su {tag}") + print(f" -> {n:<26} ISO dShFULL {r['dF']:+.3f} ({r['nF']}/{len(offs)}) " + f"ISO dShHOLD {r['dH']:+.3f} ({r['nH']}/{len(offs)}) banda " + f"[{r['lo']:+.3f},{r['hi']:+.3f}]") + for tag, r in (("Sharpe HOLD-OUT standalone", hop_leg), ("ISO dShHOLD del libro", hop_iso)): + print(f" controllo (scegliendo SULL'HOLD-OUT, cio' che NON si fa) su {tag}:") + print(f" -> {cname(r['thr'], r['w'], r['h'], r['sg']):<26} dShFULL {r['dF']:+.3f}" + f" dShHOLD {r['dH']:+.3f}") + CHOSEN = sel_iso + nmc = cname(CHOSEN["thr"], CHOSEN["w"], CHOSEN["h"], CHOSEN["sg"]) + print(f" CELLA SCELTA AL BUIO (criterio primario = ISO dShFULL in-sample): {nmc}") + + print("\n (b) DEFLATED SHARPE β€” sullo Sharpe FULL standalone della gamba (la grandezza che") + print(" `select_cell_insample` usa), mediana fra le ancore; `daily` della barra canonica") + print(" serve solo per T/asimmetria/curtosi. Sensibilita' al CONTEGGIO ottenuta") + print(" REPLICANDO la distribuzione dei trial osservati (tiling), non riempiendola con") + print(" la mediana: riempire con la mediana ABBASSA la varianza dei trial e fa SALIRE il") + print(" DSR all'aumentare di N, che e' il verso sbagliato. (Errore mio, catturato nel") + print(" pilota e corretto: il primo output dava DSR 0,966 -> 0,986 -> 0,998 da N=144 a") + print(" N=500, cioe' 'piu' trial = piu' significativo'.)") + all_sr = [r["legF"] for r in ALLC] + pos_sr = [r["legF"] for r in POS] + base_sr = float(np.median([sh(base_leg[o]) for o in offs])) + dly = LEG[nmc][can] + + def tile(v, N): + return (list(v) * (N // len(v) + 1))[:N] + + for lab, sr, d in (("cella scelta al buio", CHOSEN["legF"], dly), + ("BASELINE (size fissa)", base_sr, base_leg[can])): + for N, tagN in ((len(ALLC), f"{len(ALLC)} di QUESTA famiglia"), + (len(ALLC) + N_INHERITED, + f"{len(ALLC)+N_INHERITED} = conto onesto (+{N_INHERITED} ereditati)"), + (500, "500 (ipotetico, al rialzo)")): + dsr, sr0 = A.deflated_sharpe(sr, tile(all_sr, N), d) + print(f" {lab:<22} N={tagN:<44} DSR {dsr:.3f} max atteso sotto il null {sr0:+.3f}") + lo_n, hi_n = len(ALLC), 20000 + if A.deflated_sharpe(CHOSEN["legF"], tile(all_sr, hi_n), dly)[0] >= 0.95: + cross = f">{hi_n}" + elif A.deflated_sharpe(CHOSEN["legF"], tile(all_sr, lo_n), dly)[0] < 0.95: + cross = f"<{lo_n}" + else: + for _ in range(40): + mid = (lo_n + hi_n) // 2 + if A.deflated_sharpe(CHOSEN["legF"], tile(all_sr, mid), dly)[0] >= 0.95: + lo_n = mid + else: + hi_n = mid + cross = f"~{lo_n}" + print(f" -> IL VERDETTO SI RIBALTA A N {cross} trial: sotto quel conteggio il DSR passa,") + print(f" sopra fallisce. Il conto onesto dichiarato ({len(ALLC)+N_INHERITED}) sta dal lato") + print(f" del PASS. Precedente 30/07: un DSR passava a N=8, falliva a N=72 e a N=360; qui") + print(f" la stessa sensibilita' e' pubblicata invece di essere taciuta.") + dsp, sr0p = A.deflated_sharpe(CHOSEN["legF"], tile(pos_sr, len(POS) + N_INHERITED), dly) + print(f" sensibilita' alla COMPOSIZIONE della famiglia: escludendo il segno -1 (che e' un") + print(f" controllo e non un candidato) i trial diventano {len(POS)}+{N_INHERITED} e la loro") + print(f" dispersione cala -> DSR {dsp:.3f} (max atteso {sr0p:+.3f}). Includere il segno -1") + print(f" e' la scelta CONSERVATIVA e resta quella dichiarata.") + print(f" dispersione dei trial: sd(Sharpe FULL) = {float(np.std(all_sr, ddof=1)):.3f} sulle") + print(f" {len(ALLC)} celle, {float(np.std(pos_sr, ddof=1)):.3f} sulle {len(POS)} di segno +1.") + print(f" -> il DSR qui NON e' vacuo (il baseline lo fallisce dove il candidato lo passa):") + print(f" la famiglia contiene celle davvero diverse fra loro, al contrario della famiglia") + print(f" VOL-SIZE dove erano tutte perturbazioni della stessa size continua.") + + print("\n (c) MARGINAL vs TP01 β€” sulla gamba modificata e sulla gamba base") + for lab, s in (("BASE (size fissa)", base_leg[can]), (nmc, LEG[nmc][can]), + ("cella dello scettico", LEG[nmcan][can])): + m = A.marginal_vs_tp01(s) + b25 = m.get("blends", {}).get("w25", {}) + print(f" {lab:<26} {str(m.get('marginal_verdict')):<10} corr {m.get('corr_full')} " + f"uplift w25 full {b25.get('uplift_full')} / hold {b25.get('uplift_hold')} " + f"robust_oos={m.get('robust_oos')} insample_edge={m.get('has_insample_edge')} " + f"hedge={m.get('is_hedge')}") + imp = A.implausible_sharpe( + LEG[nmc][can], + n_trades=int(sum(len(EX[can][a]["net"]) for a in ASSETS)), + n_losing_trades=int(sum((EX[can][a]["net"] < 0).sum() for a in ASSETS))) + print(f" implausible_sharpe (gamba scelta): implausible={imp['implausible']} " + f"attive {imp.get('active_frac', 0)*100:.1f}% perdite/attive " + f"{imp.get('loss_frac', 0)*100:.1f}% Calmar {imp.get('calmar', 0):.1f}") + + # ---------------------------------------------------------- Β§6 null con potenza + print("\n" + "-" * L) + print(" 6. IL NULL CHE HA POTENZA β€” permutazione della SEQUENZA tenuti/scartati.") + print(" Stessa quota di trade tenuti, riassegnata a caso: separa 'conta QUALE trade si") + print(" scarta' (informazione) da 'conta scartarne meta'' (aritmetica). Una mediana del") + print(" null lontana da 0 direbbe che il null non e' un null (lezione 22/08 sul null") + print(f" invertito). {args.ndraw} estrazioni su TUTTE le {len(offs)} ancore, non su 3.") + print("-" * L) + rng = np.random.default_rng(20260822) + for lab, r in (("cella scelta al buio", CHOSEN), ("cella dello scettico", RES[nmcan]), + ("argmax di famiglia", argmax)): + nm = cname(r["thr"], r["w"], r["h"], r["sg"]) + real = float(np.median([sh(BK[nm][o]) - sh(CT[nm][o]) for o in offs])) + fn = size_bin(r["thr"], r["w"], r["h"], r["sg"]) + sz0 = {o: {a: fn(EX[o][a], (o, a)) for a in ASSETS} for o in offs} + nul = [] + for _ in range(args.ndraw): + v = [] + for o in offs: + szp = {a: rng.permutation(sz0[o][a]) for a in ASSETS} + lg = V.leg_daily(EX[o], szp) + b, c, _ = iso_pair(TP, lg, base_leg[o]) + v.append(sh(b) - sh(c)) + nul.append(float(np.median(v))) + nul = np.asarray(nul) + pc = float((nul < real).mean()) + print(f" {lab:<24} {nm:<24} reale {real:+.3f} null mediana {np.median(nul):+.3f} " + f"p90 {np.percentile(nul, 90):+.3f} max {nul.max():+.3f} -> p ~ {1-pc:.3f}") + print(f" (seme 20260822; {args.ndraw} estrazioni x {len(offs)} ancore)") + print("\n ⚠ LA MEDIANA DEL NULL NON E' ~0, E NON E' UN DIFETTO DEL NULL: E' UN FATTO.") + print(" Scartare a caso meta' dei trade DANNEGGIA, perche' lo Sharpe ANNUALIZZATO cresce") + print(" con la radice del numero di scommesse per anno: dimezzare i trade sullo stesso") + print(" arco di calendario costa un fattore ~sqrt(2) di Sharpe di gamba. Misurato:") + nmv = cname(CHOSEN["thr"], CHOSEN["w"], CHOSEN["h"], CHOSEN["sg"]) + fnv = size_bin(CHOSEN["thr"], CHOSEN["w"], CHOSEN["h"], CHOSEN["sg"]) + rng2 = np.random.default_rng(20260823) + shb = float(np.median([sh(base_leg[o]) for o in offs])) + shv = float(np.median([sh(LEG[nmv][o]) for o in offs])) + kp = float(np.median([np.mean(np.concatenate([fnv(EX[o][a], (o, a)) for a in ASSETS])) + for o in offs])) + shn = [] + for _ in range(min(40, args.ndraw)): + sz0 = {o: {a: fnv(EX[o][a], (o, a)) for a in ASSETS} for o in offs} + shn.append(float(np.median([sh(V.leg_daily(EX[o], {a: rng2.permutation(sz0[o][a]) + for a in ASSETS})) for o in offs]))) + print(f" Sharpe della GAMBA: baseline (tutti i trade) {shb:.3f} -> permutata " + f"{float(np.median(shn)):.3f} -> filtrata {shv:.3f}") + print(f" quota tenuta {kp*100:.0f}% -> attesa dalla sola perdita di scommesse " + f"{shb*np.sqrt(kp):.3f}; osservata sotto permutazione {float(np.median(shn)):.3f}") + print(" Conseguenza per la lettura: il null NON e' centrato su zero, quindi il p-value") + print(" risponde alla domanda 'la SELEZIONE batte una selezione casuale della stessa") + print(" taglia?' e non alla domanda 'il filtro batte il non filtrare?'. La seconda e' il") + print(" confronto con la BASE (Β§4), che e' quello su cui si decide. Il filtro deve") + print(" superare una penale meccanica prima di guadagnare qualcosa: e' un test SEVERO.") + + # ---------------------------------------------------------- Β§7 iso-peso / de-levering + print("\n" + "-" * L) + print(" 7. ISO-PESO e NULL DEL DE-LEVERING") + print("-" * L) + print(f" {'cella':<26}{'wSKH eff':>10}{'dShFULL RAW':>13}{'ISO':>9}{'maxDD var':>11}" + f"{'maxDD ctrl':>11}{'k_iso_dd':>10}") + for lab, r in (("BASE", None), ("cella dello scettico", RES[nmcan]), + ("cella scelta al buio", CHOSEN)): + if r is None: + print(f" {'BASE (size fissa)':<26}{W_SKH:>10.3f}{0.0:>+13.3f}{0.0:>+9.3f}" + f"{maxdd(book(TP, base_leg[can]))*100:>10.1f}%{'-':>11}{'-':>10}") + continue + nm = cname(r["thr"], r["w"], r["h"], r["sg"]) + raw = A.anchor_luck_delta(lambda o, n=nm: BK[n][o], + lambda o: book(TP, base_leg[o]), offs, metric=sh) + dv = float(np.median([maxdd(BK[nm][o]) for o in offs])) + dc = float(np.median([maxdd(CT[nm][o]) for o in offs])) + ks = (f"{k_iso_dd(CT[nm][can], BK[nm][can]):.3f}" if dv < dc + else "n/a") # n/a = non riduce il DD: nulla da negare + print(f" {nm:<26}{r['weff']:>10.3f}{raw['median_paired']:>+13.3f}{r['dF']:>+9.3f}" + f"{dv*100:>10.1f}%{dc*100:>10.1f}%{ks:>10}") + print(" Il null del de-levering e' DEGENERE su una ri-scalatura (sh(k*base) === sh(base)):") + print(" qui il test e' l'iso-peso sullo Sharpe. `k_iso_dd` e' riportato come diagnostica del") + print(" DD ed e' calcolato CONTRO il controllo iso-peso (non contro il baseline nudo).") + print(" NB il binario ABBASSA il peso effettivo di SKH01, non lo alza: la preoccupazione del") + print(" 26/07 ('alzare SKH01') non morde, ma l'iso-peso resta obbligatorio nell'altro verso.") + print(f"\n LIVELLI del libro (mediana fra le {len(offs)} ancore), netti fee:") + print(f" {'configurazione':<30}{'ShFULL':>9}{'ShHOLD':>9}{'ShIS':>8}{'maxDD':>9}{'CAGR':>9}" + f"{'vol':>8}") + lev = [("BASE (libro live 75/25)", {o: book(TP, base_leg[o]) for o in offs})] + for lab, r in (("BIN, peso eff. lasciato cadere", RES[nmcan]), + ("BIN, controllo ISO-PESO", None)): + if r is None: + lev.append((lab, {o: CT[nmcan][o] for o in offs})) + else: + lev.append((lab, {o: BK[nmcan][o] for o in offs})) + for lab, dd_ in lev: + f_ = float(np.median([sh(dd_[o]) for o in offs])) + h_ = float(np.median([sh(hold(dd_[o])) for o in offs])) + i_ = float(np.median([sh(ins(dd_[o])) for o in offs])) + d_ = float(np.median([maxdd(dd_[o]) for o in offs])) + c_ = float(np.median([cagr(dd_[o]) for o in offs])) + v_ = float(np.median([vol(dd_[o]) for o in offs])) + print(f" {lab:<30}{f_:>9.3f}{h_:>9.3f}{i_:>8.3f}{d_*100:>8.1f}%{c_*100:>8.1f}%" + f"{v_*100:>7.1f}%") + print(" ⚠ la DIFFERENZA di queste mediane NON e' la mediana delle differenze appaiate (Β§4):") + print(" qui le mediane cadono su ancore diverse. La tabella serve a dare i LIVELLI netti fee") + print(" richiesti dal contratto; il numero che decide resta quello appaiato.") + + # ---------------------------------------------------------- Β§8 weights_tilt_null + print("\n" + "-" * L) + print(" 8. `weights_tilt_null` β€” una modulazione binaria della size E' un cambio di pesi nel") + print(" tempo. Il gate confronta vettori STATICI: cattura la sola componente di SCALA") + print(" (limite dichiarato, come nel bersaglio). k_seen = tutti i trial dichiarati.") + print("-" * L) + cols = {"TP01": TP, "SKH01": base_leg[can]} + for lab, r in (("cella dello scettico", RES[nmcan]), ("cella scelta al buio", CHOSEN), + ("argmax di famiglia", argmax)): + nm = cname(r["thr"], r["w"], r["h"], r["sg"]) + m = MS[nm][can] + wp = {"TP01": W_TP, "SKH01": W_SKH * m} + g = weights_tilt_null(cols, {"TP01": W_TP, "SKH01": W_SKH}, wp, caps={"SKH01": 0.5}, + floor=0.05, n=300, k_seen=len(ALLC) + N_INHERITED) + print(f" {lab:<24} {nm:<24} peso equiv SKH {wp['SKH01']/sum(wp.values()):.3f} " + f"d_IS {g['delta_insample']:+.4f} d_HOLD {g['delta_hold']:+.4f} " + f"pctl {g['pctl_hold']:.1f} gate_pass={g['gate_pass']}") + print(" ⚠ LIMITE, e va detto perche' e' aggirabile: il binario ABBASSA il peso effettivo di") + print(" SKH01, e il gate misura proprio quella caduta. Nella forma di deploy naturale (si") + print(" ri-scala la gamba per riportare il peso effettivo a 0,25) il vettore proposto e'") + print(" IDENTICO a quello corrente -> d_IS = d_HOLD = 0 e il gate diventa INERTE, non") + print(" superato. Quindi `weights_tilt_null` NON puo' essere l'evidenza decisiva qui: e'") + print(" negativo nella forma nuda e vacuo nella forma compensata. Cio' che decide resta la") + print(" banda APPAIATA a iso-peso (Β§4) e la stima di famiglia.") + + # ---------------------------------------------------------- Β§9 eseguibilita' + print("\n" + "-" * L) + print(f" 9. ESEGUIBILITA' a ${CAPITAL:.0f} β€” il vantaggio DICHIARATO dal filone (dimezza gli") + print(" ordini). Target NETTATO 0.75*TP01 + 0.25*SKH sul grid 1h = cio' che il cron manda.") + print("-" * L) + print(f" {'configurazione':<26}{'Sh model':>10}{'Sh reale':>10}{'haircut':>9}" + f"{'ordini eseg':>13}{'sotto-min':>11}{'turnover/a':>12}") + EXEC = {} + for lab, r in (("BASE (size fissa)", None), ("cella dello scettico", RES[nmcan]), + ("cella scelta al buio", CHOSEN)): + nm = None if r is None else "SEL" + tm = tr = tu = 0.0 + no = ns = 0 + for a in ASSETS: + df1h = TO.get1h(a) + SZa = {} if r is None else { + "SEL": (lambda ex, rr=r, aa=a: size_bin(rr["thr"], rr["w"], rr["h"], + rr["sg"])(ex, (can, aa)))} + tgt = V.netted_target(a, EX[can][a], nm, SZa, {}, {}, {}, {}, TP, can, df1h) + ev = A.eval_weights_smallcap(df1h, tgt, capital=CAPITAL, min_order=MIN_ORDER) + tm += 0.5 * ev["modeled"]["sharpe"] + tr += 0.5 * ev["realistic"]["sharpe"] + tu += ev["executed_turnover_per_year"] + no += ev["n_executed_trades"] + dw = np.abs(np.diff(np.nan_to_num(tgt), prepend=0.0)) + ns += int(((dw > 1e-12) & (dw * CAPITAL < MIN_ORDER)).sum()) + EXEC[lab] = (tm, tr, no, ns, tu) + print(f" {lab:<26}{tm:>10.3f}{tr:>10.3f}{tm-tr:>9.3f}{no:>13}{ns:>11}{tu:>12.1f}") + print(" 'ordini eseg' e 'sotto-min' sono sui due asset sommati, su tutta la storia 1h.") + ntr_base = int(sum(len(EX[can][a]['i_ent']) for a in ASSETS)) + ntr_sel = int(sum((size_bin(CHOSEN['thr'], CHOSEN['w'], CHOSEN['h'], CHOSEN['sg']) + (EX[can][a], (can, a)) > 0).sum() for a in ASSETS)) + print(f" trade SKH eseguiti (off{can}): BASE {ntr_base} -> cella scelta {ntr_sel} " + f"({ntr_sel/ntr_base*100:.0f}%)") + print(" ⚠ IL RISPARMIO DI FEE E' GIA' DENTRO i numeri sopra: `extract` netta ogni trade di") + print(f" FEE_RT={V.FEE_RT:.4f} (10 bps RT) all'ingresso, quindi un trade scartato non paga.") + print(" Convertirlo di nuovo con la curva del 26/07 (-0,017 Sh/bps di libro, SKH 4x TP01)") + print(" sarebbe un DOPPIO CONTEGGIO. Cio' che NON e' dentro e' (a) il min-order a $635,") + print(" misurato qui sopra come haircut, e (b) il fatto che la fee reale e' 3,5 bps/lato =") + print(" 7 bps RT contro i 10 modellati -> i backtest sono CONSERVATIVI, in entrambe le braccia.") + + # ---------------------------------------------------------- Β§9b fee vs informazione + print("\n" + "-" * L) + print(" 9b. QUANTO DELL'EFFETTO E' INFORMAZIONE E QUANTO E' SOLO FEE RISPARMIATA.") + print(" Scartare meta' dei trade risparmia meta' delle fee: parte del +0,080 e' quella,") + print(" ed e' un guadagno vero ma NON e' un'ipotesi sulla vol. Lo stesso confronto,") + print(" identico in tutto, ricalcolato a fee diverse (le size si ricalcolano da zero,") + print(" perche' la vol trailing della gamba dipende anche dalle fee).") + print("-" * L) + print(f" {'lente':<40}{'cella scettico':>17}{'pos/n':>8}{'cella al buio':>17}{'pos/n':>8}") + FEEDEC = {} + keep_fee = V.FEE_RT + for fee, tag in ((keep_fee, f"MODELLATA {keep_fee*1e4:.0f} bps RT (i backtest)"), + (0.0007, "REALE 3,5 bps/lato = 7 bps RT"), + (0.0, "fee ZERO (informazione pura)")): + V.FEE_RT = fee + if fee == keep_fee: + EXf, basef = EX, base_leg + else: + EXf = {o: {a: V.extract(a, o) for a in ASSETS} for o in offs} + basef = {o: V.leg_daily(EXf[o], {a: V.size_flat(EXf[o][a]) for a in ASSETS}) + for o in offs} + row = "" + for r in (RES[nmcan], CHOSEN): + fn = size_bin(r["thr"], r["w"], r["h"], r["sg"]) + bk, ct = {}, {} + for o in offs: + lg = V.leg_daily(EXf[o], {a: fn(EXf[o][a], (tag, o, a)) for a in ASSETS}) + bk[o], ct[o], _ = iso_pair(TP, lg, basef[o]) + d = A.anchor_luck_delta(lambda o: bk[o], lambda o: ct[o], offs, metric=sh) + FEEDEC[(tag, cname(r["thr"], r["w"], r["h"], r["sg"]))] = d["median_paired"] + row += f"{d['median_paired']:>+17.3f}{d['n_positive']:>5}/{len(offs):<3}" + print(f" {tag:<40}{row}") + if fee != keep_fee: + del EXf, basef + V.FEE_RT = keep_fee + assert V.FEE_RT == 0.001 + f_mod = FEEDEC[(f"MODELLATA {keep_fee*1e4:.0f} bps RT (i backtest)", nmcan)] + f_zero = FEEDEC[("fee ZERO (informazione pura)", nmcan)] + print(f" -> a fee ZERO resta {f_zero:+.3f} di {f_mod:+.3f} = " + f"{100*f_zero/f_mod if f_mod else float('nan'):.0f}% dell'effetto: la parte restante") + print(" e' fee risparmiata. La fee vera oggi e' 3,5 bps/lato, meno di quella modellata,") + print(" quindi nel LIVE la componente-fee del vantaggio e' PIU' PICCOLA di quella misurata") + print(" sui backtest β€” il verso in cui il numero pubblicato e' ottimista per il deploy.") + + # ---------------------------------------------------------- Β§10 trasferimento su V1 + print("\n" + "-" * L) + print(" 10. TRASFERIMENTO su SKH01_V1 β€” seconda sequenza di trade, geometria d'uscita") + print(" DIVERSA (stop/target in ATR invece che a percentuale fissa). Non e' un hold-out") + print(" temporale, ma e' l'unico campione indipendente disponibile a questo costo.") + print("-" * L) + EX1 = {o: {a: V.extract(a, o, SKH01_V1) for a in ASSETS} for o in offs} + base1 = {o: V.leg_daily(EX1[o], {a: V.size_flat(EX1[o][a]) for a in ASSETS}) for o in offs} + print(f" {'cella':<26}{'ISO dShFULL':>13}{'pos/n':>8}{'ISO dShHOLD':>14}{'pos/n':>8}" + f"{'tenuti':>9}") + for lab, r in (("cella dello scettico", RES[nmcan]), ("cella scelta al buio", CHOSEN)): + fn = size_bin(r["thr"], r["w"], r["h"], r["sg"]) + b1, c1, kp = {}, {}, [] + for o in offs: + lg = V.leg_daily(EX1[o], {a: fn(EX1[o][a], ("V1", o, a)) for a in ASSETS}) + b1[o], c1[o], _ = iso_pair(TP, lg, base1[o]) + kp.append(float(np.mean(np.concatenate( + [fn(EX1[o][a], ("V1", o, a)) for a in ASSETS])))) + dF = A.anchor_luck_delta(lambda o: b1[o], lambda o: c1[o], offs, metric=sh) + dH = A.anchor_luck_delta(lambda o: hold(b1[o]), lambda o: hold(c1[o]), offs, metric=sh) + print(f" {cname(r['thr'], r['w'], r['h'], r['sg']):<26}{dF['median_paired']:>+13.3f}" + f"{dF['n_positive']:>5}/{len(offs):<3}{dH['median_paired']:>+14.3f}" + f"{dH['n_positive']:>5}/{len(offs):<3}{float(np.median(kp))*100:>8.0f}%") + + # ---------------------------------------------------------- Β§11 per anno + print("\n" + "-" * L) + print(" 11. SCOMPOSIZIONE PER ANNO (regola 22/08 dopo SOL: un contributo positivo si") + print(" scompone per ANNO prima di crederci β€” 24/24 ancore possono essere un anno solo)") + print("-" * L) + yrs = sorted({int(y) for y in BK[nmcan][can].index.year}) + print(f" {'cella':<26}" + "".join(f"{y:>8}" for y in yrs)) + for lab, r in (("cella dello scettico", RES[nmcan]), ("cella scelta al buio", CHOSEN)): + nm = cname(r["thr"], r["w"], r["h"], r["sg"]) + cc = [] + for y in yrs: + v = [sh(BK[nm][o][BK[nm][o].index.year == y]) - sh(CT[nm][o][CT[nm][o].index.year == y]) + for o in offs if (BK[nm][o].index.year == y).sum() > 20] + cc.append(float(np.median(v)) if v else float("nan")) + print(f" {nm:<26}" + "".join(f"{c:>+8.2f}" for c in cc) + + f" positivi {int(np.nansum(np.asarray(cc) > 0))}/{len(yrs)}") + + # ---------------------------------------------------------- Β§12 la domanda che decide + print("\n" + "-" * L) + print(" 12. LA DOMANDA CHE DECIDE β€” un effetto che vale un terzo della propria incertezza") + print(" merita di cambiare un sistema che gira con soldi veri?") + print("-" * L) + amp = max(ban_F) - min(ban_F) + nm = nmcan + dC = float(np.median([cagr(BK[nm][o]) - cagr(CT[nm][o]) for o in offs])) + cag_band = [cagr(book(TP, base_leg[o])) for o in offs] + print(f" (i) IL CONFRONTO GIUSTO NON E' QUELLO DELLA DOMANDA. La banda d'ancora del") + print(f" LIVELLO ({amp:.3f} di ampiezza, +-{amp/2:.3f}) e' incertezza sul livello, e") + print(f" si CANCELLA in un confronto APPAIATO: le due braccia girano sulla STESSA") + print(f" ancora. Cio' che va confrontato con l'effetto e' la dispersione della") + print(f" DIFFERENZA appaiata, che e' un ordine di grandezza piu' piccola.") + print(f" cella scettico: effetto {RES[nm]['dF']:+.3f}, banda appaiata " + f"[{RES[nm]['lo']:+.3f},{RES[nm]['hi']:+.3f}], " + f"{RES[nm]['nF']}/{len(offs)} ancore positive.") + print(f" (ii) MA la banda del livello torna a mordere DUE volte, e in modo decisivo:") + print(f" (a) la SCELTA della cella e' fatta guardando quella banda -> la stima onesta") + print(f" non e' l'argmax ma la MEDIANA DI FAMIGLIA " + f"({float(np.median([r['dF'] for r in POS])):+.3f} sul segno +1);") + print(f" (b) l'effetto e' misurato sugli STESSI 8 anni su cui SKH01 e' stato") + print(f" selezionato, e non esiste un forward capace di misurarlo (SE(Sharpe)") + print(f" ~1,4 su 6 mesi contro un effetto di 0,08).") + print(f" (iii) IL CRITERIO, dichiarato: si tocca il libro live solo se TUTTE E QUATTRO β€”") + print(f" (1) l'effetto sopravvive alla stima ONESTA di famiglia (mediana, non argmax)") + print(f" (2) `weights_tilt_null` passa (e' il gate codificato per i cambi di peso)") + print(f" (3) il gate marginale + un null CON POTENZA lo sostengono") + print(f" (4) l'effetto e' grande abbastanza da pagare un modo di fallire in piu'.") + print(f" (iv) ORDINI DI GRANDEZZA a ${CAPITAL:.0f} (regola 26/07: quando le leve hanno") + print(f" ordini di grandezza diversi, DIRLO):") + print(f" effetto della cella (ISO, mediana appaiata): dCAGR {dC*100:+.2f}%/anno") + print(f" = ${dC*CAPITAL:+.2f}/anno = EUR {dC*CAPITAL/365*0.86:+.4f}/giorno") + print(f" banda d'ancora del CAGR del libro: " + f"[{min(cag_band)*100:.1f}%, {max(cag_band)*100:.1f}%] " + f"= ${(max(cag_band)-min(cag_band))*CAPITAL:.0f}/anno di incertezza") + print(f" versare EUR 500/mese (il piano dell'operatore) = EUR 16,44/giorno di capitale") + print(f" nuovo, cioe' ~{16.44/max(1e-9, abs(dC*CAPITAL/365*0.86)):.0f}x l'effetto misurato qui.") + print(f" (v) IL CRITERIO APPLICATO, riga per riga:") + med_pos = float(np.median([r['dF'] for r in POS])) + c1 = med_pos > 0 + c3 = True + print(f" (1) stima ONESTA di famiglia (mediana segno +1) = {med_pos:+.3f} " + f"contro argmax {argmax['dF']:+.3f} e cella pubblicata {RES[nmcan]['dF']:+.3f}" + f" -> {'PASS' if c1 else 'FAIL'}") + print(f" (2) `weights_tilt_null`: gate_pass=False nella forma nuda, INERTE nella forma") + print(f" compensata -> il gate non lo sostiene in nessuna delle due -> FAIL/vacuo") + print(f" (3) marginale ADDS + null di permutazione con potenza (Β§6) -> vedi sopra") + print(f" (4) taglia contro il modo di fallire in piu': " + f"EUR {dC*CAPITAL/365*0.86:+.4f}/giorno a ${CAPITAL:.0f} -> FAIL") + print(f" Di cui, per giunta, solo il {100*f_zero/f_mod if f_mod else float('nan'):.0f}% " + f"e' informazione e il resto e' fee risparmiata (Β§9b),") + print(f" e la fee vera e' piu' bassa di quella modellata.") + + print("\n" + "=" * L) + print(f" fatto in {time.time()-t0:.0f}s") + print("=" * L) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822b_bocpd.py b/scripts/research/r0822b_bocpd.py new file mode 100644 index 0000000..0673a12 --- /dev/null +++ b/scripts/research/r0822b_bocpd.py @@ -0,0 +1,957 @@ +#!/usr/bin/env python +"""r0822b_bocpd.py β€” IL FALSIFICATORE NOMINATO E MAI ESEGUITO del filone ADAPTIVE-HORIZON. + +CONTESTO (perche' questo file esiste) +------------------------------------- +Il 22/08 `r0822_adaptive_horizon.py` ha scartato il TSMOM a orizzonte adattivo con una diagnosi +netta: il "vincitore adattivo" aveva L_t **incollato al bordo il 100% del tempo** = un TSMOM a +orizzonte COSTANTE di 20 giorni travestito (corr 1.000, dSh 0.00 in 8/8 anni e 0/12 ancore); +isolando le 52 celle davvero adattive -> NEUTRAL, corr->TP01 0.90 = TP01 travestito. +Ma quell'agente ha dichiarato **cosa lo smentirebbe** e non ha potuto eseguirlo: + + "Un rilevatore il cui L_t si muove DAVVERO e batte il miglior lookback COSTANTE (non TP01) in + >=6/8 anni appaiati con corr->TP01 < 0.6. La mia famiglia copre 3 rilevatori a soglia/finestra + fissa β€” un run-length posterior bayesiano (BOCPD/PELT vero) o un L scelto per walk-forward sul + PnL realizzato non sono stati provati." + +Questo file esegue **quel** test. Oggi il filone e' chiuso su UNA famiglia (3 proxy a soglia +fissa), non sul MECCANISMO. Una seconda chiusura, con rilevatori veri, rende il "no" definitivo. + +PERCHE' BOCPD E' STRUTTURALMENTE DIVERSO DAI 3 PROXY DEL PRIMO TENTATIVO +----------------------------------------------------------------------- +I 3 proxy (CUSUM a soglia k, variance-ratio, Hurst) producevano una STATISTICA che veniva poi +MAPPATA linearmente dentro [Lmin, Lmax]: la mappatura e' arbitraria, e infatti la statistica +saturava contro il clip (bordo 100%). Un run-length posterior NON ha bisogno di mappatura: +la lunghezza di run **e' gia' un lookback**, nelle stesse unita' (barre). "Quanto indietro posso +guardare" = "da quanto tempo non cambia il regime" e' un'identita', non una scelta di scala. + * BOCPD (Adams & MacKay 2007): posterior online su r_t con hazard costante e verosimiglianza + Normale-InverseGamma coniugata (predittiva Student-t). Causale PER COSTRUZIONE: e' un filtro + in avanti, ogni P(r_t | x_{1:t}) usa solo x_{1:t}. + * OP/PELT causale: PELT e' un'accelerazione (pruning) dell'**optimal partitioning** + F(t) = min_{s e' un asse] + blend 2 (single L | triple L/3,L,2L β€” con L=90 il triple E' TP01) + timeframe 2 (1d | 12h) [>=12h: sotto, costi+overfit dominano, 19/06] + = 2*3*2*2*2*2 = **96 trial** + B) controllo L COSTANTE 12 L x 2 blend x 2 TF = **48 trial** + C) WF-PnL (L da walk-forward) 3 finestre x 2 blend x 2 TF = **12 trial** + D) taratura della SCALA 8 penalita' OP + 5 hazard BOCPD = **13** (sezione 1-pre) + ---------------------------------------------------------------- TOTALE DICHIARATO **169** +⚠️ (D) e' una scansione fatta guardando SOLO il numero di change-point rilevati β€” una proprieta' +del RILEVATORE β€” e mai uno Sharpe: nessuna di quelle 13 configurazioni produce un rendimento. Si +dichiara lo stesso, al rialzo, perche' ha ristretto la griglia di (A). Il suo esito e' gia' un +risultato: a penalita' OP >= 5 l'eta' resta incollata al tetto della finestra il 44% del tempo, +cioe' **l'artefatto del 22/08 riprodotto su un rilevatore vero** β€” e la griglia (A) usa solo la +regione in cui il rilevatore rileva davvero (0.5/1.0/2.0 = 18-40 change-point l'anno). +Costanti FISSATE A PRIORI (gradi di liberta' non cercati, dichiarati perche' esistono): +Lmin=20g, Lmax=200g (contengono 30..180 del canonico e sono gli stessi del primo tentativo); +BOCPD su rendimenti log STANDARDIZZATI da vol rolling causale 30g (il bersaglio e' il DRIFT, non +la vol); prior NIG mu0=0, kappa0=1, alpha0=1, beta0=1; troncamento del posterior a Lmax barre; +costo OP = mean-shift gaussiano su dati standardizzati, penalita' beta = k*log(t) causale. +Il deflated-Sharpe e' riportato a N=169 (mia famiglia), N=281 (mia + le 112 del primo tentativo: +**stessa domanda**, i trial si sommano) e N=449 (di SCREEN β€” lezione 22/08 del filone ORTHO: +su BTC/ETH direzionale il massimo atteso dal rumore su 168 trial e' Sharpe 1.572, SOPRA il +soffitto misurato ~1.3, quindi il conteggio di screen e' quello che decide). + +ORDINE DI ESECUZIONE (imposto dalla missione, ed e' l'ordine giusto) +-------------------------------------------------------------------- +La diagnostica che ha smascherato il primo tentativo va PER PRIMA: quanto si muove davvero L_t? +Se sta al bordo, l'artefatto e' riprodotto e il filone si chiude di nuovo senza guardare i +rendimenti. Solo se L_t si muove ha senso misurare se il movimento PAGA. + +Runtime atteso ~6-10 min su 2 core. Nessun file toccato fuori da questo. +""" +from __future__ import annotations + +import sys +import time +from pathlib import Path + +import numpy as np +import pandas as pd +from scipy.special import gammaln +from scipy.stats import norm + +ROOT = Path("/opt/docker/PythagorasGoal") +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) +sys.path.insert(0, str(ROOT)) + +import altlib as A # noqa: E402 +from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402 + +ASSETS = A.CERTIFIED +HOLDOUT = A.HOLDOUT + +# ---- costanti FISSATE A PRIORI (dichiarate sopra, non cercate) ------------------------- +LMIN_D, LMAX_D = 20, 200 # banda dell'orizzonte, IDENTICA al primo tentativo +FIXED_H = (30, 90, 180) # == (L/3, L, 2L) con L=90 -> TP01 canonico +TGT_VOL, LEV, VOLWIN = 0.20, 2.0, 30 +NIG_MU0, NIG_KAP0, NIG_AL0, NIG_BE0 = 0.0, 1.0, 1.0, 1.0 +STD_WIN_D = 30 # finestra della standardizzazione causale dei rendimenti + +TFS = ("1d", "12h") +DETECTORS = ("bocpd", "op") +PARAMS = {"bocpd": (50, 100, 250), "op": (0.5, 1.0, 2.0)} +# TARATURA DELLA SCALA (sezione 1-pre): scansione ESPLORATIVA fatta guardando SOLO il numero di +# change-point rilevati β€” una proprieta' del RILEVATORE β€” e mai lo Sharpe. Un rilevatore che non +# rileva nulla non e' un rilevatore: a pen_k>=5 l'eta' resta incollata al tetto della finestra +# (44% del tempo) = ESATTAMENTE l'artefatto del 22/08, riprodotto qui su un rilevatore vero. +CAL_PEN = (0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0) +CAL_LAM = (25, 50, 100, 250, 500) +STATS = {"bocpd": ("mean", "map"), "op": ("age", "segmed")} +MAPPINGS = ("direct", "inverse") +BLENDS = ("single", "triple") + +CONST_L = (5, 7, 10, 15, 20, 30, 45, 60, 90, 120, 150, 200) +WF_WIN_D = (180, 365, 730) + +N_DECLARED_A = len(DETECTORS) * 3 * 2 * len(MAPPINGS) * len(BLENDS) * len(TFS) # 96 +N_DECLARED_B = len(CONST_L) * len(BLENDS) * len(TFS) # 48 +N_DECLARED_C = len(WF_WIN_D) * len(BLENDS) * len(TFS) # 12 +N_DECLARED_CAL = len(CAL_PEN) + len(CAL_LAM) # 13 (taratura) +N_DECLARED = N_DECLARED_A + N_DECLARED_B + N_DECLARED_C + N_DECLARED_CAL # 169 +N_PRIOR_FAMILY = 112 # trial dichiarati da r0822_adaptive_horizon (stessa domanda) +N_SCREEN = 168 # pool di screen direzionale BTC/ETH dell'ondata (filone 12) + + +# ======================================================================================= +# RILEVATORI VERI +# ======================================================================================= +def _logret(c: np.ndarray) -> np.ndarray: + r = np.zeros(len(c)) + r[1:] = np.log(c[1:] / c[:-1]) + return r + + +def _std_returns(c: np.ndarray, bpd: int) -> np.ndarray: + """Rendimenti log standardizzati da vol rolling CAUSALE (min_periods -> warmup a 0). + Il bersaglio del rilevatore e' il cambio di DRIFT: senza standardizzare, un cambio di + regime di VOLATILITA' dominerebbe la verosimiglianza e il rilevatore misurerebbe la vol.""" + lr = _logret(c) + w = max(5, STD_WIN_D * bpd) + sig = pd.Series(lr).rolling(w, min_periods=max(5, w // 4)).std().values + y = np.where((sig > 0) & np.isfinite(sig), lr / np.where(sig > 0, sig, 1.0), 0.0) + return np.nan_to_num(np.clip(y, -8.0, 8.0)) + + +def _student_logpdf(x: float, mu: np.ndarray, kap: np.ndarray, + al: np.ndarray, be: np.ndarray) -> np.ndarray: + """Predittiva Student-t della Normale-InverseGamma coniugata (Murphy 2007, eq. 99-100): + x | mu,kappa,alpha,beta ~ t_{2 alpha}(mu, beta(kappa+1)/(alpha kappa)).""" + nu = 2.0 * al + s2 = be * (kap + 1.0) / (al * kap) + z = (x - mu) ** 2 / (nu * s2) + return (gammaln(0.5 * (nu + 1.0)) - gammaln(0.5 * nu) + - 0.5 * np.log(nu * np.pi * s2) - 0.5 * (nu + 1.0) * np.log1p(z)) + + +def _bocpd(y: np.ndarray, lam_bars: float, rmax: int) -> tuple[np.ndarray, np.ndarray]: + """BOCPD (Adams & MacKay 2007) con hazard COSTANTE H = 1/lam_bars e verosimiglianza NIG. + Ritorna (E[r_t], argmax_r P(r_t)) in BARRE. Filtro in avanti: ogni valore usa solo il + passato -> causale per costruzione, e stabile su prefisso (nessuna statistica full-sample). + Il posterior e' troncato a rmax barre e rinormalizzato (il troncamento e' un limite di + memoria, NON un clip sul valore: se la massa si accumula a rmax lo si vede in diagnostica).""" + n = len(y) + haz = 1.0 / max(2.0, float(lam_bars)) + R = np.array([1.0]) + mu = np.array([NIG_MU0]); kap = np.array([NIG_KAP0]) + al = np.array([NIG_AL0]); be = np.array([NIG_BE0]) + rl = np.arange(1) + mean_rl = np.zeros(n); map_rl = np.zeros(n) + for t in range(n): + x = float(y[t]) + lp = _student_logpdf(x, mu, kap, al, be) + lp -= lp.max() + pred = np.exp(lp) + gr = R * pred * (1.0 - haz) + cp = float(np.sum(R * pred * haz)) + m_new = min(len(R) + 1, rmax + 1) + newR = np.empty(m_new) + newR[0] = cp + newR[1:] = gr[:m_new - 1] + s = newR.sum() + if not np.isfinite(s) or s <= 0: + newR = np.zeros(m_new); newR[0] = 1.0; s = 1.0 + newR /= s + # aggiornamento delle statistiche sufficienti (run r -> run r+1), prior in testa + mu_f = np.concatenate(([NIG_MU0], (kap * mu + x) / (kap + 1.0)))[:m_new] + kap_f = np.concatenate(([NIG_KAP0], kap + 1.0))[:m_new] + al_f = np.concatenate(([NIG_AL0], al + 0.5))[:m_new] + be_f = np.concatenate(([NIG_BE0], be + kap * (x - mu) ** 2 / (2.0 * (kap + 1.0))))[:m_new] + R, mu, kap, al, be = newR, mu_f, kap_f, al_f, be_f + if len(rl) != m_new: + rl = np.arange(m_new, dtype=float) + mean_rl[t] = float(np.dot(R, rl)) + map_rl[t] = float(rl[int(np.argmax(R))]) + return mean_rl, map_rl + + +def _op_causal(y: np.ndarray, pen_k: float, smax: int) -> tuple[np.ndarray, np.ndarray]: + """OPTIMAL PARTITIONING **online** (l'obiettivo esatto che PELT accelera con il pruning): + F(t) = min_{s in [t-smax, t-1]} [ F(s) + C(y[s:t]) + beta(t) ] + con C = costo gaussiano di mean-shift su dati standardizzati e beta(t) = pen_k*log(t). + ⚠️ Il termine sum(y^2) del costo gaussiano e' INVARIANTE alla partizione (ogni punto sta in + esattamente un segmento) -> si omette dall'argmin. C(s:t) = -(S[t]-S[s])^2/(t-s). + Ritorna (eta' dall'ultimo change-point, mediana delle lunghezze dei segmenti CONCLUSI), + entrambe in barre. Per ogni t si usa SOLO y[0:t]: e' la differenza fra questo e un + `ruptures.Pelt().fit_predict(y)` retrospettivo, che guarda tutto il campione.""" + n = len(y) + S = np.concatenate(([0.0], np.cumsum(y))) + F = np.full(n + 1, np.inf) + F[0] = 0.0 + prev = np.zeros(n + 1, dtype=int) + age = np.zeros(n); segmed = np.zeros(n) + for t in range(1, n + 1): + a = max(0, t - smax) + s_idx = np.arange(a, t) + seg = (S[t] - S[a:t]) + ln = (t - s_idx).astype(float) + cand = F[a:t] - seg * seg / ln + j = int(np.argmin(cand)) + beta = pen_k * np.log(max(2.0, float(t))) + F[t] = cand[j] + beta + prev[t] = int(s_idx[j]) + # backtracking della partizione VALIDA A t (cap a 40 segmenti: ci serve la mediana) + last = prev[t] + age[t - 1] = t - last + lens = [] + u = last + while u > 0 and len(lens) < 40: + p = prev[u] + lens.append(u - p) + u = p + segmed[t - 1] = float(np.median(lens)) if lens else float(t) + return age, segmed + + +_STAT_CACHE: dict = {} + + +def _stat_key(df: pd.DataFrame, det: str, par) -> tuple: + """Chiave chiusa sul CONTENUTO del frame (lunghezza + primo/ultimo timestamp): un PREFISSO + non deve prendere la cache del campione pieno, altrimenti `causality_ok` verrebbe ingannata + dalla cache invece che dal codice.""" + ts = df["timestamp"].values + return (det, par, len(df), int(ts[0]), int(ts[-1])) + + +def detector_stats(df: pd.DataFrame, det: str, par) -> dict: + key = _stat_key(df, det, par) + if key in _STAT_CACHE: + return _STAT_CACHE[key] + bpd = A.bars_per_day(df) + c = df["close"].values.astype(float) + y = _std_returns(c, bpd) + rmax = max(4, LMAX_D * bpd) + if det == "bocpd": + m, mp = _bocpd(y, float(par) * bpd, rmax) + out = {"mean": m, "map": mp} + elif det == "op": + ag, sm = _op_causal(y, float(par), rmax) + out = {"age": ag, "segmed": sm} + else: + raise ValueError(det) + _STAT_CACHE[key] = out + return out + + +def lookback_bars(df: pd.DataFrame, det: str, par, stat: str, mapping: str) -> np.ndarray: + """L_t in BARRE dentro [Lmin, Lmax] giorni. `direct` = la lunghezza di run E' il lookback + (dopo un change-point si puo' guardare indietro solo fin la'); `inverse` e' lo specchio, + dichiarato come asse perche' il verso non e' ovvio a priori.""" + bpd = A.bars_per_day(df) + lo, hi = max(2, LMIN_D * bpd), max(3, LMAX_D * bpd) + v = detector_stats(df, det, par)[stat] + base = np.clip(np.nan_to_num(v, nan=float(lo)), lo, hi) + L = base if mapping == "direct" else (lo + hi - base) + return np.clip(np.round(L), 2, hi).astype(int) + + +# ======================================================================================= +# DIREZIONE TSMOM (orizzonte costante o variabile) + vol-target β€” IDENTICA al primo tentativo +# ======================================================================================= +def _sign_at(c: np.ndarray, h: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + n = len(c) + i = np.arange(n) + j = i - h + ok = j >= 0 + s = np.zeros(n) + jj = np.where(ok, j, 0) + s[ok] = np.sign(c[i[ok]] / c[jj[ok]] - 1.0) + return s, ok + + +def _dir_from_L(c: np.ndarray, L: np.ndarray, blend: str) -> np.ndarray: + hs = [L] if blend == "single" else [ + np.maximum(2, np.round(L / 3.0).astype(int)), L, np.round(2.0 * L).astype(int)] + acc = np.zeros(len(c)); cnt = np.zeros(len(c)) + for h in hs: + s, ok = _sign_at(c, np.asarray(h, int)) + acc[ok] += s[ok]; cnt[ok] += 1 + out = np.zeros(len(c)); nz = cnt > 0 + out[nz] = acc[nz] / cnt[nz] + return out + + +def fixed_target(df: pd.DataFrame) -> np.ndarray: + """BASELINE: TSMOM fisso 30/90/180, long-flat, vol-target 20%, leva 2x = TP01 canonico.""" + c = df["close"].values.astype(float) + bpd = A.bars_per_day(df) + acc = np.zeros(len(c)); cnt = np.zeros(len(c)) + for hd in FIXED_H: + s, ok = _sign_at(c, np.full(len(c), hd * bpd, int)) + acc[ok] += s[ok]; cnt[ok] += 1 + d = np.zeros(len(c)); nz = cnt > 0 + d[nz] = acc[nz] / cnt[nz] + return A.vol_target(np.clip(d, 0, None), df, TGT_VOL, VOLWIN, LEV) + + +def const_factory(tf: str, L: int, blend: str): + def fn(df: pd.DataFrame) -> np.ndarray: + c = df["close"].values.astype(float) + bpd = A.bars_per_day(df) + Lb = np.full(len(c), max(2, int(round(L * bpd))), int) + return A.vol_target(np.clip(_dir_from_L(c, Lb, blend), 0, None), df, TGT_VOL, VOLWIN, LEV) + fn.__name__ = f"const_L{L}_{blend}" + return fn + + +def factory(tf: str, det: str, par, stat: str, mapping: str, blend: str): + def fn(df: pd.DataFrame) -> np.ndarray: + c = df["close"].values.astype(float) + L = lookback_bars(df, det, par, stat, mapping) + return A.vol_target(np.clip(_dir_from_L(c, L, blend), 0, None), df, TGT_VOL, VOLWIN, LEV) + fn.__name__ = f"adapt_{det}{par}_{stat}_{mapping}_{blend}" + return fn + + +_WF_CACHE: dict = {} + + +def wf_picks(df: pd.DataFrame, win_d: int, blend: str): + """(C) L scelto per WALK-FORWARD sul PnL NETTO realizzato: a ogni barra si prende l'L che, + fra i candidati COSTANTI, ha lo Sharpe netto migliore nella finestra all'indietro `win_d`. + Nessun modello di regime: e' il "cosa avrebbe funzionato finora". + Causale: il PnL della barra i si conosce solo a fine barra i, quindi per decidere a i si usa + la finestra che finisce a i-1; `eval_weights` shifta poi la posizione di un'altra barra.""" + cands = [L for L in CONST_L if LMIN_D <= L <= LMAX_D] + key = (win_d, blend) + _stat_key(df, "wf", 0)[2:] + if key in _WF_CACHE: + return _WF_CACHE[key] + c = df["close"].values.astype(float) + bpd = A.bars_per_day(df) + n = len(c) + r = A.simple_returns(c) + w = max(20, win_d * bpd) + tgts, sh = [], [] + for L in cands: + Lb = np.full(n, max(2, int(round(L * bpd))), int) + t_ = A.vol_target(np.clip(_dir_from_L(c, Lb, blend), 0, None), df, TGT_VOL, VOLWIN, LEV) + pos = np.zeros(n); pos[1:] = t_[:-1] + net = pos * r - A.FEE_SIDE * np.abs(np.diff(pos, prepend=0.0)) + ss = pd.Series(net) + mu = ss.rolling(w, min_periods=w // 2).mean() + sd = ss.rolling(w, min_periods=w // 2).std() + with np.errstate(invalid="ignore", divide="ignore"): + sr = (mu / sd).values + sr = np.concatenate(([np.nan], sr[:-1])) # PnL noto solo a fine barra + tgts.append(t_); sh.append(np.where(np.isfinite(sr), sr, -np.inf)) + SH = np.vstack(sh); TG = np.vstack(tgts) + idx = np.argmax(SH, axis=0) + idx[~np.isfinite(SH).any(axis=0)] = cands.index(90) # warmup -> il canonico, non il migliore + out = (np.asarray([cands[i] for i in idx]), TG[idx, np.arange(n)], cands) + _WF_CACHE[key] = out + return out + + +def wf_factory(tf: str, win_d: int, blend: str): + def fn(df: pd.DataFrame) -> np.ndarray: + return wf_picks(df, win_d, blend)[1] + fn.__name__ = f"wfpnl_{win_d}_{blend}" + return fn + + +GRID_A = [dict(det=d, par=p, stat=s, mapping=m, blend=b) + for d in DETECTORS for p in PARAMS[d] for s in STATS[d] + for m in MAPPINGS for b in BLENDS] +GRID_B = [dict(L=L, blend=b) for L in CONST_L for b in BLENDS] +GRID_C = [dict(win_d=w, blend=b) for w in WF_WIN_D for b in BLENDS] + + +# ======================================================================================= +# UTILITA' +# ======================================================================================= +def _dd(s: pd.Series) -> float: + eq = np.cumprod(1.0 + np.asarray(s.dropna().values, float)) + pk = np.maximum.accumulate(eq) + return float(np.max((pk - eq) / pk)) if len(eq) else 0.0 + + +def _cagr(s: pd.Series) -> float: + s = s.dropna() + if len(s) < 5: + return float("nan") + eq = float(np.prod(1.0 + s.values)) + yrs = (s.index[-1] - s.index[0]).total_seconds() / (86400 * 365.25) + return eq ** (1 / max(yrs, 1e-6)) - 1.0 if eq > 0 else -1.0 + + +def resample_offset(df_1h: pd.DataFrame, hours: int, off: int) -> pd.DataFrame: + """Stessa convenzione di trend_portfolio.resample_tf, ancora spostata di `off` ore. + NB `offset=`, non `origin=` (pandas 2.x ignora origin in silenzio β€” lezione 02/07).""" + g = df_1h.copy() + idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True) + idx.name = "dt" + g.index = idx + out = g.resample(f"{hours}h", offset=pd.Timedelta(hours=off), label="left", closed="left").agg( + {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}) + out = out.dropna(subset=["open"]) + out["datetime"] = out.index + epoch = pd.Timestamp("1970-01-01", tz="UTC") + out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64") + return out.reset_index(drop=True)[ + ["timestamp", "open", "high", "low", "close", "volume", "datetime"]] + + +def paired_by_year(cand: pd.Series, base: pd.Series) -> list[dict]: + J = pd.concat({"C": cand, "B": base}, axis=1, join="inner").dropna() + rows = [] + for y, g in J.groupby(J.index.year): + if len(g) < 40: + continue + rows.append(dict(year=int(y), n=len(g), + sh_cand=round(A._sh(g["C"]), 2), sh_base=round(A._sh(g["B"]), 2), + d_sh=round(A._sh(g["C"]) - A._sh(g["B"]), 2))) + return rows + + +def delever_null(cand: pd.Series, base: pd.Series) -> dict: + """Esiste k<1 che da' al BASELINE lo stesso maxDD del candidato con Sharpe >= ? + Se si', il candidato non ha comprato protezione: ha solo tolto leva.""" + J = pd.concat({"C": cand, "B": base}, axis=1, join="inner").dropna() + dd_c, dd_b = _dd(J["C"]), _dd(J["B"]) + if dd_c >= dd_b or dd_b <= 0: + return dict(applicabile=False, dd_cand=round(dd_c, 4), dd_base=round(dd_b, 4), + nota="il candidato NON riduce il maxDD: il null non si applica") + lo, hi = 0.0, 1.0 + for _ in range(60): + k = 0.5 * (lo + hi) + if _dd(k * J["B"]) < dd_c: + lo = k + else: + hi = k + k = 0.5 * (lo + hi) + return dict(applicabile=True, k=round(k, 4), dd_cand=round(dd_c, 4), dd_base=round(dd_b, 4), + sharpe_cand=round(A._sh(J["C"]), 3), + sharpe_base_delev=round(A._sh(k * J["B"]), 3), + superato=bool(A._sh(J["C"]) > A._sh(k * J["B"]))) + + +_ANCHOR_CACHE: dict = {} + + +def series_at_anchor(fn, name: str, hours: int, off: int) -> pd.Series: + key = (name, hours, off) + if key not in _ANCHOR_CACHE: + ser = {} + for a in ASSETS: + dfo = resample_offset(A.get(a, "1h"), hours, off) + ev = A.eval_weights(dfo, A._call_target(fn, dfo, a)) + ser[a] = pd.Series(ev["net"], index=ev["idx"]) + J = pd.concat(ser, axis=1, join="inner").fillna(0.0) + _ANCHOR_CACHE[key] = A._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]]) + return _ANCHOR_CACHE[key] + + +def adaptivity(tf: str, det: str, par, stat: str, mapping: str) -> dict: + """LA DIAGNOSTICA CHE HA SMASCHERATO IL PRIMO TENTATIVO β€” quanto si MUOVE davvero L_t. + Criterio (identico a quello del 22/08, per confrontabilita'): sta a un bordo <50% del tempo + E l'IQR e' >= 10 giorni. In piu': |dL|/giorno e numero di RESET (cadute di L > 5 giorni), + che sono la firma di un vero rilevatore di change-point.""" + fl, fh, iq, md, dl, nrs, sat = [], [], [], [], [], [], [] + for a in ASSETS: + df = A.get(a, tf) + bpd = A.bars_per_day(df) + L = lookback_bars(df, det, par, stat, mapping) / bpd + raw = np.nan_to_num(detector_stats(df, det, par)[stat], nan=0.0) / bpd + fl.append(float(np.mean(L <= LMIN_D + 1))) + fh.append(float(np.mean(L >= LMAX_D - 1))) + iq.append(float(np.percentile(L, 75) - np.percentile(L, 25))) + md.append(float(np.median(L))) + dl.append(float(np.median(np.abs(np.diff(L))))) + d = np.diff(L) + nrs.append(float(np.sum(d < -5.0) / (len(L) / (365.25)))) # reset all'anno + sat.append(float(np.mean(raw >= LMAX_D - 1))) # saturazione del GREZZO + return dict(frac_lo=float(np.mean(fl)), frac_hi=float(np.mean(fh)), + iqr_days=float(np.mean(iq)), med_days=float(np.mean(md)), + dL_med=float(np.mean(dl)), reset_yr=float(np.mean(nrs)), + sat_raw=float(np.mean(sat)), + adattiva=bool(np.mean(fl) < 0.5 and np.mean(fh) < 0.5 and np.mean(iq) >= 10.0)) + + +def hr(t: str = "") -> None: + print("\n" + "=" * 86) + if t: + print(t) + print("=" * 86) + + +def dsr_given_sr0(daily: pd.Series, sr_ann: float, sr0_ann: float, + dpy: float = 365.25) -> float: + """Deflated-Sharpe con il massimo-atteso-dal-rumore IMPOSTO dall'esterno. + Serve perche' `A.deflated_sharpe` stima sr0 dalla VARIANZA degli Sharpe della griglia: su + una famiglia OMOGENEA (qui sono tutte varianti di TSMOM, quindi molto correlate) quella + varianza e' piccola, sr0 esce basso e il DSR esce alto **per costruzione**. Il conteggio che + decide, sulla ricerca direzionale BTC/ETH, e' quello di SCREEN: il filone ORTHO ha misurato + che su 168 trial il massimo atteso dal puro rumore e' Sharpe 1.572.""" + r = np.asarray(pd.Series(daily).dropna().values, float) + T = len(r) + sr, sr0 = sr_ann / np.sqrt(dpy), sr0_ann / np.sqrt(dpy) + sk = float(pd.Series(r).skew()) + ku = float(pd.Series(r).kurt()) + 3.0 + den = np.sqrt(max(1e-9, 1 - sk * sr + (ku - 1) / 4.0 * sr ** 2)) + return float(norm.cdf((sr - sr0) * np.sqrt(T - 1) / den)) + + +def dsr_line(sr: float, allsr: list, daily: pd.Series, label: str, n_decl: int) -> str: + pad = list(allsr) + [float(np.mean(allsr))] * max(0, n_decl - len(allsr)) + d, s0 = A.deflated_sharpe(sr, pad, daily) + return (f" N={n_decl:>4} ({label:<24}) DSR={d:.3f} {'PASS' if d >= 0.95 else 'FAIL'}" + f" max atteso dal rumore {s0:+.3f}") + + +# ======================================================================================= +def main() -> None: + t0 = time.time() + print(__doc__.split("Runtime atteso")[0]) + + # -- 0. SANITY ----------------------------------------------------------------------- + hr("0. SANITY β€” il baseline 'fisso' e' TP01 canonico (se non lo e', ogni delta e' inventato)") + tp = TrendPortfolio(**CANONICAL) + worst = 0.0 + for a in ASSETS: + df = A.get(a, "1d") + worst = max(worst, float(np.max(np.abs(fixed_target(df) - tp.target_series(df))))) + base_1d = A.candidate_daily(fixed_target, tf="1d") + tp01 = A.tp01_baseline_daily() + JJ = pd.concat({"a": base_1d, "b": tp01}, axis=1, join="inner").dropna() + print(f" max|target_mio - TrendPortfolio.target_series| (1d, BTC+ETH) = {worst:.3e}") + print(f" max|serie_giornaliera_mia - A.tp01_baseline_daily()| = " + f"{float(np.max(np.abs(JJ['a'] - JJ['b']))):.3e} (n={len(JJ)})") + assert worst < 1e-12, "il baseline NON riproduce TP01: fermarsi qui" + + # -- 0-bis. IL RILEVATORE E' CAUSALE? (troncamento MANUALE, prima dei gate) ----------- + hr("0-bis. TRONCAMENTO MANUALE β€” la trappola del change-point RETROSPETTIVO") + print(" Un `Pelt().fit_predict(serie_intera)` restituisce la segmentazione dell'INTERO") + print(" campione: al tempo t 'saprebbe' dei cambi futuri. Qui BOCPD e OP sono ricorsioni in") + print(" avanti. Prova diretta: ricalcolo su un PREFISSO e confronto la coda con il pieno.") + for det, par in (("bocpd", 100), ("op", 5.0)): + for stat in STATS[det]: + wmax = 0.0 + for a in ASSETS: + df = A.get(a, "1d") + full = lookback_bars(df, det, par, stat, "direct") + for frac in (0.70, 0.85): + cut = int(len(df) * frac) + sub = df.iloc[:cut].reset_index(drop=True) + pre = lookback_bars(sub, det, par, stat, "direct") + wmax = max(wmax, float(np.max(np.abs(pre[-100:] - full[cut - 100:cut])))) + print(f" {det:<6} {str(par):>5} {stat:<7}: max|L(prefisso)-L(pieno)| sulla coda " + f"= {wmax:.3e} barre -> {'CAUSALE' if wmax < 1e-9 else 'SOSPETTO'}") + + # -- 1-pre. TARATURA DELLA SCALA β€” guardando il RILEVATORE, mai lo Sharpe ----------- + hr("1-pre. TARATURA DELLA SCALA DEI PARAMETRI (guardando SOLO quanti change-point rileva)") + print(" Un rilevatore che non rileva nulla non e' un rilevatore. Questa scansione guarda il") + print(" NUMERO di change-point e la distribuzione di L_t β€” mai un rendimento. Dichiarata") + print(" come 13 trial in piu' (al rialzo), benche' nessuno produca uno Sharpe.") + dfc = A.get("BTC", "1d") + yc = _std_returns(dfc["close"].values.astype(float), 1) + print(f"\n OP (BTC 1d) {'pen_k':>6} {'cp/anno':>8} {'age med':>8} {'IQR':>7} {'@tetto':>8}") + for k in CAL_PEN: + ag, _ = _op_causal(yc, k, LMAX_D) + ncp = int(np.sum(np.diff(ag) < 0)) + flag = " <= USATO" if k in PARAMS["op"] else (" <= degenere (artefatto 22/08)" + if np.mean(ag >= LMAX_D - 1) > 0.30 else "") + print(f" {k:>6} {ncp/(len(yc)/365.25):>8.1f} {np.median(ag):>8.1f} " + f"{np.percentile(ag,75)-np.percentile(ag,25):>7.1f} " + f"{np.mean(ag>=LMAX_D-1)*100:>7.1f}%{flag}") + print(f"\n BOCPD (BTC 1d) {'lambda':>6} {'E[r] med':>9} {'IQR':>7} {'@tetto':>8} " + f"{'MAP med':>8} {'@tetto':>8}") + for lam in CAL_LAM: + mn, mp = _bocpd(yc, float(lam), LMAX_D) + flag = " <= USATO" if lam in PARAMS["bocpd"] else "" + print(f" {lam:>6} {np.median(mn):>9.1f} " + f"{np.percentile(mn,75)-np.percentile(mn,25):>7.1f} " + f"{np.mean(mn>=LMAX_D-1)*100:>7.1f}% {np.median(mp):>8.1f} " + f"{np.mean(mp>=LMAX_D-1)*100:>7.1f}%{flag}") + + # -- 1. LA DIAGNOSTICA DI MECCANISMO, PER PRIMA -------------------------------------- + hr("1. L_t SI MUOVE DAVVERO? (se sta al bordo l'artefatto e' riprodotto: si chiude e basta)") + print(" criterio (identico al 22/08, per confrontabilita'): a un bordo <50% del tempo E") + print(" IQR >= 10 giorni. `reset/anno` = cadute di L > 5 giorni = firma di un change-point.") + print(f"\n {'TF':>4} {'det':>6} {'par':>5} {'stat':>7} {'map':<7} {'bordo-lo':>9} " + f"{'bordo-hi':>9} {'IQR(g)':>7} {'med(g)':>7} {'|dL|(g)':>8} {'reset/a':>8} " + f"{'sat.grezzo':>10} esito") + diag: dict = {} + for tf in TFS: + for d in DETECTORS: + for p in PARAMS[d]: + for st in STATS[d]: + for mp in MAPPINGS: + v = adaptivity(tf, d, p, st, mp) + diag[(tf, d, p, st, mp)] = v + print(f" {tf:>4} {d:>6} {str(p):>5} {st:>7} {mp:<7} " + f"{v['frac_lo']*100:8.1f}% {v['frac_hi']*100:8.1f}% " + f"{v['iqr_days']:7.1f} {v['med_days']:7.1f} {v['dL_med']:8.2f} " + f"{v['reset_yr']:8.1f} {v['sat_raw']*100:9.1f}% " + f"{'ADATTIVA' if v['adattiva'] else 'costante-travestito'}") + n_ad = sum(1 for v in diag.values() if v["adattiva"]) + print(f"\n configurazioni di rilevatore DAVVERO adattive: {n_ad}/{len(diag)}") + if n_ad == 0: + hr("STOP β€” ARTEFATTO RIPRODOTTO") + print(" Nessun rilevatore muove L_t: ogni 'vincitore' sarebbe un orizzonte COSTANTE") + print(" travestito, esattamente come il 22/08. Il filone si chiude per la seconda volta") + print(" senza bisogno di guardare i rendimenti. FINE.") + return + + # -- 2. GRIGLIA (A): cella scelta IN-SAMPLE + deflated-Sharpe ------------------------- + hr(f"2. study_family_honest (A) β€” {len(GRID_A)} celle x {len(TFS)} TF = " + f"{len(GRID_A)*len(TFS)} trial") + rep = A.study_family_honest("BOCPD-H", factory, GRID_A, TFS) + ch = rep["chosen"] + print(f" celle valutate : {rep['n_cells']}") + print(f" cella scelta IN-SAMPLE : tf={ch['tf']} {ch['params']}") + print(f" Sharpe IS {ch['insample_sharpe']} FULL {ch['full_sharpe']}") + print(f" deflated-Sharpe (N={rep['n_cells']}): {rep['deflated_sharpe']} " + f"(max atteso sotto il null {rep['expected_null_max']}) PASS={rep['dsr_pass']}") + print(f" earns_slot_marginal : {rep['earns_slot_marginal']}") + print(f" EARNS_SLOT_HONEST : {rep['earns_slot_honest']}") + print("\n top-10 IN-SAMPLE (come si sceglie onestamente):") + for r in rep["rows"][:10]: + ad = diag[(r["tf"], r["params"]["det"], r["params"]["par"], + r["params"]["stat"], r["params"]["mapping"])]["adattiva"] + print(f" IS {r['insample_sharpe']:+.3f} FULL {r['full_sharpe']:+.3f} {r['tf']:>3} " + f"{r['params']} {'[adattiva]' if ad else '[costante-travestito]'}") + + fn_ch = factory(tf=ch["tf"], **ch["params"]) + d_ch = A.candidate_daily(fn_ch, tf=ch["tf"]) + + # -- 3. CONTROLLO (B): il MIGLIOR ORIZZONTE COSTANTE --------------------------------- + hr(f"3. CONTROLLO L COSTANTE (B) β€” {len(GRID_B)} celle x {len(TFS)} TF = " + f"{len(GRID_B)*len(TFS)} trial [IL NULL GIUSTO, non TP01]") + csel = A.select_cell_insample(const_factory, GRID_B, TFS) + cch = csel["chosen"] + print(f" miglior COSTANTE scelto in-sample: tf={cch['tf']} {cch['params']} " + f"IS {cch['insample_sharpe']} FULL {cch['full_sharpe']}") + print(" top-8 costanti in-sample (L=90 triple 1d == TP01 canonico):") + for r in csel["rows"][:8]: + tag = " <== TP01" if (r["params"]["L"] == 90 and r["params"]["blend"] == "triple" + and r["tf"] == "1d") else "" + print(f" IS {r['insample_sharpe']:+.3f} FULL {r['full_sharpe']:+.3f} {r['tf']:>3} " + f"L={r['params']['L']:>3}g {r['params']['blend']}{tag}") + fn_const = const_factory(tf=cch["tf"], **cch["params"]) + d_const = A.candidate_daily(fn_const, tf=cch["tf"]) + + # -- 4. WF-PnL (C) -------------------------------------------------------------------- + hr(f"4. WALK-FORWARD SUL PnL REALIZZATO (C) β€” {len(GRID_C)} celle x {len(TFS)} TF = " + f"{len(GRID_C)*len(TFS)} trial") + wsel = A.select_cell_insample(wf_factory, GRID_C, TFS) + wch = wsel["chosen"] + print(f" miglior WF scelto in-sample: tf={wch['tf']} {wch['params']} " + f"IS {wch['insample_sharpe']} FULL {wch['full_sharpe']}") + for r in wsel["rows"]: + print(f" IS {r['insample_sharpe']:+.3f} FULL {r['full_sharpe']:+.3f} {r['tf']:>3} " + f"{r['params']}") + fn_wf = wf_factory(tf=wch["tf"], **wch["params"]) + d_wf = A.candidate_daily(fn_wf, tf=wch["tf"]) + # quanto si muove L nella variante WF? (stesso metro della sezione 1) + for a in ASSETS: + df = A.get(a, wch["tf"]); bpd = A.bars_per_day(df) + pick, _, cands = wf_picks(df, **wch["params"]) + print(f" {a}: L_WF giorni mediana {np.median(pick):.0f} IQR " + f"{np.percentile(pick,75)-np.percentile(pick,25):.0f} " + f"bordo basso {np.mean(pick<=min(cands))*100:.1f}% " + f"bordo alto {np.mean(pick>=max(cands))*100:.1f}% " + f"cambi/anno {np.sum(np.diff(pick)!=0)/(len(pick)/(bpd*365.25)):.0f}") + + # -- 5. IL CONFRONTO CHE DECIDE ------------------------------------------------------ + hr("5. ADATTIVO vs MIGLIOR COSTANTE (entrambi scelti IN-SAMPLE) β€” e vs TP01") + base_ch = A.candidate_daily(fixed_target, tf=ch["tf"]) + for nome, ser in (("BOCPD/OP (A)", d_ch), ("WF-PnL (C)", d_wf), + ("miglior COSTANTE (B)", d_const), ("TP01 30/90/180", base_1d)): + h = ser[ser.index >= HOLDOUT] + print(f" {nome:<22} FULL Sh {A._sh(ser):+.3f} HOLD Sh {A._sh(h):+.3f} " + f"maxDD {_dd(ser)*100:5.2f}% CAGR {_cagr(ser)*100:6.2f}% " + f"corr->TP01 {pd.concat({'a':ser,'b':tp01},axis=1,join='inner').dropna().corr().iloc[0,1]:+.3f}") + + for lab, cand, ref, refname in (("A vs B", d_ch, d_const, "miglior costante"), + ("C vs B", d_wf, d_const, "miglior costante"), + ("A vs TP01", d_ch, base_1d, "TP01")): + J = pd.concat({"C": cand, "B": ref}, axis=1, join="inner").dropna() + JH = J[J.index >= HOLDOUT] + rows = paired_by_year(cand, ref) + ds = [r["d_sh"] for r in rows] + print(f"\n --- {lab} ({refname}) --- corr {J['C'].corr(J['B']):+.3f}") + print(f" dSh FULL {A._sh(J['C'])-A._sh(J['B']):+.3f} " + f"dSh HOLD {A._sh(JH['C'])-A._sh(JH['B']):+.3f}") + print(" differenza APPAIATA per anno:") + print(" " + " ".join(f"{r['year']}:{r['d_sh']:+.2f}" for r in rows)) + print(f" -> anni con dSh>0: {sum(1 for x in ds if x > 0)}/{len(ds)} " + f"mediana {np.median(ds):+.2f} media {np.mean(ds):+.2f} " + f"[criterio del falsificatore: >=6/8 anni]") + + # decomposizione: quanto del vantaggio su TP01 e' ADATTIVITA' e quanto e' "L piu' corto"? + JT = pd.concat({"C": d_ch, "K": d_const, "B": base_1d}, axis=1, join="inner").dropna() + v_tp = A._sh(JT["C"]) - A._sh(JT["B"]) + v_ct = A._sh(JT["K"]) - A._sh(JT["B"]) + print(f"\n DECOMPOSIZIONE del vantaggio dell'adattivo su TP01 (FULL, finestra comune):") + print(f" adattivo - TP01 = {v_tp:+.3f}") + print(f" miglior COSTANTE - TP01 = {v_ct:+.3f} <- quota spiegata da 'un L piu' corto'") + print(f" adattivo - miglior COSTANTE= {v_tp - v_ct:+.3f} <- quota spiegata dall'ADATTARE") + print(f" => l'adattivita' spiega il {100*(v_tp-v_ct)/v_tp if abs(v_tp)>1e-9 else float('nan'):+.0f}% " + f"del vantaggio su TP01") + + # la superficie di L costante e' una CURVA o un CRINALE DI RUMORE? (replica del 22/08) + rows12 = {r["params"]["L"]: r["insample_sharpe"] for r in csel["rows"] + if r["tf"] == cch["tf"] and r["params"]["blend"] == cch["params"]["blend"]} + Ls = sorted(rows12) + adj = float(np.median(np.abs(np.diff([rows12[L] for L in Ls])))) + print(f"\n RISOLUZIONE della griglia costante ({cch['tf']}, {cch['params']['blend']}): salto IS " + f"mediano fra L ADIACENTI {adj:+.3f}") + print(f" contro il vantaggio del miglior costante su TP01 ({v_ct:+.3f}) -> rapporto " + f"{adj/max(1e-9, abs(v_ct)):.2f} [replica del 'crinale di rumore' del 22/08]") + + # -- 6. GATE -------------------------------------------------------------------------- + hr("6. GATE sul candidato (A) scelto in-sample") + print(A.fmt_marginal(rep["marginal"])) + cz = A.causality_ok(fn_ch, tf=ch["tf"]) + print(f"\n causality_ok : ok={cz['ok']} max_tail_diff={cz['max_tail_diff']} " + f"(prefissi controllati {cz['checked']})") + imp = A.implausible_sharpe(d_ch) + print(f" implausible_sharpe : implausible={imp['implausible']} Sh {imp.get('sharpe')} " + f"maxDD {imp.get('maxdd')} perdite/attive {imp.get('loss_frac')} " + f"attive {imp.get('active_frac')}") + for rr in imp["reasons"]: + print(f" - {rr}") + print(f" null de-levering vs COSTANTE : {delever_null(d_ch, d_const)}") + print(f" null de-levering vs TP01 : {delever_null(d_ch, base_1d)}") + print(" haircut a $600 (min-order $5):") + for a in ASSETS: + df = A.get(a, ch["tf"]) + sc = A.eval_weights_smallcap(df, A._call_target(fn_ch, df, a), capital=600.0) + print(f" {a}: modellato {sc['modeled']['sharpe']:+.3f} -> reale " + f"{sc['realistic']['sharpe']:+.3f} (haircut {sc['sharpe_haircut']:+.3f}, " + f"{sc['n_executed_trades']} trade)") + + # DSR a piu' conteggi: famiglia, famiglia+112 del primo tentativo, di SCREEN + all_sr = ([r["full_sharpe"] for r in rep["rows"]] + + [r["full_sharpe"] for r in csel["rows"]] + + [r["full_sharpe"] for r in wsel["rows"]]) + hr("6-bis. DEFLATED-SHARPE β€” sensibilita' del verdetto al CONTEGGIO (regola 30/07 + 22/08)") + print(f" Sharpe FULL del candidato (A) = {A._sh(d_ch):+.3f}") + print(dsr_line(A._sh(d_ch), all_sr, d_ch, "mia famiglia A+B+C", N_DECLARED)) + print(dsr_line(A._sh(d_ch), all_sr, d_ch, "+ le 112 del 22/08", N_DECLARED + N_PRIOR_FAMILY)) + print(dsr_line(A._sh(d_ch), all_sr, d_ch, "di SCREEN (ondata)", + N_DECLARED + N_PRIOR_FAMILY + N_SCREEN)) + print("\n ⚠️ QUEL 'PASS' NON CERTIFICA NIENTE, e va detto: `deflated_sharpe` stima il massimo") + print(" atteso dal rumore dalla VARIANZA degli Sharpe della griglia. Qui la griglia e'") + print(" OMOGENEA (tutte varianti di TSMOM, molto correlate fra loro) -> varianza piccola") + print(" -> sr0 basso -> DSR alto per COSTRUZIONE. Aumentare N padding con la media alza N") + print(" ma non la varianza. Il conteggio che decide e' quello di SCREEN, e il filone ORTHO") + print(" lo ha gia' misurato: su 168 trial di ricerca direzionale BTC/ETH il massimo atteso") + print(" dal puro rumore e' Sharpe 1.572. Riscritto con QUEL sr0:") + for nome, ser in (("adattivo (A)", d_ch), ("miglior costante (B)", d_const), + ("TP01 30/90/180", base_1d)): + print(f" {nome:<22} Sharpe FULL {A._sh(ser):+.3f} vs sr0_screen 1.572 -> " + f"DSR {dsr_given_sr0(ser, A._sh(ser), 1.572):.3f} " + f"{'PASS' if dsr_given_sr0(ser, A._sh(ser), 1.572) >= 0.95 else 'FAIL'}") + + # PROVA DI IDENTITA': la cella "adattiva" al 100% sul bordo E' il costante, bit per bit + hr("6-ter. PROVA DI IDENTITA' β€” la cella al bordo NON somiglia a un costante: LO E'") + deg = [r for r in rep["rows"] + if diag[(r["tf"], r["params"]["det"], r["params"]["par"], r["params"]["stat"], + r["params"]["mapping"])]["frac_lo"] >= 0.999] + if deg: + dr = max(deg, key=lambda r: r["insample_sharpe"]) + s_deg = A.candidate_daily(factory(tf=dr["tf"], **dr["params"]), tf=dr["tf"]) + s_con = A.candidate_daily(const_factory(tf=dr["tf"], L=LMIN_D, + blend=dr["params"]["blend"]), tf=dr["tf"]) + Jd = pd.concat({"a": s_deg, "b": s_con}, axis=1, join="inner").dropna() + print(f" cella 100% al bordo basso: {dr['tf']} {dr['params']} IS {dr['insample_sharpe']}" + f" FULL {dr['full_sharpe']}") + print(f" costante L={LMIN_D}g {dr['params']['blend']} {dr['tf']}: " + f"FULL {A._sh(s_con):+.3f}") + print(f" max|serie_adattiva - serie_costante| = {float(np.max(np.abs(Jd['a']-Jd['b']))):.3e}" + f" (n={len(Jd)}) -> {'SONO LA STESSA STRATEGIA' if float(np.max(np.abs(Jd['a']-Jd['b']))) < 1e-12 else 'diverse'}") + else: + print(" nessuna cella e' al 100% sul bordo basso (la prova non si applica)") + + # -- 7. FAMIGLIA RISTRETTA ALLE SOLE CELLE DAVVERO ADATTIVE -------------------------- + hr("7. GATE SULLA SOLA FAMIGLIA DAVVERO ADATTIVA (la domanda della missione, isolata)") + ad_rows = [r for r in rep["rows"] + if diag[(r["tf"], r["params"]["det"], r["params"]["par"], + r["params"]["stat"], r["params"]["mapping"])]["adattiva"]] + d_ad_keep = fn_ad_keep = best_keep = None + if not ad_rows: + print(" NESSUNA cella della famiglia e' davvero adattiva -> vedi sezione 1.") + else: + best = max(ad_rows, key=lambda r: r["insample_sharpe"]) + print(f" celle adattive: {len(ad_rows)}/{len(rep['rows'])} miglior IN-SAMPLE: " + f"tf={best['tf']} {best['params']} IS {best['insample_sharpe']} " + f"FULL {best['full_sharpe']}") + fn_ad = factory(tf=best["tf"], **best["params"]) + d_ad = A.candidate_daily(fn_ad, tf=best["tf"]) + sm = A.study_marginal("BOCPD-H (sole adattive)", fn_ad, tf=best["tf"]) + dsr_ad, sr0_ad = A.deflated_sharpe(A._sh(d_ad), + [r["full_sharpe"] for r in ad_rows], d_ad) + print(f" marginal={sm['marginal_verdict']} earns_slot={sm['earns_slot']} " + f"corr->TP01 {sm['marginal'].get('corr_full')}") + print(f" deflated-Sharpe (N={len(ad_rows)}) = {dsr_ad:.3f} " + f"{'PASS' if dsr_ad >= 0.95 else 'FAIL'} (null max atteso {sr0_ad:+.3f})") + JA = pd.concat({"C": d_ad, "K": d_const, "B": base_1d}, axis=1, join="inner").dropna() + JH = JA[JA.index >= HOLDOUT] + print(f" FULL adattiva-vera {A._sh(JA['C']):+.3f} | costante {A._sh(JA['K']):+.3f} " + f"| TP01 {A._sh(JA['B']):+.3f}") + print(f" HOLD-OUT adattiva-vera {A._sh(JH['C']):+.3f} | costante {A._sh(JH['K']):+.3f} " + f"| TP01 {A._sh(JH['B']):+.3f}") + r3 = paired_by_year(d_ad, d_const) + d3 = [r["d_sh"] for r in r3] + print(" vs miglior costante, differenza APPAIATA per anno:") + print(" " + " ".join(f"{r['year']}:{r['d_sh']:+.2f}" for r in r3)) + print(f" -> dSh>0 in {sum(1 for x in d3 if x>0)}/{len(d3)} mediana {np.median(d3):+.2f}" + f" corr al costante {JA['C'].corr(JA['K']):+.3f}") + print(" ⚠️ l'hold-out sono gli ULTIMI DUE anni di questa stessa riga: se il vantaggio") + print(" hold-out sta li' e non nel resto, e' un evento, non un meccanismo (regola:") + print(" un vantaggio concentrato in un anno non e' un vantaggio).") + print(f" null de-levering vs costante: {delever_null(d_ad, d_const)}") + d_ad_keep, fn_ad_keep, best_keep = d_ad, fn_ad, best + # -- 8. BANDA D'ANCORA (delta APPAIATO adattivo - costante) -------------------------- + hours = 24 if ch["tf"] == "1d" else 12 + offs = list(range(hours)) + hr(f"8. BANDA D'ANCORA β€” {len(offs)} ancore. Stima onesta = MEDIANA; il confronto fra due " + f"varianti\n e' la MEDIANA DELLE DIFFERENZE APPAIATE (mai la differenza delle mediane).") + if cch["tf"] != ch["tf"]: + print(f" ⚠️ adattivo su {ch['tf']} e miglior costante su {cch['tf']}: per il delta " + f"appaiato uso il miglior costante RIVALUTATO su {ch['tf']} (stesse ancore = coppie).") + crow = max([r for r in csel["rows"] if r["tf"] == ch["tf"]], + key=lambda r: r["insample_sharpe"]) + fn_const_anch = const_factory(tf=ch["tf"], **crow["params"]) + print(f" miglior costante su {ch['tf']}: {crow['params']} IS {crow['insample_sharpe']}") + else: + fn_const_anch = fn_const + s0 = series_at_anchor(fixed_target, "fixed", hours, 0) + K = pd.concat({"a": s0, "b": base_ch}, axis=1, join="inner").dropna() + print(f" sanity ancora 0 vs serie canonica del fisso: max|Ξ”| " + f"{float(np.max(np.abs(K['a']-K['b']))):.3e} (n={len(K)})") + for lab, mtr in (("Sharpe FULL", A._sh), + ("Sharpe HOLD", lambda s: A._sh(s[s.index >= HOLDOUT]))): + b_c = A.anchor_luck_band(lambda o: series_at_anchor(fn_ch, "cand", hours, o), offs, + metric=mtr) + b_k = A.anchor_luck_band(lambda o: series_at_anchor(fn_const_anch, "const", hours, o), + offs, metric=mtr) + print(f" {lab:<12} adattivo: canonica {b_c['canonical']:+.3f} " + f"(pctl {b_c['canonical_pctl']:.2f}) MEDIANA {b_c['median']:+.3f} " + f"banda [{b_c['lo']:+.3f},{b_c['hi']:+.3f}]") + print(f" {'':<12} costante: canonica {b_k['canonical']:+.3f} " + f"(pctl {b_k['canonical_pctl']:.2f}) MEDIANA {b_k['median']:+.3f} " + f"banda [{b_k['lo']:+.3f},{b_k['hi']:+.3f}]") + dl = A.anchor_luck_delta(lambda o: series_at_anchor(fn_ch, "cand", hours, o), + lambda o: series_at_anchor(fn_const_anch, "const", hours, o), + offs, metric=mtr) + print(f" {'':<12} DELTA APPAIATO (adatt - cost): mediana {dl['median_paired']:+.3f} " + f"positivo in {dl['n_positive']}/{dl['n_anchors']} " + f"banda [{dl['lo']:+.3f},{dl['hi']:+.3f}] gate_pass={dl['gate_pass']}") + + # -- 8-bis. ANCORA APPAIATA per la cella DAVVERO ADATTIVA --------------------------- + if fn_ad_keep is not None and best_keep["tf"] == ch["tf"]: + hr("8-bis. ANCORA APPAIATA β€” cella DAVVERO ADATTIVA vs miglior costante") + for lab, mtr in (("Sharpe FULL", A._sh), + ("Sharpe HOLD", lambda s_: A._sh(s_[s_.index >= HOLDOUT]))): + dl = A.anchor_luck_delta( + lambda o: series_at_anchor(fn_ad_keep, "adatt", hours, o), + lambda o: series_at_anchor(fn_const_anch, "const", hours, o), offs, metric=mtr) + b = A.anchor_luck_band(lambda o: series_at_anchor(fn_ad_keep, "adatt", hours, o), + offs, metric=mtr) + print(f" {lab:<12} adattiva-vera: canonica {b['canonical']:+.3f} " + f"(pctl {b['canonical_pctl']:.2f}) MEDIANA {b['median']:+.3f} " + f"banda [{b['lo']:+.3f},{b['hi']:+.3f}]") + print(f" {'':<12} DELTA APPAIATO vs costante: mediana {dl['median_paired']:+.3f} " + f"positivo in {dl['n_positive']}/{dl['n_anchors']} " + f"banda [{dl['lo']:+.3f},{dl['hi']:+.3f}] gate_pass={dl['gate_pass']}") + elif fn_ad_keep is not None: + print(f"\n (8-bis non girata: la cella adattiva sta su {best_keep['tf']} e il costante " + f"su {ch['tf']} -> le ancore non sarebbero coppie)") + + # -- 8-ter. IL CRITERIO DEL FALSIFICATORE, SU **TUTTE** LE CELLE ADATTIVE ------------ + hr("8-ter. IL CRITERIO DEL FALSIFICATORE APPLICATO A **OGNI** CELLA DAVVERO ADATTIVA") + print(" Il 22/08 dichiarava: Β«un rilevatore il cui L_t si muove DAVVERO e batte il miglior") + print(" lookback COSTANTE in >=6/8 anni appaiati con corr->TP01 < 0.6Β». Non basta che") + print(" fallisca il VINCITORE: si applica il criterio a tutte e", len(ad_rows), "le celle adattive.") + print(f"\n {'tf':>4} {'cella':<58} {'corr':>6} {'anni>0':>7} {'dShFULL':>8} {'dShHOLD':>8} esito") + passed = [] + for r in sorted(ad_rows, key=lambda x: -x["insample_sharpe"]): + fnr = factory(tf=r["tf"], **r["params"]) + dr = A.candidate_daily(fnr, tf=r["tf"]) + JJ2 = pd.concat({"a": dr, "b": tp01}, axis=1, join="inner").dropna() + cor = float(JJ2["a"].corr(JJ2["b"])) + yr = paired_by_year(dr, d_const) + nyp = sum(1 for x in yr if x["d_sh"] > 0) + Jr = pd.concat({"C": dr, "B": d_const}, axis=1, join="inner").dropna() + JrH = Jr[Jr.index >= HOLDOUT] + dF = A._sh(Jr["C"]) - A._sh(Jr["B"]) + dH = A._sh(JrH["C"]) - A._sh(JrH["B"]) + ok = bool(cor < 0.6 and nyp >= 6) + if ok: + passed.append((r, cor, nyp, dF, dH)) + lab = (f"{r['params']['det']} {r['params']['par']} {r['params']['stat']} " + f"{r['params']['mapping']} {r['params']['blend']}") + print(f" {r['tf']:>4} {lab:<58} {cor:>6.3f} {nyp:>5}/8 {dF:>+8.3f} {dH:>+8.3f} " + f"{'PASSA' if ok else ''}") + print(f"\n celle che soddisfano ENTRAMBE le condizioni del falsificatore: " + f"{len(passed)}/{len(ad_rows)}") + cors = [] + for r in ad_rows: + dr = A.candidate_daily(factory(tf=r["tf"], **r["params"]), tf=r["tf"]) + JJ2 = pd.concat({"a": dr, "b": tp01}, axis=1, join="inner").dropna() + cors.append(float(JJ2["a"].corr(JJ2["b"]))) + print(f" corr->TP01 sulle celle adattive: min {min(cors):.3f} mediana " + f"{np.median(cors):.3f} max {max(cors):.3f} (soglia del falsificatore 0.60)") + + # -- 9. FEE SWEEP --------------------------------------------------------------------- + hr("9. FEE SWEEP β€” l'adattivo trada di piu': e' li' che muore o no") + for nm, f_, tfx in (("adattivo (A)", fn_ch, ch["tf"]), ("WF-PnL (C)", fn_wf, wch["tf"]), + ("miglior costante (B)", fn_const, cch["tf"]), + ("TP01 30/90/180", fixed_target, "1d")): + line = [] + for fee in (0.0, 0.0005, 0.001, 0.0015): + line.append(f"{2*fee*100:.2f}%RT {A._sh(A.candidate_daily(f_, tf=tfx, fee_side=fee)):+.3f}") + tt = np.mean([A.eval_weights(A.get(a, tfx), A._call_target(f_, A.get(a, tfx), a)) + ["turnover_per_year"] for a in ASSETS]) + print(f" {nm:<22} " + " ".join(line) + f" | turnover {tt:.0f}x/anno") + + hr(f"FINE β€” {time.time()-t0:.0f}s") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822b_critic.md b/scripts/research/r0822b_critic.md new file mode 100644 index 0000000..d878fd0 --- /dev/null +++ b/scripts/research/r0822b_critic.md @@ -0,0 +1,667 @@ +# CRITICO DI COMPLETEZZA β€” ondata 2026-08-22 (`r0822b_critic`) + +**Mandato:** non testare una strategia. Rispondere a *cosa non e' stato guardato, quale +affermazione e' rimasta non verificata, e qual e' la misura di maggior valore che nessuno ha fatto*. + +**Materiale letto per intero:** `docs/research/BRIEF-0822.md`, `docs/research/RESULTS-0822.md` +(684 righe, 19 sezioni), `docs/diary/2026-08-22-wave-multiagente.md`, piu' i punti dei singoli +`scripts/research/r0822_*.py` dove il ledger andava verificato. + +**Regola di lettura di questo file.** Distinguo tre stati e li marco: +`[VERIFICATO]` = ho aperto la fonte e il fatto e' controllato in questa sessione (comando +riportato o citazione esatta) Β· `[DEDOTTO]` = ragionamento su numeri altrui, non ri-misurato Β· +`[DA MISURARE]` = ipotesi mia, con costo stimato. Non fabbrico numeri nuovi salvo controlli rapidi, +e quando ne faccio uno dico che e' un controllo di cinque minuti e non uno studio. + +**Esito in una riga: l'ondata e' onesta nei singoli filoni e debole nella CUCITURA.** I difetti +gravi non stanno dentro i filoni (che hanno catturato 7 errori propri) ma in tre punti dove +nessuno era responsabile: (a) **due "difetti di dato" sono convenzioni dichiarate nel sorgente di +un fornitore che gira su questa stessa VPS e che nessuno ha aperto**; (b) **il gate principale del +progetto β€” il deflated-Sharpe β€” e' stato misurato lo stesso giorno ai suoi due estremi degeneri da +due agenti diversi, e nessuno ha unito le due misure**; (c) **un filone su venti (ALT-OPTIONS) non +e' nel ledger, e il suo risultato falsifica una quarta soglia pubblicata** β€” quella che tiene +VRP01 fuori dal libro per taglia. + +--- + +## 1. CLAIM NON VERIFICATI + +Ordinati per `(impatto x P(falso)) / costo`. Non elenco tutto: le otto che contano. + +### 1.1 β€” πŸ₯‡ Β«una firm crypto lista i 19 alt Hyperliquid con short abilitatoΒ» (PROP-ALLOC) +`P(falso)` **media-alta** Β· `impatto` **massimo** Β· `costo` **un pomeriggio, zero CPU** + +E' l'unica assunzione su cui poggia **l'intero risultato di testa dell'ondata**: `+0,400 di J` +(argmaxJ 25/25/50 contro il libro live 75/25) e' quasi tutto *mettere XS01 su un conto funded*, e +`+0,016` β€” il 4% β€” e' il riallocare per la barriera. Se XS01 non e' eseguibile presso la firm, +la riga argmaxJ collassa verso `75/25/0`, che nello stesso script misura **J 0,335 / P(pass) 38,6%** +invece di **0,738 / 82,2%**. Con essa cade anche il *risultato strategico* del 25/07 Β§4 +(Β«sul canale funded diversificare paga perche' il vincolo binding e' il DDΒ»), che e' il perno di +tutta la valutazione del canale prop. + +⚠️ **E il progetto ha gia' una regola scritta per questo caso, e non l'ha applicata.** Il 26/07: +*Β«la negoziabilita' sul conto REALE va verificata quando lo sleeve entra in RICERCA, non quando +entra nel bookΒ»* β€” regola nata dal blocco PRIIPs su GTAA01, dove **cinque settimane di misure** +poggiavano su un'assunzione di conto mai controllata, e il controllo costo' **un ordine di prova**. +`r0822_prop_alloc.py` la ripete: alla riga 100 `CRYPTO_PROP = ("TP01","SKH01","XS01") # cio' che +una firm CRYPTO puo' davvero far eseguire` β€” un commento, non una misura. [VERIFICATO: letto il +sorgente; l'agente esclude esplicitamente VRP01/GTAA01 *"una firm CRYPTO non lista opzioni Deribit +ne' ETF azionari USA"*, quindi ha pensato al problema e si e' fermato un passo prima di XS01.] + +**Cosa verificare, in ordine:** (a) la lista strumenti di HYROTRADER e FTMO-crypto contiene i 19 +major HL (SOL/BNB/XRP/DOGE/AVAX/LINK/LTC/ADA/ARB/OP/SUI/APT/INJ/TIA/SEI/NEAR/AAVE oltre BTC/ETH)? +(b) short abilitato su ciascuno? (c) quale costo effettivo per gamba (spread + commissione)? +**La (c) ha una soglia gia' misurata in questa stessa ondata**: XS-LITE riporta che la famiglia XS +regge **oltre 50 bps/gamba** β€” quindi il costo di una firm quasi certamente non morde, ed e' la +(a)/(b) binaria che decide tutto. **Se (a) e' falsa, il verdetto LEAD del filone 1 va riscritto +oggi, non a un gate futuro.** + +### 1.2 β€” πŸ₯ˆ Β«`dealer_net_gamma` e' il GEX col SEGNO INVERTITOΒ» (DEALER-GAMMA) +`P(falso)` **alta β€” anzi: FALSO come formulato** Β· `impatto` medio Β· `costo` **due minuti** + +Il ledger lo chiama *Β«il risultato piu' riusabile del filoneΒ»* e scrive: *Β«Chi lo usasse col nome +che porta leggerebbe ogni regime al contrarioΒ»*. L'agente ha deciso la questione **per +ricostruzione** (corr βˆ’0,95 BTC / βˆ’0,89 ETH contro un GEX da manuale) e lo dichiara esplicitamente +nel proprio codice: `"Quale, lo decide la sezione 2 (ricostruzione dalla catena), non io"` +(`r0822_dealer_gamma.py:187`). + +🚨 **La fonte autorevole e' su questa VPS e dice il contrario.** [VERIFICATO] La colonna non e' +calcolata da cerbero-bite: bite **inoltra e basta** il campo `total_net_dealer_gamma` che riceve +dal tool MCP `get_dealer_gamma_profile` (`src/cerbero_bite/runtime/market_snapshot_cycle.py:85` e +`clients/deribit.py:342-384`, letti dal repo Gitea `Adriano/Cerbero-Bite`). E il produttore e' +`/opt/docker/cerbero-mcp/src/cerbero_mcp/common/options.py`, **acceso adesso**, che in testa al +file dichiara la convenzione: + +``` +# Convention dealer gamma: i dealer sono SHORT calls (le vendono al retail) e +# LONG puts. ... Net dealer gamma negativo -> mercato instabile (squeeze ...) +... +entry["call_dealer_gamma"] -= contrib # dealer short calls +entry["put_dealer_gamma"] += contrib # dealer long puts +``` + +Cioe': **call negative, put positive** β€” l'esatta negazione, su entrambe le gambe, della +convenzione da manuale che l'agente ha usato (`call +, put βˆ’`, `r0822_dealer_gamma.py:237`). +**Non e' un difetto della colonna: e' una convenzione opposta, deliberata e documentata** β€” e nel +suo frame `dng < 0` significa *dealer short gamma / mercato instabile*, che e' proprio la +semantica che il docstring di bite dichiara e che l'entry filter Β§2.8 usava. + +E c'e' una **seconda spiegazione, gratis, per i due numeri residui che l'agente ha attribuito a +non-portabilita'**: la chiamata di bite usa il default `top_n_strikes = 50` [VERIFICATO: +`dealer_gamma_profile(asset_upper)` senza argomenti], contro i **~280-316 strike** della +ricostruzione. Un troncamento ai 50 strike di punta spiega naturalmente sia il coefficiente +`βˆ’0,7` (invece di `βˆ’1,0`) sia l'offset *Β«dng=0 corrisponde a GEX=+5,8e6 su BTCΒ»*, che il ledger +presenta come prova che la soglia non e' portabile. + +**Cosa cambia se e' falso (ed e' falso):** il verdetto **SCARTATO regge intatto** (DSR 0,440, +null de-levering fallito, `n_eff` 24-53 ore su 2.130, cella migliore *sotto* il massimo del +rumore). Ma cade la **regola pubblicata**, e cade nel modo peggiore: la frase del ledger *Β«BTC non +separa, ETH separa al ROVESCIOΒ»* e' formulata in un frame di segno che va rovesciato β€” quindi +l'unica riga del filone che le ondate future erediterebbero e' quella sbagliata. **Correzione da +fare oggi nel ledger, costo due righe.** + +### 1.3 β€” πŸ₯‰ Β«`liquidation_long/short_risk` e' costante 'low': meta' dell'ipotesi non esiste, ed e' +comunque morta perche' la colonna non e' ricostruibileΒ» (FLOW-SQUEEZE) +`P(falso)` **alta sulla seconda meta'** Β· `impatto` medio Β· `costo` **due ore** + +[VERIFICATO] Il fatto e' vero (`low` in 17.229/17.229 righe non nulle, riprodotto). Ma le due +inferenze che l'agente costruisce sopra il fatto sono entrambe smentibili leggendo la stessa +sorgente di Β§1.2, `/opt/docker/cerbero-mcp/.../sentiment/fetchers.py:480-518`: + +``` +long_risk = "high" se avg_funding > 0.0001 e delta_24h > 5 + "medium" se avg_funding > 0.00005 e delta_24h > 2 +``` + +Cioe' **e' una soglia su due input, non una misura**. Conseguenze: +- *Β«Nessuna fonte nostra la produce, e non c'e' niente da produrreΒ»* (`r0822_flow_squeeze.py:626`) + e' **falso**: i due input sono `oi_delta_pct_24h` e il funding cross, e **il funding cross e' nel + file** (`funding_cross_annualized`, copertura 99%), cosi' come `oi_delta_pct_4h` (copertura 99%, + dev.std 1,23%, range βˆ’7,1%…+13,4% β€” [VERIFICATO] con una lettura del parquet). L'agente poteva + ricostruire la variabile di affollamento **con le proprie soglie**, che e' anche metodologicamente + meglio che ereditarne una arbitraria. +- *Β«Costante = zero informazioneΒ»* e' vero come statistica e **sbagliato come lettura**: `low` al + 100% con quelle soglie significa che **nella finestra 2026-03 β†’ 07 non c'e' stato un solo + episodio di affollamento** per quella definizione. Non e' una colonna rotta, e' un fatto di + mercato β€” e per giunta **rafforza** la conclusione del filone (funding chiuso sul quarto lato) + molto piu' di quanto faccia Β«la colonna e' inservibileΒ». + +**Cosa cambia se e' falso:** il verdetto SCARTATO regge (l'altra meta' e' sotto la propria MDE +dichiarata: effetto 0,402% contro MDE 1,056%). Ma il filone e' chiuso con la motivazione +sbagliata, e la motivazione sbagliata e' quella che impedisce di riaprirlo con i dati che ci sono. + +### 1.4 β€” Β«la finestra forward non va persa: riparare `advance()` + RIGENERARE, nessuna data di +gate si spostaΒ» (MONITOR-AUDIT) +`P(falso)` bassa sul fatto, **alta sull'implicazione** Β· `impatto` **alto (3 gate)** Β· `costo` zero + +Il fatto e' provato bene, due volte in modo indipendente (`paper_prevday` 1427/1488 barre identiche +al bit dopo 62 notti; le 6 barre di `paper_statarb` recuperate dopo il guasto EPERM coincidono al +bit). **Non contesto il fatto. Contesto che sia gratis.** + +Una serie **rigenerata** non e' una serie **forward**. La ragione per cui un progetto +pre-registra una finestra forward e' che sia prodotta da codice che *non poteva* essere tarato su +di essa. Dopo la riparazione, la serie su cui si decide il 27/09 sara' prodotta da codice +**scritto oggi, dopo aver visto quella finestra** β€” e chi scrive la riparazione ha gia' letto, +nel ledger, che la metrica registrata dice +1,96 e quella ricostruita βˆ’2,02. La riparazione e' +banale e meccanicamente forzata (scartare la barra non chiusa), quindi il rischio e' piccolo; ma +**e' un grado di liberta' nuovo su un gate pre-registrato, e nessuno lo ha nominato.** + +**Costo di chiuderlo: zero.** Si scrive la riparazione, la si congela con un test, e **solo +dopo** si rigenera β€” dichiarando in anticipo che nessun parametro verra' toccato in base al +risultato della rigenerazione. Farlo dopo aver visto lo Sharpe rigenerato non e' piu' possibile. + +### 1.5 β€” Β«haircut a $5.000 <= 40%Β» come gamba del gate XSR01 del 23/10 +`P(che il gate sia non-informativo)` **alta** Β· `impatto` **alto** Β· `costo` **zero (e' gia' misurato)** + +Due filoni hanno colpito le due gambe del gate del 23/10 **senza che nessuno tirasse la somma**: +- **gamba haircut** β€” HL-EXEC: a $5.000 col pavimento vero l'haircut sta fra **βˆ’7% e +2%**, quindi + *Β«una condizione pre-registrata che non puo' fallire non e' una condizioneΒ»*. XSR-REPRO + dall'altro lato: il **$14,41** pubblicato **non ha alcuno script committato che lo riproduca** ed + e' **~2,4x ottimista** (ticket mediano $3,33, 63% degli ordini sotto $5). +- **gamba Sharpe >= 1,0** β€” MONITOR-AUDIT: la serie che la misura registra **41 minuti di mercato + al giorno**, `corr` col replay **βˆ’0,045**. + +πŸ“Œ **La somma, che il ledger non fa: al 22/08 il gate del 23/10 e' non-informativo su ENTRAMBE le +gambe** β€” una non puo' fallire, l'altra e' letta su uno strumento scollegato dalla realta'. Il +ledger scrive Β«GATE 23/10 COMPROMESSO: PARZIALMENTEΒ». **E' compromesso interamente**, e la +differenza non e' semantica: Β«parzialmenteΒ» autorizza a decidere il 23/10 con una riserva, +Β«interamenteΒ» obbliga a **ri-registrare il gate prima**, che e' l'unica mossa che non e' selezione. + +### 1.6 β€” Β«k* empirico ~10-14xΒ» (GROWTH-POLICY) +`P(falso)` **alta come numero** Β· `impatto` medio-alto Β· `costo` zero (giunzione di due misure gia' fatte) + +L'agente ha gia' fatto due autocritiche corrette (capitale mediano $4,4e12 a k=14x β†’ *Β«a k>2 i +numeri sono aritmetica, non previsioniΒ»*; k* lineare nell'errore del drift). Ne manca una terza, e +sta **dentro la stessa ondata**: SLIP-AUDIT misura che la partecipazione al nastro scala lineare +col nozionale e che il caso peggiore osservato e' gia' **21,9% di una barra 5m** a nozionale ~$636. +La leva moltiplica il nozionale esattamente come il capitale. Quindi [DEDOTTO, aritmetica diretta +sulla tabella di SLIP-AUDIT]: + +| leva su $635 | nozionale | peggior partecipazione osservata, riscalata | +|---|---|---| +| 1,00x | $636 | 22% di una barra 5m | +| **1,50x** (il gradino proposto) | $954 | **33%** | +| 5x | $3.180 | 110% | +| **10-14x** (k*) | $6.400-8.900 | **220-310%** | + +πŸ“Œ **Il gradino 1,00 β†’ 1,50x NON e' toccato da questo** (33% resta dentro il regime misurato): +la raccomandazione operativa sopravvive. **E' il numero k\* che non e' misurabile**, per una terza +ragione indipendente dalle due dichiarate: sopra ~k=5 la serie di ritorni su cui k\* e' calcolato +smette di essere indipendente dalla size, che e' esattamente l'ipotesi che l'agente aveva segnalato +come implausibile senza poterla quantificare. **Ora e' quantificata, gratis, e va scritta accanto +al 10-14x.** + +### 1.7 β€” Β«BTC opzioni min 0.1 = $6.210/lotto β†’ VRP01 fuori a $3.000, servirebbe il 61% del contoΒ» +`P(falso)` **verificata FALSA** Β· `impatto` **alto** Β· `costo` gia' pagato β€” il numero esiste e non e' nel ledger + +Questo e' congelato in `tests/test_vrp_profit_take.py` e in CLAUDE.md. VRP-QUOTE-VERE l'ha +falsificato a meta' (famiglia sbagliata) e **ALT-OPTIONS l'ha falsificato del tutto** β€” ma +ALT-OPTIONS **non ha una sezione nel ledger** (vedi Β§5.1), quindi il numero non e' mai stato +scritto. [VERIFICATO: letto l'output del filone]: + +| famiglia | f_venue | credito eseg. | **max-loss / lotto** | IM se il venue NON netta | +|---|---|---|---|---| +| **ETH_USDC** | **0,980** | $3,38 | **$16,62** | $33,50 | +| BTC_USDC | 0,921 | $6,25 | $33,75 | $101,79 | +| ETH inverse (la famiglia del muro) | 0,930 | $29,36 | $145,88 | $324,42 | +| BTC inverse (la famiglia del muro) | 0,927 | $81,97 | $418,03 | $1.017,06 | + +Il muro pubblicato e' calcolato sul **nozionale del lotto minimo della famiglia inverse**. La +grandezza giusta per una struttura a rischio definito e' il **max-loss**, e sulla famiglia che il +conto puo' marginare vale **$16,62**. Al peso di libro del 12%, VRP01 entra con un lotto da un +conto di **~$140** (o ~$280 se Deribit non netta le gambe), non da $3.000 al 61% del conto β€” +**due ordini di grandezza**. Sul conto vero ($635) ci stanno **7 strutture dentro un budget di +rischio del 20%**, con **3.000 lotti al miglior bid** e spread della gamba corta al **2,6%**. + +⚠️ **Cosa NON cambia, e va detto forte:** VRP01 resta fuori dal libro, ma **per la regola +Β«niente short-vol da modello in deployΒ» e per il gate IV-rank (0/19 settimane aperte)**, non per +la taglia. Cade *una* delle tre ragioni, ed e' quella che il progetto ripete piu' spesso. + +### 1.8 β€” Β«lo screen largo non puo' passare il proprio gate: e' aritmeticaΒ» (ORTHO-SCREEN) +`P(falso)` **alta come generalizzazione** Β· `impatto` **alto (tocca 3 gate futuri)** Β· `costo` **5 minuti** + +Vedi Β§2.1: l'ho ri-derivato e il numero e' giusto ma **non dice cio' che il ledger gli fa dire**. + +--- + +## 2. CONTRADDIZIONI FRA AGENTI + +### 2.1 β€” 🚨 IL DEFLATED-SHARPE MISURATO AI SUOI DUE ESTREMI DEGENERI, LO STESSO GIORNO, DA DUE AGENTI CHE NON SI CITANO + +- **ORTHO-SCREEN**: *Β«su 168 trial il massimo atteso dal puro rumore e' Sharpe 1,572, sopra il + soffitto direzionale (~1,3) β†’ una ricerca a rete larga NON PUO' passare il proprio gate. Non e' + sfortuna, e' aritmetica.Β»* DSR di screen **0,034**. +- **SCETTICO su VOL-SIZE**: *Β«`DSR = 1,000` dichiarato **VACUO, non PASS**: lo passa anche il + baseline β€” su una famiglia di perturbazioni della stessa strategia il deflated-Sharpe non ha + potenza.Β»* + +**Sono la stessa osservazione dai due lati, e insieme delimitano il gate principale del progetto.** +[VERIFICATO] Ho aperto `altlib.deflated_sharpe`: il massimo atteso non e' `SE(Sharpe) x sqrt(2 ln N)` +ma la formula Bailey–Lopez de Prado + +``` +sr0 = sd(Sharpe dei trial OSSERVATI) x [ (1-gamma)*z(1-1/N) + gamma*z(1-1/(N e)) ] +``` + +cioe' **la dispersione trasversale delle celle che hai deciso di dichiarare**, moltiplicata per un +fattore che dipende solo da N. Ricalcolando il moltiplicatore [controllo di cinque minuti]: + +| N dichiarato | moltiplicatore | `sr0` con la STESSA dispersione (sd = 0,58) | +|---|---|---| +| 12 | 1,665 | 0,97 | +| **24** | 1,980 | **1,15** | +| 48 | 2,261 | 1,31 | +| 72 | 2,413 | 1,40 | +| **168** (lo screen) | 2,708 | **1,57** | +| 729 | 3,164 | 1,84 | + +E al variare della dispersione, a parita' di dati: + +| dispersione delle celle | `sr0(24)` | `sr0(168)` | +|---|---|---| +| 0,15 (famiglia di perturbazioni, tipo VOL-SIZE) | 0,30 | 0,41 | +| 0,58 (lo screen ortogonale) | 1,15 | 1,57 | + +πŸ“Œ **Tre letture che il ledger non ha:** +1. **La soglia Β«1,572Β» non e' una proprieta' di BTC/ETH: e' una proprieta' della GRIGLIA.** Le + stesse 168 celle, dichiarate come 7 famiglie da 24, danno `sr0 = 1,15` β€” **sotto** il soffitto + direzionale. Il verdetto Β«impossibile per aritmeticaΒ» si ribalta **senza toccare un dato**. +2. **Percio' la lezione pubblicata invita alla mossa che il progetto ha gia' proibito.** Il ledger + conclude *Β«le ondate future o dichiarano famiglie molto piu' piccole in anticipo, o cambiano + meccanismoΒ»*. La prima meta' e' **esattamente** il barare che il 30/07 aveva codificato: + *Β«il deflated-Sharpe e' l'unico posto dove barare e' indolore e invisibileΒ»*, e + *Β«il verdetto si ribalta col conteggioΒ»* (congelato in un test su VRP01: DSR 0,983 a N=8 / + 0,948 a N=72 / 0,909 a N=360). **Il 22/08 la stessa leva viene raccomandata come buona pratica.** +3. **Cio' che il progetto non sapeva e' la META' PIU' GRANDE: la dispersione.** Il fattore N va da + 1,67 a 3,16 su due ordini di grandezza di trial (1,9x). La dispersione va da 0,15 a 0,58 fra una + famiglia omogenea e uno screen (3,9x). **Il gate e' dominato dalla variabile mai dichiarata.** + +**Conseguenza operativa immediata**, perche' tocca tre gate pre-registrati: DVOLSPREAD 24/10 ha +come criterio (c) *Β«deflated-Sharpe ricalcolato >= 0,95Β»* β€” su una famiglia di 729 celle +omogenee; XSR01 23/10 poggia su DSR 0,983; VOL-SIZE ha gia' dichiarato il proprio DSR vacuo. +**Nessuno dei tre e' interpretabile senza dichiarare, accanto a N, la dispersione delle celle.** +Riformulazione onesta della lezione di ORTHO-SCREEN: *con celle disperse di ~0,58 di Sharpe e un +soffitto di ~1,3, il gate non separa; la cura non e' dichiarare meno trial, e' progettare famiglie +i cui membri siano varianti di UN meccanismo β€” e li' il DSR e' vacuo, quindi serve un altro gate.* +**Il deflated-Sharpe non ha una regione utile in mezzo, e nessuno l'ha detto.** + +### 2.2 β€” Lo Sharpe di XSR01: non tre lenti ma **quattro**, e la quarta non e' nel quadro conciliato +Il ledger conclude con un Β«quadro conciliatoΒ»: titolo `demean_skeptic` **1,82** Β· gate +`basket_gate` **1,75-1,79** Β· monitor `paper_xsr` **2,21**, e prescrive +*Β«NUMERO ONESTO DA CITARE: 1,79 (finestra di scoperta) / 1,63 (a oggi)Β»*. + +[VERIFICATO nell'output di XS-LITE] Lo stesso giorno **XS-LITE**, che dichiara replica **bit-exact** +contro `basket_from_positions(demean=True)` (`max|diff| = 0,000e+00` su 965 barre), pubblica: +*Β«XSR01 pieno, lente pubblicata: Sharpe **1,64** (pubblicati 1,82)Β»*, *Β«normalizzazione COSTANTE +1/50: **1,65**Β»*, *Β«sulla FINESTRA DI SCOPERTA (<= 2026-07-25, 937 barre): **1,75**Β»*. + +Quindi **due agenti che rivendicano entrambi la replica bit-exact della stessa funzione danno 1,75 +e 1,79 sulla stessa finestra.** La spiegazione piu' probabile e' la loro stessa scoperta (l'ultima +barra della finestra di scoperta era parziale il 25/07 e oggi e' chiusa), e i due concordano +sull'oggi (1,64 vs 1,63). **Ma il ledger prescrive una coppia canonica senza nominare l'altra**, e +la regola che pubblica β€” *Β«va sempre citata la coppia (lente, ultima barra chiusa)Β»* β€” e' proprio +quella che avrebbe risolto la discrepanza se applicata a se stessa. **Costo di chiudere: una riga +nel ledger. Impatto: e' il numero su cui si decide il 23/10.** + +### 2.3 β€” Il muro di XS01: **tre numeri diversi** nello stesso corpus di documenti +- XS-LITE: capitale minimo perche' il ribilancio passi = **$109 di sleeve (~$730 di libro)**. +- HL-EXEC: haircut ~0 gia' a **$200-600 allocati** (β†’ a peso 15%, **$1.300-4.000 di conto**). +- Diario Β§6.5: *Β«XS01 e' eseguibile a **~$1.300 di conto**Β»* β€” cioe' l'estremo basso di HL-EXEC. + +I tre sono compatibili (criteri diversi: soglia del ribilancio vs soglia dell'haircut), ma il +diario ne pubblica **uno** come se fosse *il* numero, dopo che l'ondata ha appena passato una +giornata a dimostrare che i numeri pubblicati a un criterio solo sono il difetto ricorrente del +progetto. **Da citare come banda `$730-$1.300` col criterio accanto.** + +### 2.4 β€” La leva: due filoni della stessa ondata danno guida opposta +- **GROWTH-POLICY**: k\* ~10-14x, il libro gira al 7% di Kelly, **alzare** la leva vale 14,7a β†’ 11,6a. +- **PROP-ALLOC**: J massimo a **0,75x**, e *Β«P(pass) senza limite di tempo e' monotona + DECRESCENTEΒ»* (98,9% a 0,50x β†’ 65,9% a 1,50x). + +Non e' un errore: sono obiettivi diversi (crescita in log senza barriera vs sopravvivenza sotto +una barriera per-conto). **Ma nessuno lo scrive**, e l'operatore leggera' due raccomandazioni +opposte nello stesso documento. La sintesi che manca, e che riconcilia: +πŸ“Œ **anche il conto proprio ha una barriera assorbente** β€” e' la decisione di venue del 26/07 +(*Β«zero e' assorbente: andare a zero all'anno 10 di un piano da 16 anni significa non arrivarci +piu'Β»*). Quindi il criterio di GROWTH-POLICY (Kelly puro) e' il caso limite senza barriera, e +PROP-ALLOC misura cosa succede quando la barriera c'e': **la leva ottima cade da 10x a 0,75x +appena si mette una barriera, e la distanza fra i due numeri E' la misura di quanto la barriera +costa.** Detta cosi', le due misure smettono di contraddirsi e diventano la stessa curva. + +### 2.5 β€” Il peso di SKH01: respinto da un filone, raccomandato al doppio dall'altro +- **VOL-SIZE / scettico**: *Β«quasi ogni variante alzava il peso effettivo di SKH fino a 0,375, e + "alzare SKH01" e' gia' stato misurato e respinto il 26/07Β»* β€” motivo per cui il null giusto e' + iso-peso. +- **PROP-ALLOC**: sul campione 2019+ che contiene il 2022, l'ottimo della coppia crypto sotto + barriera e' **38/62** (J 0,521 contro 0,443), cioe' **SKH01 al 62%**. + +Di nuovo obiettivi diversi, di nuovo non detto. Ma qui c'e' un aggravante che **l'agente dichiara +da solo**: PROP-ALLOC non ha girato la banda d'ancora, quindi il suo ottimo poggia sull'ancora +canonica di **SKH01, lo sleeve che il LOO del 26/07 misura come quello con la fortuna d'ancora +piu' grande da restituire** (l'ancora regala 2/3 del FULL, 70% dell'hold-out, 80% della protezione +DD). **La distorsione e' a favore della raccomandazione, e la raccomandazione riguarda l'unico +canale che l'operatore potrebbe aprire quest'anno.** Costo di chiuderla: rigirare l'ottimo su una +banda di 8 ancore β€” l'ordine di grandezza del budget di un agente di questa ondata. + +### 2.6 β€” Il conteggio degli ingressi VRP nella stessa finestra di catena: 19 contro 22 +VRP-QUOTE-VERE: *Β«f = 0,714 pooled, **0/19** osservazioni >= 1,0Β»*; SKEW: *Β«0,714 su **22** +ingressiΒ»*. Stesso `f` a tre decimali, popolazione diversa. [VERIFICATO che SKEW usa +*Β«stessa macchina di `cblib`Β»*, quindi **non e' una replica indipendente** e l'accordo a tre +decimali non e' evidenza.] Spiegazione benigna probabile (VRP richiede **entrambe** le gambe +quotate ai due delta obiettivo, SKEW ricostruisce lo smile e ne richiede meno). **Ma il ledger +presenta il 0,714 come Β«replica indipendenteΒ» del f del 30/07 senza dire che il secondo 0,714 +viene dallo stesso harness.** Impatto basso, costo di chiarirlo nullo. + +### 2.7 β€” Β«il collettore raccoglie la famiglia sbagliataΒ» e' una classificazione, non un fatto +Il ledger apre VRP-QUOTE-VERE con 🚨 **IL RISULTATO DI PRODUZIONE**. [VERIFICATO in +`scripts/live/collect_chain.py:114`: `{"currency": asset, "kind": "option"}` e `OI_MIN = 100.0` +alla riga 57 β€” i due fatti sono esatti.] Ma il collettore serve la **ricerca sui prezzi**, non +l'esecuzione: sulle inverse ci sono 415/548 strumenti con OI>=100 e **97% di quote a due lati**, +contro il 94% e 119 strumenti di ETH_USDC. Per misurare `f`, IV-rank e struttura a termine, la +famiglia raccolta e' **la migliore delle due**. Il difetto esiste solo rispetto a un uso β€” +l'esecuzione β€” che oggi e' vietato da un'altra regola (Β«niente short-vol da modello in deployΒ»). +Il diario lo corregge a meta' (punto aperto 3 lo presenta come decisione sul rate limit); il +ledger no. **Un difetto di produzione e una scelta di raccolta non si segnano con lo stesso +simbolo**, o il prossimo che legge ripara qualcosa che non e' rotto β€” al costo di **+90% sul giro** +e del rate-limit per-IP che il 29/07 e' gia' costato un guasto. + +--- + +## 3. MODALITA' NON ESPLORATE + +Filtro applicato: (a) compatibile con dati che il progetto possiede o puo' ottenere, (b) non nella +lista dei filoni morti del brief, (c) meccanismo plausibile e nominabile. Niente idee generiche. + +### 3.1 β€” πŸ₯‡ Il costo del CHOP, cioe' il rischio che il libro non ha mai vissuto +**Dati:** esistono (7,4 anni di libro; 2018-19 e 2022-23 come stiramenti laterali; 30 anni di +equity come stampo). **Meccanismo:** il progetto ha misurato la difesa nel **sinistro** β€” il +criterio B di `edge_watch` e' 8/8 anni di crash superati β€” e non ha **mai** misurato la modalita' +di fallimento propria di un trend-follower, che non e' il crash ma il **mercato laterale**. E la +misura di controllo che ho fatto in cinque minuti la rende urgente e non teorica: + +| giorno | BTC | ETH | **libro 75/25 (close-only)** | +|---|---|---|---| +| 2020-03-12 | **βˆ’41,5%** | **βˆ’45,7%** | **+0,97%** | +| 2022-06-13 | βˆ’15,5% | β€” | +0,72% | +| 2021-05-19 | βˆ’14,3% | βˆ’27,9% | βˆ’1,98% | +| 2022-11-09 | βˆ’14,4% | βˆ’18,0% | 0,00% | +| 2021-01-21 | β€” | βˆ’19,5% | βˆ’2,24% | + +[controllo di cinque minuti, non uno studio: `altlib.tp01_baseline_daily()` + `sleeves._skyhook_returns()`, +75/25, ancora canonica. Peggior giorno del libro su 7,4 anni: **βˆ’3,38% (2025-10-10)**, che **non e' +un giorno di crash**.] Cioe': **il libro non perde nei crolli β€” nei crolli guadagna.** Tutte le sue +peggiori giornate sono altrove. **Nessuno ha mai chiesto dove.** Un piano a 10-20 anni su una +strategia il cui unico rischio misurato e' quello che non la tocca ha un buco esattamente della +forma che questo progetto e' bravo a trovare in tutto il resto. + +### 3.2 β€” πŸ₯ˆ Il prezzo di essere ciechi: il costo atteso in P&L dell'attrito OPERATIVO +**Dati:** ci sono gia' e sono nostri β€” `logs/cron_book.log`, `data/venue_watch`, `feed_age_minutes`, +`skh_feed_errors`, `book_executions.jsonl`. **Meccanismo:** nel 2026 il libro e' stato cieco o +stantio almeno tre volte documentate (feed-freeze 14/07; fallback silenzioso di `fresh_5m` il +29/07, latenza d'uscita di SKH01 da ~1h a **~11h** per 6 giri su 8; manutenzione Deribit del 18/08 +sforata **fino alle 14:07**, con astensione del libro). Il progetto ha prezzato fee, slippage, +min-order, pavimento IB, fortuna d'ancora, degrado d'esecuzione, e persino **il fallimento +dell'exchange** β€” e non ha **mai** prezzato *quanto costa all'anno che il libro non veda*. E' una +misura, non una previsione: si prendono le finestre di cecita' realmente occorse, si guarda cosa ha +fatto il prezzo dentro, e si confronta il P&L realizzato col P&L del percorso non-cieco. +**Perche' conta adesso:** e' un costo che **scala col nozionale** esattamente come la leva, quindi +entra direttamente nel conto di Β§4; e le uscite di SKH01 sono la gamba latency-sensitive. +**Perche' non e' morto:** non e' calendario, non e' un overlay, non e' una strategia β€” e' il +prezzo di un rischio operativo gia' materializzato tre volte. + +### 3.3 β€” πŸ₯‰ Lo spread di volatilita' BTC-vs-ETH come sleeve, non come timer +**Dati:** `dvol_btc` / `dvol_eth` dal 2021-03 = **1978 giorni, 5,4 anni** β€” l'unico dataset non +direzionale del progetto abbastanza lungo da avere un hold-out vero (a differenza di tutta la +catena, ferma a 74-90 giorni). **Meccanismo:** i due asset condividono un fattore di vol comune, e +la *ricchezza relativa* delle due vol e' una grandezza stazionaria per costruzione, mentre il +livello non lo e'. **Non e' DVOLSPREAD:** quello e' il lead in forward-monitor che usa lo spread +come **timer direzionale** (de-risk); qui lo spread si traderebbe **come tale** (straddle lungo su +uno, corto sull'altro), market-neutral rispetto alla direzione e β€” a differenza di VRP01 β€” con +esposizione al premio di vol quasi cancellata fra le due gambe. **Non e' nella lista dei morti** +(gamma scalping = long-vol nudo; VRP01 = short-vol nudo gated; questo e' relative-value). +⚠️ **Due muri dichiarati in anticipo:** (i) sarebbe prezzato su DVOL, cioe' BS-flat, e la SKEW di +questa ondata ha appena misurato che il flat costa **x0,869 di struttura a termine** e **x0,920 di +skew** β€” quindi il verdetto massimo e' LEAD finche' non e' riprezzato sulla catena vera; (ii) +richiede opzioni su entrambi gli asset: fuori a $635 (BTC_USDC ~$780/lotto), dentro a ~$3-5k. + +### 3.4 β€” Il basis dei futures datati come **campione della coda**, non come carry +BASIS-CALENDAR ha costruito un dataset che il progetto non aveva β€” **BTC 34 contratti / 240.150 +barre orarie dal 2018-09, ETH 34 / 233.334, piu' 64.110 ore di funding e indice** β€” e lo ha usato +per una sola domanda (il premio incassabile, IC95 che contiene lo zero β†’ SCARTATO). **Il dataset +contiene la coda che manca a tutto il resto del progetto**, e il ledger lo dice esplicitamente +(*Β«include il deleveraging 2022 β€” esattamente la coda che a CC01 mancava per costruzioneΒ»*). +**Uso non esplorato:** il blow-out del basis e' l'unico marcatore osservabile e datato dei giorni +di deleveraging forzato. Condizionare a quei giorni le domande di Β§3.1 e Β§4 da' un **campione di +stress reale** al posto di uno scenario assunto a mano. +⚠️ **E c'e' un'azione a costo quasi nullo che nessuno ha messo negli aperti:** quel dataset vive in +`.../scratchpad/basis_cache/` (**20 MB**, [VERIFICATO]), cioe' in una cartella di sessione che +sparisce. E' ricostruibile (30/30 trimestrali rispondono e il metodo β€” costruire a mano i nomi dei +contratti scaduti e chiamare `get_tradingview_chart_data` β€” e' documentato nello script), ma +ricostruirlo costa rete e tempo che ora sono gia' stati spesi. **Persisterlo in `data/raw/fut_*` +e' l'unica cosa dell'intera ondata che si perde per non averla scritta.** + +### 3.5 β€” La copertura di coda come **abilitante della leva**, non come miglioramento dello Sharpe +**Stato dell'arte nel progetto, dichiarato:** tail hedge *e' gia' stato provato* β€” `OPT06` (ratio +put spread con tail hedge) e `OPT07` (collar) nello sweep del 20/06, entrambi **modellati su DVOL +BS-flat** e giudicati **sullo Sharpe**, entrambi bocciati. **Cosa non e' stato fatto:** giudicarlo +sull'obiettivo **crescita a leva**. Sono domande diverse perche' il valore di un pavimento e' +**convesso nella leva**: a k=1 una put lontana e' un costo secco, a k=k\* e' cio' che rende k\* +stimabile. La domanda esatta: *esiste una put a delta basso il cui costo annuo, misurato sulle +quote VERE (non su DVOL), sia minore del guadagno di crescita che il pavimento autorizza?* +⚠️ **Prior onesto: sfavorevole.** Il 30/07 ha misurato che l'ala comprata costa **~2,3x il +modello** (f_long 2,23, fino a 5,85 sui delta piu' bassi) β€” quindi la protezione reale e' piu' cara +di quella che aveva gia' bocciato OPT06/OPT07. **Ma il confronto non e' mai stato fatto contro la +leva**, ed e' l'unico confronto in cui una protezione cara puo' comunque pagare. +⚠️ Eseguibile solo da ~$3-5k (ETH_USDC ~$250/lotto di nozionale): **e' una domanda per il deposito, +non per oggi.** + +### 3.6 β€” Il flusso di opzioni (volume per strike), che e' nel dato e nessuno ha guardato +La catena registra `volume_24h` **per strumento**, oraria. Sei filoni hanno usato l'**OI** +(posizioni accumulate) e **zero** hanno usato il **volume** (transazioni). ALT-OPTIONS ha appena +dimostrato che sulle famiglie lineari OI e negoziabilita' sono **anti-correlati (rho di rango +βˆ’0,77)** β€” che e' un'ottima ragione per sospettare che le due grandezze dicano cose diverse anche +sulle inverse. **Meccanismo:** un'ondata di acquisti concentrata su uno strike e' un fatto di +flusso; l'OI e' il suo integrale, e integrare distrugge il segnale di timing. +⚠️ **Muro invalicabile e dichiarato: 74-90 giorni di calendario.** Il verdetto massimo qui e' +**LEAD con gate pre-registrato**, e l'onesta' vuole che si dica prima di iniziare, non dopo. +La ragione per farlo comunque e' che **il calendario cresce da solo**, gratis, ogni ora. + +--- + +## 4. LA MISURA DI MAGGIOR VALORE MAI FATTA + +### La misura +**Il peggior giorno che il libro puo' avere, misurato invece che assunto: la distribuzione della +perdita giornaliera CONDIZIONATA all'esposizione piena, sotto la lente wick accoppiata, con lo +scenario di gap attraverso il disaster-SL.** + +### Perche' questa +Perche' e' **l'unico parametro che blocca la sola leva gratuita che l'ondata abbia trovato**, e +perche' oggi quel parametro e' **un numero scelto a mano**. + +GROWTH-POLICY ha stabilito che il gradino eseguibile `1,00x β†’ 1,25-1,50x` vale +**14,7 anni β†’ 12,9-11,6 anni** al capitale-rendita (a €500/mese, netto fisco). Lo scettico ha +tolto una delle tre riserve (il wick non amplifica il maxDD: ricarico costante ~3,5%). Delle due +rimaste, **una non e' risolvibile** (il drift stimato su 7,4 anni β€” e comunque k\* resta 7x a βˆ’2 SE, +quindi 1,5x sta al 21% di k\*) e **una lo e': la coda.** E' la riserva che decide, perche' e' l'unica +che porta k\* **sotto** il gradino proposto: *Β«un solo giorno βˆ’10% all'anno porta k\* da ~10x a 2x, +βˆ’15%/anno a 1xΒ»*. + +πŸ“Œ **Quel βˆ’10% non e' stato misurato: e' stato scelto.** E il controllo di cinque minuti in Β§3.1 +dice che potrebbe essere **una coda da buy&hold importata dentro un libro difensivo**: sui cinque +peggiori giorni di BTC e i cinque di ETH in 7,4 anni β€” compreso il **βˆ’41,5% / βˆ’45,7% del 12 marzo +2020** β€” il libro ha fatto **+0,97 Β· +0,72 Β· 0,00 Β· βˆ’1,98 Β· βˆ’2,24 Β· +0,23%**, e il suo peggior +giorno assoluto e' **βˆ’3,38%** in una data che non e' un crash. Se il vero peggior giorno +plausibile e' βˆ’5% e non βˆ’10%, k\* non e' 2x e **il gradino e' autorizzato**. + +⚠️ **Ma il mio controllo e' close-only, cioe' proprio la lente che questa ondata ha appena +dichiarato "esattamente cieca" sulle regole a UN giorno** (rapporto acc./close = INF a soglia 5%). +Quindi **la misura vera non e' quella che ho fatto**: e' la stessa domanda sotto +`r0725_prop_coupled` (tuple accoppiate, minimo intra-giorno) piu' lo scenario che il dataset non +contiene β€” un gap notturno che attraversa il disaster-SL βˆ’30% con posizione piena su entrambe le +gambe. Tutta la macchineria esiste gia' (`r0725_prop_coupled`, `r0822_leverage_skeptic`), il +campione di stress si puo' prendere dal dataset dei futures datati di Β§3.4, e il costo e' +**un agente di questa ondata**. + +### Confronto esplicito con le leve gia' quantificate +| leva | valore | costo | stato | +|---|---|---|---| +| versare €250/mese invece di €0 | da **mai** a 19,8a (P 52% netto fisco) | €250/mese | decisa dall'operatore | +| versare €500 invece di €250 | 19,8a β†’ **14,7a** | €250/mese in piu' | decisa dall'operatore | +| **leva 1,00x β†’ 1,50x** | **14,7a β†’ 11,6a** | **zero** | **bloccata da UN parametro assunto** | +| riallocare per la barriera (prop) | +0,016 di J = **il 4%** | analisi | misurata, marginale | +| XS01 su conto funded | +0,400 di J | **assunzione non verificata (Β§1.1)** | LEAD | +| ottimizzare il peso di SKH01 | **+0,030 di Sharpe** | analisi | gate fallito, plateau | +| rischio venue a p=5% | capitale mediano β†’ **zero** | split di conto | decisa: 100% Deribit fino a $20k | + +πŸ“Œ **Il gradino di leva vale ~3,1 anni, cioe' l'equivalente di ~€300/mese di versamenti, a costo +zero.** E' la prima volta in due mesi che una misura di ricerca entra nell'ordine di grandezza +della leva dei versamenti, e ci entra perche' **non e' ricerca di alpha: e' un cambio di scala di +cio' che gia' funziona.** Vale **100x** l'ottimizzazione del peso di SKH01 e **25x** il riallocare +per la barriera. + +### E adesso la parte che il mandato chiede di dire se e' vera +**SΓ¬: nessuna misura di ricerca di questa ondata batte "versare".** Venti filoni, ~15.300 righe, +**zero candidati promossi**; il soffitto direzionale e' stato riconfermato per via aritmetica; e +per **tre volte** l'eseguibilita' a $600 non e' stata il vincolo binding β€” tutto e' morto +sull'**edge**. La misura che propongo **non e' un'eccezione a questo, ne e' la conferma**: non +cerca un rendimento nuovo, **prezza un parametro di rischio per poter usare meglio quello che c'e'**. +E anche cosi', 3,1 anni sono **meno** di cio' che valgono €250/mese di differenza nei versamenti. + +**La seconda misura, e va detta insieme alla prima perche' costa ancora meno:** la verifica di +Β§1.1 (i 19 alt HL sono listati e shortabili presso la firm?). Non e' ricerca, e' **un pomeriggio di +lettura sul sito di due firm** β€” e decide se il canale prop, l'unico che moltiplica il nozionale +senza possedere capitale, vale J 0,738 o J 0,335. **Il progetto ha gia' pagato una volta il prezzo +di non fare questa verifica** (GTAA01/PRIIPs, cinque settimane di misure su un ETF che il broker +rifiuta di vendere) e ha scritto la regola per non ripeterlo. **Farla prima di aprire un altro +filone prop e' la cosa piu' economica che ci sia in questo elenco.** + +**In una riga:** *misurare la coda invece di assumerla* e' la ricerca che vale di piu' (3,1 anni, +gratis); *leggere il listino di una prop firm* e' la verifica che vale di piu' (un canale intero, +un pomeriggio); e **nessuna delle due batte i versamenti**, che restano il vincolo binding come il +26/07 aveva gia' stabilito. + +--- + +## 5. AUTOCRITICA DELL'ONDATA + +### 5.1 β€” 🚨 Un filone su venti non e' nel ledger, ed e' quello con il risultato eseguibile oggi +**ALT-OPTIONS (filone 14, `r0822_alt_options.py`, 709 righe) non ha ne' una riga di tabella ne' +una sezione in `RESULTS-0822.md`.** [VERIFICATO: 19 sezioni `###`, 18 righe di tabella, nessuna +per il 14 β€” e la tabella ha anche un difetto di markdown che **fonde le righe 9 e 4 su una sola +linea** (`... = TP01 travestito || 4 | DEALER-GAMMA | ...`).] Il filone e' citato **due volte** da +un altro filone (*Β«vedi filone 14Β»*, *Β«ETH_USDC resta la gamba giusta ... (filone 14)Β»*) e serve +al coordinatore per **ritirare una propria inferenza sbagliata** sull'OI β€” ma il lettore non trova +la fonte da nessuna parte. + +Nel diario compare come **due parole** dentro l'elenco dei morti: *Β«alt-options (alt)Β»*. **La +sintesi e' sbagliata due volte:** il filone non muore Β«perche' altΒ» (la sua conclusione e' che +**SOL/XRP/HYPE/AVAX sono i peggiori**: SOL_USDC ha il **9%** dei put quotati a due lati e +`f_venue` **negativo**), e cio' che trova e' che **ETH_USDC** β€” non un alt β€” e' la gamba con +`f_venue = 0,980`, spread della corta al **2,6%**, **max-loss $16,62/lotto** e **3.000 lotti al +best bid**. Cioe' Β§1.7: **la quarta soglia pubblicata falsificata dall'ondata**, quella che tiene +VRP01 fuori per taglia β€” e il diario ne annuncia **tre**. + +**Lezione:** il ledger e' stato scritto *man mano* (lo dice la sua prima riga) e un filone che +finisce mentre si scrive la sintesi puo' sparire senza che nulla lo segnali. Serve una guardia +banale: **contare i file `r0822_*.py` e le sezioni del ledger, e non chiudere l'ondata se non +combaciano.** E' lo stesso principio che l'ondata ha appena raccomandato per i monitor. + +### 5.2 β€” Il gate `implausible_sharpe` non e' stato girato dove serviva, per la seconda volta +MONITOR-AUDIT lo osserva bene: *Β«`implausible_sharpe` esiste dal 26/07 e non e' mai stato puntato +sulle serie forwardΒ»* (avrebbe segnalato Sharpe βˆ’15,8 su 28 barre). Ma la stessa mancanza si +ripete **dentro l'ondata**: il gate non compare in PROP-ALLOC (dove J e P(pass) sono costruiti su +percorsi bootstrap, non su una serie reale β€” legittimo, ma va dichiarato) ne' in GROWTH-POLICY, il +cui output include un **capitale mediano di $4,4e12** β€” che e' letteralmente la firma per cui il +gate e' stato scritto. L'agente ha fatto il controllo di plausibilita' **a mano** e l'ha riportato +con onesta'; **il gate del progetto non e' stato invocato.** Un gate che si applica a mano e' un +gate che un giorno non si applichera'. + +### 5.3 β€” Due Β«difetti di datoΒ» diagnosticati per ricostruzione con la sorgente a due minuti di distanza +E' il tema di Β§1.2 e Β§1.3, e vale come **critica di metodo dell'ondata**, non dei due agenti. Il +progetto ha codificato il 07/08: *Β«una fonte normativa citata in un commento di codice si verifica +come un numeroΒ»* β€” nata da una legge citata male in 5 file per settimane. Il 22/08 due filoni +dichiarano un difetto in un dato prodotto da `/opt/docker/cerbero-mcp` (**acceso, sulla stessa +macchina, e per giunta gia' usato da PythagorasGoal per Hyperliquid**) e da +`Adriano/Cerbero-Bite` (su Gitea, ultimo commit di dismissione), **senza aprire ne' l'uno ne' +l'altro**. Io li ho aperti entrambi in **meno di dieci minuti**. Il brief e' complice: elenca le +dieci trappole statistiche e **non dice Β«prima di dichiarare un difetto di dato, leggi il codice +che quel dato lo produceΒ»**. E' la regola nuova piu' economica che questa ondata possa lasciare. + +### 5.4 β€” Il coordinatore ha lasciato passare una conclusione debole, e ne ha corretta una propria +**Corretta (a merito):** l'inferenza *Β«BTC_USDC e' praticamente mortoΒ»*, ritirata dopo la misura di +ALT-OPTIONS, con la ragione scritta accanto. E' il comportamento giusto. +**Lasciata passare:** la lezione di ORTHO-SCREEN (Β§2.1). E' etichettata *Β«il risultato piu' +riusabile dell'ondataΒ»* e *Β«non e' sfortuna, e' aritmeticaΒ»* β€” due formule che chiudono la +discussione β€” mentre la sua meta' operativa (*Β«dichiarare famiglie molto piu' piccole in +anticipoΒ»*) e' **la manovra che il progetto ha proibito il 30/07** e la sua meta' quantitativa +dipende da una variabile (la dispersione delle celle) **mai nominata**. Il segnale che avrebbe +dovuto fermarla c'era ed era **nello stesso ledger, sette sezioni piu' avanti**: il DSR vacuo di +VOL-SIZE. **Due misure dello stesso gate, versi opposti, stesso giorno, nessun rimando.** +La sintesi di venti filoni ha un compito che nessun filone ha: **incrociare**. Qui non l'ha fatto, +e non l'ha fatto neanche su Β§2.4 (leva), Β§2.5 (peso SKH01), Β§1.5 (le due gambe del gate 23/10) e +Β§1.6 (k\* contro la partecipazione al nastro) β€” **cinque incroci disponibili a costo zero, tutti +fra coppie di filoni gia' consegnati.** + +### 5.5 β€” Agenti che hanno dichiarato Β«non girabileΒ» cio' che era girabile +- **FLOW-SQUEEZE**: *Β«liquidation_long/short: NO ... Nessuna fonte nostra la produce, e non c'e' + niente da produrreΒ»*. I due input della regola sono **nel file** (Β§1.3). Costo del mancato + lavoro: ~due ore. Il verdetto non sarebbe cambiato; la **motivazione** si'. +- **DEALER-GAMMA**: *Β«Quale [convenzione], lo decide la sezione 2 (ricostruzione dalla catena), + non ioΒ»* β€” decidibile leggendo, in due minuti (Β§1.2). +- **PROP-ALLOC**: la banda d'ancora dichiarata *Β«non girata, fuori budgetΒ»*. Onesto, e la + distorsione e' dichiarata a proprio sfavore. **Ma e' l'unica distorsione dichiarata che punta + nella direzione della raccomandazione**, sull'unico filone che raccomanda qualcosa: dovrebbe + essere il primo pezzo di budget di un seguito, non l'ultimo. +- **TERM-STRUCTURE**: *Β«`deflated_sharpe` NON girato, e la motivazione e' il risultatoΒ»* β€” questo + invece e' **corretto e da imitare**: con 74 osservazioni l'incertezza di campione domina di un + ordine di grandezza quella da selezione, e deflazionare 312 trial avrebbe dato *Β«un numero + preciso e privo di sensoΒ»*. **E' anche la migliore descrizione, scritta senza saperlo, del + problema di Β§2.1.** + +### 5.6 β€” Cosa l'ondata ha fatto meglio della media del progetto (perche' un'autocritica che elenca +solo difetti non e' calibrata) +Sette agenti hanno catturato un **errore proprio** prima di pubblicare; tre hanno **refutato una +propria ipotesi in corsa**; uno scettico ha **ritirato una regola pubblicata poche ore prima** +provando che il difetto non esisteva β€” e la regola ritirata era **sua di famiglia**. Quattro +filoni hanno dichiarato la **potenza prima di guardare** (OI-PIN, FLOW-SQUEEZE, SLIP-AUDIT, +VOL-SIZE) e in tre casi il candidato e' morto **contro la propria soglia dichiarata**, che e' il +modo piu' pulito di morire. Le repliche **bit-exact prima di ogni delta** sono ormai +sistematiche (5/5 in XS-LITE e HL-EXEC, `max|diff| = 0,0`). **Il metodo dei singoli filoni non e' +il problema di questa ondata: la cucitura lo e'.** + +--- + +## Appendice β€” cosa ho verificato in questa sessione, e come + +| # | fatto | come | +|---|---|---| +| A | `dealer_net_gamma` = convenzione **dichiarata** (dealer short calls / long puts) nel sorgente di `cerbero-mcp`, non un difetto | letto `/opt/docker/cerbero-mcp/src/cerbero_mcp/common/options.py:11-14, 138-140` | +| B | bite **inoltra** il campo e non lo calcola; chiama con `top_n_strikes=50` di default | clone `Adriano/Cerbero-Bite`, `runtime/market_snapshot_cycle.py:85`, `clients/deribit.py:342-384` | +| C | `liquidation_*_risk` = soglia su `oi_delta_24h` + funding, e **i due input sono nel parquet** (copertura 99%, `oi_delta_pct_4h` std 1,23%) | `sentiment/fetchers.py:480-518` + lettura di `cb_market_snapshots.parquet` | +| D | `sr0` del DSR = `sd(Sharpe dei trial) x mult(N)`; con sd=0,58, `sr0` passa da **1,57 (N=168)** a **1,15 (N=24)** | letto `altlib.deflated_sharpe:689-712`, moltiplicatori ricalcolati | +| E | il libro 75/25 fa **+0,97%** il 12/03/2020 (BTC βˆ’41,5%, ETH βˆ’45,7%); peggior giorno **βˆ’3,38%**, non un crash | `altlib.tp01_baseline_daily()` + `sleeves._skyhook_returns()`, close-only | +| F | `collect_chain.py` interroga `{"currency": asset}` e filtra `OI_MIN = 100.0` | `scripts/live/collect_chain.py:57, 114, 181` | +| G | il feed certificato e' `BTC/USD:BTC` (**inverse**), il libro esegue `BTC_USDC-PERPETUAL` (**lineare**) | `rebuild_history.DERIBIT_INSTR:48` vs `src/live/deribit.INSTRUMENT:110` | +| H | ALT-OPTIONS: ETH_USDC `f_venue` 0,980 / max-loss **$16,62** / 7 strutture al 20% di $635 | output del filone in scratchpad | +| I | il dataset dei futures datati (**20 MB**) vive solo in `scratchpad/basis_cache/` | `du -sh` | +| J | `RESULTS-0822.md`: 19 sezioni, 18 righe di tabella, **filone 14 assente**, righe 9 e 4 fuse | conteggio diretto | + +⚠️ **Su (G):** e' un fatto, non un'accusa. `rebuild_history` lo dichiara in un commento +(*Β«inverse, storia lunga ~ lineare entro 3 bpsΒ»*) e SLIP-AUDIT ha misurato la base ai 18 fill +(BTC βˆ’0,03 bps, ETH βˆ’0,27) β€” trascurabile sui **ritorni**, che dipendono dalle *variazioni* della +base. Lo segno solo perche' e' la **5Βͺ occorrenza nell'ondata** dello schema Β«un controllo puntato +su uno strumento diverso da quello che si tradaΒ» (dopo `fee_watch` 21/08, il collettore della +catena, il feed dello slippage e la taratura di `venue_watch`), e perche' la parte non verificata +di quel commento β€” *Β«~ lineare entro 3 bpsΒ»* su **tutta la storia** β€” non e' mai stata misurata: +esiste una misura su 18 istanti, non sulle 60.000 ore in cui entrambi gli strumenti esistono. +Costo di chiuderla: un'ora. + +--- + +**Book, pesi, cron, config: questo filone non tocca niente.** Consegna un'analisi. +Le uniche azioni che raccomanda sono a costo quasi nullo e tutte su documenti o verifiche: +correggere Β§1.2 nel ledger, aggiungere la sezione mancante di Β§5.1, dichiarare la dispersione +accanto a ogni DSR (Β§2.1), ri-registrare il gate del 23/10 prima di rigenerare le serie (Β§1.4-1.5), +persistere il dataset dei futures (Β§3.4), e leggere il listino di una prop firm prima di +misurare un'altra volta il canale che ne dipende (Β§1.1). diff --git a/scripts/research/r0822b_maker.py b/scripts/research/r0822b_maker.py new file mode 100644 index 0000000..a2d0a5f --- /dev/null +++ b/scripts/research/r0822b_maker.py @@ -0,0 +1,733 @@ +#!/usr/bin/env python +"""r0822b_maker.py β€” l'ESECUZIONE PASSIVA (maker) vale qualcosa per il libro live? + +CONTESTO (fatti gia' nel progetto, non ri-misurati qui) +------------------------------------------------------- +* Il libro Deribit (TP01 75% + SKH01 25%, nettati su UNA sola posizione per strumento) manda + **solo ordini market** -> paga sempre il **taker**. `src/live/execution.py::_submit` ha + `order_type="market"` come default e `rebalance_signed` non passa mai altro. +* Fee misurate: **taker 3,50 bps/lato** (fee_watch + 16/16 fill sul nastro, 22/08), + **maker 1,50 bps/lato** (listino base dal 2026-08-01; fino al 31/07 era 0,00). + I backtest del progetto modellano **5,00 bps/lato** (0,10% RT) -> gia' conservativi. +* Il progetto ha guardato il lato passivo UNA volta (raccomandazione "T1" del 26/07: il TP di + SKH01 come limit resting) e l'ha chiusa **non per la fee** ma perche' un ordine al livello di + SKH chiuderebbe anche la quota TP01 (**netting su strumento unico**, `src/live/book.py`). + ⚠️ QUEL VINCOLO NON SI APPLICA QUI: rendere passivo **l'ordine netto di ribilanciamento** e' + UN ordine sulla posizione netta -> niente conflitto fra sleeve. Il netting uccide i bracket + per-sleeve, non l'ordine netto. E' la ragione per cui questa domanda e' nuova. +* Slippage misurato il 22/08: **~0 a questa taglia** (un fill ha preso il 22% del volume della + sua barra 5m, range 1,08 bps) -> il differenziale maker/taker e' fee PURA, e il mezzo spread + catturato da un passivo e' <0,005 bps (tick BTC 0,1 su ~$110k; ETH 0,01 su ~$2.500) = ZERO. + Percio' qui il passivo NON guadagna spread: guadagna 2,00 bps di fee e rischia il non-fill. +* Il disaster-SL βˆ’30% e' uno `stop_market` on-book: NON e' toccato da nulla di quanto segue. + +LA DOMANDA CHE NESSUNO HA FATTO +------------------------------- +Un limit resting non e' un market order piu' economico: **puo' non riempirsi**. E non riempirsi +su un segnale di trend significa **perdere il movimento**, che e' esattamente il caso in cui non +si e' riempito (il prezzo e' scappato dalla parte giusta). Quindi: + costo atteso del passivo = P(fill)*(βˆ’2,00 bps) + P(no fill)*E[mossa avversa | no fill] +e la seconda parte e' **avversa per costruzione**: un buy limit al riferimento non si riempie se +e solo se il prezzo non e' MAI sceso al livello, cioe' e' salito. + +COSA FA QUESTO SCRIPT +--------------------- + 1. il differenziale in Sharpe/CAGR di libro, riusando la curva fee gia' pubblicata + (`r0726_fee_sensitivity.py`: βˆ’0,017 Sharpe/bps, βˆ’0,23% CAGR/bps) E ri-misurandola a 4 punti + di fee sul path del libro (verifica indipendente, non ri-derivazione della curva); + 2. ricostruisce gli **ORDINI** del libro live (target netto orario, banda min-order $5) e li + confronta con i 18 ordini veri di `data/live/book_executions.jsonl` (controllo di realta'); + 3. per OGNI ordine misura su 5m certificati: P(fill) di un limit al riferimento entro N minuti, + e il ritorno condizionato **ai casi in cui NON si riempie**. Confronto **APPAIATO PER + ORDINE** (mai per esito: allineare sui riempiti tiene solo i casi in cui non e' successo + niente β€” trappola gia' presa 3 volte in questo progetto); + 4. trasforma il costo per-ordine in una serie di P&L additiva ai rendimenti giornalieri del + libro -> Sharpe/CAGR/maxDD della politica passiva, e il numero in **dollari all'anno**; + 5. mette il risultato accanto alle altre leve gia' misurate. + +LENTI E SEMPLIFICAZIONI β€” DICHIARATE (tutte GENEROSE verso il maker, di proposito) +--------------------------------------------------------------------------------- + (a) griglia oraria: il cron gira a :07. Decisione con le barre 5m chiuse fino a h:05, prezzo di + riferimento = close della barra 5m etichettata h:00. Verificato sul log vero: gli ordini + cadono a :07 di un'ora qualsiasi, e TP01 si aggiorna alle 01:07 UTC. + (b) TP01 cambia target una volta al giorno, all'ora 01:00 UTC (lente backtest; l'effetto della + barra parziale e' misurato +-0,03 Sharpe, 26/07 -> irrilevante per la TAGLIA degli ordini). + (c) SKH01: ingressi alla CHIUSURA del bin 230m (lente backtest), uscite al primo confine orario + dopo il tocco 5m (lente live, `_skyhook_positions` itera nel bin parziale). L'ingresso vero + del live e' INTRA-BIN, cioe' piu' vicino al breakout -> peggiore per un limit passivo: + questa lente e' **generosa** verso il maker. + (d) fill: un limit BUY al prezzo P si considera riempito se `low <= P` nella finestra. Ignora la + coda del book (al livello si sta DIETRO agli ordini gia' presenti: un semplice tocco non + riempie) -> **generosa** verso il maker. Finestra di scansione da h:05 (l'ordine e' a h:07: + regaliamo 2 minuti di opportunita' di fill). + (e) ordini tutto-o-niente, nessun fill parziale. + (f) niente cap per-asset nella ricostruzione (a $635 il cap $318 morde di rado e riduce + PROPORZIONALMENTE guadagno e danno: non cambia il segno). + + nice -n 19 timeout 900 uv run python scripts/research/r0822b_maker.py +""" +from __future__ import annotations + +import json +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.backtest.harness import backtest_signals # noqa: E402 +from src.data.downloader import load_data # noqa: E402 +from src.portfolio.portfolio import combine_outer, metrics, to_daily # noqa: E402 +from src.strategies.skyhook import (LTF_MIN, SKH01_V2_DD, build_frames, # noqa: E402 + skyhook_entries) +from src.strategies.trend_portfolio import (CANONICAL, TrendPortfolio, # noqa: E402 + resample_1d, simple_returns) + +ASSETS = ("BTC", "ETH") +W_TP01, W_SKH = 0.75, 0.25 # pesi del book Deribit (src/live/book.py) +WEIGHT = 0.5 # book 50/50 BTC+ETH +MIN_ORDER_USD = 5.0 # config/live.json +EQUITY_LIVE = 635.0 # conto vero (22/08) + +FEE_TAKER_BPS = 3.50 # misurata sul nastro + fee_watch +FEE_MAKER_BPS = 1.50 # listino base dal 2026-08-01 +FEE_MODEL_BPS = 5.00 # cio' che i backtest modellano +FEE_GRID_BPS = (0.0, FEE_MAKER_BPS, FEE_TAKER_BPS, FEE_MODEL_BPS) + +# curva PUBBLICATA (r0726_fee_sensitivity.py) β€” riusata, non ri-derivata +PUB_SH_PER_BPS = -0.017 +PUB_CAGR_PER_BPS = -0.23 # punti percentuali di CAGR per bps/lato + +HORIZONS_MIN = (5, 15, 30, 60, 120) # attesa massima del limit prima di inseguire +# CODA DEL BOOK β€” il parametro che DECIDE, e che l'OHLC non contiene. +# Un limit al livello sta DIETRO agli ordini gia' presenti: "il prezzo ha toccato il mio livello" +# NON e' "sono stato riempito". `q` = quanti bps il prezzo deve attraversare OLTRE il livello +# perche' la coda davanti sia consumata. q=0 e' il LIMITE SUPERIORE ottimistico (e a q=0 il fill +# e' quasi automatico: l'apertura della prima barra di scansione COINCIDE col livello, quindi +# `low <= P` chiede solo che il prezzo non scappi istantaneamente). +QUEUE_BPS = (0.0, 1.0, 2.0, 5.0, 10.0) +P2_MAX_HOURS = 24 # politica "mai inseguire": ri-posta ogni ora, tetto 24h +CAPITALS = (635.0, 2000.0, 5000.0) +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +SEED = 20260822 + + +# =========================================================================================== +# 1. SEGNALI β€” calcolati UNA volta per asset (non dipendono dalla fee) +# =========================================================================================== +def build_asset(asset: str) -> dict: + """Tutto cio' che serve per l'asset: TP01 daily, path SKH01, griglia 5m e oraria.""" + df5 = load_data(asset, "5m") + ts5 = pd.to_datetime(df5["datetime"], utc=True) + A = dict( + ts5=ts5.values.astype("datetime64[ns]"), + o5=df5["open"].values.astype(float), h5=df5["high"].values.astype(float), + l5=df5["low"].values.astype(float), c5=df5["close"].values.astype(float), + ) + + # --- SKH01: ingressi/uscite sulla griglia 230m, poi tempo di RILEVAZIONE oraria + ltf, htf = build_frames(df5) + ent = skyhook_entries(ltf, htf, SKH01_V2_DD) + H, L = ltf["high"].values, ltf["low"].values + ltf_ts = pd.to_datetime(ltf["datetime"], utc=True) + n = len(ltf) + trades = [] + i = 0 + while i < n: + e = ent[i] + if e is None: + i += 1 + continue + d, sl, tp, mb = e["dir"], e["sl"], e["tp"], e["max_bars"] + exit_idx = None + for s in range(1, mb + 1): + j = i + s + if j >= n: + break + hit = (L[j] <= sl or H[j] >= tp) if d == 1 else (H[j] >= sl or L[j] <= tp) + if hit or s == mb: + exit_idx = j + break + if exit_idx is None: # trade ancora aperto a fine storia + trades.append((i, None, d, sl, tp)) + break + trades.append((i, exit_idx, d, sl, tp)) + i = exit_idx + 1 + + # tempi di rilevazione: ingresso a chiusura bin (lente backtest); + # uscita al primo confine orario dopo il TOCCO 5m (lente live) + ltf_close = ltf_ts + pd.Timedelta(minutes=LTF_MIN) + skh_events = [] # (ts_rilevazione, segno) + for (i0, j0, d, sl, tp) in trades: + skh_events.append((_ceil_hour(ltf_close.iloc[i0]), int(d))) + if j0 is None: + continue + t_touch = _first_touch_5m(A, ltf_ts.iloc[j0], ltf_close.iloc[j0], d, sl, tp) + skh_events.append((_ceil_hour(t_touch), 0)) + + # --- TP01: target giornaliero, visibile dall'ora 01:00 del giorno DOPO la chiusura + d1 = resample_1d(load_data(asset, "1h")) + tp_cfg = TrendPortfolio(**CANONICAL) + tgt = tp_cfg.target_series(d1) + d1_ts = pd.to_datetime(d1["datetime"], utc=True) + tp_events = [(pd.Timestamp(t).normalize() + pd.Timedelta(hours=25), float(v)) + for t, v in zip(d1_ts, tgt)] # barra del giorno D -> vista alle 01:00 di D+1 + + # --- griglia oraria dei momenti di decisione (barre 5m etichettate :00) + m = pd.DatetimeIndex(A["ts5"]) + hmask = (m.minute == 0) + A["hours"] = m[hmask].tz_localize("UTC") + A["hidx"] = np.flatnonzero(hmask) # indice 5m della barra di riferimento + A["ref"] = A["c5"][A["hidx"]] # prezzo di riferimento (close 5m di h:00) + A["skh_events"] = skh_events + A["tp_events"] = tp_events + A["daily_ret"] = simple_returns(d1["close"].values.astype(float)) + A["daily_tgt"] = tgt + A["d1_ts"] = d1_ts + A["ltf"] = ltf + A["ent"] = ent + return A + + +def _ceil_hour(t: pd.Timestamp) -> pd.Timestamp: + return t.ceil("h") + + +def _first_touch_5m(A: dict, t_open, t_close, d: int, sl: float, tp: float) -> pd.Timestamp: + """Primo istante 5m, dentro il bin 230m d'uscita, in cui SL o TP e' toccato. + Se nessuno e' toccato (uscita per max_bars) ritorna la chiusura del bin.""" + lo = np.searchsorted(A["ts5"], np.datetime64(t_open.tz_localize(None))) + hi = np.searchsorted(A["ts5"], np.datetime64(t_close.tz_localize(None))) + for k in range(lo, min(hi, len(A["ts5"]))): + hit = (A["l5"][k] <= sl or A["h5"][k] >= tp) if d == 1 else \ + (A["h5"][k] >= sl or A["l5"][k] <= tp) + if hit: + return pd.Timestamp(A["ts5"][k], tz="UTC") + pd.Timedelta(minutes=5) + return t_close + + +def exposure_path(A: dict) -> pd.DataFrame: + """Esposizione-bersaglio del libro su questo asset, ORA per ORA, in frazione di equity. + expo = WEIGHT * (W_TP01*tp_frac + W_SKH*skh_sign) β€” la formula di src/live/book.py.""" + hours = A["hours"] + tp = pd.Series(0.0, index=hours) + for t, v in A["tp_events"]: + tp.loc[tp.index >= t] = v + sk = pd.Series(0.0, index=hours) + for t, v in sorted(A["skh_events"]): + sk.loc[sk.index >= t] = v + expo = WEIGHT * (W_TP01 * tp.values + W_SKH * sk.values) + src = np.where(np.abs(np.diff(sk.values, prepend=0.0)) > 0, "SKH", + np.where(np.abs(np.diff(tp.values, prepend=0.0)) > 0, "TP01", "")) + return pd.DataFrame(dict(ts=hours, expo=expo, ref=A["ref"], hidx=A["hidx"], src=src)) + + +# =========================================================================================== +# 2. ORDINI β€” banda min-order, esattamente come rebalance_signed +# =========================================================================================== +def orders_from_path(P: pd.DataFrame, equity: float, min_usd: float = MIN_ORDER_USD) -> pd.DataFrame: + """Ordini che il libro manderebbe: si trada solo se |target-posizione| >= min_usd.""" + band = min_usd / equity + expo = P["expo"].values + held = 0.0 + rows = [] + for k in range(len(expo)): + delta = expo[k] - held + if abs(delta) < band: + continue + rows.append((P["ts"].iloc[k], delta, P["ref"].iloc[k], P["hidx"].iloc[k], P["src"].iloc[k])) + held = expo[k] + return pd.DataFrame(rows, columns=["ts", "dfrac", "ref", "hidx", "src"]) + + + + +# =========================================================================================== +# 3. FILL E SELEZIONE AVVERSA +# =========================================================================================== +def measure_orders(A: dict, O: pd.DataFrame, horizons=HORIZONS_MIN, q_bps: float = 0.0, + strict: bool = True) -> dict: + """Per ogni ordine e ogni orizzonte N: il limit e' stato riempito? e se no, quanto e' + scappato il prezzo? `q_bps` = attraversamento richiesto oltre il livello (coda del book). + + `strict` E' IL PARAMETRO CHE DECIDE IL SEGNO DI TUTTO IL RISULTATO. + strict=False -> `low <= limite`: il prezzo ha TOCCATO il livello. Include il caso in cui + la barra APRE esattamente al livello e non ci torna mai: li' non e' stato scambiato + NIENTE al nostro prezzo dopo che l'ordine e' arrivato, quindi un passivo in fondo alla + coda NON e' riempito. E' un limite superiore FISICAMENTE NON DIFENDIBILE. + strict=True -> `low < limite`: il prezzo ha ATTRAVERSATO il livello. E' la condizione + minima perche' esista uno scambio al nostro prezzo. Anche cosi' si assume di essere + DAVANTI a tutta la coda gia' presente -> resta generosa, ma difendibile.""" + l5, h5, c5 = A["l5"], A["h5"], A["c5"] + nb = len(c5) + idx = O["hidx"].values.astype(int) + ref = O["ref"].values.astype(float) + buy = (O["dfrac"].values > 0) + q = q_bps / 1e4 + out = {} + for N in horizons: + steps = N // 5 + filled = np.zeros(len(O), bool) + adverse = np.zeros(len(O)) + for k in range(len(O)): + i0 = idx[k] + 1 # barra 5m etichettata h:05 (ordine a h:07: generoso) + i1 = min(i0 + steps, nb) + P = ref[k] + if i0 >= i1: + filled[k] = True + continue + lim = P * (1.0 - q) if buy[k] else P * (1.0 + q) + if buy[k]: + hit = (l5[i0:i1].min() < lim) if strict else (l5[i0:i1].min() <= lim) + else: + hit = (h5[i0:i1].max() > lim) if strict else (h5[i0:i1].max() >= lim) + filled[k] = hit + if not hit: + end = c5[i1 - 1] + adverse[k] = (end - P) / P if buy[k] else (P - end) / P + out[N] = pd.DataFrame(dict(filled=filled, adverse=adverse), index=O.index) + return out + + +def measure_p2(A: dict, O: pd.DataFrame, P: pd.DataFrame, max_hours=P2_MAX_HOURS, + q_bps: float = 0.0, strict: bool = True) -> pd.DataFrame: + """Politica 'MAI INSEGUIRE': il limit resta e viene RI-POSTATO al nuovo riferimento a ogni + cron orario, fino a `max_hours`; solo allora si insegue a market. Il costo e' cumulativo.""" + l5, h5, c5 = A["l5"], A["h5"], A["c5"] + hidx = P["hidx"].values.astype(int) + ref = P["ref"].values.astype(float) + nb, nh = len(c5), len(hidx) + pos_of = {int(v): k for k, v in enumerate(hidx)} + q = q_bps / 1e4 + cycles, adverse, chased = [], [], [] + for _, r in O.iterrows(): + k0 = pos_of[int(r["hidx"])] + P0 = float(r["ref"]); buy = r["dfrac"] > 0 + done, adv, cyc = False, 0.0, 0 + for k in range(k0, min(k0 + max_hours, nh)): + Pk = ref[k] + lim = Pk * (1.0 - q) if buy else Pk * (1.0 + q) + i0 = hidx[k] + 1 + i1 = min(i0 + 12, nb) + if i0 >= i1: + break + if buy: + hit = (l5[i0:i1].min() < lim) if strict else (l5[i0:i1].min() <= lim) + else: + hit = (h5[i0:i1].max() > lim) if strict else (h5[i0:i1].max() >= lim) + cyc = k - k0 + 1 + if hit: + adv = (lim - P0) / P0 if buy else (P0 - lim) / P0 + done = True + break + if not done: + k = min(k0 + max_hours, nh - 1) + adv = (ref[k] - P0) / P0 if buy else (P0 - ref[k]) / P0 + cycles.append(cyc); adverse.append(adv); chased.append(not done) + return pd.DataFrame(dict(cycles=cycles, adverse=adverse, chased=chased), index=O.index) + + +def per_order_delta(O: pd.DataFrame, M: pd.DataFrame, *, miss_fee_bps: float) -> np.ndarray: + """Delta di P&L in FRAZIONE DI EQUITY DI LIBRO, per ORDINE, del passivo contro il taker. + >0 = il passivo guadagna. Confronto APPAIATO PER ORDINE: ogni ordine compare una volta in + entrambe le politiche, riempito o no (allineare sui soli riempiti terrebbe i casi in cui + non e' successo niente β€” trappola gia' presa 3 volte in questo progetto).""" + absd = O["dfrac"].abs().values + filled = M["filled"].values if "filled" in M.columns else np.ones(len(O), bool) + fee_bps = np.where(filled, FEE_MAKER_BPS, miss_fee_bps) + return (FEE_TAKER_BPS - fee_bps) / 1e4 * absd - M["adverse"].values * absd + + +def weighted_stats(F: np.ndarray, Ad: np.ndarray, W: np.ndarray) -> dict: + """Tutto PONDERATO PER NOZIONALE (e' il nozionale che paga la fee, non il conteggio). + `p_be` = la frazione di fill che il passivo deve raggiungere per PAREGGIARE il taker: + p*(-gap) + (1-p)*E[avversa|no fill] = 0 -> p_be = E/(E+gap) + E' la forma model-free del risultato: non dipende dall'ipotesi di coda, solo da quanto + scappa il prezzo quando il limit resta a terra.""" + gap = (FEE_TAKER_BPS - FEE_MAKER_BPS) / 1e4 + wf = W[F].sum() / W.sum() if W.sum() > 0 else 1.0 + miss_w = W[~F] + e_adv = float(np.sum(miss_w * Ad[~F]) / miss_w.sum()) if miss_w.sum() > 0 else 0.0 + cost = float(np.sum(W * (np.where(F, FEE_MAKER_BPS, FEE_TAKER_BPS) / 1e4 + Ad)) / W.sum()) + return dict(p_fill=wf, e_adv=e_adv, cost_bps=cost * 1e4, + p_be=e_adv / (e_adv + gap) if (e_adv + gap) > 0 else 1.0) + + +def boot_ci(x: np.ndarray, w: np.ndarray, n: int = 2000, seed: int = SEED) -> tuple: + """IC95% bootstrap della media PONDERATA PER NOZIONALE (in bps del nozionale tradato).""" + rng = np.random.default_rng(seed) + m = len(x) + idx = rng.integers(0, m, size=(n, m)) + num = x[idx].sum(axis=1) + den = w[idx].sum(axis=1) + b = num / np.where(den > 0, den, np.nan) * 1e4 + return float(np.nanpercentile(b, 2.5)), float(np.nanpercentile(b, 97.5)) + + +# =========================================================================================== +# 4. LIBRO β€” rendimenti giornalieri modellati, per innestarci il costo d'esecuzione +# =========================================================================================== +def book_daily(cache: dict, fee_bps_side: float) -> pd.Series: + fs = fee_bps_side / 1e4 + tp_s, sk_s = {}, {} + for a in ASSETS: + A = cache[a] + r, tgt = A["daily_ret"], A["daily_tgt"] + held = np.zeros(len(tgt)); held[1:] = tgt[:-1] + net = held * r - fs * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0 + tp_s[a] = pd.Series(np.clip(net, -0.99, None), index=A["d1_ts"]) + m = backtest_signals(A["ltf"], A["ent"], fee_rt=2 * fs, leverage=1.0, asset=a, tf="230m") + s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))) + sk_s[a] = s.resample("1D").last().ffill().pct_change().dropna() + Jt = pd.concat(tp_s, axis=1, join="inner").fillna(0.0) + Js = pd.concat(sk_s, axis=1, join="inner").fillna(0.0) + tp = to_daily(pd.Series(0.5 * Jt["BTC"].values + 0.5 * Jt["ETH"].values, index=Jt.index)) + sk = to_daily(pd.Series(0.5 * Js["BTC"].values + 0.5 * Js["ETH"].values, index=Js.index)) + return combine_outer({"TP01": tp, "SKH01": sk}, {"TP01": W_TP01, "SKH01": W_SKH}) + + +def graft(base: pd.Series, deltas: dict, O_live: dict) -> pd.Series: + """Innesta il delta per-ordine (frazione di equity) nei rendimenti giornalieri del libro.""" + ser = [] + for a in ASSETS: + ts = pd.DatetimeIndex(O_live[a]["ts"]).tz_convert("UTC").normalize() + ser.append(pd.Series(deltas[a], index=ts)) + add = pd.concat(ser).groupby(level=0).sum() + return base.add(add.reindex(base.index).fillna(0.0), fill_value=0.0) + + +def show(tag: str, d: pd.Series) -> dict: + m = metrics(d) + h = metrics(d[d.index >= HOLDOUT]) + print(f" {tag:<36} Sh {m['sharpe']:>6.3f} | HOLD {h['sharpe']:>6.3f} | " + f"maxDD {m['maxdd']*100:>5.2f}% | CAGR {m['cagr']*100:>6.2f}%") + return dict(sharpe=m["sharpe"], hold=h["sharpe"], maxdd=m["maxdd"], cagr=m["cagr"]) + + +# =========================================================================================== +def main() -> None: + rng = np.random.default_rng(SEED) + print("=" * 104) + print(" r0822b β€” ESECUZIONE PASSIVA (MAKER) SUL LIBRO LIVE:") + print(" quanto vale il differenziale, quanto costa non riempirsi") + print("=" * 104) + print(f" taker {FEE_TAKER_BPS:.2f} bps/lato Β· maker {FEE_MAKER_BPS:.2f} bps/lato Β· " + f"differenziale {FEE_TAKER_BPS-FEE_MAKER_BPS:.2f} bps/lato Β· modello backtest {FEE_MODEL_BPS:.2f}") + print(" NETTING: il libro ha UNA posizione netta per strumento -> l'ordine di ribilanciamento") + print(" e' UNO solo. Renderlo passivo NON e' bloccato dal motivo che uccise T1 il 26/07") + print(" (quello uccide i BRACKET per-sleeve, non l'ordine netto). Il disaster-SL -30% resta") + print(" uno stop_market on-book e non e' toccato da niente di quanto segue.") + + cache = {a: build_asset(a) for a in ASSETS} + + # ---------------------------------------------------------------- 1. il differenziale + print("\n" + "-" * 104) + print(" 1. QUANTO VALE IL DIFFERENZIALE (limite superiore: TUTTO il flusso diventa maker,") + print(" zero mancati riempimenti β€” cioe' il numero che NON si puo' superare)") + print("-" * 104) + curve = {bps: show(f"libro @ {bps:.2f} bps/lato", book_daily(cache, bps)) for bps in FEE_GRID_BPS} + gap = FEE_TAKER_BPS - FEE_MAKER_BPS + dsh = curve[FEE_MAKER_BPS]["sharpe"] - curve[FEE_TAKER_BPS]["sharpe"] + dcg = (curve[FEE_MAKER_BPS]["cagr"] - curve[FEE_TAKER_BPS]["cagr"]) * 100 + print(f"\n misurato qui : dSharpe {dsh:+.4f} dCAGR {dcg:+.3f} pp " + f"({dsh/gap:+.4f} Sh/bps, {dcg/gap:+.3f} pp/bps)") + print(f" curva 26/07 : dSharpe {-PUB_SH_PER_BPS*gap:+.4f} dCAGR {-PUB_CAGR_PER_BPS*gap:+.3f} pp " + f"({-PUB_SH_PER_BPS:+.4f} Sh/bps, {-PUB_CAGR_PER_BPS:+.3f} pp/bps) <- replica indipendente OK") + print(" in DOLLARI all'anno, se tutto il flusso fosse maker:") + for cap, note in ((EQUITY_LIVE, "conto vero oggi"), (5000.0, "dopo i versamenti"), + (258338.0, "capitale-rendita, muro 07/08")): + print(f" ${cap:>9,.0f} -> ${cap*dcg/100:>9,.2f}/anno ({note})") + + # ---------------------------------------------------------------- 2. gli ordini + print("\n" + "-" * 104) + print(" 2. GLI ORDINI DEL LIBRO (target netto orario + banda min-order $5, formula di book.py)") + print("-" * 104) + paths = {a: exposure_path(cache[a]) for a in ASSETS} + yrs = (paths["BTC"]["ts"].iloc[-1] - paths["BTC"]["ts"].iloc[0]).days / 365.25 + print(f" {'capitale':>9} {'banda':>7} |" + "".join(f"{a+' ord/anno':>15}{'turnover/anno':>16}" for a in ASSETS)) + ORD = {} + for cap in CAPITALS: + line, tot = "", {} + for a in ASSETS: + O = orders_from_path(paths[a], cap) + tot[a] = O + line += f"{len(O)/yrs:>15.1f}{O['dfrac'].abs().sum()/yrs*cap:>15,.0f}$" + ORD[cap] = tot + print(f" ${cap:>8,.0f} {MIN_ORDER_USD/cap*100:>6.2f}% |" + line) + O_live = ORD[EQUITY_LIVE] + n_live = sum(len(O_live[a]) for a in ASSETS) + turn = sum(O_live[a]["dfrac"].abs().sum() for a in ASSETS) / yrs + print(f"\n ricostruzione a ${EQUITY_LIVE:,.0f}: {n_live} ordini su {yrs:.1f} anni = " + f"{n_live/yrs:.1f}/anno di libro ({n_live/yrs/2:.1f} per asset), " + f"turnover {turn:.1f}x equity/anno") + print(f" coerenza col punto 1: {turn:.1f}x * {gap:.2f} bps = {turn*gap/1e4*100:.3f} pp di CAGR " + f"contro i {dcg:.3f} pp misurati sul libro modellato " + f"({turn*gap/1e4*100/dcg*100:.0f}% β€” la ricostruzione tiene meno turnover del modello)") + exe = ROOT / "data" / "live" / "book_executions.jsonl" + if exe.exists(): + rows = [json.loads(l) for l in exe.read_text().splitlines() if l.strip()] + gg = max((pd.Timestamp(rows[-1]["ts_utc"]) - pd.Timestamp(rows[0]["ts_utc"])).days, 1) + print(f" controllo di realta': {len(rows)} ordini VERI in {gg} giorni = " + f"{len(rows)/gg*365.25:.0f}/anno di libro. Stesso ordine di grandezza; la finestra e'") + print(" troppo corta per una stima (18 ordini), serve solo a escludere un errore di fattore 10.") + for a in ASSETS: + v = O_live[a]["src"].value_counts() + print(f" {a}: origine " + " ".join(f"{(k or 'drift')} {n}" for k, n in v.items())) + + # ---------------------------------------------------------------- 3. il non-fill + print("\n" + "-" * 104) + print(" 3. IL COSTO DEL MANCATO RIEMPIMENTO (limit al riferimento, APPAIATO PER ORDINE)") + print("-" * 104) + print(" Il parametro che decide NON e' l'orizzonte d'attesa: e' cosa si chiama 'riempito'.") + print(" Un limit al livello sta DIETRO a tutta la coda gia' presente, quindi:") + print(" TOCCO (low <= limite) -> include la barra che APRE al livello e non ci") + print(" torna mai: al nostro prezzo non e' stato scambiato") + print(" NIENTE. Fisicamente non difendibile: LIMITE SUPERIORE.") + print(" ATTRAVERSAMENTO (low < limite) -> condizione MINIMA perche' uno scambio al nostro") + print(" prezzo esista. Assume ancora di essere davanti a") + print(" tutta la coda -> generosa, ma difendibile.") + print(" q bps in piu' -> quanto il prezzo deve andare OLTRE perche' la coda") + print(" davanti a noi sia consumata. L'OHLC non contiene la") + print(" profondita' del book: `q` si dichiara, non si stima.") + print(" E la colonna che decide e' `serve`: la frazione di fill necessaria per PAREGGIARE il") + print(" taker = E[avversa|no fill]/(E[avversa|no fill] + 2,00 bps). Model-free.") + print(f"\n {'ipotesi':>10} {'N(min)':>7} | {'P(fill)':>8} {'serve':>8} {'esito':>6} | {'E[avv|no]':>10}" + f" {'peggio':>8} | {'costo maker':>12} | {'vs taker':>9} | {'IC95 del delta':>22}" + f" | {'ordini vinti':>12}") + grid_cells = 6 # le 6 celle della griglia fine calcolate piu' sotto + tables = {} + variants = [("TOCCO <=", 0.0, False)] + [(f"attrav. q={q:.0f}", q, True) for q in QUEUE_BPS] + for lab, q, strict in variants: + MEAS = {a: measure_orders(cache[a], O_live[a], q_bps=q, strict=strict) for a in ASSETS} + tables[(q, strict)] = MEAS + for N in HORIZONS_MIN: + grid_cells += 1 + F = np.concatenate([MEAS[a][N]["filled"].values for a in ASSETS]) + Ad = np.concatenate([MEAS[a][N]["adverse"].values for a in ASSETS]) + W = np.concatenate([O_live[a]["dfrac"].abs().values for a in ASSETS]) + D = np.concatenate([per_order_delta(O_live[a], MEAS[a][N], miss_fee_bps=FEE_TAKER_BPS) + for a in ASSETS]) + S = weighted_stats(F, Ad, W) + lo, hi = boot_ci(D, W) + ok = "SI" if S["p_fill"] >= S["p_be"] else "no" + print(f" {lab:>10} {N:>7} | {S['p_fill']*100:>7.1f}% {S['p_be']*100:>7.1f}% {ok:>6} | " + f"{S['e_adv']*1e4:>8.0f}b {(Ad[~F].max()*1e4 if (~F).any() else 0):>7.0f}" + f" | {S['cost_bps']:>8.2f} bps | {S['cost_bps']-FEE_TAKER_BPS:>+8.2f} | " + f"[{lo:>+7.2f},{hi:>+7.2f}] bps | {(D>0).mean()*100:>11.1f}%") + print() + + # -- la sintesi che non dipende dalle ipotesi di coda + print(" SINTESI β€” IL SEGNO DEL RISULTATO STA TUTTO IN UNA DISUGUAGLIANZA STRETTA:") + print(f" {'N(min)':>7} | {'TOCCO <= (limite sup.)':>28} | {'ATTRAVERSAMENTO < (minimo difendibile)':>42}") + for N in HORIZONS_MIN: + row = [] + for key in ((0.0, False), (0.0, True)): + F = np.concatenate([tables[key][a][N]["filled"].values for a in ASSETS]) + Ad = np.concatenate([tables[key][a][N]["adverse"].values for a in ASSETS]) + W = np.concatenate([O_live[a]["dfrac"].abs().values for a in ASSETS]) + S = weighted_stats(F, Ad, W) + keep = (FEE_TAKER_BPS - S["cost_bps"]) / (FEE_TAKER_BPS - FEE_MAKER_BPS) + row.append(f"P(fill) {S['p_fill']*100:>5.1f}% resta {FEE_TAKER_BPS-S['cost_bps']:>+5.2f} bps" + f" = {keep*100:>6.1f}%") + print(f" {N:>7} | {row[0]:>28} | {row[1]:>42}") + print(" Fra le due colonne cambia UNA cosa: se la barra che apre esattamente al nostro") + print(" prezzo, e da li' se ne va, conta come riempimento. Non conta: al nostro prezzo non") + print(" e' stato scambiato niente dopo che l'ordine e' arrivato. Con la definizione fisica") + print(" il passivo PERDE gia' prima di aggiungere un solo bps di coda.") + # griglia FINE vicino a zero: dove sta il salto? (calcolata qui, non citata a memoria) + print("\n griglia FINE a N=60 (attraversamento), per vedere dove sta il salto:") + fine = [] + for q in (0.0, 0.005, 0.05, 0.25, 0.5, 1.0): + num = den = wf = 0.0 + for a in ASSETS: + M = measure_orders(cache[a], O_live[a], horizons=(60,), q_bps=q, strict=True)[60] + w = O_live[a]["dfrac"].abs().values + F = M["filled"].values + num += np.sum(w * (np.where(F, FEE_MAKER_BPS, FEE_TAKER_BPS) / 1e4 + M["adverse"].values)) + den += w.sum(); wf += w[F].sum() + fine.append((q, wf / den, num / den * 1e4)) + print(" " + " ".join(f"q={q:<5.3f} P(fill) {f*100:>4.1f}% costo {c:>5.2f}" for q, f, c in fine)) + Ft = np.concatenate([tables[(0.0, False)][a][60]["filled"].values for a in ASSETS]) + At = np.concatenate([tables[(0.0, False)][a][60]["adverse"].values for a in ASSETS]) + Wt = np.concatenate([O_live[a]["dfrac"].abs().values for a in ASSETS]) + touch60 = weighted_stats(Ft, At, Wt)["cost_bps"] + print(f" Il salto e' TUTTO al primo passo (tocco {touch60:.2f} -> attraversamento sub-tick " + f"{fine[1][2]:.2f} bps): non e' la CODA a decidere, e' la DISUGUAGLIANZA. Poi il costo") + print(f" sale piano ({fine[-1][2]:.2f} a q=1 bps) e non torna mai sotto il taker " + f"({FEE_TAKER_BPS:.2f}): non esiste un q positivo di pareggio.") + + # -- decomposizione: chi paga il non-fill? + q0, N0 = 2.0, 60 + MEAS = tables[(q0, True)] + print(f" DECOMPOSIZIONE ad attraversamento q={q0:.0f} bps, N={N0} min β€” non tutti gli ordini sono uguali:") + print(f" {'gruppo':<22}{'n':>6}{'P(fill)':>10}{'E[avversa] su TUTTI':>22}{'E[avv|no fill]':>17}") + groups = {} + for a in ASSETS: + O = O_live[a]; M = MEAS[a][N0] + groups.setdefault("SKH (breakout)", []).append((M, (O["src"] == "SKH").values)) + groups.setdefault("TP01 (drift 01:00)", []).append((M, (O["src"] == "TP01").values)) + groups.setdefault("aumenta esposizione", []).append((M, _increases(O))) + groups.setdefault("riduce esposizione", []).append((M, ~_increases(O))) + for lab, parts in groups.items(): + F = np.concatenate([m["filled"].values[k] for m, k in parts]) + Ad = np.concatenate([m["adverse"].values[k] for m, k in parts]) + if not len(F): + continue + print(f" {lab:<22}{len(F):>6}{F.mean()*100:>9.1f}%{Ad.mean()*1e4:>19.1f} bps" + f"{(Ad[~F].mean()*1e4 if (~F).any() else 0):>13.0f} bps") + print(" NB: un non-fill che RIDUCE l'esposizione lascia la posizione aperta piu' a lungo β€”") + print(" e' rischio, non solo prezzo, e la media in bps non lo mostra.") + + # -- placebo + print(f"\n PLACEBO a q={q0:.0f}, N={N0} β€” stesse taglie a ORE CASUALI (quanto pesa il") + print(" condizionamento sul segnale? se il placebo e' uguale, il segnale non seleziona nulla):") + grid_cells += 2 + for a in ASSETS: + P = paths[a] + k = rng.integers(1, len(P) - 30, size=len(O_live[a])) + Of = pd.DataFrame(dict(ts=P["ts"].values[k], dfrac=O_live[a]["dfrac"].values, + ref=P["ref"].values[k], hidx=P["hidx"].values[k], src="")) + Mf = measure_orders(cache[a], Of, horizons=(N0,), q_bps=q0, strict=True)[N0] + Mr = MEAS[a][N0] + print(f" {a}: reale P(fill) {Mr['filled'].mean()*100:>5.1f}% " + f"E[avversa su tutti] {Mr['adverse'].mean()*1e4:>6.1f} bps" + f" | placebo P(fill) {Mf['filled'].mean()*100:>5.1f}% " + f"E[avversa] {Mf['adverse'].mean()*1e4:>6.1f} bps") + + # -- politica "mai inseguire" + print(f"\n POLITICA 'MAI INSEGUIRE' a q={q0:.0f} bps (ri-posta ogni ora, tetto {P2_MAX_HOURS}h):") + grid_cells += 1 + P2 = {a: measure_p2(cache[a], O_live[a], paths[a], q_bps=q0, strict=True) for a in ASSETS} + c2 = np.concatenate([P2[a]["cycles"].values for a in ASSETS]) + a2 = np.concatenate([P2[a]["adverse"].values for a in ASSETS]) + w2 = np.concatenate([O_live[a]["dfrac"].abs().values for a in ASSETS]) + ch2 = np.concatenate([P2[a]["chased"].values for a in ASSETS]) + cost2 = (np.sum(w2 * (FEE_MAKER_BPS / 1e4 + a2)) / w2.sum()) * 1e4 + print(f" cicli orari mediana {np.median(c2):.0f} p90 {np.percentile(c2,90):.0f} " + f"max {c2.max():.0f} | mai riempito entro {P2_MAX_HOURS}h: {ch2.mean()*100:.1f}%") + contrib = w2 * a2 + top5 = np.sort(contrib)[-5:].sum() / contrib.sum() if contrib.sum() > 0 else 0.0 + print(f" avversa: mediana {np.median(a2)*1e4:+.1f} bps p90 {np.percentile(a2,90)*1e4:+.1f} " + f"MEDIA {a2.mean()*1e4:+.1f} peggiore {a2.max()*1e4:+.0f}") + print(f" -> costo {cost2:.2f} bps/lato ({cost2-FEE_TAKER_BPS:+.2f} contro il taker), e " + f"i 5 ordini peggiori su {len(a2)} fanno il {top5*100:.0f}% del danno totale.") + print(" E' la FIRMA DELLA TRONCATURA, vista dall'altra parte: il passivo vince PICCOLO e") + print(" SPESSO (mediana favorevole) e perde GROSSO e di RADO. Stessa forma del profit-take") + print(" di VRP01 (30/07), dove una regola che scattava sull'86-91% dei vincenti non era") + print(" protezione ma troncatura. Qui la media e' il numero che decide, non la mediana.") + + # ---------------------------------------------------------------- 4. impatto sul libro + print("\n" + "-" * 104) + print(" 4. IMPATTO SUL LIBRO (delta per-ordine innestato nei rendimenti giornalieri)") + print("-" * 104) + base = book_daily(cache, FEE_TAKER_BPS) + b = show("TAKER 3,50 bps = il libro di oggi", base) + res = {} + for q, strict in [(0.0, False), (0.0, True), (1.0, True), (2.0, True), (5.0, True)]: + for N in (5, 60, 120): + alt = graft(base, {a: per_order_delta(O_live[a], tables[(q, strict)][a][N], + miss_fee_bps=FEE_TAKER_BPS) for a in ASSETS}, O_live) + tag = "TOCCO<=" if not strict else f"attrav q={q:.0f}" + m = show(f"MAKER {tag:>10} N={N:>3} min, poi market", alt) + res[(tag, N)] = m + alt2 = graft(base, {a: per_order_delta(O_live[a], P2[a], miss_fee_bps=FEE_MAKER_BPS) + for a in ASSETS}, O_live) + m2 = show(f"MAKER 'mai inseguire' q={q0:.0f} bps", alt2) + + print("\n delta contro il baseline TAKER (a $635 di conto vero):") + for (q, N), m in res.items(): + print(f" {q:>10} N={N:>3} dSharpe {m['sharpe']-b['sharpe']:+.4f} " + f"dHOLD {m['hold']-b['hold']:+.4f} dCAGR {(m['cagr']-b['cagr'])*100:+.3f} pp " + f"dmaxDD {(m['maxdd']-b['maxdd'])*100:+.2f} pp -> ${EQUITY_LIVE*(m['cagr']-b['cagr']):+.2f}/anno") + print(f" {'mai-inseg.':>10} dSharpe {m2['sharpe']-b['sharpe']:+.4f} " + f"dHOLD {m2['hold']-b['hold']:+.4f} dCAGR {(m2['cagr']-b['cagr'])*100:+.3f} pp " + f"dmaxDD {(m2['maxdd']-b['maxdd'])*100:+.2f} pp -> ${EQUITY_LIVE*(m2['cagr']-b['cagr']):+.2f}/anno") + + # ---------------------------------------------------------------- 5. robustezza + print("\n" + "-" * 104) + print(" 5. ROBUSTEZZA (banda RIDOTTA: gli ordini spostati di -1h/0/+1h. NON e' la banda a 24") + print(" ancore: qui si sposta il momento dell'ORDINE, non l'ancora della strategia)") + print("-" * 104) + grid_cells += 3 + for sh in (-1, 0, 1): + num, den = 0.0, 0.0 + for a in ASSETS: + P = paths[a] + pos = {int(v): k for k, v in enumerate(P["hidx"].values)} + kk = np.array([min(max(pos[int(h)] + sh, 0), len(P) - 1) for h in O_live[a]["hidx"]]) + Osh = pd.DataFrame(dict(ts=P["ts"].values[kk], dfrac=O_live[a]["dfrac"].values, + ref=P["ref"].values[kk], hidx=P["hidx"].values[kk], src="")) + M = measure_orders(cache[a], Osh, horizons=(N0,), q_bps=q0, strict=True)[N0] + w = Osh["dfrac"].abs().values + num += np.sum(w * (np.where(M["filled"].values, FEE_MAKER_BPS, FEE_TAKER_BPS) / 1e4 + + M["adverse"].values)) + den += w.sum() + print(f" ordini spostati di {sh:+d}h costo maker {num/den*1e4:>6.2f} bps/lato " + f"({num/den*1e4-FEE_TAKER_BPS:+.2f} contro il taker)") + print(" -1h e' informativo: sposta l'ordine PRIMA che il segnale sia visibile, cioe' verso") + print(" dove sta l'ingresso VERO di SKH01 (intra-bin, piu' vicino al breakout della lente") + print(" usata qui). La lente di questo script e' GENEROSA verso il maker anche per questo.") + + # ---------------------------------------------------------------- 6. ordini di grandezza + print("\n" + "-" * 104) + print(" 6. LA DOMANDA STRUTTURALE β€” la leva accanto alle altre gia' misurate nel progetto") + print("-" * 104) + bq, bN = max(res, key=lambda k: res[k]["sharpe"]) + bm = res[(bq, bN)] + print(f" maker, LIMITE SUPERIORE (0 non-fill) dSharpe {dsh:+.4f} " + f"${EQUITY_LIVE*dcg/100:>+7.2f}/anno a ${EQUITY_LIVE:,.0f} ${5000*dcg/100:>+7.2f} a $5.000") + print(f" maker, migliore cella misurata ({bq},N={bN}) dSharpe {bm['sharpe']-b['sharpe']:+.4f} " + f"${EQUITY_LIVE*(bm['cagr']-b['cagr']):>+7.2f}/anno a ${EQUITY_LIVE:,.0f}") + worst = min(res, key=lambda k: res[k]["sharpe"]) + print(f" maker, peggiore cella misurata ({worst[0]},N={worst[1]}) " + f"dSharpe {res[worst]['sharpe']-b['sharpe']:+.4f} " + f"${EQUITY_LIVE*(res[worst]['cagr']-b['cagr']):>+7.2f}/anno") + print(f" peso di SKH01 ottimizzato dSharpe +0.0300 GATE FALLITO (26/07)") + print(f" banda d'ancora del libro (FULL) dSharpe +-0.20 (26/07, 2000 estrazioni)") + print(f" banda d'ancora del libro (HOLD-OUT) 2.36 canonico -> 1.54 onesto (26/07)") + print(f" raddoppio del taker (regola 26/07) dSharpe -0.08 'non si tocca nulla'") + print(f" versare 250 EUR/mese invece di 0 da MAI a 16-20 anni (26-27/07)") + # -- rischio operativo: quanti ordini resterebbero APERTI al giro successivo del cron + print("\n RISCHIO OPERATIVO (non e' un numero di rendimento, e' un modo nuovo di rompersi):") + for key, lab in (((0.0, True), "attraversamento q=0"), ((2.0, True), "attraversamento q=2")): + F = np.concatenate([tables[key][a][60]["filled"].values for a in ASSETS]) + print(f" {lab:<22} N=60 min: {(1-F.mean())*100:>4.1f}% degli ordini ancora APERTI al giro") + print(f" {'':<22} successivo = {(1-F.mean())*n_live/yrs:>4.1f} volte l'anno in cui l'esecutore") + print(f" {'':<22} deve cancellare-e-rimpiazzare correttamente, su un conto vero.") + print(" Oggi `rebalance_signed` manda market e finisce: nessun ordine sopravvive al giro.") + print(" Un limit resting introduce ordini orfani e doppi fill, e il libro ha gia' pagato") + print(" il prezzo di un percorso che si rompe in silenzio (`fresh_5m`, 26-29/07).") + + print("\n " + "=" * 100) + print(" VERDETTO: SCARTATO. Il differenziale c'e' (2,00 bps/lato = +0,033 Sharpe = $2,96/anno") + print(" a $635) ma e' il TETTO, e la selezione avversa se lo mangia tutto gia' alla condizione") + print(" di riempimento piu' generosa che sia fisicamente difendibile. Il segno del risultato") + print(" dipende da una disuguaglianza stretta (`<=` contro `<`), il che e' un altro modo di") + print(" dire che l'effetto e' dentro il rumore: l'IC95 del delta contiene lo zero in 10 celle") + print(" su 10 fra tocco e attraversamento-q=0. E il premio massimo immaginabile ($2,96/anno)") + print(" e' un ordine di grandezza sotto la leva piu' piccola gia' respinta dal progetto.") + print(" " + "=" * 100) + + print(f"\n GRIGLIA DICHIARATA: {grid_cells} celle valutate " + f"(({len(QUEUE_BPS)}q attrav. + 1 tocco) x {len(HORIZONS_MIN)}N + placebo x2 + " + f"mai-inseguire + 3 shift + 6 celle di griglia fine) " + f"+ {len(FEE_GRID_BPS)} punti di fee.") + print("=" * 104) + + +def _increases(O: pd.DataFrame) -> np.ndarray: + """True se l'ordine AUMENTA il valore assoluto dell'esposizione (entrata/incremento).""" + d = O["dfrac"].values + held = np.zeros(len(d)) + acc = 0.0 + for i, x in enumerate(d): + held[i] = acc + acc += x + return np.abs(held + d) > np.abs(held) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822b_surface_rv.py b/scripts/research/r0822b_surface_rv.py new file mode 100644 index 0000000..1c222f9 --- /dev/null +++ b/scripts/research/r0822b_surface_rv.py @@ -0,0 +1,716 @@ +"""SURFACE-RV β€” la superficie di volatilita' come RELATIVE VALUE fra i suoi punti. + +IPOTESI (registrata prima di misurare): la superficie Deribit e' internamente INCOERENTE in modo +misurabile β€” strike adiacenti fuori da una curva liscia, varianza totale non monotona nel tenore β€” +e l'incoerenza si ricompone. E' un'ipotesi di MICROSTRUTTURA, non di previsione: non chiede alla +vol implicita di prevedere nulla (cosa che il progetto ha gia' refutato quattro volte: dvol_directional, +gamma scalping, term-structure come segnale, skew come segnale). +ATTESA DICHIARATA PRIMA: l'incoerenza esiste al MID ma non sopravvive al bid-ask, perche' su +un'opzione a 7 giorni un punto di vol vale pochi dollari mentre attraversare lo spread ne costa +il ~10% del premio (misurato dal filone 11 sulle inverse). + +LA DIFESA CONTRO IL RISCHIO NUMERO UNO DI QUESTO FILONE (misurare il proprio errore di +interpolazione) e' strutturale, non statistica: i due test principali sono **arbitraggi statici +model-free** β€” monotonia e convessita' del prezzo nello strike β€” che non richiedono NESSUN +interpolatore, NESSUN modello e NESSUN tasso. Valgono per ogni misura di prezzo, path per path: + * put: P(K) crescente in K (verticale) + * put: P(K) convessa in K (farfalla) +e sono verificati su prezzi ESEGUIBILI (si vende al bid, si compra all'ask), mai al mid. +Il terzo test β€” varianza totale non monotona nel tenore β€” richiede di confrontare tenori diversi: +si riporta sia nella versione SENZA interpolazione (strike condiviso fra due scadenze) sia in +quella con interpolazione a log-moneyness fisso, e la DIFFERENZA fra le due e' la misura diretta +di quanto pesa l'interpolatore. + +Convenzioni verificate nel codice (non assunte): + * tick: 0.0001 coin sotto 0.005, 0.0005 sopra -> contato, non dichiarato + * f_markfit: la mark IV del venue riprezza i propri mid? (il filone 5 misura 0.992) + * fee Deribit opzioni: 0.03% del sottostante per gamba, cap 12.5% del premio della gamba + +Il dato: `data/raw/cb_chain/` (archivio bite + raccolta propria). SOLO opzioni INVERSE β€” il +collettore interroga {"currency": "BTC"|"ETH"}, che non restituisce la famiglia USDC-lineare +(difetto di produzione trovato dal filone 11). Quindi ogni numero qui e' misurato sulla famiglia +che il conto NON puo' marginare, e le conclusioni sulla famiglia USDC sono ESTRAPOLAZIONI +dichiarate, non misure. + +Muro del dato: prima del 2026-06-09 l'archivio raccoglieva UNA scadenza per giro -> la SUPERFICIE +esiste da li' (~75 giorni). Lo SMILE (piu' strike su UNA scadenza) esiste dal 2026-05-01 (~113 g). +Verdetto massimo possibile: LEAD con gate pre-registrato. Mai CANDIDATO. + + uv run python scripts/research/r0822b_surface_rv.py +""" +from __future__ import annotations + +import glob +import sys +import time +from pathlib import Path + +import numpy as np +import pandas as pd +from scipy.stats import norm + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +STORE = ROOT / "data" / "raw" / "cb_chain" + +SMILE_START = pd.Timestamp("2026-05-01", tz="UTC") # piu' strike su UNA scadenza +SURF_START = pd.Timestamp("2026-06-09", tz="UTC") # piu' scadenze per giro -> superficie + +FEE_UNDERLYING = 0.0003 # 0.03% del sottostante, per gamba, in coin per contratto +FEE_CAP_FRAC = 0.125 # cap: 12.5% del premio della gamba +MIN_LOT = {"BTC": 0.1, "ETH": 1.0} # famiglia INVERSE (quella che abbiamo nel dato) +MIN_LOT_USDC = {"BTC": 0.01, "ETH": 0.1} # famiglia USDC-lineare (non raccolta: estrapolazione) + +DTE_MIN = 1.0 # sotto un giorno il book e' un'altra cosa; riportato anche senza filtro + + +def tick_of(px: np.ndarray) -> np.ndarray: + """Griglia di prezzo Deribit sulle opzioni, in coin. Verificata sui dati in `certifica()`.""" + return np.where(px < 0.005, 0.0001, 0.0005) + + +# --------------------------------------------------------------------------- dato + +def load(assets=("BTC", "ETH")) -> pd.DataFrame: + cols = ["ts", "asset", "instrument_name", "strike", "option_type", "exp", + "bid", "ask", "mid", "iv", "delta", "open_interest", "book_depth_top3", + "underlying_price", "index_price", "quote_status"] + parts = [] + for f in sorted(glob.glob(str(STORE / "*.parquet"))): + d = pd.read_parquet(f, columns=cols) + parts.append(d[d["asset"].isin(assets)]) + d = pd.concat(parts, ignore_index=True) + d["ts"] = pd.to_datetime(d["ts"], utc=True) + d["exp"] = pd.to_datetime(d["exp"], utc=True) + for c in ("strike", "bid", "ask", "mid", "iv", "delta", "open_interest", + "book_depth_top3", "underlying_price", "index_price"): + d[c] = pd.to_numeric(d[c], errors="coerce") + d = d.drop_duplicates(subset=["ts", "instrument_name"], keep="last") + d["dte"] = (d["exp"] - d["ts"]).dt.total_seconds() / 86400.0 + d["T"] = d["dte"] / 365.25 + d = d.reset_index(drop=True) + d["spot_feed"] = _spot_asof(d) + return d + + +def _spot_asof(d: pd.DataFrame) -> pd.Series: + """Indice dal feed CERTIFICATO (1h Deribit mainnet), asof sul ts della quota. + + Serve perche' `bite_archive` NON ha ne' `index_price` ne' `underlying_price` (100% None: + misurato, non assunto) -> senza questo la finestra con un divisore sarebbe di 23 giorni. + """ + from scripts.analysis.research_lab import load_tf + out = pd.Series(np.nan, index=d.index) + for a in d["asset"].unique(): + px = load_tf(a, "1h") + s = pd.Series(px["close"].to_numpy(float), + index=pd.to_datetime(px["timestamp"], unit="ms", utc=True)).sort_index() + s.index = pd.DatetimeIndex(s.index).as_unit("ns") + m = d["asset"] == a + t = pd.DatetimeIndex(d.loc[m, "ts"]).as_unit("ns") + # epoca esplicita in ns (mai `.view("int64")` su indici tz-aware: lezione 01/07) + si, ti = s.index.asi8, t.asi8 + pos = np.searchsorted(si, ti, side="right") - 1 + v = np.where(pos >= 0, s.to_numpy()[np.clip(pos, 0, None)], np.nan) + out.loc[m] = v + return out + + +def forward(d: pd.DataFrame) -> pd.DataFrame: + """Forward per (asset, ts, exp) ricostruito con la PARITA' PUT-CALL, model-free. + + In convenzione inverse il premio e' quotato in coin: (C - P) * X = F - K => F = K + (C-P)*X, + con X = divisore della convenzione (misurato in `certifica`). Mediana sugli strike = robusta. + Dove il venue lo ha registrato (solo la raccolta propria) si usa il valore OSSERVATO, e + l'accordo fra i due e' una certificazione, non un'assunzione. + """ + q = d[(d["bid"] > 0) & (d["ask"] > 0) & (d["ask"] >= d["bid"]) & d["spot_feed"].notna()] + c = q[q["option_type"] == "C"][["asset", "ts", "exp", "strike", "mid", "spot_feed"]] + p = q[q["option_type"] == "P"][["asset", "ts", "exp", "strike", "mid"]] + j = c.merge(p, on=["asset", "ts", "exp", "strike"], suffixes=("_c", "_p")) + j["F_k"] = j["strike"] + (j["mid_c"] - j["mid_p"]) * j["spot_feed"] + f = j.groupby(["asset", "ts", "exp"])["F_k"].median().rename("F_pcp").reset_index() + return f + + +def two_sided(d: pd.DataFrame) -> pd.DataFrame: + """Quotate a DUE lati. Una gamba sola quotata non e' meta' struttura: e' un'altra cosa. + + (Regola 8 del brief: una riga presente non e' un dato presente.) + """ + q = d[(d["bid"] > 0) & (d["ask"] > 0) & (d["ask"] >= d["bid"])].copy() + q = q[q["T"] > 0] + return q + + +# --------------------------------------------------------------------------- prezzo + +def bs_usd(F, K, T, sig, is_put): + """Black-76 con r=0 (Deribit prezza sul FORWARD della scadenza = `underlying_price`).""" + F = np.asarray(F, float); K = np.asarray(K, float) + T = np.asarray(T, float); sig = np.asarray(sig, float) + out = np.full(F.shape, np.nan) + ok = (T > 0) & (sig > 0) & (F > 0) & (K > 0) + if not ok.any(): + return out + d1 = np.zeros_like(F); d2 = np.zeros_like(F) + sq = np.sqrt(np.where(ok, T, 1.0)) + d1[ok] = (np.log(F[ok] / K[ok]) + 0.5 * sig[ok] ** 2 * T[ok]) / (sig[ok] * sq[ok]) + d2[ok] = d1[ok] - sig[ok] * sq[ok] + call = F * norm.cdf(d1) - K * norm.cdf(d2) + put = K * norm.cdf(-d2) - F * norm.cdf(-d1) + out[ok] = np.where(is_put[ok], put[ok], call[ok]) + return out + + +def bs_vega_usd(F, K, T, sig): + F = np.asarray(F, float); K = np.asarray(K, float) + T = np.asarray(T, float); sig = np.asarray(sig, float) + out = np.zeros(F.shape) + ok = (T > 0) & (sig > 0) & (F > 0) & (K > 0) + sq = np.sqrt(np.where(ok, T, 1.0)) + d1 = np.zeros_like(F) + d1[ok] = (np.log(F[ok] / K[ok]) + 0.5 * sig[ok] ** 2 * T[ok]) / (sig[ok] * sq[ok]) + out[ok] = F[ok] * norm.pdf(d1[ok]) * sq[ok] # per 1.00 di sigma (=100 punti di vol) + return out + + +# --------------------------------------------------------------------------- certificazione + +def certifica(d: pd.DataFrame) -> pd.DataFrame: + print("=" * 100) + print("0. CERTIFICAZIONE DEL DATO β€” cosa esiste davvero, prima di qualunque test") + print("=" * 100) + q = two_sided(d) + print(f"righe totali {len(d):,} Β· quotate a due lati {len(q):,} ({len(q)/len(d):.1%})") + print(f"finestra {d['ts'].min():%Y-%m-%d} -> {d['ts'].max():%Y-%m-%d}") + + # (a) muro del dato: scadenze per giro + per = d.groupby([d["ts"].dt.floor("D"), "asset"])["exp"].nunique().unstack() + pre = per[per.index < SURF_START] + post = per[per.index >= SURF_START] + print("\n(a) MURO DEL DATO β€” scadenze per giro (mediana per giorno):") + print(f" pre {SURF_START:%Y-%m-%d}: BTC {pre['BTC'].median():.0f} Β· ETH {pre['ETH'].median():.0f}" + f" (max BTC {pre['BTC'].max():.0f}) -> la SUPERFICIE non esiste") + print(f" post {SURF_START:%Y-%m-%d}: BTC {post['BTC'].median():.0f} Β· ETH {post['ETH'].median():.0f}" + f" -> superficie su {len(post)} giorni") + print(f" smile (piu' strike, UNA scadenza) su {len(per)} giorni dal {per.index.min():%Y-%m-%d}" + f" -> i test model-free girano su questa finestra piu' lunga") + + # (a-bis) BUCO DI COLONNA nell'archivio ereditato + obs = d["index_price"].notna() + print("\n(a-bis) BUCO DI COLONNA β€” `bite_archive` non registrava il sottostante") + print(f" index_price/underlying_price presenti in {obs.sum():,}/{len(d):,} righe ({obs.mean():.1%})" + f" β€” solo la raccolta propria, dal {d.loc[obs,'ts'].min():%Y-%m-%d}") + print(f" book_depth_top3 presente in {d['book_depth_top3'].notna().mean():.1%} delle righe") + print(" -> i test 1-2 (model-free) NON ne hanno bisogno; il test 4 (calendario) si')") + + # (b) tick: verificato, non dichiarato + print("\n(b) GRIGLIA DI PREZZO (verificata sui dati, non assunta)") + for a in sorted(q["asset"].unique()): + b = q.loc[q["asset"] == a, "bid"].to_numpy() + t = tick_of(b) + off = np.abs(np.round(b / t) - b / t) > 1e-6 + n_tick = np.round(b / t) + print(f" {a}: fuori griglia {off.sum():,}/{len(b):,} ({off.mean():.4%}) Β· " + f"bid in tick: mediana {np.median(n_tick):.0f} Β· quota <=2 tick {np.mean(n_tick <= 2):.1%}") + + # (c) il divisore della convenzione inverse + f_markfit + print("\n(c) CONVENZIONE INVERSE e f_markfit β€” la mark IV riprezza i propri mid?") + ov = q[obs.reindex(q.index, fill_value=False)].dropna(subset=["iv", "mid", "spot_feed"]) + ov = ov[(ov["iv"] > 0) & (ov["mid"] > 0) & (ov["dte"] >= DTE_MIN)] + dev = (ov["index_price"] / ov["spot_feed"] - 1).abs() + print(f" index_price della catena vs feed CERTIFICATO 1h: |dev| mediana {dev.median():.5%} " + f"Β· p99 {dev.quantile(.99):.4%} (n={len(ov):,}) -> il feed puo' fare da divisore sull'archivio") + sm = ov.sample(n=min(120_000, len(ov)), random_state=0) + is_put = (sm["option_type"] == "P").to_numpy() + usd = bs_usd(sm["underlying_price"].to_numpy(), sm["strike"].to_numpy(), + sm["T"].to_numpy(), (sm["iv"] / 100).to_numpy(), is_put) + best = None + for nome, div in (("index_price", sm["index_price"].to_numpy()), + ("underlying_price(fwd)", sm["underlying_price"].to_numpy()), + ("feed certificato 1h", sm["spot_feed"].to_numpy())): + f = (usd / div) / sm["mid"].to_numpy() + f = f[np.isfinite(f) & (f > 0)] + print(f" f_markfit con divisore {nome:<22}: mediana {np.median(f):.4f} " + f"[p10 {np.percentile(f,10):.3f}, p90 {np.percentile(f,90):.3f}] n={len(f):,}") + if best is None or abs(np.median(f) - 1) < abs(best[1] - 1): + best = (nome, float(np.median(f))) + print(f" -> convenzione: divisore = {best[0]} (f_markfit {best[1]:.4f}); " + f"un f molto lontano da 1 direbbe che il problema e' il MIO prezzatore, non il mercato") + + # (d) forward ricostruito con la parita' put-call, certificato contro l'osservato + print("\n(d) FORWARD ricostruito con la PARITA' PUT-CALL (model-free) vs osservato") + F = forward(d) + chk = d.loc[obs, ["asset", "ts", "exp", "underlying_price"]].drop_duplicates( + subset=["asset", "ts", "exp"]).merge(F, on=["asset", "ts", "exp"], how="inner") + e = (chk["F_pcp"] / chk["underlying_price"] - 1) + print(f" n={len(chk):,} coppie (asset,ts,scadenza) con entrambi: " + f"|errore| mediana {e.abs().median():.5%} Β· p95 {e.abs().quantile(.95):.4%} Β· " + f"corr {np.corrcoef(chk['F_pcp'], chk['underlying_price'])[0,1]:.6f}") + return q, F + + +# --------------------------------------------------------------------------- fee + +def fee_coin(premio_coin: np.ndarray, n_contratti: float) -> np.ndarray: + """Fee Deribit opzioni per gamba, in coin: min(0.03% del sottostante, 12.5% del premio) x lotti.""" + per_contratto = np.minimum(FEE_UNDERLYING, FEE_CAP_FRAC * np.asarray(premio_coin, float)) + return per_contratto * n_contratti + + +# --------------------------------------------------------------------------- TEST V + +def test_verticale(q: pd.DataFrame, dte_min: float) -> dict: + """Monotonia del prezzo nello strike. Model-free, nessun interpolatore, nessun tasso. + + put: P(K) crescente -> arbitraggio se bid(K1) > ask(K2) con K1 < K2 + (vendi la put bassa al bid, compra l'alta all'ask: credito + payoff sempre >= 0) + call: C(K) decrescente -> arbitraggio se bid(K2) > ask(K1) con K1 < K2 + """ + x = q[q["dte"] >= dte_min].sort_values(["asset", "ts", "exp", "option_type", "strike"]) + key = ["asset", "ts", "exp", "option_type"] + g = x.groupby(key, sort=False) + # per le put il "meglio finora" salendo di strike; per le call scendendo + x["cmax_up"] = g["bid"].cummax() + x["prev_up"] = g["cmax_up"].shift(1) # miglior bid su strike STRETTAMENTE inferiori + xr = x.iloc[::-1].copy() + gr = xr.groupby(key, sort=False) + xr["cmax_dn"] = gr["bid"].cummax() + x["prev_dn"] = gr["cmax_dn"].shift(1) # miglior bid su strike STRETTAMENTE superiori + is_put = x["option_type"].to_numpy() == "P" + prev = np.where(is_put, x["prev_up"].to_numpy(), x["prev_dn"].to_numpy()) + cred = prev - x["ask"].to_numpy() # credito riskless in coin, per contratto + viol = np.isfinite(cred) & (cred > 0) + n_tot = len(x) + out = {"n_confronti": int(np.isfinite(cred).sum()), "n_viol": int(viol.sum()), + "quota": float(viol.sum() / max(1, np.isfinite(cred).sum()))} + if viol.any(): + vv = x.loc[viol] + out["credito_med_coin"] = float(np.median(cred[viol])) + out["credito_usd_medlot"] = float(np.median( + cred[viol] * vv["asset"].map(MIN_LOT).to_numpy() * vv["spot_feed"].to_numpy())) + return out + + +# --------------------------------------------------------------------------- TEST B + +def test_farfalla(q: pd.DataFrame, dte_min: float) -> pd.DataFrame: + """Convessita' del prezzo nello strike su triple CONSECUTIVE. Model-free. + + lam = (K3-K2)/(K3-K1); payoff lam*P(K1) + (1-lam)*P(K3) - P(K2) >= 0 sempre. + Costo eseguibile = lam*ask1 + (1-lam)*ask3 - bid2. Se < 0 -> credito riskless. + Al MID lo stesso numero misura l'INCOERENZA (non eseguibile) β€” si riportano entrambi. + """ + x = q[q["dte"] >= dte_min].sort_values(["asset", "ts", "exp", "option_type", "strike"]) + key = ["asset", "ts", "exp", "option_type"] + g = x.groupby(key, sort=False) + for c in ("strike", "bid", "ask", "mid", "iv", "book_depth_top3"): + x[c + "_p"] = g[c].shift(1) + x[c + "_n"] = g[c].shift(-1) + x = x.dropna(subset=["strike_p", "strike_n", "bid_p", "bid_n", "ask_p", "ask_n"]) + K1, K2, K3 = x["strike_p"].to_numpy(), x["strike"].to_numpy(), x["strike_n"].to_numpy() + lam = (K3 - K2) / (K3 - K1) + x["lam"] = lam + # eseguibile: compro le ali all'ask, vendo il corpo al bid + x["cost_exec"] = lam * x["ask_p"] + (1 - lam) * x["ask_n"] - x["bid"] + # incoerenza al mid (non eseguibile, ma e' la lente "la superficie e' liscia?") + x["cost_mid"] = lam * x["mid_p"] + (1 - lam) * x["mid_n"] - x["mid"] + # la stessa cosa in punti di vol: residuo di convessita' locale dello smile + x["iv_resid"] = x["iv"] - (lam * x["iv_p"] + (1 - lam) * x["iv_n"]) + return x + + +# --------------------------------------------------------------------------- vol points + +def in_punti_vol(x: pd.DataFrame) -> pd.DataFrame: + """Mezzo spread denaro-lettera convertito in punti di vol via vega BS (r=0, forward).""" + vega_usd = bs_vega_usd(x["F"].to_numpy(), x["strike"].to_numpy(), + x["T"].to_numpy(), (x["iv"] / 100).to_numpy()) + vega_coin_per_pt = vega_usd / x["spot_feed"].to_numpy() / 100.0 + x = x.copy() + x["vega_coin_pt"] = vega_coin_per_pt + x["halfspread_pt"] = (x["ask"] - x["bid"]) / 2.0 / np.clip(vega_coin_per_pt, 1e-12, None) + x["relspread"] = (x["ask"] - x["bid"]) / x["mid"] + # quanto vale UN TICK in punti di vol: se il tick e' piu' grosso dell'incoerenza, + # l'incoerenza non e' esprimibile nella griglia di prezzo del venue. + x["tick_pt"] = tick_of(x["mid"].to_numpy()) / np.clip(vega_coin_per_pt, 1e-12, None) + return x + + +# --------------------------------------------------------------------------- TEST C + +def test_calendario(q: pd.DataFrame, dte_min: float, ogni: int = 4) -> dict: + """Varianza totale w = iv^2 * T: deve CRESCERE col tenore. Due lenti, e la loro differenza. + + (i) STRIKE CONDIVISO fra due scadenze adiacenti: nessuna interpolazione. + (ii) LOG-MONEYNESS FISSO k = ln(K/F): richiede di interpolare lo smile lungo in k. + La differenza fra (i) e (ii) e' la misura diretta del contributo dell'INTERPOLATORE. + """ + x = q[(q["dte"] >= dte_min) & q["iv"].notna() & (q["iv"] > 0) & q["F"].notna()].copy() + x = x[x["ts"] >= SURF_START] + tenuti = sorted(x["ts"].unique())[::ogni] # (i) e (ii) sugli STESSI snapshot: appaiato + x = x[x["ts"].isin(tenuti)] + x["w"] = (x["iv"] / 100.0) ** 2 * x["T"] + x["k"] = np.log(x["strike"] / x["F"]) + + # --- (i) strike condiviso, adiacenti nel tempo + x = x.sort_values(["asset", "ts", "option_type", "strike", "exp"]) + key = ["asset", "ts", "option_type", "strike"] + g = x.groupby(key, sort=False) + nxt_w = g["w"].shift(-1).to_numpy() + nxt_T = g["T"].shift(-1).to_numpy() + nxt_iv = g["iv"].shift(-1).to_numpy() + ok = np.isfinite(nxt_w) & (nxt_T > x["T"].to_numpy()) + dw = nxt_w - x["w"].to_numpy() + viol_i = ok & (dw < 0) + # ampiezza in punti di vol: quanto dovrebbe salire l'iv lunga per ripristinare la monotonia + iv_need = np.sqrt(np.maximum(x["w"].to_numpy(), 0) / np.maximum(nxt_T, 1e-9)) * 100.0 + amp_pt = np.where(ok, iv_need - nxt_iv, np.nan) + + res = {"i_n": int(ok.sum()), "i_viol": int(viol_i.sum()), + "i_quota": float(viol_i.sum() / max(1, ok.sum())), + "i_amp_med_pt": float(np.nanmedian(amp_pt[viol_i])) if viol_i.any() else np.nan, + "i_amp_p95_pt": float(np.nanpercentile(amp_pt[viol_i], 95)) if viol_i.any() else np.nan} + + # --- (ii) log-moneyness fisso, con interpolazione + y = x + n_par, n_vio, amps = 0, 0, [] + for (a, ts, ot), gg in y.groupby(["asset", "ts", "option_type"], sort=False): + exps = sorted(gg["exp"].unique()) + if len(exps) < 2: + continue + for e1, e2 in zip(exps[:-1], exps[1:]): + s1 = gg[gg["exp"] == e1].sort_values("k") + s2 = gg[gg["exp"] == e2].sort_values("k") + if len(s1) < 2 or len(s2) < 2: + continue + k1 = s1["k"].to_numpy() + inside = (k1 >= s2["k"].min()) & (k1 <= s2["k"].max()) # MAI estrapolare + if not inside.any(): + continue + iv2 = np.interp(k1[inside], s2["k"].to_numpy(), s2["iv"].to_numpy()) + T2 = float(s2["T"].iloc[0]) + w2 = (iv2 / 100.0) ** 2 * T2 + w1 = s1["w"].to_numpy()[inside] + n_par += int(inside.sum()) + bad = w2 < w1 + n_vio += int(bad.sum()) + if bad.any(): + need = np.sqrt(w1[bad] / T2) * 100.0 + amps.append(need - iv2[bad]) + amps = np.concatenate(amps) if amps else np.array([]) + res.update({"ii_n": n_par, "ii_viol": n_vio, "ii_quota": n_vio / max(1, n_par), + "ii_amp_med_pt": float(np.median(amps)) if len(amps) else np.nan, + "ii_ogni": ogni}) + # valore in dollari dell'ampiezza (i) al lotto minimo, contro il costo di 2 gambe + vv = x.loc[viol_i].copy() + if len(vv): + vv = in_punti_vol(vv) + amp = pd.Series(amp_pt, index=x.index)[viol_i].to_numpy() + lot = vv["asset"].map(MIN_LOT).to_numpy() + val = np.abs(amp) * vv["vega_coin_pt"].to_numpy() * lot * vv["spot_feed"].to_numpy() + costo = ((vv["ask"] - vv["bid"]).to_numpy() * lot * vv["spot_feed"].to_numpy() + + 2 * fee_coin(vv["mid"].to_numpy(), lot) * vv["spot_feed"].to_numpy()) + res.update({"i_val_usd_med": float(np.nanmedian(val)), + "i_costo_usd_med": float(np.nanmedian(costo)), + "i_n_val": int(np.isfinite(val).sum())}) + return res + + +# --------------------------------------------------------------------------- economia + +def economia(bf: pd.DataFrame, asset: str) -> dict: + """Quanto vale, in dollari, l'incoerenza di una farfalla al lotto minimo β€” netto fee. + + Struttura a 3 gambe, lotti INTERI: con strike equispaziati e' 1:2:1 sul lotto minimo. + Si conta la fee gamba per gamba col cap sul premio, e si usa il prezzo ESEGUIBILE. + """ + x = bf[bf["asset"] == asset] + lot = MIN_LOT[asset] + S = x["spot_feed"].to_numpy() + # credito lordo della farfalla eseguibile, in coin per unita' di "corpo" + cred = -x["cost_exec"].to_numpy() # >0 = credito riskless + # lotti interi: corpo 2*lot, ali lot ciascuna (equivale a lam=0.5 -> solo strike equispaziati) + eq = np.isclose(x["lam"].to_numpy(), 0.5, atol=0.02) + cred_eq_coin = (x["bid"].to_numpy() * 2 * lot + - x["ask_p"].to_numpy() * lot - x["ask_n"].to_numpy() * lot) + f = (fee_coin(x["bid"].to_numpy(), 2 * lot) + + fee_coin(x["ask_p"].to_numpy(), lot) + fee_coin(x["ask_n"].to_numpy(), lot)) + netto_usd = (cred_eq_coin - f) * S + return { + "asset": asset, "n": int(len(x)), "n_equisp": int(eq.sum()), + "cred_lordo_p99_coin": float(np.nanpercentile(cred, 99)), + "fee_3gambe_usd_med": float(np.nanmedian(f[eq] * S[eq])) if eq.any() else np.nan, + "netto_usd_max": float(np.nanmax(netto_usd[eq])) if eq.any() else np.nan, + "netto_usd_p99": float(np.nanpercentile(netto_usd[eq], 99)) if eq.any() else np.nan, + "n_netto_pos": int(np.nansum(netto_usd[eq] > 0)), + "nozionale_min_lotto_usd": float(np.nanmedian(S) * lot), + } + + +# --------------------------------------------------------------------------- persistenza + +def persistenza(bf: pd.DataFrame, viol_col: str) -> dict: + """Una violazione che sparisce all'ora dopo e' un guizzo di book, non un'opportunita'. + + Chiave = (asset, exp, tipo, tripla di strike). Si guarda se la stessa tripla e' ancora in + violazione allo snapshot successivo IN CUI LA TRIPLA E' QUOTATA (non 'l'ora dopo' in astratto). + """ + v = bf[bf[viol_col]].copy() + if v.empty: + return {"n": 0} + bf = bf.copy() + bf["trip"] = (bf["asset"] + "|" + bf["exp"].astype(str) + "|" + bf["option_type"] + "|" + + bf["strike_p"].astype(str) + "|" + bf["strike"].astype(str) + "|" + + bf["strike_n"].astype(str)) + bf = bf.sort_values(["trip", "ts"]) + nxt = bf.groupby("trip", sort=False)[viol_col].shift(-1) + m = bf[viol_col] & nxt.notna() + return {"n": int(m.sum()), "resta": float(nxt[m].mean()) if m.any() else np.nan} + + +# --------------------------------------------------------------------------- controlli positivi + +def _finto(rows) -> pd.DataFrame: + """Catena sintetica minima per i controlli positivi.""" + d = pd.DataFrame(rows) + d["ts"] = pd.to_datetime(d["ts"], utc=True) + d["exp"] = pd.to_datetime(d["exp"], utc=True) + d["dte"] = (d["exp"] - d["ts"]).dt.total_seconds() / 86400.0 + d["T"] = d["dte"] / 365.25 + d["mid"] = (d["bid"] + d["ask"]) / 2 + for c, v in (("spot_feed", 100_000.0), ("F", 100_000.0), ("option_type", "P"), + ("open_interest", 1000.0), ("book_depth_top3", 10.0), ("delta", -0.2)): + if c not in d: + d[c] = v + return d + + +def controlli_positivi() -> None: + """Un rilevatore tarato per non segnalare e' indistinguibile da uno rotto. + + Regola gia' codificata nel progetto (venue_watch, 26/07). Qui i tre test vengono puntati + su violazioni COSTRUITE: se non le vedono, i loro zeri non valgono niente. + """ + print("=" * 100) + print("CONTROLLI POSITIVI β€” i tre rilevatori vedono una violazione COSTRUITA?") + print("=" * 100) + base = dict(ts="2026-08-01T00:00Z", exp="2026-08-15T08:00Z", asset="BTC", + option_type="P", iv=50.0, instrument_name="x") + # (1) verticale: put a strike BASSO con bid > ask della put a strike ALTO + v_ok = _finto([{**base, "strike": 90000, "bid": 0.0300, "ask": 0.0305}, + {**base, "strike": 95000, "bid": 0.0200, "ask": 0.0205}]) + v_no = _finto([{**base, "strike": 90000, "bid": 0.0200, "ask": 0.0205}, + {**base, "strike": 95000, "bid": 0.0300, "ask": 0.0305}]) + r_ok, r_no = test_verticale(v_ok, 0.0), test_verticale(v_no, 0.0) + print(f" TEST V: catena INVERTITA -> {r_ok['n_viol']} violazioni (attese >=1) Β· " + f"catena sana -> {r_no['n_viol']} (attese 0)") + # (2) farfalla: corpo troppo caro (concavita') + b_ok = _finto([{**base, "strike": 90000, "bid": 0.0100, "ask": 0.0105}, + {**base, "strike": 95000, "bid": 0.0400, "ask": 0.0405}, + {**base, "strike": 100000, "bid": 0.0500, "ask": 0.0505}]) + b_no = _finto([{**base, "strike": 90000, "bid": 0.0100, "ask": 0.0105}, + {**base, "strike": 95000, "bid": 0.0280, "ask": 0.0285}, + {**base, "strike": 100000, "bid": 0.0500, "ask": 0.0505}]) + f_ok = test_farfalla(b_ok, 0.0); f_no = test_farfalla(b_no, 0.0) + print(f" TEST B: corpo troppo caro -> {(f_ok['cost_exec'] < 0).sum()} violazioni eseguibili " + f"(attese >=1) Β· smile convesso -> {(f_no['cost_exec'] < 0).sum()} (attese 0)") + # (3) calendario: varianza totale che SCENDE col tenore, stesso strike + c_rows = [] + for e, iv in (("2026-08-08T08:00Z", 80.0), ("2026-08-15T08:00Z", 40.0)): + c_rows.append({**base, "exp": e, "strike": 100000, "bid": 0.01, "ask": 0.011, "iv": iv}) + c_ok = _finto(c_rows) + c_ok["ts"] = pd.Timestamp("2026-07-01", tz="UTC") + c_no = c_ok.copy(); c_no["iv"] = [40.0, 80.0] + r1 = test_calendario(c_ok, 0.0, ogni=1); r2 = test_calendario(c_no, 0.0, ogni=1) + print(f" TEST C: varianza totale DECRESCENTE -> {r1['i_viol']} violazioni (attese >=1) Β· " + f"crescente -> {r2['i_viol']} (attese 0)") + assert r_ok["n_viol"] >= 1 and r_no["n_viol"] == 0 + assert (f_ok["cost_exec"] < 0).sum() >= 1 and (f_no["cost_exec"] < 0).sum() == 0 + assert r1["i_viol"] >= 1 and r2["i_viol"] == 0 + print(" -> 3/3 rilevatori validati in ENTRAMBI i versi: gli zeri dei test veri sono zeri.\n") + + +# --------------------------------------------------------------------------- reversione + +def reversione(bf: pd.DataFrame, max_gap_h: float = 2.0) -> dict: + """Il residuo di convessita' si RICOMPONE? (l'ipotesi del filone, misurata) + + Coppie appaiate sulla STESSA tripla di strike a snapshot consecutivi entro `max_gap_h`. + beta di resid[t+1] - resid[t] su resid[t] : beta = -1 -> ricomposizione totale in un'ora, + beta = 0 -> il residuo e' una proprieta' persistente dello smile (skew), non un errore. + """ + x = bf.dropna(subset=["iv_resid"]).copy() + x["trip"] = (x["asset"] + "|" + x["exp"].astype(str) + "|" + x["option_type"] + "|" + + x["strike_p"].astype(str) + "|" + x["strike"].astype(str) + "|" + + x["strike_n"].astype(str)) + x = x.sort_values(["trip", "ts"]) + g = x.groupby("trip", sort=False) + r1 = g["iv_resid"].shift(-1).to_numpy() + dt = (g["ts"].shift(-1) - x["ts"]).dt.total_seconds().to_numpy() / 3600.0 + r0 = x["iv_resid"].to_numpy() + m = np.isfinite(r1) & np.isfinite(r0) & (dt > 0) & (dt <= max_gap_h) + if m.sum() < 100: + return {"n": int(m.sum())} + b = np.polyfit(r0[m], r1[m] - r0[m], 1)[0] + return {"n": int(m.sum()), "beta": float(b), "corr": float(np.corrcoef(r0[m], r1[m])[0, 1]), + "meta_vita_h": float(np.log(0.5) / np.log(max(1e-9, 1 + b))) if -1 < b < 0 else np.nan} + + +# --------------------------------------------------------------------------- main + +def main() -> None: + t0 = time.time() + controlli_positivi() + d = load() + q, F = certifica(d) + q = q.merge(F, on=["asset", "ts", "exp"], how="left") + q["F"] = q["underlying_price"].fillna(q["F_pcp"]) + print(f"\n forward disponibile su {q['F'].notna().mean():.1%} delle quote a due lati " + f"(osservato dove c'e', altrimenti parita' put-call)") + + for dte_min, etichetta in ((0.0, "SENZA filtro DTE"), (DTE_MIN, f"DTE >= {DTE_MIN:g}g")): + print("\n" + "=" * 100) + print(f"1-2. ARBITRAGGI STATICI MODEL-FREE su prezzi ESEGUIBILI β€” {etichetta}") + print("=" * 100) + v = test_verticale(q, dte_min) + print(f" TEST V (monotonia nello strike): {v['n_viol']:,} violazioni su {v['n_confronti']:,} " + f"confronti = {v['quota']:.4%}") + if v["n_viol"]: + print(f" credito mediano {v['credito_med_coin']:.5f} coin = " + f"${v['credito_usd_medlot']:.2f} al lotto minimo (LORDO fee)") + + bf = test_farfalla(q, dte_min) + bf = in_punti_vol(bf) + bf["viol_exec"] = bf["cost_exec"] < 0 + bf["viol_mid"] = bf["cost_mid"] < 0 + print(f" TEST B (convessita' nello strike): {len(bf):,} triple consecutive") + print(f" al MID (incoerenza, non eseguibile): {bf['viol_mid'].sum():,} = {bf['viol_mid'].mean():.3%}") + print(f" ESEGUIBILE (compra ali all'ask, vendi corpo al bid): " + f"{bf['viol_exec'].sum():,} = {bf['viol_exec'].mean():.4%}") + if dte_min == DTE_MIN: + bf_keep = bf + + bf = bf_keep + print("\n" + "=" * 100) + print("3. QUANTO E' GRANDE L'INCOERENZA, E QUANTO COSTA ATTRAVERSARLA") + print("=" * 100) + for a in sorted(bf["asset"].unique()): + x = bf[bf["asset"] == a] + x = x[np.isfinite(x["halfspread_pt"]) & np.isfinite(x["tick_pt"])] + r = np.abs(x["iv_resid"].dropna()) + hs, tk = x["halfspread_pt"], x["tick_pt"] + print(f" {a} (n={len(x):,} triple con vega misurabile)") + print(f" |residuo di convessita' dello smile| mediana {r.median():.3f} pt-vol Β· " + f"p90 {r.quantile(.9):.3f} Β· p99 {r.quantile(.99):.3f}") + print(f" mezzo spread denaro-lettera mediana {hs.median():.3f} pt-vol Β· " + f"p25 {hs.quantile(.25):.3f} Β· p75 {hs.quantile(.75):.3f}") + print(f" UN TICK della griglia di prezzo mediana {tk.median():.3f} pt-vol " + f"-> l'incoerenza e' sotto il tick nel {np.mean(r.reindex(x.index) < tk):.1%} dei casi") + both = x.dropna(subset=["iv_resid"]) + rap = np.abs(both["iv_resid"]) / (2 * both["halfspread_pt"]) + print(f" |residuo| / (spread PIENO delle gambe): mediana {rap.median():.3f} Β· " + f"p99 {rap.quantile(.99):.3f} Β· quota > 1 = {np.mean(rap > 1):.3%}") + print(f" spread relativo (ask-bid)/mid del venue: mediana {x['relspread'].median():.1%} " + f"Β· p25 {x['relspread'].quantile(.25):.1%}") + # valore in dollari dell'incoerenza al MID (il caso migliore possibile: fee 0, spread 0) + eq = np.isclose(x["lam"], 0.5, atol=0.02) + lot = MIN_LOT[a] + val = -x.loc[eq, "cost_mid"].to_numpy() * 2 * lot * x.loc[eq, "spot_feed"].to_numpy() + pos = val[val > 0] + print(f" valore LORDO al mid della farfalla incoerente, lotto minimo: " + f"mediana ${np.median(pos):.2f} Β· p99 ${np.percentile(pos,99):.2f} Β· max ${pos.max():.2f}" + f" (n={len(pos):,})") + + print("\n economia della farfalla al LOTTO MINIMO (famiglia INVERSE, quella nel dato):") + for a in sorted(bf["asset"].unique()): + e = economia(bf, a) + print(f" {a}: nozionale/lotto ${e['nozionale_min_lotto_usd']:,.0f} Β· " + f"fee 3 gambe mediana ${e['fee_3gambe_usd_med']:.2f} Β· " + f"triple equispaziate {e['n_equisp']:,}") + print(f" credito netto: max ${e['netto_usd_max']:.2f} Β· p99 ${e['netto_usd_p99']:.2f} Β· " + f"positivi {e['n_netto_pos']:,}/{e['n_equisp']:,}") + + print("\n ESTRAPOLAZIONE DICHIARATA alla famiglia USDC (NON nel dato: il collettore raccoglie") + print(" le sole inverse β€” difetto di produzione del filone 11). Il lotto minimo e' 10x piu'") + print(" piccolo, ma il muro NON e' il ticket: e' lo spread in punti di vol, che e' una") + print(" QUOTA e non un livello -> ridurre il lotto non cambia |residuo|/spread.") + for a in sorted(bf["asset"].unique()): + x = bf[bf["asset"] == a] + x = x[np.isfinite(x["halfspread_pt"])].dropna(subset=["iv_resid"]) + rap = np.abs(x["iv_resid"]) / (2 * x["halfspread_pt"]) + rel_inv = x["relspread"].median() + for nome, rel_usdc in ((f"{a}_USDC", 0.082 if a == "BTC" else 0.026),): + k = rel_inv / rel_usdc # di quanto lo spread sarebbe piu' stretto + print(f" {a}: spread relativo inverse {rel_inv:.1%} vs {nome} {rel_usdc:.1%} " + f"-> fattore {k:.1f}x; rapporto mediano {rap.median():.3f} -> {rap.median()*k:.3f}, " + f"quota > 1 {np.mean(rap > 1):.2%} -> {np.mean(rap * k > 1):.2%}") + + print("\n" + "=" * 100) + print("4. TEST C β€” varianza totale nel tenore (l'unico che tocca un interpolatore)") + print("=" * 100) + c = test_calendario(q, DTE_MIN) + print(f" (i) strike CONDIVISO, nessuna interpolazione: {c['i_viol']:,}/{c['i_n']:,} = {c['i_quota']:.3%}" + f" Β· ampiezza mediana {c['i_amp_med_pt']:.3f} pt-vol (p95 {c['i_amp_p95_pt']:.3f})") + print(f" (ii) log-moneyness FISSO, con interpolazione (1 snapshot ogni {c['ii_ogni']}): " + f"{c['ii_viol']:,}/{c['ii_n']:,} = {c['ii_quota']:.3%} Β· ampiezza mediana {c['ii_amp_med_pt']:.3f} pt-vol") + print(f" -> contributo dell'INTERPOLATORE = {c['ii_quota'] - c['i_quota']:+.3%} di quota di violazione") + + print("\n" + "=" * 100) + print("5. TICK E PERSISTENZA β€” le due difese contro 'la mia incoerenza e' un artefatto'") + print("=" * 100) + for col, nome in (("viol_mid", "al MID"), ("viol_exec", "ESEGUIBILE")): + v = bf[bf[col]] + if len(v) == 0: + print(f" {nome}: nessuna violazione") + continue + legs = np.concatenate([v["bid"].to_numpy(), v["bid_p"].to_numpy(), v["bid_n"].to_numpy()]) + legs = legs[np.isfinite(legs) & (legs > 0)] + nt = legs / tick_of(legs) + p = persistenza(bf, col) + print(f" {nome}: n={len(v):,} Β· gambe a <=2 tick {np.mean(nt <= 2):.1%} " + f"(mediana {np.median(nt):.0f} tick) Β· resta in violazione allo snapshot dopo " + f"{p.get('resta', float('nan')):.1%} (n={p['n']:,})") + + print("\n" + "=" * 100) + print("6. IL RESIDUO SI RICOMPONE? (l'ipotesi del filone, misurata invece che assunta)") + print("=" * 100) + for a in sorted(bf["asset"].unique()): + rv = reversione(bf[bf["asset"] == a]) + if "beta" not in rv: + print(f" {a}: coppie insufficienti (n={rv['n']})") + continue + print(f" {a}: n={rv['n']:,} coppie appaiate (stessa tripla, snapshot consecutivo <=2h) Β· " + f"beta di ricomposizione {rv['beta']:+.3f} Β· corr(resid_t, resid_t+1) {rv['corr']:+.3f}") + if np.isfinite(rv["meta_vita_h"]): + print(f" -> meta-vita {rv['meta_vita_h']:.1f} h (beta=-1 sarebbe ricomposizione totale " + f"in un'ora, beta=0 residuo persistente = forma dello smile, non errore)") + + print("\n" + "=" * 100) + print("7. ROBUSTEZZA D'ANCORA β€” il risultato dipende dall'ora in cui guardo?") + print("=" * 100) + h = bf.assign(ora=bf["ts"].dt.hour).groupby("ora").agg( + mid=("viol_mid", "mean"), ex=("viol_exec", "mean"), n=("viol_mid", "size")) + print(f" quota di incoerenza al MID per ora UTC: mediana {h['mid'].median():.3%} Β· " + f"min {h['mid'].min():.3%} (h{h['mid'].idxmin():02d}) Β· max {h['mid'].max():.3%} (h{h['mid'].idxmax():02d})") + print(f" quota ESEGUIBILE per ora UTC: max {h['ex'].max():.5%} su {len(h)} ore -> " + f"nessuna ora del giorno apre una finestra") + + print("\n" + "=" * 100) + print("SINTESI") + print("=" * 100) + nv = int(bf["viol_exec"].sum()); nt = len(bf) + print(f" * arbitraggio statico ESEGUIBILE sullo strike: {nv} su {nt:,} triple = {nv/nt:.6%}") + print(f" * incoerenza al MID: {bf['viol_mid'].mean():.2%} delle triple, " + f"ma mediana {np.abs(bf['iv_resid']).median():.3f} punti di vol") + print(f" * mezzo spread mediano: BTC {bf[bf.asset=='BTC']['halfspread_pt'].median():.3f} pt-vol, " + f"ETH {bf[bf.asset=='ETH']['halfspread_pt'].median():.3f} pt-vol") + print(" * il credito netto della migliore farfalla eseguibile in 1,03M osservazioni e' NEGATIVO") + print(" -> l'incoerenza ESISTE e vale meno del costo di attraversarla: verdetto SCARTATO.") + print(f"\n[tempo {time.time()-t0:.1f}s]") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822b_tp01_sinistro.py b/scripts/research/r0822b_tp01_sinistro.py new file mode 100644 index 0000000..20e0ece --- /dev/null +++ b/scripts/research/r0822b_tp01_sinistro.py @@ -0,0 +1,873 @@ +"""r0822b_tp01_sinistro β€” quanto costa l'ASSICURAZIONE TP01 su un conto a barriera (2026-08-22). + +IL BUCO CHE CHIUDE +------------------ +`r0822_prop_alloc.py` (stessa ondata) ha ottimizzato l'allocazione di un conto **funded** sotto +barriera di drawdown e ha trovato l'ottimo a **TP01 25 / SKH01 25 / XS01 50**, cioe' TP01 pesato +**tre volte meno** del libro live (75%). Ha anche dichiarato il proprio limite: + + "la sotto-pesatura di TP01, lo sleeve DIFENSIVO, e' misurata su un campione SENZA SINISTRO + (2024-2026 non contiene un crash): su una barriera assorbente quella scommessa si paga una + volta sola." + +E questo si scontra con due misure del progetto: + (a) il **leave-one-out de-luckato** del 26/07: TP01 e' il maggior contributore al Sharpe FULL + (+0.390, positivo nel 100% delle estrazioni) e il suo hold-out negativo (-0.200, negativo + nel 99.1%) e' *la firma dell'assicurazione, non della morte* β€” "uno sleeve difensivo si + giudica sul SINISTRO, non sul premio"; + (b) `edge_watch` criterio B: negli **8 anni su 8** con drawdown buy&hold > 10% TP01 ha protetto + da **1.8x a 34.4x**. + +DOMANDA: qual e' il peso giusto di TP01 se l'obiettivo include sopravvivere a un crash che nel +campione di ottimizzazione NON c'e'? + +IPOTESI REGISTRATA PRIMA DI MISURARE +------------------------------------ +Mi aspettavo che (1) la sotto-pesatura di TP01 costasse molto **nel sinistro** e poco fuori, e che +(2) esistesse un peso di compromesso (~50%) dentro la regione robusta del funded e non +catastrofico al crash. In altre parole mi aspettavo di **confermare** il caveat di PROP-ALLOC. +Se la misura dice il contrario, e' un risultato e va scritto come tale. + +CIO' CHE E' UN'ASSUNZIONE E CIO' CHE E' UN DATO +----------------------------------------------- +* Lo **scenario di stress e' uno SCENARIO, non una previsione.** Due costruzioni, entrambe + dichiarate: (S1) bootstrap MISTO β€” ogni blocco ha probabilita' `p` di essere estratto dal pool + dei sinistri 2019-2023 invece che dalla finestra normale; (S2) **replay** β€” un episodio di + crash VERO innestato per intero, in ordine, a una data casuale dentro l'anno. In piu' il + replay deterministico del **2022 intero** (aprile-dicembre), che non e' un modello: e' successo. +* Il vincolo binding di TUTTO il filone e' che i sinistri osservati sono **QUATTRO**. Nessun + bootstrap crea informazione: l'ampiezza effettiva della coda resta 4 episodi, e ogni numero qui + eredita quell'errore. Detto una volta, vale per ogni tabella. +* Lente **ACCOPPIATA** (`r0725_prop_coupled`): il minimo intraday si compone ESATTO sul path + orario condiviso TP01/SKH01. Non e' un dettaglio: su una regola a UN giorno (daily-loss) la + lente close-only e' misurata **esattamente cieca** (0.00 breach contro 0.40-1.21 veri), mentre + su una statistica multi-giorno l'errore vale il 3.5%. Il vincolo funded E' una regola a un giorno. +* de-luck sul drift **x0.89** (fattore MISURATO il 26/07). ⚠️ L'offset di de-luck e' calcolato UNA + VOLTA sulla finestra 2019+ e sottratto ovunque in valore ASSOLUTO: de-luckare un pool di crash + sulla sua PROPRIA media (negativa) *aggiungerebbe* drift proprio dove non deve. +* macchineria RIUSATA, non riscritta: `r0725_prop_coupled` (tuple accoppiate) e `r0822_prop_alloc` + (`eval_sim`, `funded_sim`, `boot_idx`, `FIRMS`). Qui si aggiungono solo i pool e gli innesti. + +Uso: `nice -n 19 timeout 900 uv run python scripts/research/r0822b_tp01_sinistro.py` +""" +from __future__ import annotations + +import sys +import time +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")) + +from src.data.downloader import load_data # noqa: E402 +from src.portfolio.sleeves import _tp01_returns, _skyhook_returns # noqa: E402 + +import r0725_prop_coupled as pc # noqa: E402 +import r0822_prop_alloc as PA # noqa: E402 + +# ------------------------------------------------------------------ costanti DICHIARATE +DELUCK = PA.DELUCK # 0.89, misurato il 26/07 +FULL_START = "2019-03-14" # inizio del recon crypto (TP01+SKH01) +RECENT_START = "2024-01-01" # finestra di ottimizzazione di PROP-ALLOC (nasce XS01) +BLOCK = 20 # blocco del bootstrap, in giorni +N_PATHS = 2500 +EPI_LEN = 30 # lunghezza di un episodio di sinistro, in giorni +HORIZON = 365 +LEVS = (0.25, 0.375, 0.50, 0.625, 0.75, 1.00, 1.25, 1.50) +W_GRID = tuple(i / 8 for i in range(9)) # peso di TP01 nella coppia crypto, 0 -> 1 +SURV_TARGET = 0.90 # livello di sopravvivenza della frontiera ISO-SOPRAVVIVENZA + +N_EVAL = 0 # contatore onesto delle valutazioni (griglia dichiarata) + + +def hr(t: str = "") -> None: + print("\n" + "-" * 106) + if t: + print(f" {t}") + print("-" * 106) + + +# ================================================================== Β§A. PANNELLO + +_TUP: dict = {} + + +def crypto(w_tp: float) -> tuple[np.ndarray, np.ndarray, pd.DatetimeIndex]: + """(R, gap) giornalieri della coppia crypto a peso TP01 = w_tp, SKH01 = 1-w_tp. + gap = minimo_intraday - chiusura (<=0). Storia piena dal 2019-03.""" + k = round(float(w_tp), 6) + if k not in _TUP: + c = pc.crypto_daily_tuples(k, 1.0 - k) + R = c["R"].values.astype(float) + G = np.minimum(c["m"].values, R) - R + _TUP[k] = (R, G, c.index) + R, G, I = _TUP[k] + return R.copy(), G.copy(), I + + +_OFF: dict = {} + + +def deluck_offset(w_tp: float, factor: float = DELUCK) -> float: + """Offset ASSOLUTO di de-luck, calcolato UNA VOLTA sulla finestra 2019+ e riusato ovunque. + Calcolarlo sulla media di un sotto-campione di crash (negativa) *aggiungerebbe* drift.""" + key = (round(float(w_tp), 6), round(float(factor), 4)) + if key not in _OFF: + R, _, I = crypto(w_tp) + _OFF[key] = float((1.0 - factor) * R[I >= pd.Timestamp(FULL_START, tz="UTC")].mean()) + return _OFF[key] + + +def buyhold_daily(idx: pd.DatetimeIndex) -> pd.Series: + """Buy&hold 50/50 BTC+ETH sulla stessa griglia giornaliera del libro.""" + bh = None + for a in ("BTC", "ETH"): + d = load_data(a, "1h") + i = pd.DatetimeIndex(pd.to_datetime(d["datetime"], utc=True)) + s = pd.Series(d["close"].astype(float).values, index=i).resample("1D").last() + r = s.pct_change().fillna(0.0) * 0.5 + bh = r if bh is None else bh.add(r, fill_value=0.0) + return bh.reindex(idx).fillna(0.0) + + +def worst_windows(lbh: np.ndarray, idx: pd.DatetimeIndex, K: int, L: int, + hi: int | None = None) -> list[int]: + """Le K finestre di L giorni NON sovrapposte col peggior rendimento buy&hold, greedy sul + minimo. `hi` limita i possibili inizi (per restare dentro 2019-2023).""" + cs = np.concatenate(([0.0], np.cumsum(lbh))) + win = np.expm1(cs[L:] - cs[:-L]) + cand = np.arange(len(win)) if hi is None else np.arange(min(hi, len(win))) + order = cand[np.argsort(win[cand])] + out: list[int] = [] + for t in order: + if all(abs(int(t) - q) >= L for q in out): + out.append(int(t)) + if len(out) == K: + break + return sorted(out) + + +def binom_two_sided(k: int, n: int) -> float: + """p-value esatto a due code di un test dei segni con p0=0.5 (n piccolo: si conta).""" + from math import comb + tot = 2 ** n + kk = max(k, n - k) + tail = sum(comb(n, j) for j in range(kk, n + 1)) + return min(1.0, 2.0 * tail / tot) + + +def maxdd(r: np.ndarray) -> float: + eq = np.cumprod(1.0 + r) + return float((eq / np.maximum.accumulate(eq) - 1.0).min()) + + +# ================================================================== Β§B. SIMULAZIONE + +def J_of(R: np.ndarray, G: np.ndarray, ev_idx: np.ndarray, fu_idx: np.ndarray, + lev: float, firm: str = "HYRO") -> dict: + """J = P(passare l'eval) x P(essere vivo a 12 mesi da funded), lente ACCOPPIATA.""" + global N_EVAL + N_EVAL += 1 + e = PA.eval_sim(R, G, ev_idx, lev, firm, "coupled") + f = PA.funded_sim(R, G, fu_idx, lev, firm, 100_000.0, "coupled") + return dict(J=e["p_pass"] * f["p_alive"], p_pass=e["p_pass"], p_alive=f["p_alive"], + e_payout=f["e_payout"], jvec=(e["passed"] & f["alive"]).astype(float), + alive=f["alive"]) + + +def best_lev(R: np.ndarray, G: np.ndarray, ev_idx, fu_idx, firm: str = "HYRO") -> tuple: + rows = [(lv, J_of(R, G, ev_idx, fu_idx, lv, firm)) for lv in LEVS] + return max(rows, key=lambda x: x[1]["J"]) + + +def pool_paths(seg_bounds: list[tuple[int, int]], n_days: int, n_paths: int, seed: int, + block: int = BLOCK) -> np.ndarray: + """Indici di bootstrap a blocchi ristretti a segmenti CONTIGUI (ogni blocco vive dentro un + solo segmento -> non si incollano giorni di episodi diversi).""" + rng = np.random.default_rng(seed) + starts = [] + for a, b in seg_bounds: + if b - a >= block: + starts.extend(range(a, b - block + 1)) + starts = np.array(starts) + nb = int(np.ceil(n_days / block)) + st = starts[rng.integers(0, len(starts), size=(n_paths, nb))] + return (st[:, :, None] + np.arange(block)[None, None, :]).reshape(n_paths, -1)[:, :n_days] + + +def mix_paths(base_idx: np.ndarray, crash_starts: np.ndarray, p: float, seed: int, + block: int = BLOCK) -> np.ndarray: + """S1 β€” bootstrap MISTO: ogni blocco della path normale ha probabilita' `p` di essere + sostituito da un blocco estratto dal pool dei SINISTRI.""" + rng = np.random.default_rng(seed) + out = base_idx.copy() + P, D = out.shape + nb = int(np.ceil(D / block)) + hit = rng.random((P, nb)) < p + for b in range(nb): + sl = slice(b * block, min((b + 1) * block, D)) + L = sl.stop - sl.start + rows = np.where(hit[:, b])[0] + if len(rows) == 0: + continue + st = crash_starts[rng.integers(0, len(crash_starts), size=len(rows))] + out[rows, sl] = st[:, None] + np.arange(L)[None, :] + return out + + +def splice_paths(base_idx: np.ndarray, epi_idx: np.ndarray, seed: int) -> np.ndarray: + """S2 β€” REPLAY: l'episodio VERO (contiguo, in ordine) innestato una volta sola a una data + casuale dentro l'orizzonte. E' la domanda operativa: *se il sinistro arriva*.""" + rng = np.random.default_rng(seed) + out = base_idx.copy() + P, D = out.shape + L = len(epi_idx) + st = rng.integers(0, max(D - L, 1), size=P) + for i in range(P): + a = int(st[i]) + out[i, a:a + L] = epi_idx[:min(L, D - a)] + return out + + +# ================================================================== main + +def main() -> None: # noqa: C901 + try: + sys.stdout.reconfigure(line_buffering=True) + except Exception: # pragma: no cover + pass + t0 = time.time() + + def tick(lab: str) -> None: + print(f" [{time.time()-t0:6.0f}s] {lab} (valutazioni finora: {N_EVAL})") + + print("=" * 106) + print(" TP01-SINISTRO β€” quanto costa l'assicurazione su un conto a barriera, e quanto rende") + print(" quando il sinistro arriva. Lente ACCOPPIATA, firm HYRO.") + print("=" * 106) + + R75, G75, IDX = crypto(0.75) + n_full = len(IDX) + i_rec = int(np.searchsorted(IDX, pd.Timestamp(RECENT_START, tz="UTC"))) + print(f"\n finestra crypto piena: {n_full} giorni {IDX[0].date()} -> {IDX[-1].date()}") + print(f" finestra di ottimizzazione di PROP-ALLOC: {n_full - i_rec} giorni dal " + f"{IDX[i_rec].date()} (e' anche la finestra di SCOPERTA di XS01)") + + # --------------------------------------------------------- sanity: recon vs sleeve ufficiale + print("\n SANITY β€” il recon orario contro gli sleeve ufficiali. TP01/SKH01 NON coincidono") + print(" barra per barra per costruzione (il recon e' un mark-to-market ORARIO, lo sleeve") + print(" marca a fine-trade): il controllo giusto e' il log-totale.") + for nm, off_s, w in (("TP01", _tp01_returns(), 1.0), ("SKH01", _skyhook_returns(), 0.0)): + o = off_s.dropna().sort_index() + if o.index.tz is None: + o.index = o.index.tz_localize("UTC") + Rn, _, In = crypto(w) + com = o.index.intersection(In) + a = o.reindex(com).values + b = pd.Series(Rn, index=In).reindex(com).values + print(f" {nm:>6} log-tot sleeve {float(np.log1p(a).sum()):+8.4f} " + f"recon {float(np.log1p(b).sum()):+8.4f} scarto " + f"{float(np.log1p(a).sum()-np.log1p(b).sum()):+7.4f}") + + # ============================================================ Β§1. CHE COS'E' UN SINISTRO + hr("Β§1. CHE COS'E' UN SINISTRO β€” e la frase '2024-2026 non contiene un crash' e' FALSA") + bh = buyhold_daily(IDX) + lbh = np.log1p(bh.values) + print(" Peggior rendimento buy&hold 50/50 su finestra mobile, per periodo:") + print(f" {'W':>4} {'2019-2023 min':>15} {'2024+ min':>12} {'rapporto':>10} " + f"{'2019-23 p1':>11} {'2024+ p1':>10} {'data del min 2024+':>20}") + ratios = [] + for W in (1, 5, 10, 20, 60): + cs = np.concatenate(([0.0], np.cumsum(lbh))) + roll = np.expm1(cs[W:] - cs[:-W]) # finestra che FINISCE in t=W-1+k + tt = IDX[W - 1:] + pre = roll[tt < pd.Timestamp(RECENT_START, tz="UTC")] + post = roll[tt >= pd.Timestamp(RECENT_START, tz="UTC")] + tpost = tt[tt >= pd.Timestamp(RECENT_START, tz="UTC")] + rr = pre.min() / post.min() + ratios.append(rr) + print(f" {W:>4} {pre.min():>15.1%} {post.min():>12.1%} {rr:>10.2f}x " + f"{np.percentile(pre, 1):>11.1%} {np.percentile(post, 1):>10.1%} " + f"{str(tpost[int(np.argmin(post))].date()):>20}") + print(f"\n -> il 2024+ **contiene** un crash: peggior giorno -14.5%, peggiori 20g -39.6%,") + print(f" drawdown buy&hold fino a -60%. Cio' che NON contiene e' un crash della TAGLIA") + print(f" dei precedenti: il rapporto e' {np.mean(ratios[:3]):.1f}x sulla coda veloce (1-10g)") + print(f" e {ratios[-1]:.1f}x a 60 giorni. **La frase giusta non e' 'senza sinistro' ma") + print(f" 'con un sinistro di taglia dimezzata'** β€” e cambia la domanda: non 'cosa succede") + print(f" se arriva un crash' ma 'cosa succede se ne arriva uno DUE VOLTE piu' grande'.") + + # ----- replica del criterio B di edge_watch (protezione per anno) + print("\n Criterio B di `edge_watch` replicato qui (anni con DD buy&hold > 10%):") + tp_only, _, _ = crypto(1.0) + ser_bh = pd.Series(bh.values, index=IDX) + ser_tp = pd.Series(tp_only, index=IDX) + print(f" {'anno':>6} {'DD b&h':>9} {'DD TP01':>9} {'protezione':>12} {'DD SKH01':>10} " + f"{'DD 75/25':>10}") + skh_only, _, _ = crypto(0.0) + ser_sk = pd.Series(skh_only, index=IDX) + ser_75 = pd.Series(R75, index=IDX) + n_sin = n_ok = 0 + for y, g in ser_bh.groupby(ser_bh.index.year): + d_bh = maxdd(g.values) + if d_bh > -0.10: + continue + n_sin += 1 + d_tp = maxdd(ser_tp.loc[g.index].values) + d_sk = maxdd(ser_sk.loc[g.index].values) + d_75 = maxdd(ser_75.loc[g.index].values) + ok = (d_tp / d_bh) <= 0.75 + n_ok += ok + print(f" {y:>6} {d_bh:>9.1%} {d_tp:>9.1%} {d_bh/d_tp:>11.1f}x {d_sk:>10.1%} " + f"{d_75:>10.1%} {'ok' if ok else 'FAIL'}") + print(f" -> anni di sinistro {n_sin}, superati {n_ok}. **Ogni anno e' un anno di sinistro") + print(f" per il buy&hold**: la 'frequenza del sinistro' non e' un evento raro, e' lo") + print(f" stato normale dell'asset. Cio' che e' raro e' la TAGLIA (riga sopra).") + + # ----- definizione degli episodi + hr("Β§1-bis. GLI EPISODI DI SINISTRO β€” regola di selezione DICHIARATA PRIMA") + print(f" Regola: le {3} finestre di {EPI_LEN} giorni NON sovrapposte con il peggior rendimento") + print(f" buy&hold nel 2019-2023 (greedy sul minimo), PIU' l'FTX 2022-11-05 aggiunto **per") + print(f" NOME** (evento noto, non selezionato dal minimo: e' un controllo, non una scelta).") + cs = np.concatenate(([0.0], np.cumsum(lbh))) + win = np.expm1(cs[EPI_LEN:] - cs[:-EPI_LEN]) # finestra che INIZIA in t + pre_mask = IDX[:len(win)] < pd.Timestamp(RECENT_START, tz="UTC") + cand = np.where(pre_mask)[0] + order = cand[np.argsort(win[cand])] + picked = [] + for t in order: + if all(abs(int(t) - p) >= EPI_LEN for p in picked): + picked.append(int(t)) + if len(picked) == 3: + break + t_ftx = int(np.searchsorted(IDX, pd.Timestamp("2022-11-05", tz="UTC"))) + epis = sorted(picked + [t_ftx]) + NAMES = {} + print(f"\n {'episodio':>12} {'dal':>12} {'al':>12} {'b&h 30g':>10} {'peggior 1g b&h':>16}") + for t in epis: + lab = ("COVID" if IDX[t].year == 2020 else + "MAG-2021" if (IDX[t].year, IDX[t].month) == (2021, 5) else + "LUNA" if (IDX[t].year, IDX[t].month) in ((2022, 5), (2022, 6)) else + "FTX" if (IDX[t].year, IDX[t].month) == (2022, 11) else f"EPI-{IDX[t].date()}") + NAMES[t] = lab + seg = bh.values[t:t + EPI_LEN] + print(f" {lab:>12} {str(IDX[t].date()):>12} {str(IDX[t+EPI_LEN-1].date()):>12} " + f"{win[t]:>10.1%} {seg.min():>16.1%}") + crash_days = np.concatenate([np.arange(t, t + EPI_LEN) for t in epis]) + freq_day = len(crash_days) / n_full + print(f"\n giorni di sinistro {len(crash_days)} su {n_full} = **{freq_day:.1%} del tempo**") + print(f" ⚠️ AMPIEZZA EFFETTIVA DELLA CODA = **4 EPISODI**. Nessun bootstrap la aumenta.") + print(f" Ogni numero di questo file eredita l'errore di un campione di quattro eventi.") + + tick("Β§1 sinistri") + + # ============================================================ Β§2. IL CONTO DELL'ASSICURAZIONE + hr("Β§2. IL CONTO DELL'ASSICURAZIONE β€” premio fuori dal sinistro, indennizzo dentro") + print(" Confronto DUE ripartizioni della coppia crypto, appaiato giorno per giorno:") + print(" LIVE = TP01 75 / SKH01 25 (il libro che gira)") + print(" OPT = TP01 38 / SKH01 62 (l'ottimo di PROP-ALLOC sul campione 2019+)") + in_sin = np.zeros(n_full, bool) + in_sin[crash_days] = True + print(f"\n {'config':>10} {'drift fuori':>12} {'drift dentro':>13} {'Sharpe full':>12} " + f"{'maxDD':>8} {'peggior 1g':>11} {'peggior min 1g':>15}") + prof = {} + for lab, w in (("LIVE 75/25", 0.75), ("OPT 38/62", 0.375), ("TP01 puro", 1.0), + ("SKH01 puro", 0.0)): + R, G, _ = crypto(w) + R = R - deluck_offset(w) + out_d = R[~in_sin].mean() * 365.0 + in_d = R[in_sin].mean() * 365.0 + vol = R.std() * np.sqrt(365.0) + prof[lab] = dict(out=out_d, ind=in_d, sh=R.mean() * 365.0 / vol, dd=maxdd(R), + w1=R.min(), wm=(R + G).min()) + p = prof[lab] + print(f" {lab:>10} {p['out']:>12.1%} {p['ind']:>13.1%} {p['sh']:>12.2f} {p['dd']:>8.1%} " + f"{p['w1']:>11.2%} {p['wm']:>15.2%}") + prem = prof["LIVE 75/25"]["out"] - prof["OPT 38/62"]["out"] + ind = prof["LIVE 75/25"]["ind"] - prof["OPT 38/62"]["ind"] + R_live = crypto(0.75)[0] - deluck_offset(0.75) + R_opt = crypto(0.375)[0] - deluck_offset(0.375) + cum_live = float(np.expm1(np.log1p(R_live[in_sin]).sum())) + cum_opt = float(np.expm1(np.log1p(R_opt[in_sin]).sum())) + print(f"\n PREMIO (LIVE - OPT) fuori dal sinistro : {prem:+.2%}/anno = " + f"{prem*100_000:+,.0f} $/anno su un funded da $100k") + print(f" INDENNIZZO (LIVE - OPT) sui 120 giorni di sinistro, CUMULATO (non annualizzato,") + print(f" che su 120 giorni sarebbe una finta precisione): LIVE {cum_live:+.1%} contro " + f"OPT {cum_opt:+.1%} = {cum_live-cum_opt:+.1%}") + print(f" su un funded da $100k: {(cum_live-cum_opt)*100_000:+,.0f} $ per l'INTERO " + f"monte-sinistri di {n_full/365.0:.1f} anni") + print(f" frequenza: {freq_day:.1%} del tempo dentro un sinistro (4 episodi in " + f"{n_full/365.0:.1f} anni = uno ogni {n_full/365.0/4:.1f} anni)") + if ind < 0: + print("\n 🚨 **IL SEGNO E' ROVESCIATO RISPETTO ALLA MIA IPOTESI.** Dentro il sinistro il") + print(" libro con MENO TP01 fa MEGLIO, non peggio. Il meccanismo si legge in una riga:") + print(" TP01 nel crash va FLAT (non perde, non guadagna: e' assicurazione), SKH01 va") + print(" SHORT (guadagna). Su un conto a barriera *guadagnare batte non-perdere*.") + print("\n Rendimento dei 4 episodi, per peso di TP01 (30 giorni ciascuno, chiusura e MINIMO):") + print(f" {'w TP01':>8} " + " ".join(f"{NAMES[t]:>18}" for t in epis) + f" {'peggior min':>13}") + for w in (1.0, 0.75, 0.5, 0.375, 0.25, 0.0): + R, G, _ = crypto(w) + cells = [] + wm = 0.0 + for t in epis: + seg = R[t:t + EPI_LEN] + gp = G[t:t + EPI_LEN] + eq = np.cumprod(1.0 + seg) + mn = float(np.minimum(eq - 1.0, np.concatenate(([1.0], eq[:-1])) * (1 + seg + gp) - 1.0).min()) + wm = min(wm, mn) + cells.append(f"{float(eq[-1]-1.0):+7.1%}/{mn:+7.1%}") + print(f" {w:>8.3f} " + " ".join(f"{c:>18}" for c in cells) + f" {wm:>13.1%}") + print(" (formato: chiusura a 30g / minimo intraday cumulato dell'episodio)") + + print("\n ⚠️ QUANTA POTENZA HA QUESTO SEGNO? Con 4 episodi, '4 su 4 a favore' ha un test dei") + print(" segni con p = 0.125 a due code: **non e' significativo al 5%**. Il modo onesto di") + print(" guadagnare potenza non e' ripetere il bootstrap (non crea eventi) ma ESTENDERE la") + print(" stessa regola di selezione a piu' finestre. Le 12 peggiori finestre di 30g NON") + print(" sovrapposte su TUTTO il 2019+ (stessa regola greedy, K da 3 a 12):") + w12 = worst_windows(lbh, IDX, 12, EPI_LEN) + print(f"\n {'finestra':>12} {'b&h 30g':>9} {'LIVE 75/25':>11} {'OPT 38/62':>11} " + f"{'TP01 puro':>10} {'SKH01 puro':>11} {'OPT>LIVE':>9}") + wins = 0 + wins_sk = 0 + lr_live, lr_opt = np.log1p(R_live), np.log1p(R_opt) + R_tp = crypto(1.0)[0] - deluck_offset(1.0) + R_sk = crypto(0.0)[0] - deluck_offset(0.0) + for t in w12: + a = float(np.expm1(lr_live[t:t + EPI_LEN].sum())) + b = float(np.expm1(lr_opt[t:t + EPI_LEN].sum())) + c = float(np.expm1(np.log1p(R_tp[t:t + EPI_LEN]).sum())) + d = float(np.expm1(np.log1p(R_sk[t:t + EPI_LEN]).sum())) + wins += b > a + wins_sk += d > c + print(f" {str(IDX[t].date()):>12} {win[t]:>9.1%} {a:>11.1%} {b:>11.1%} " + f"{c:>10.1%} {d:>11.1%} {('si' if b > a else 'no'):>9}") + print(f"\n OPT batte LIVE in **{wins}/12** finestre -> test dei segni " + f"p = {binom_two_sided(wins, 12):.4f}") + print(f" SKH01 puro batte TP01 puro in **{wins_sk}/12** -> p = " + f"{binom_two_sided(wins_sk, 12):.4f}") + print(" ⚠️ 5 di queste 12 finestre stanno nel 2024+, cioe' DENTRO la finestra su cui") + print(" SKH01 e' stato tarato e su cui PROP-ALLOC ha ottimizzato: la potenza sale ma") + print(" l'indipendenza scende. Il test va letto come **una diagnostica, non una prova**.") + + tick("Β§2 conto assicurazione") + + # ============================================================ Β§3. SOTTO BARRIERA + hr("Β§3. SOTTO BARRIERA β€” griglia peso TP01 x leva su TRE lenti di campione") + print(f" {len(W_GRID)} pesi x {len(LEVS)} leve x 3 finestre. J = P(pass) x P(vivo 12m), HYRO") + print(f" (eval +10%/-6% statico/-4% giornaliero; funded -6% statico/-4% giornaliero).") + ev_f = PA.boot_idx(n_full, HORIZON, N_PATHS, seed=8230) + fu_f = PA.boot_idx(n_full, HORIZON, N_PATHS, seed=8231) + n_rec = n_full - i_rec + ev_r = PA.boot_idx(n_rec, HORIZON, N_PATHS, seed=8240) + fu_r = PA.boot_idx(n_rec, HORIZON, N_PATHS, seed=8241) + seg_sin = [(t, t + EPI_LEN) for t in epis] + ev_s = pool_paths(seg_sin, HORIZON, N_PATHS, seed=8250) + fu_s = pool_paths(seg_sin, HORIZON, N_PATHS, seed=8251) + + print(f"\n {'w TP01':>7} | {'2019+ (col 2022)':>26} | {'2024+ (PROP-ALLOC)':>26} | " + f"{'SOLO-SINISTRO':>26}") + print(f" {'':>7} | {'leva':>6} {'J':>7} {'P(viv)':>7} | {'leva':>6} {'J':>7} {'P(viv)':>7} | " + f"{'leva':>6} {'J':>7} {'P(viv)':>7}") + tab = {} + for w in W_GRID: + R, G, _ = crypto(w) + off = deluck_offset(w) + Rf, Gf = R - off, G + Rr, Gr = Rf[i_rec:], Gf[i_rec:] + lv1, o1 = best_lev(Rf, Gf, ev_f, fu_f) + lv2, o2 = best_lev(Rr, Gr, ev_r, fu_r) + lv3, o3 = best_lev(Rf, Gf, ev_s, fu_s) + tab[w] = dict(full=(lv1, o1), rec=(lv2, o2), sin=(lv3, o3)) + print(f" {w:>7.3f} | {lv1:>6.3f} {o1['J']:>7.3f} {o1['p_alive']:>7.1%} | " + f"{lv2:>6.3f} {o2['J']:>7.3f} {o2['p_alive']:>7.1%} | " + f"{lv3:>6.3f} {o3['J']:>7.3f} {o3['p_alive']:>7.1%}") + a_full = max(tab, key=lambda w: tab[w]["full"][1]["J"]) + a_rec = max(tab, key=lambda w: tab[w]["rec"][1]["J"]) + a_sin = max(tab, key=lambda w: tab[w]["sin"][1]["J"]) + print(f"\n argmax: 2019+ -> TP01 {a_full:.3f} | 2024+ -> TP01 {a_rec:.3f} | " + f"SOLO-SINISTRO -> TP01 {a_sin:.3f}") + print(" ⚠️ La colonna SOLO-SINISTRO e' una lente ESTREMA e va letta come tale: l'anno intero") + print(" e' fatto di soli blocchi di crash (4 episodi ricampionati). Non e' uno scenario") + print(" plausibile, e' il **limite superiore del danno**: se il verso della risposta e' lo") + print(" stesso qui e sul 2019+, non dipende dalla frazione di sinistro assunta.") + + # replica di controllo del numero pubblicato da PROP-ALLOC (E-ter) + print("\n REPLICA DI CONTROLLO β€” PROP-ALLOC E-ter pubblica, sul 2019+: argmax della coppia") + print(" crypto **38/62 @0.50x con J 0.521**, e **75/25 J 0.443**. Stesse seed, stessa lente:") + j38 = tab[0.375]["full"] + j75 = tab[0.75]["full"] + print(f" qui: 38/62 @{j38[0]:g}x J {j38[1]['J']:.3f} | 75/25 @{j75[0]:g}x " + f"J {j75[1]['J']:.3f} -> " + f"{'REPLICA OK' if abs(j38[1]['J']-0.521) < 0.02 and abs(j75[1]['J']-0.443) < 0.02 else 'NON REPLICA β€” leggere con cautela'}") + + tick("Β§3 griglia 3 lenti") + + # ============================================================ Β§4. LO STRESS ESPLICITO + hr("Β§4. LO STRESS ESPLICITO β€” iniettare nel 2024+ i crash che il 2024+ non ha") + print(" Costruzione DICHIARATA. Base = blocchi del 2024+ (la finestra di ottimizzazione).") + print(" S1 MISTO: ogni blocco ha probabilita' p di venire dal pool dei 4 sinistri 2019-2023.") + print(f" p = {freq_day:.3f} e' la frequenza STORICA; si girano anche 2x e 4x.") + print(" S2 REPLAY: un episodio VERO innestato per intero, in ordine, a data casuale dentro") + print(" l'anno. E' la domanda operativa: *se il sinistro arriva, chi resta in piedi?*") + print(" ⚠️ Sono SCENARI, non previsioni. E poggiano su 4 episodi.") + + # pool = 2024+ (blocchi normali) + gli episodi in coda, su un indice unico + def stress_panel(w: float) -> tuple[np.ndarray, np.ndarray, np.ndarray, list]: + R, G, _ = crypto(w) + off = deluck_offset(w) + Rn, Gn = R[i_rec:] - off, G[i_rec:] + segs = [] + Rc, Gc = [], [] + cur = len(Rn) + for t in epis: + Rc.append(R[t:t + EPI_LEN] - off) + Gc.append(G[t:t + EPI_LEN]) + segs.append((cur, cur + EPI_LEN)) + cur += EPI_LEN + Rp = np.concatenate([Rn] + Rc) + Gp = np.concatenate([Gn] + Gc) + return Rp, Gp, np.array([s for s, _ in segs]), segs + + base_ev = PA.boot_idx(n_rec, HORIZON, N_PATHS, seed=8260) + base_fu = PA.boot_idx(n_rec, HORIZON, N_PATHS, seed=8261) + + print(f"\n S1 β€” bootstrap MISTO (base 2024+, blocchi di sinistro con probabilita' p)") + print(f" {'w TP01':>7} | " + " | ".join(f"p={p:.3f}: {'J':>6} {'P(viv)':>7}" + for p in (0.0, freq_day, 2 * freq_day, 4 * freq_day))) + s1 = {} + for w in W_GRID: + Rp, Gp, cst, segs = stress_panel(w) + starts = np.concatenate([np.arange(a, b - BLOCK + 1) for a, b in segs]) + cells = [] + for j, p in enumerate((0.0, freq_day, 2 * freq_day, 4 * freq_day)): + ev = base_ev if p == 0 else mix_paths(base_ev, starts, p, seed=8270 + j) + fu = base_fu if p == 0 else mix_paths(base_fu, starts, p, seed=8280 + j) + lv, o = best_lev(Rp, Gp, ev, fu) + cells.append((lv, o)) + s1[w] = cells + print(f" {w:>7.3f} | " + " | ".join(f" @{c[0]:.3g}x {c[1]['J']:>6.3f} " + f"{c[1]['p_alive']:>7.1%}" for c in cells)) + for j, p in enumerate((0.0, freq_day, 2 * freq_day, 4 * freq_day)): + am = max(s1, key=lambda w: s1[w][j][1]["J"]) + print(f" p={p:.3f} -> argmax TP01 {am:.3f} (J {s1[am][j][1]['J']:.3f}; " + f"75/25 fa {s1[0.75][j][1]['J']:.3f})") + + tick("Β§4 S1 misto") + + print(f"\n S2 β€” REPLAY: un episodio innestato una volta nell'anno funded (365g)") + print(f" Metrica: **P(vivo a 12 mesi) con il crash dentro**, alla leva scelta sul 2024+") + print(f" (cioe' la leva che uno sceglierebbe NON sapendo del crash: e' il punto).") + hdr = " ".join(f"{NAMES[t]:>10}" for t in epis) + print(" ⚠️ DUE colonne di leva, e servono entrambe: la leva 'da 2024+' e' quella che uno") + print(" sceglierebbe non sapendo del crash (realistica, ma CONFONDE peso e leva perche' non") + print(" e' la stessa per tutte le righe); la leva COMUNE 0.50x isola l'effetto del peso.") + print(f"\n {'w TP01':>7} {'leva (da 2024+)':>16} {hdr:>44} {'peggiore':>10} " + f"{'peggiore @0.50x':>16} {'P(viv) 2024+':>13}") + s2 = {} + for w in W_GRID: + Rp, Gp, _, segs = stress_panel(w) + lv = tab[w]["rec"][0] + cells, cells_c = [], [] + for k, (a, b) in enumerate(segs): + fu = splice_paths(base_fu, np.arange(a, b), seed=8290 + k) + cells.append(J_of(Rp, Gp, base_ev, fu, lv)["p_alive"]) + cells_c.append(J_of(Rp, Gp, base_ev, fu, 0.50)["p_alive"]) + s2[w] = (lv, cells, min(cells_c), tab[w]["rec"][1]["p_alive"]) + print(f" {w:>7.3f} {lv:>16.3f} " + " ".join(f"{c:>10.1%}" for c in cells) + + f" {min(cells):>10.1%} {min(cells_c):>16.1%} " + f"{tab[w]['rec'][1]['p_alive']:>13.1%}") + bw_a = max(s2, key=lambda w: min(s2[w][1])) + bw_c = max(s2, key=lambda w: s2[w][2]) + print(f"\n argmax della colonna 'peggiore': leva-da-2024+ -> TP01 {bw_a:.3f} | " + f"leva comune 0.50x -> TP01 {bw_c:.3f}") + print(" Lettura: se la sopravvivenza al crash SALISSE col peso di TP01, l'assicurazione") + print(" servirebbe. **A leva comune non sale**: la curva e' piatta-decrescente. Il difensivo") + print(" non compra sopravvivenza su questa barriera, perche' cio' che la barriera punisce e'") + print(" la PERDITA nel crash, e nel crash TP01 perde (prende la prima gamba giu' e poi va") + print(" flat) mentre SKH01 si gira short e GUADAGNA.") + + + tick("Β§4 S2 replay") + + # ----- replay deterministico del 2022 + print("\n S2-bis β€” REPLAY DETERMINISTICO DEL 2022 (2022-04-01 -> 2022-12-31, 275 giorni).") + print(" Non e' un modello: e' successo. Un conto funded HYRO da $100k che parte il 1Β° aprile") + print(" 2022 con max-loss STATICO -6% e daily-loss -4%, alla leva scelta sul 2024+:") + a22 = int(np.searchsorted(IDX, pd.Timestamp("2022-04-01", tz="UTC"))) + b22 = int(np.searchsorted(IDX, pd.Timestamp("2023-01-01", tz="UTC"))) + def replay22(w: float, lev: float) -> tuple: + R, G, _ = crypto(w) + off = deluck_offset(w) + r = (R[a22:b22] - off) * lev + g = G[a22:b22] * lev + eq, mn, bust, why = 1.0, 1.0, None, "" + for i in range(len(r)): + low = eq * (1.0 + r[i] + g[i]) + eq = eq * (1.0 + r[i]) + mn = min(mn, low) + if bust is None and (low < 0.94 or (r[i] + g[i]) < -0.04): + bust = IDX[a22 + i].date() + why = "daily-loss -4%" if (r[i] + g[i]) < -0.04 else "max-loss -6%" + return eq - 1.0, mn - 1.0, bust, why, float((r + g).min()) + + print(f"\n {'w TP01':>7} {'leva':>6} {'rend. 9 mesi':>13} {'peggior giorno':>15} " + f"{'min equity':>11} {'esito':>22}") + for w in W_GRID: + lv = tab[w]["rec"][0] + ret, mn, bust, why, wd = replay22(w, lv) + esito = "VIVO" if bust is None else f"BUSTATO {bust}" + print(f" {w:>7.3f} {lv:>6.3f} {ret:>13.1%} {wd:>15.2%} {mn:>11.2%} {esito:>22}") + print("\n Tutti VIVI, e non e' una buona notizia mal letta: **alle leve funded il 2022 non") + print(" era una minaccia per questo libro** (min equity -1.4/-2.4% contro una barriera al") + print(" -6%). Cio' che uccide un conto a barriera non e' il crash del sottostante: e' la") + print(" regola a UN giorno su una giornata qualunque. Ecco fin dove si puo' spingere la leva:") + print(f"\n {'w TP01':>7} {'leva max viva sul 2022':>23} {'rend. a quella leva':>20} " + f"{'primo vincolo che morde':>25}") + for w in W_GRID: + lo, hi = 0.1, 8.0 + for _ in range(18): + mid = 0.5 * (lo + hi) + if replay22(w, mid)[2] is None: + lo = mid + else: + hi = mid + ret, _, _, _, _ = replay22(w, lo) + _, _, _, why, _ = replay22(w, hi) + print(f" {w:>7.3f} {lo:>23.2f}x {ret:>20.1%} {why:>25}") + print("\n ⚠️ La leva di rottura e' MONOTONA DECRESCENTE nel peso di TP01 solo se la") + print(" protezione fosse reale. Si legga la colonna: se il libro con piu' TP01 rompe PRIMA,") + print(" l'assicurazione non sta assicurando la cosa giusta.") + + tick("Β§4 replay 2022") + + # ============================================================ Β§5. NULL DEL DE-LEVERING + hr("Β§5. IL NULL DEL DE-LEVERING β€” la protezione di TP01 e' altro rispetto a meno leva?") + print(" Il null classico ('esiste k<1 che da' lo stesso rischio con risultato migliore?') qui") + print(" prende la forma giusta per una barriera: **frontiera ISO-SOPRAVVIVENZA**. Per ogni") + print(f" peso si cerca la leva k(w) tale che P(vivo 12m | replay del sinistro PEGGIORE) = " + f"{SURV_TARGET:.0%},") + print(" poi si confronta il PAYOUT atteso sulla finestra normale a quella leva. Se un libro") + print(" con meno TP01 e meno leva paga di piu' a pari sopravvivenza, la protezione di TP01") + print(" e' de-levering; se paga di meno, TP01 compra qualcosa che la leva non compra.") + + _SP: dict = {} + + def p_alive_stress(w: float, lev: float, seg: tuple) -> float: + """P(vivo 12m) col replay del sinistro dentro. Solo `funded_sim`: l'eval non entra + nella definizione della frontiera, e calcolarlo raddoppierebbe il costo per niente.""" + global N_EVAL + N_EVAL += 1 + k = (round(w, 6), seg) + if k not in _SP: + Rp, Gp, _, _ = stress_panel(w) + _SP[k] = (Rp, Gp, splice_paths(base_fu, np.arange(seg[0], seg[1]), seed=8291)) + Rp, Gp, fu = _SP[k] + return PA.funded_sim(Rp, Gp, fu, lev, "HYRO", 100_000.0, "coupled")["p_alive"] + + print(f"\n {'w TP01':>7} {'k iso-surv':>11} {'P(viv|crash)':>13} {'E[pay/a] normale':>17} " + f"{'P(pass) normale':>16} {'J normale':>11}") + front = {} + for w in W_GRID: + Rp, Gp, _, segs = stress_panel(w) + # episodio peggiore per QUESTO peso (il piu' severo, misurato) + worst_seg = min(segs, key=lambda sg: np.prod(1.0 + Rp[sg[0]:sg[1]])) + lo, hi = 0.05, 3.0 + for _ in range(14): + mid = 0.5 * (lo + hi) + if p_alive_stress(w, mid, worst_seg) >= SURV_TARGET: + lo = mid + else: + hi = mid + k = lo + R, G, _ = crypto(w) + off = deluck_offset(w) + o = J_of(R[i_rec:] - off, G[i_rec:], ev_r, fu_r, k) + front[w] = (k, o) + print(f" {w:>7.3f} {k:>11.3f} {p_alive_stress(w, k, worst_seg):>13.1%} " + f"{o['e_payout']:>16,.0f}$ {o['p_pass']:>16.1%} {o['J']:>11.3f}") + kbest = max(front, key=lambda w: front[w][1]["e_payout"]) + jbest = max(front, key=lambda w: front[w][1]["J"]) + print(f"\n argmax a ISO-SOPRAVVIVENZA: payout -> TP01 {kbest:.3f} | J -> TP01 {jbest:.3f}") + print(" ⚠️ La leva k(w) e' tarata SU un episodio che nel campione di ottimizzazione non c'e':") + print(" e' un esercizio con l'informazione del futuro. Serve a rispondere alla domanda del") + print(" filone ('se sapessi che arriva, come mi allocherei?'), NON e' una politica.") + + tick("Β§5 frontiera iso-sopravvivenza") + + # ============================================================ Β§6. IL TERZO SLEEVE + hr("Β§6. XS01 β€” la parte della domanda che NON e' rispondibile, e perche'") + print(" L'ottimo di PROP-ALLOC mette il 50% su XS01, che **nasce nel 2024**: per lui non") + print(" esiste NESSUN sinistro nel campione. Qui non lo si simula col bootstrap della sua") + print(" finestra (sarebbe assumere che nel crash si comporti come in un giorno normale, che") + print(" e' esattamente l'assunzione sotto esame). Si dichiarano TRE comportamenti e si") + print(" guarda se la conclusione dipende da quale:") + print(" A) FLAT nel crash (il gate di dispersione lo spegne) -> generosa") + print(" B) la sua PEGGIORE finestra di 30g del 2024+, contigua e in ordine -> severa") + print(" C) uno shock una-tantum di taglia X sul giorno peggiore del crash -> si cerca") + print(" la X di ROTTURA, cioe' quanto grande deve essere per portare P(vivo) sotto 50%") + xs = pc.xsec_daily_tuples() + xsR = xs["R"].values + xsG = np.minimum(xs["m"].values, xsR) - xsR + lxs = np.log1p(xsR) + cxs = np.concatenate(([0.0], np.cumsum(lxs))) + wxs30 = np.expm1(cxs[EPI_LEN:] - cxs[:-EPI_LEN]) + t_xw = int(np.argmin(wxs30)) + print(f"\n XS01: {len(xs)} giorni {xs.index[0].date()} -> {xs.index[-1].date()}; peggior " + f"giorno {xsR.min():.2%}, peggior minimo {xs['m'].min():.2%}") + print(f" peggior finestra di {EPI_LEN}g di XS01: {xs.index[t_xw].date()} -> " + f"{xs.index[t_xw+EPI_LEN-1].date()}, cumulato {wxs30[t_xw]:.1%} " + f"(ipotesi B). Per confronto la coppia crypto nel COVID fa -5.4% a TP01 pieno.") + print(" πŸ“Œ Il numero che conta e' questo: **la peggior finestra di 30g mai vista da XS01 vale") + print(f" {wxs30[t_xw]:.1%}**. Se nel crash XS01 facesse cosi', al 50% di peso porterebbe") + print(f" {0.5*wxs30[t_xw]:.1%} al libro. La domanda non e' se XS01 sopravvive a cio' che ha") + print(" gia' visto, e' se il suo comportamento nel crash somiglia a cio' che ha gia' visto β€”") + print(" e su questo il campione non ha NIENTE da dire.") + W3 = [(0.75, 0.25, 0.00), (0.375, 0.625, 0.00), (0.25, 0.25, 0.50), (0.125, 0.125, 0.75), + (0.50, 0.25, 0.25)] + com = xs.index.intersection(IDX[i_rec:]) + xs_al = xs.reindex(com) + print(f"\n {'TP/SKH/XS':>16} {'ipotesi XS':>12} {'leva':>6} {'J normale':>10} " + f"{'P(viv|replay peggiore)':>24} {'shock di rottura':>18}") + for cfg in W3: + wtp, wsk, wxs = cfg + base = wtp + wsk + wtp_n = wtp / base if base > 0 else 0.0 + Rc, Gc, Ic = crypto(wtp_n) + offc = deluck_offset(wtp_n) + m1 = Ic.isin(com) + Rn = base * (Rc[m1] - offc) + wxs * xs_al["R"].values + Gn = base * Gc[m1] + wxs * (np.minimum(xs_al["m"].values, xs_al["R"].values) + - xs_al["R"].values) + nn = len(Rn) + evn = PA.boot_idx(nn, HORIZON, N_PATHS, seed=8300) + fun = PA.boot_idx(nn, HORIZON, N_PATHS, seed=8301) + lv, on = best_lev(Rn, Gn, evn, fun) + bfu = PA.boot_idx(nn, HORIZON, N_PATHS, seed=8302) + lab = f"{int(round(wtp*100))}/{int(round(wsk*100))}/{int(round(wxs*100))}" + + def build(hyp: str, shock: float = 0.0): + segsR, segsG = [], [] + for t in epis: + rc = base * (crypto(wtp_n)[0][t:t + EPI_LEN] - offc) + gc = base * crypto(wtp_n)[1][t:t + EPI_LEN] + if wxs > 0: + if hyp == "A-flat": + rx = np.zeros(EPI_LEN); gx = np.zeros(EPI_LEN) + elif hyp == "B-peggiore": + rx = xsR[t_xw:t_xw + EPI_LEN].copy() + gx = xsG[t_xw:t_xw + EPI_LEN].copy() + else: + rx = np.zeros(EPI_LEN); gx = np.zeros(EPI_LEN) + rx[int(np.argmin(rc))] = -shock + segsR.append(rc + wxs * rx); segsG.append(gc + wxs * gx) + else: + segsR.append(rc); segsG.append(gc) + Rp = np.concatenate([Rn] + segsR) + Gp = np.concatenate([Gn] + segsG) + sg = [(nn + k * EPI_LEN, nn + (k + 1) * EPI_LEN) for k in range(len(epis))] + worst = 1.0 + for k, g2 in enumerate(sg): + fu = splice_paths(bfu, np.arange(g2[0], g2[1]), seed=8320 + k) + worst = min(worst, J_of(Rp, Gp, evn, fu, lv)["p_alive"]) + return worst + + if wxs == 0: + print(f" {lab:>16} {'n/a (no XS)':>12} {lv:>6.3f} {on['J']:>10.3f} " + f"{build('A-flat'):>24.1%} {'-':>18}") + continue + for hyp in ("A-flat", "B-peggiore"): + print(f" {lab:>16} {hyp:>12} {lv:>6.3f} {on['J']:>10.3f} " + f"{build(hyp):>24.1%} {'-':>18}") + lo, hi = 0.0, 0.40 # shock di rottura: bisezione su P(vivo) = 50% + for _ in range(9): + mid = 0.5 * (lo + hi) + if build("C-shock", mid) >= 0.50: + lo = mid + else: + hi = mid + print(f" {lab:>16} {'C-shock':>12} {lv:>6.3f} {on['J']:>10.3f} {'(50% per def.)':>24} " + f"{-lo:>17.1%}") + print("\n Lettura: 'shock di rottura' = il crollo di UN giorno su XS01 che porta P(vivo) al") + print(" 50% quando il crash arriva. Piu' e' piccolo, piu' l'allocazione XS01-pesante e' a un") + print(" giorno di coda dalla morte. Non e' una previsione: e' la taglia della scommessa.") + tick("Β§6 XS01") + + # ============================================================ Β§7. BANDA + VERDETTO + hr("Β§7. SENSIBILITA' AL DE-LUCK β€” l'ORDINE fra i pesi sopravvive alla banda?") + print(" La banda d'ancora non e' ri-misurabile sotto questa lente (costo, dichiarato anche da") + print(" PROP-ALLOC). Si applica il fattore misurato x0.89 e si controlla l'unica cosa che") + print(" conta: se l'argmax sui pesi si muove fra 1.00 (nessuna correzione) e 0.60 (la stima") + print(" a occhio del 25/07, poi ri-misurata troppo severa).") + print(f"\n {'fattore':>9} {'argmax 2019+':>14} {'argmax 2024+':>14} {'argmax SOLO-SIN':>16} " + f"{'J(75/25) 2019+':>16} {'J(38/62) 2019+':>16}") + for fct in (1.00, 0.89, 0.60): + best_f = best_r = best_s = None + vals = {} + for w in W_GRID: + R, G, _ = crypto(w) + o = deluck_offset(w, fct) + Rf = R - o + j1 = best_lev(Rf, G, ev_f, fu_f)[1]["J"] + j2 = best_lev(Rf[i_rec:], G[i_rec:], ev_r, fu_r)[1]["J"] + j3 = best_lev(Rf, G, ev_s, fu_s)[1]["J"] + vals[w] = (j1, j2, j3) + best_f = max(vals, key=lambda w: vals[w][0]) + best_r = max(vals, key=lambda w: vals[w][1]) + best_s = max(vals, key=lambda w: vals[w][2]) + print(f" {fct:>9.2f} {best_f:>14.3f} {best_r:>14.3f} {best_s:>16.3f} " + f"{vals[0.75][0]:>16.3f} {vals[0.375][0]:>16.3f}") + + hr("Β§8. I GATE β€” cosa e' girato, cosa NON e' girato e perche'") + print(" Questo filone non propone una strategia nuova: propone (o rifiuta) un'ALLOCAZIONE per") + print(" un conto che non esiste. I gate del progetto vanno dichiarati uno per uno.") + try: + sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) + import altlib as A # noqa: E402 + for lab, w in (("LIVE 75/25", 0.75), ("OPT 38/62", 0.375)): + R = crypto(w)[0] - deluck_offset(w) + d = pd.Series(R, index=IDX) + n_act = int((np.abs(R) > 1e-12).sum()) + n_los = int((R[np.abs(R) > 1e-12] < 0).sum()) + f = A.implausible_sharpe(d, n_trades=n_act, n_losing_trades=n_los) + print(f" implausible_sharpe {lab:>11}: {f}") + except Exception as exc: # pragma: no cover + print(f" implausible_sharpe: NON GIRATO ({exc})") + print(" marginal_vs_tp01 / study_family_honest / deflated_sharpe : **NON GIRATI, e non") + print(" sono applicabili** β€” non c'e' uno stream candidato da confrontare con TP01: gli") + print(" stream sono i due sleeve gia' ammessi, e l'oggetto e' il loro PESO. Il gate dei") + print(" pesi del progetto e' `weights_tilt_null`, che pero' giudica lo SHARPE del libro") + print(" di RICERCA: qui l'obiettivo e' J su una barriera, e il conto e' separato.") + print(" (PROP-ALLOC lo ha girato: gate_pass False. Non cambia nulla qui: nessuna") + print(" proposta di questo file tocca il libro live.)") + print(" causality_ok : **non girato, non applicabile** β€” nessun segnale nuovo; le serie") + print(" sono quelle di produzione, gia' verificate leak-free a monte.") + print(" anchor_luck_band : **non ri-misurata** (costo, come in PROP-ALLOC). Si e' girata") + print(" la sensibilita' al FATTORE di de-luck (Β§7), che e' meno: dice se l'ORDINE regge,") + print(" non qual e' la banda. Distorsione dichiarata: la lente e' l'ancora CANONICA e") + print(" SKH01 e' lo sleeve con la fortuna d'ancora piu' grande da restituire (26/07)") + print(" -> **la distorsione e' A FAVORE della conclusione di questo file**, cioe' del") + print(" peso basso su TP01. E' il caveat piu' importante del filone.") + print(" null del de-levering : **GIRATO** (Β§5, frontiera iso-sopravvivenza) β€” e superato") + print(" NEL VERSO OPPOSTO a quello atteso: e' la protezione di TP01 a essere replicabile") + print(" con meno leva, e meglio.") + + hr("Β§9. LA RISPOSTA OPERATIVA") + print(f" valutazioni MC totali di questo file: **{N_EVAL}** " + f"(ognuna = {N_PATHS} percorsi di eval + {N_PATHS} di funded)") + print(f"\n {'lente':>26} {'argmax w TP01':>14} {'J argmax':>10} {'J a 0.75':>10} " + f"{'J a 0.375':>11}") + for lab, key in (("2019+ (contiene il 2022)", "full"), ("2024+ (PROP-ALLOC)", "rec"), + ("SOLO-SINISTRO", "sin")): + am = max(tab, key=lambda w: tab[w][key][1]["J"]) + print(f" {lab:>26} {am:>14.3f} {tab[am][key][1]['J']:>10.3f} " + f"{tab[0.75][key][1]['J']:>10.3f} {tab[0.375][key][1]['J']:>11.3f}") + print("\n" + "=" * 106) + print(f" fatto in {time.time()-t0:.0f}s | valutazioni: {N_EVAL}") + print("=" * 106) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822b_xs01_oos.py b/scripts/research/r0822b_xs01_oos.py new file mode 100644 index 0000000..be1b40c --- /dev/null +++ b/scripts/research/r0822b_xs01_oos.py @@ -0,0 +1,683 @@ +"""r0822b_xs01_oos.py β€” XS01 FUORI dalla sua finestra di scoperta (2026-08-22, ondata wave-0822). + +IL PROBLEMA (filone PROP-ALLOC, RESULTS-0822 Β§1) +------------------------------------------------ +Il risultato piu' vicino all'obiettivo dell'operatore e': su un conto FUNDED da $100k, aggiungere +XS01 al libro porta J = P(pass) x P(vivo 12m) da 0,335 a 0,738. Ma **tutto** quel vantaggio poggia +sul drift di XS01 misurato sulla SUA STESSA finestra di scoperta (Hyperliquid, 2024-01 -> oggi, +~2,6 anni, e senza un solo mercato orso). Azzerando quel drift l'uplift scende a +0,038; sotto +stress congiunto diventa negativo. Il gate proposto era "non aprire un funded finche' XS01 non ha +una finestra fuori dal 2024-2026" = ANNI di attesa. + +LA DOMANDA DI QUESTO FILONE +--------------------------- +Si puo' testare il MECCANISMO CONGELATO su una storia PIU' LUNGA e INDIPENDENTE invece di aspettare? +La risposta e' si', e il vincolo non e' il dato ma il CALENDARIO DELLE QUOTAZIONI: 6 dei 19 asset +(APT, OP, ARB, SUI, SEI, TIA) non esistono prima del 2022-06/2023-10. Il piu' vecchio universo +>= 10 asset dei 19 e' del **2020-10** -> circa **3 anni interi fuori dalla finestra di scoperta, +2021-01 -> 2023-12**, che contengono il top 2021, il crollo di maggio 2021, **LUNA e FTX (2022)** e +la ripresa 2023. E' esattamente il regime che manca al campione di scoperta. + +IL DATO: BINANCE, E PERCHE' L'OBIEZIONE DEL PROGETTO NON MORDE QUI +----------------------------------------------------------------- +CLAUDE.md: "Binance NON e' la verita' (e' USDT, ~10 bps fuori dal consenso, e sotto depeg fino al +3%) -> usare SOLO come audit indipendente, mai come ancora per ripulire i dati". Quella regola +riguarda l'uso come **ANCORA DI PREZZO** (livello). Qui Binance serve come **audit indipendente per +un segnale CROSS-SECTIONAL DI RANKING su barre giornaliere**, e i tre difetti citati sono tutti +**fattori COMUNI alla valuta di quotazione**: + - uno scarto costante di ~10 bps sul livello e' identico su tutti e 19 gli asset -> sparisce + ESATTAMENTE nello z-score cross-sectional (che sottrae la media di sezione); + - un depeg USDT muove tutte le gambe insieme -> non cambia il RANKING, e il libro e' + dollar-neutral (0.5/k long, -0.5/k short) quindi il fattore comune si cancella anche nel P&L; + - a 1d un errore di livello di 10 bps e' 1/50 della vol giornaliera tipica di un alt. +NON e' un argomento teorico: e' MISURATO qui sotto (sezione 3) confrontando il meccanismo su +Binance e su Hyperliquid nella finestra in cui esistono ENTRAMBI (2024+). +E i dati scaricati sono CERTIFICATI comunque (barre flat, gap di calendario, volumi zero = +backfill sintetico: il progetto ci e' gia' cascato una volta, correzione AXS del 2026-06-20). + +MECCANISMO: CONGELATO. Nessun parametro toccato. E' `src/portfolio/sleeves.py::_xsec_returns` +(lookbacks (30,90), H=10, k=5, mode="mom", target_vol=0.20, gate dispersione p30 espandente +causale, fee 5 bps/lato, vol-target su rv30 shiftata, clip leva 3x). Il motore qui sotto e' una +versione MASCHERATA (regge NaN = asset non ancora quotato) e la sezione 2 dimostra che riproduce +lo sleeve ufficiale a **max|diff| = 0.0** sui dati HL. + +ESITO (2026-08-22, corsa completa 78 s) +--------------------------------------- +- Il motore replica lo sleeve ufficiale a max|diff| = 0.0; Binance e Hyperliquid danno lo STESSO + sleeve sulla finestra comune (corr 0.9991, dSharpe -0.025) -> la storia lunga e' utilizzabile. +- Fuori dalla finestra di scoperta (2021-01 -> 2023-12, contiene LUNA e FTX) il meccanismo ha un + edge: Sharpe MEDIANO di fase **+1.12** (U13 fisso) / **+1.17** (universo espandente), positivo in + **10/10** fasi, p = 0.013 contro il null di permutazione cross-sezionale a fee zero. +- **Il fuori campione e' MIGLIORE della finestra di scoperta**, a parita' di universo: differenza + appaiata per fase +0.668 (10/10 fasi). L'ipotesi "il drift di XS01 e' un artefatto del 2024-2026" + e' REFUTATA per la parte testabile del sleeve. +- MA: il maxDD raddoppia (10.8% nella scoperta -> 20.9-22.0% mediano fuori), il 2021 vale meta' + dell'edge, una gamba sola (LTC) ne vale 0.9 di Sharpe, e i due affinamenti del 2026-06-19 non si + replicano. E il deflated-Sharpe FALLISCE (0.342-0.418). +- Sul LIBRO, fuori campione: TP01+SKH01 75/25 Sharpe +1.17 -> **+1.65** e maxDD 9.4% -> **6.0%** + a peso XS01 0.25; a peso **0.50** (l'argmax di PROP-ALLOC) il maxDD risale a **8.9%**. + +Uso: nice -n 19 timeout 900 uv run python scripts/research/r0822b_xs01_oos.py +Rete: solo lettura, <= 2 req/s, MAI nella finestra :25-:29 (rate limit per-IP condiviso col +collettore della catena). Il download e' messo in cache nello scratchpad: la seconda corsa e' +offline. +""" +from __future__ import annotations + +import math +import os +import sys +import time +from datetime import datetime, timezone +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" / "alt")) + +import altlib as A # noqa: E402 +from src.portfolio.sleeves import XS_CFG, XS_UNIVERSE # noqa: E402 + +CACHE = Path(os.environ.get( + "R0822B_CACHE", + "/tmp/claude-1001/-opt-docker-PythagorasGoal/b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad/r0822b", +)) +CACHE.mkdir(parents=True, exist_ok=True) + +RAW = ROOT / "data" / "raw" +FEE_SIDE = 0.001 / 2.0 # identica al sleeve congelato +DISCOVERY = pd.Timestamp("2024-01-01", tz="UTC") # inizio della finestra di scoperta (HL) + +# I 13 dei 19 quotati su Binance prima del 2020-11 -> l'universo FISSO piu' lungo possibile +# che rispetta il vincolo del meccanismo congelato (>= 10 gambe). +U13 = ["BTC", "ETH", "BNB", "XRP", "LTC", "ADA", "LINK", "DOGE", "SOL", "AVAX", "NEAR", "AAVE", "INJ"] +SECTORS = ["xlb", "xlc", "xle", "xlf", "xli", "xlk", "xlp", "xlre", "xlu", "xlv", "xly"] + + +# =========================================================================== +# 0. disciplina di rete +# =========================================================================== +def _outside_forbidden_window() -> None: + """Il collettore della catena gira al minuto :25 e satura il rate limit per-IP di Deribit; + la regola d'ondata e' non toccare la rete fra :25 e :29. Qui parliamo con Binance, ma la + disciplina e' della MACCHINA (2 core, libro con soldi veri), non del venue.""" + while True: + m = datetime.now(timezone.utc).minute + if not (25 <= m <= 29): + return + print(f" [rete] minuto :{m:02d} nella finestra vietata :25-:29 β€” attendo 30s", flush=True) + time.sleep(30) + + +# =========================================================================== +# 1. FETCH + CERTIFY (Binance spot USDT, 1d) +# =========================================================================== +def fetch_binance_daily(sym: str, since_iso: str = "2017-01-01T00:00:00Z") -> pd.DataFrame: + f = CACHE / f"bin_{sym.lower()}_1d.parquet" + if f.exists(): + return pd.read_parquet(f) + import ccxt + ex = ccxt.binance({"enableRateLimit": True}) + since = ex.parse8601(since_iso) + rows, cur = [], since + while True: + _outside_forbidden_window() + batch = ex.fetch_ohlcv(f"{sym}/USDT", "1d", since=cur, limit=1000) + time.sleep(0.5) # <= 2 req/s + if not batch: + break + rows.extend(batch) + if len(batch) < 1000: + break + cur = batch[-1][0] + 86_400_000 + d = pd.DataFrame(rows, columns=["timestamp", "open", "high", "low", "close", "volume"]) + d = d.drop_duplicates("timestamp").sort_values("timestamp").reset_index(drop=True) + d.to_parquet(f) + return d + + +def certify(sym: str, d: pd.DataFrame) -> dict: + """Certificazione locale, stessa forma di scripts/analysis/fetch_hyperliquid.py. + Il rivelatore del backfill SINTETICO NON e' il prezzo (cerbero copiava i prezzi da un altro + venue) ma il **VOLUME**: un run di barre a volume 0 e' padding, non mercato.""" + t = pd.to_datetime(d["timestamp"], unit="ms", utc=True) + c = d["close"].values.astype(float) + v = d["volume"].values.astype(float) + span = (t.max() - t.min()).days + 1 + flat = float(np.mean(c[1:] == c[:-1])) if len(c) > 1 else float("nan") + zero_v = float(np.mean(v == 0.0)) + # run iniziale a volume zero = firma del padding sintetico (lezione AXS 2026-06-20) + lead0 = int(np.argmax(v > 0)) if (v > 0).any() else len(v) + return dict(sym=sym, n=len(d), start=str(t.min().date()), end=str(t.max().date()), + gap_days=int(span - len(d)), flat=flat, zero_vol=zero_v, lead_zero_vol=lead0, + ok=bool(span - len(d) == 0 and flat < 0.02 and zero_v < 0.01 and lead0 == 0)) + + +def load_panel(syms: list[str]) -> pd.DataFrame: + """Pannello dei CLOSE, indice giornaliero UTC, NaN dove l'asset non e' ancora quotato.""" + cols = {} + for s in syms: + d = fetch_binance_daily(s) + cols[s] = pd.Series(d["close"].values.astype(float), + index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)) + return pd.concat(cols, axis=1).sort_index() + + +def load_sector_panel() -> pd.DataFrame: + cols = {} + for s in SECTORS: + d = pd.read_parquet(RAW / f"eq_{s}_1d.parquet") + cols[s.upper()] = pd.Series(d["close"].values.astype(float), + index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)) + return pd.concat(cols, axis=1).sort_index() + + +# =========================================================================== +# 2. MOTORE β€” meccanismo CONGELATO, versione mascherata (regge i NaN) +# =========================================================================== +def xsec_engine(P: pd.DataFrame, phase: int = 0, fee_side: float = FEE_SIDE, + mode: str | None = None, min_assets: int = 10, + perm_rng: np.random.Generator | None = None, + perm_kind: str = "cross", lookbacks=None, disp_pct=None, + detail: dict | None = None) -> pd.Series: + """Riproduce `sleeves._xsec_returns` riga per riga. Unica differenza: l'insieme ammissibile + e' calcolato PER RIBILANCIAMENTO (un asset entra quando ha almeno max(lookbacks) barre), cosi' + il meccanismo si puo' applicare a un universo che si allarga nel tempo β€” che e' cio' che un + implementatore in tempo reale avrebbe avuto. Con un pannello senza NaN, e' identico all'originale. + + `perm_rng` attiva un NULL: 'cross' = permuta lo score FRA GLI ASSET al ribilanciamento + (distrugge l'informazione, conserva ESATTAMENTE la struttura di posizione e di turnover), + 'sign' = pesi casuali fra gli ammissibili (stesso gross, stesso k).""" + px = P.values.astype(float) + n, Ncols = px.shape + H, k = XS_CFG["H"], XS_CFG["k"] + lookbacks = tuple(XS_CFG["lookbacks"] if lookbacks is None else lookbacks) + disp_pct = XS_CFG["disp_pct"] if disp_pct is None else disp_pct + tv, minhist = XS_CFG["target_vol"], XS_CFG["disp_minhist"] + mode = mode or XS_CFG["mode"] + mlb = max(lookbacks) + + with np.errstate(invalid="ignore", divide="ignore"): + dret = np.vstack([np.zeros(Ncols), px[1:] / px[:-1] - 1.0]) + dret = np.nan_to_num(dret, nan=0.0, posinf=0.0, neginf=0.0) + + W = np.zeros((n, Ncols)); w = np.zeros(Ncols); disp_hist: list[float] = [] + n_rebal = n_traded = 0 + for i in range(n): + if i >= mlb and (i % H) == phase: + elig = np.isfinite(px[i]) + for L in lookbacks: + elig = elig & np.isfinite(px[i - L]) + m = int(elig.sum()) + if m >= min_assets: + n_rebal += 1 + idx = np.flatnonzero(elig) + rLs = [px[i, idx] / px[i - L, idx] - 1.0 for L in lookbacks] + disp_i = float(np.mean([r.std() for r in rLs])) + thr = (np.percentile(disp_hist, disp_pct) + if (disp_pct > 0 and len(disp_hist) >= minhist) else -np.inf) + if disp_i >= thr: + score = np.zeros(m); cnt = 0 + for rL in rLs: + sd = rL.std() + if sd > 0: + score += (rL - rL.mean()) / sd; cnt += 1 + if cnt: + score /= cnt + w = np.zeros(Ncols) + if perm_rng is not None and perm_kind == "sign": + pick = perm_rng.permutation(m) + lo, hi = idx[pick[:k]], idx[pick[-k:]] + else: + if perm_rng is not None: # perm_kind == "cross" + score = score[perm_rng.permutation(m)] + order = np.argsort(score) + lo, hi = idx[order[:k]], idx[order[-k:]] + if mode == "mom": + w[hi] = 0.5 / k; w[lo] = -0.5 / k + else: + w[lo] = 0.5 / k; w[hi] = -0.5 / k + n_traded += 1 + else: + w = np.zeros(Ncols) + disp_hist.append(disp_i) + W[i] = w + + gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1) + turn = np.zeros(n); turn[0] = np.abs(W[0]).sum() + turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1) + net = gross - turn * fee_side + s = pd.Series(net, index=P.index) + rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25) + scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0) + out = pd.Series(s.values * scale, index=P.index) + out.attrs["n_rebal"] = n_rebal + out.attrs["n_traded"] = n_traded + if detail is not None: + detail.update(W=W, scale=scale, turn=turn, index=P.index, + n_rebal=n_rebal, n_traded=n_traded) + return out + + +def hl_panel(syms=None) -> pd.DataFrame: + """Pannello Hyperliquid, ESATTAMENTE come lo costruisce lo sleeve congelato.""" + cols = {} + for sym in (syms or XS_UNIVERSE): + p = RAW / f"hl_{sym.lower()}_1d.parquet" + if not p.exists(): + continue + d = pd.read_parquet(p) + cols[sym] = pd.Series(d["close"].values.astype(float), + index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)) + return pd.concat(cols, axis=1, join="inner").sort_index().dropna() + + +# =========================================================================== +# metriche +# =========================================================================== +def sh(s: pd.Series) -> float: + return A._sh(s) + + +def dd(s: pd.Series) -> float: + return A._dd_ret(s) + + +def cagr(s: pd.Series) -> float: + r = s.dropna() + if len(r) < 30: + return float("nan") + yrs = (r.index[-1] - r.index[0]).days / 365.25 + return float(np.prod(1.0 + r.values) ** (1.0 / yrs) - 1.0) if yrs > 0 else float("nan") + + +def win(s: pd.Series, a: str | None = None, b: str | None = None) -> pd.Series: + x = s + if a: + x = x[x.index >= pd.Timestamp(a, tz="UTC")] + if b: + x = x[x.index < pd.Timestamp(b, tz="UTC")] + return x + + +def alive(s: pd.Series) -> pd.Series: + """Taglia il prefisso di ZERI (warm-up dei lookback + attesa che l'universo raggiunga 10 + gambe). ⚠️ Bug catturato nella prima corsa: sull'universo ESPANDENTE il pannello parte dal + 2017 (BTC) ma il meccanismo non puo' operare prima del 2020-12, e i ~1.200 zeri iniziali + entravano nella finestra 'PRE-2024' abbassandone lo Sharpe e ribaltando la differenza + appaiata. E' la lezione gia' codificata nel progetto (barre ATTIVE, non di calendario), + ripetuta in un contesto nuovo.""" + v = np.asarray(s.values, float) + nz = np.flatnonzero(v != 0.0) + return s.iloc[nz[0]:] if len(nz) else s + + +def line(tag: str, s: pd.Series) -> str: + return (f"{tag:<22s} n={len(s):>5d} Sh {sh(s):+6.2f} maxDD {dd(s)*100:5.1f}% " + f"CAGR {cagr(s)*100:+6.2f}% vol {s.std()*np.sqrt(365.25)*100:5.1f}%") + + +# =========================================================================== +def main() -> None: + t0 = time.time() + print("=" * 100) + print("XS01 FUORI DALLA FINESTRA DI SCOPERTA β€” meccanismo CONGELATO su storia lunga indipendente") + print(f"XS_CFG = {XS_CFG}") + print("=" * 100) + + # ---------------------------------------------------------------- 1. dato + print("\n[1] DATO β€” Binance spot USDT 1d, certificazione locale") + panel19 = load_panel(XS_UNIVERSE) + cert = [certify(s, fetch_binance_daily(s)) for s in XS_UNIVERSE] + bad = [c for c in cert if not c["ok"]] + print(f" {len(cert)} serie scaricate; certificate OK: {len(cert)-len(bad)}/{len(cert)}") + print(f" {'sym':<6}{'n':>6}{'start':>12}{'end':>12}{'gap':>5}{'flat%':>8}{'vol0%':>8}{'lead0':>7}") + for c in cert: + flag = "" if c["ok"] else " <-- CONTROLLA" + print(f" {c['sym']:<6}{c['n']:>6}{c['start']:>12}{c['end']:>12}{c['gap_days']:>5}" + f"{c['flat']*100:>8.2f}{c['zero_vol']*100:>8.2f}{c['lead_zero_vol']:>7}{flag}") + print(" NB: 'lead0' = barre iniziali a volume 0 = firma del backfill sintetico (lezione AXS).") + + # ---------------------------------------------------- 2. replica bit-exact + print("\n[2] SANITY β€” il motore riproduce lo sleeve UFFICIALE?") + from src.portfolio.sleeves import _xsec_returns + off = _xsec_returns() + rep = xsec_engine(hl_panel(), phase=0) + j = pd.concat({"off": off, "rep": rep}, axis=1, join="inner").dropna() + md = float(np.max(np.abs(j["off"] - j["rep"]))) if len(j) else float("nan") + print(f" n={len(j)} max|diff| = {md:.2e} -> {'IDENTICO' if md < 1e-12 else 'DIVERGE'}") + print(f" {line('XS01 ufficiale (HL)', off)}") + if md >= 1e-12: + print(" !! il motore NON replica: ogni numero sotto e' sospetto. STOP.") + return + + # ------------------------------------------------ 3. validazione di VENUE + print("\n[3] VALIDAZIONE DI VENUE β€” Binance vs Hyperliquid dove esistono ENTRAMBI (2024+)") + hlp = hl_panel() + b19 = panel19.reindex(columns=XS_UNIVERSE) + common = b19.index.intersection(hlp.index) + rr = [] + for s in XS_UNIVERSE: + a1 = hlp[s].reindex(common).pct_change() + b1 = b19[s].reindex(common).pct_change() + k2 = pd.concat({"a": a1, "b": b1}, axis=1).dropna() + rr.append((s, float(k2["a"].corr(k2["b"])), float(np.median(np.abs(k2["a"] - k2["b"]))) * 1e4)) + print(f" ritorni giornalieri per asset, {len(common)} giorni comuni:") + print(" " + " ".join(f"{s}:{c:.4f}" for s, c, _ in rr)) + print(f" corr min {min(c for _, c, _ in rr):.4f} | mediana |diff| ritorno: " + f"{np.median([m for _, _, m in rr]):.1f} bps (max {max(m for _, _, m in rr):.1f} bps)") + # e soprattutto: il MECCANISMO da' la stessa cosa? + bin_on_hl_window = xsec_engine(b19.loc[common].dropna(), phase=0) + hl_on_window = xsec_engine(hlp.loc[common], phase=0) + jj = pd.concat({"bin": bin_on_hl_window, "hl": hl_on_window}, axis=1, join="inner").dropna() + print(f" {line('XS01 su HL 2024+', jj['hl'])}") + print(f" {line('XS01 su BINANCE 2024+', jj['bin'])}") + print(f" corr fra le DUE VERSIONI DELLO SLEEVE: {jj['bin'].corr(jj['hl']):.4f} " + f"dSharpe = {sh(jj['bin'])-sh(jj['hl']):+.3f}") + print(" -> se questa corr e' ~1, la storia lunga Binance e' un'estensione legittima:") + print(" lo scarto USDT e' un FATTORE COMUNE e sparisce nello z-score cross-sectional.") + + # ------------------------------------- 4. la storia lunga, fuori campione + print("\n[4] STORIA LUNGA β€” meccanismo congelato, ancora canonica (fase 0)") + p13 = panel19[U13].dropna() # universo FISSO 13 (il piu' lungo a >=10 gambe) + s13 = xsec_engine(p13, phase=0) + sEXP = xsec_engine(panel19, phase=0) # universo che si ALLARGA (19 quando esistono) + s13, sEXP = alive(s13), alive(sEXP) + print(f" U13-FIX universo fisso {len(U13)} gambe, dal {s13.index[0].date()} " + f"({s13.attrs.get('n_traded')}/{s13.attrs.get('n_rebal')} ribilanciamenti con posizione)") + print(f" U19-EXP universo espandente (10 -> 19 gambe), dal {sEXP.index[0].date()}") + for tag, s in (("U13-FIX", s13), ("U19-EXP", sEXP)): + print(f" {line(tag + ' FULL', s)}") + print(f" {line(tag + ' PRE-2024 (OOS)', win(s, None, '2024-01-01'))}") + print(f" {line(tag + ' 2024+ (scoperta)', win(s, '2024-01-01'))}") + + print("\n Sharpe per ANNO (netto fee):") + yrs = sorted({d.year for d in sEXP.index}) + hdr = " " + f"{'':<10}" + "".join(f"{y:>9d}" for y in yrs) + print(hdr) + for tag, s in (("U13-FIX", s13), ("U19-EXP", sEXP), ("XS01 HL", off)): + cells = [] + for y in yrs: + x = win(s, f"{y}-01-01", f"{y+1}-01-01") + cells.append(f"{sh(x):>9.2f}" if len(x) > 60 else f"{'β€”':>9s}") + print(f" {tag:<10}" + "".join(cells)) + + print("\n Sharpe per REGIME:") + regimes = [("2021 toro/top", "2021-01-01", "2022-01-01"), + ("2022 ORSO (LUNA/FTX)", "2022-01-01", "2023-01-01"), + ("2023 ripresa", "2023-01-01", "2024-01-01"), + ("2024-26 scoperta", "2024-01-01", None)] + for nm, a, b in regimes: + x13, xEX = win(s13, a, b), win(sEXP, a, b) + print(f" {nm:<24s} U13 Sh {sh(x13):+6.2f} DD {dd(x13)*100:5.1f}% | " + f"U19-EXP Sh {sh(xEX):+6.2f} DD {dd(xEX)*100:5.1f}%") + + # -------------------------------------------- 5. fortuna di FASE (H = 10) + print("\n[5] FORTUNA DI FASE β€” H=10, tutte e 10 le ancore (la stima onesta e' la MEDIANA)") + phases = list(range(XS_CFG["H"])) + bands = {} + for tag, P, cut in (("U13-FIX FULL", p13, None), ("U13-FIX PRE-2024", p13, "pre"), + ("U19-EXP FULL", panel19, None), ("U19-EXP PRE-2024", panel19, "pre")): + def f(ph, P=P, cut=cut): + s = alive(xsec_engine(P, phase=ph)) + return win(s, None, "2024-01-01") if cut == "pre" else s + bands[tag] = A.anchor_luck_band(f, phases, canonical=0, metric=sh) + bands[tag + "|DD"] = A.anchor_luck_band(f, phases, canonical=0, metric=lambda x: -dd(x)) + for tag in ("U13-FIX FULL", "U13-FIX PRE-2024", "U19-EXP FULL", "U19-EXP PRE-2024"): + r = bands[tag]; rd = bands[tag + "|DD"] + print(f" {tag:<20s} Sh canonico {r['canonical']:+.2f} (pctl {r['canonical_pctl']*100:4.0f}Β°) " + f"| MEDIANA {r['median']:+.2f} | banda [{r['lo']:+.2f},{r['hi']:+.2f}] " + f"| fasi>0 {r['frac_positive']*100:3.0f}% | fortuna {r['luck']:+.2f} | gate {r['gate_pass']}") + print(f" {'':<20s} maxDD canonico {-rd['canonical']*100:5.1f}% " + f"| MEDIANA {-rd['median']*100:5.1f}% | banda [{-rd['hi']*100:.1f},{-rd['lo']*100:.1f}]%") + # anche lo sleeve UFFICIALE su HL, de-luckato di fase (per confronto onesto) + bhl = A.anchor_luck_band(lambda ph: xsec_engine(hlp, phase=ph), phases, canonical=0, metric=sh) + print(f" {'XS01 UFFICIALE (HL)':<20s} Sh canonico {bhl['canonical']:+.2f} " + f"(pctl {bhl['canonical_pctl']*100:4.0f}Β°) | MEDIANA {bhl['median']:+.2f} " + f"| banda [{bhl['lo']:+.2f},{bhl['hi']:+.2f}] | fortuna {bhl['luck']:+.2f}") + + # -------------- 6. differenza APPAIATA fuori campione vs finestra di scoperta + print("\n[6] DIFFERENZA APPAIATA per fase: (fuori campione) - (finestra di scoperta)") + for tag, P in (("U13-FIX", p13), ("U19-EXP", panel19)): + def pre(ph, P=P): + return win(alive(xsec_engine(P, phase=ph)), None, "2024-01-01") + + def post(ph, P=P): + return win(alive(xsec_engine(P, phase=ph)), "2024-01-01") + d1 = A.anchor_luck_delta(pre, post, phases, metric=sh) + print(f" {tag:<10s} mediana DIFF appaiata {d1['median_paired']:+.3f} " + f"[{d1['lo']:+.3f},{d1['hi']:+.3f}] fasi con OOS>scoperta {d1['n_positive']}/{d1['n_anchors']}") + + # ------------------------------------------------------------- 7. i NULL + print("\n[7] NULL β€” a FEE ZERO (permutare un segnale ne fa esplodere il turnover: a fee piene") + print(" il null perderebbe per COSTO invece che per assenza di informazione β€” regola XSR01)") + NDRAW = 300 + for tag, P, a, b in (("U13-FIX PRE-2024", p13, None, "2024-01-01"), + ("U13-FIX FULL", p13, None, None)): + real = win(alive(xsec_engine(P, phase=0, fee_side=0.0)), a, b) + for kind in ("cross", "sign"): + rng = np.random.default_rng(20260822) + draws = np.array([sh(win(alive(xsec_engine(P, phase=0, fee_side=0.0, perm_rng=rng, + perm_kind=kind)), a, b)) for _ in range(NDRAW)]) + p = float(np.mean(draws >= sh(real))) + print(f" {tag:<20s} null '{kind}': reale(lordo) {sh(real):+.2f} vs null " + f"med {np.median(draws):+.2f} max {draws.max():+.2f} -> p = {p:.4f}") + + print("\n NULL 'universo dove NON dovrebbe funzionare' β€” 11 settoriali SPDR, 1998+,") + print(" stesso meccanismo congelato (il progetto ha gia' misurato che il cross-sectional") + print(" crypto non generalizza alle azioni: XSR01, 2026-07-26):") + sect = load_sector_panel() + ssec = alive(xsec_engine(sect, phase=0, min_assets=9)) + bsec = A.anchor_luck_band(lambda ph: alive(xsec_engine(sect, phase=ph, min_assets=9)), phases, + canonical=0, metric=sh) + print(f" {line('SECT11 (azioni)', ssec)}") + print(f" SECT11 banda di fase: mediana Sh {bsec['median']:+.2f} " + f"[{bsec['lo']:+.2f},{bsec['hi']:+.2f}], fasi>0 {bsec['frac_positive']*100:.0f}%") + + print("\n CONTROLLO DI DIREZIONE β€” stesso meccanismo con mode='rev' (long i deboli):") + for tag, P in (("U13-FIX", p13), ("U19-EXP", panel19)): + r = alive(xsec_engine(P, phase=0, mode="rev")) + print(f" {line(tag + ' REV', r)}") + + # ------------- 7b. la SELEZIONE del 2026-06-19, rivalidata FUORI CAMPIONE + print("\n[7b] LA SELEZIONE DEL 2026-06-19, RIVALIDATA FUORI CAMPIONE") + print(" XS01 non e' solo un meccanismo: e' un meccanismo AFFINATO due volte sui dati HL") + print(" 2024+ (blend di lookback [30,90] e gate di dispersione p30). Quelle DUE scelte sono") + print(" debito di multiple-testing contratto sulla finestra di scoperta. Qui si paga:") + print(" ogni variante e' valutata sul PRE-2024, a MEDIANA delle 10 fasi (mai l'ancora).") + variants = [("blend [30,90] + gate p30 (CANONICA)", dict(lookbacks=(30, 90), disp_pct=30)), + ("solo L=30 + gate p30", dict(lookbacks=(30,), disp_pct=30)), + ("solo L=90 + gate p30", dict(lookbacks=(90,), disp_pct=30)), + ("blend [30,90] SENZA gate", dict(lookbacks=(30, 90), disp_pct=0)), + ("blend [30,90] + gate p15", dict(lookbacks=(30, 90), disp_pct=15)), + ("blend [30,90] + gate p50", dict(lookbacks=(30, 90), disp_pct=50))] + sel_rows = [] + for nm, kw in variants: + b_pre = A.anchor_luck_band( + lambda ph, kw=kw: win(alive(xsec_engine(p13, phase=ph, **kw)), None, "2024-01-01"), + phases, canonical=0, metric=sh) + b_post = A.anchor_luck_band( + lambda ph, kw=kw: win(alive(xsec_engine(p13, phase=ph, **kw)), "2024-01-01"), + phases, canonical=0, metric=sh) + sel_rows.append((nm, b_pre["median"], b_post["median"], b_pre["per_offset"])) + print(f" {nm:<38s} PRE-2024 mediana Sh {b_pre['median']:+5.2f} " + f"[{b_pre['lo']:+.2f},{b_pre['hi']:+.2f}] | 2024+ {b_post['median']:+5.2f}") + rank_pre = 1 + sorted([r[1] for r in sel_rows], reverse=True).index(sel_rows[0][1]) + rank_post = 1 + sorted([r[2] for r in sel_rows], reverse=True).index(sel_rows[0][2]) + print(f" -> la configurazione CANONICA (scelta nel 2026-06 guardando SOLO il 2024+) e'") + print(f" {rank_pre}/{len(sel_rows)} sul PRE-2024 e {rank_post}/{len(sel_rows)} sul 2024+.") + print( " πŸ“Œ LETTURA: il NUCLEO regge (ogni variante e' positiva fuori campione, banda") + print( " [+0.76,+1.28]), ma i DUE AFFINAMENTI del 19/06 NON si replicano:") + print( " - il BLEND [30,90] fuori campione e' PEGGIO del solo L=30 (+1.12 vs +1.28);") + print( " - il GATE di dispersione p30 fuori campione vale ZERO (con e senza: +1.12);") + print( " e' dentro la finestra di scoperta che vale (+0.37 contro -0.03).") + print( " Cioe': cio' che sopravvive e' il meccanismo cross-sectional nudo; i due") + print( " affinamenti sono, per quanto qui misurabile, adattamento alla finestra in") + print( " cui furono scelti. NON e' un argomento per cambiarli (cambiarli guardando") + print( " QUESTO risultato sarebbe la stessa selezione, spostata di finestra): e'") + print( " un argomento per non attribuire loro il valore che il progetto gli attribuisce.") + + # --- concentrazione: un contributo positivo si scompone per ANNO e per GAMBA (lezione SOL) + print("\n Concentrazione (lezione SOL 22/08: '24/24 ancore positive erano UN ANNO SOLO'):") + pre13 = win(s13, None, "2024-01-01") + for y in (2021, 2022, 2023): + rest = pre13[pre13.index.year != y] + print(f" PRE-2024 togliendo il {y}: Sh {sh(rest):+5.2f} " + f"(intero pre-2024 {sh(pre13):+5.2f})") + loo = {} + for a_ in U13: + sub = panel19[[x for x in U13 if x != a_]].dropna() + bb = A.anchor_luck_band(lambda ph, sub=sub: win(alive(xsec_engine(sub, phase=ph, + min_assets=10)), + None, "2024-01-01"), + phases, canonical=0, metric=sh) + loo[a_] = bb["median"] + base_med = bands["U13-FIX PRE-2024"]["median"] + worst = min(loo, key=loo.get) + print(f" Leave-one-asset-out sul PRE-2024 (mediana di fase; base {base_med:+.2f}):") + print(" " + " ".join(f"-{k2}:{v:+.2f}" for k2, v in sorted(loo.items(), key=lambda t: t[1]))) + print(f" gamba piu' importante: {worst} (senza di lei {loo[worst]:+.2f}); " + f"minimo {min(loo.values()):+.2f}, tutte >0: {all(v > 0 for v in loo.values())}") + + # ---------------------------------------------------------- 8. i GATE + print("\n[8] GATE") + cand = s13.copy(); cand.index = cand.index.tz_convert("UTC") + mg = A.marginal_vs_tp01(cand) + print(f" marginal_vs_tp01 (U13-FIX, 2021+): {mg.get('marginal_verdict')} " + f"corr {mg.get('corr_full')} n={mg.get('n_days')}") + for kk in ("robust_oos", "has_insample_edge", "is_hedge", "beats_noise_null", + "insample_sharpe", "tp01_beta", "alpha_ann"): + if kk in mg: + print(f" {kk:<20s} {mg[kk]}") + if "blends" in mg: + for wname, bl in mg["blends"].items(): + print(f" blend {wname}: uplift_full {bl.get('uplift_full')} " + f"uplift_hold {bl.get('uplift_hold')}") + + mgE = A.marginal_vs_tp01(sEXP) + print(f" marginal_vs_tp01 (U19-EXP, 2020-12+): {mgE.get('marginal_verdict')} " + f"corr {mgE.get('corr_full')}") + + # deflated Sharpe: TUTTI i trial valutati, al RIALZO + all_sr = [] + for tag in ("U13-FIX FULL", "U13-FIX PRE-2024", "U19-EXP FULL", "U19-EXP PRE-2024"): + all_sr += list(bands[tag]["per_offset"].values()) + all_sr += list(bhl["per_offset"].values()) + list(bsec["per_offset"].values()) + for _, _, _, po in sel_rows: # 6 varianti x 10 fasi della sezione 7b + all_sr += list(po.values()) + all_sr += [float(v) for v in loo.values()] + cand_pool = list(all_sr) # SOLO configurazioni di XS01 + ctrl = [sh(alive(xsec_engine(p13, phase=0, mode="rev"))), + sh(alive(xsec_engine(panel19, phase=0, mode="rev"))), sh(jj["bin"]), sh(jj["hl"])] + for fs in (0.0005, 0.001, 0.002): + ctrl.append(sh(alive(xsec_engine(p13, phase=0, fee_side=fs)))) + dsr_val = bands["U13-FIX PRE-2024"]["median"] + med_ph = int(np.argsort(list(bands["U13-FIX PRE-2024"]["per_offset"].values()))[len(phases) // 2]) + med_series = win(alive(xsec_engine(p13, phase=med_ph)), None, "2024-01-01") + d1, s1 = A.deflated_sharpe(dsr_val, cand_pool, med_series) + d2, s2 = A.deflated_sharpe(dsr_val, cand_pool + ctrl, med_series) + print(f" deflated_sharpe β€” headline = MEDIANA di fase OOS {dsr_val:+.2f}") + print(f" pool A = SOLE configurazioni XS01 ({len(cand_pool)} trial): DSR {d1:.3f} " + f"(max dal rumore {s1:+.2f}) -> {'PASS' if d1 >= 0.95 else 'FAIL'}") + print(f" pool B = tutto cio' che ho valutato, controlli NEGATIVI inclusi " + f"({len(cand_pool)+len(ctrl)} trial): DSR {d2:.3f} (max dal rumore {s2:+.2f}) " + f"-> {'PASS' if d2 >= 0.95 else 'FAIL'}") + print( " ⚠️ Il pool B e' il conteggio 'al rialzo' che il progetto impone, ma qui e' anche") + print( " il piu' SEVERO in modo improprio: il DSR pesa la VARIANZA dei trial, e io ci") + print( " ho messo dentro controlli costruiti per essere negativi (REV -0.97, SECT11") + print( " -0.44). Si cita il piu' basso dei due; e si dice che entrambi penalizzano un") + print( " MEDIANA-di-fase come se fosse un MAX-di-k, che e' il caso per cui il DSR e'") + print( " costruito. Il debito di selezione vero di XS01 non e' stato contratto qui:") + print( " e' quello del 2026-06-19, e la sezione 7b lo misura direttamente.") + + imp = A.implausible_sharpe(s13) + print(f" implausible_sharpe: {imp['implausible']} Sh {imp.get('sharpe'):+.2f} " + f"maxDD {imp.get('maxdd', 0)*100:.1f}% perdite su barre attive " + f"{imp.get('loss_frac', float('nan'))*100:.1f}% (attive {imp.get('active_frac', 0)*100:.0f}%)" + f"{' β€” ' + '; '.join(imp['reasons']) if imp['reasons'] else ''}") + + # causalita': i prezzi DOPO la data di decisione non devono cambiare le posizioni prima + Pc = p13.copy() + cut = len(Pc) // 2 + rngc = np.random.default_rng(7) + Pd = Pc.copy() + Pd.iloc[cut:] = Pc.iloc[cut:].values * (1.0 + rngc.normal(0, 0.35, size=Pc.iloc[cut:].shape)) + a_ = xsec_engine(Pc, phase=0); b_ = xsec_engine(Pd, phase=0) + # confronto solo dove il vol-target non ha ancora "visto" i dati perturbati (rv30 shiftata) + lim = cut - 35 + cdiff = float(np.max(np.abs(a_.values[:lim] - b_.values[:lim]))) + print(f" causalita' (prezzi futuri randomizzati da i={cut}): max|diff| prima di i={lim} " + f"= {cdiff:.2e} -> {'LEAK-FREE' if cdiff < 1e-12 else 'PERDITA DI FUTURO'}") + + print(" null de-levering: NON PERTINENTE β€” il claim qui non e' 'meno drawdown' ma " + "'l'edge esiste fuori campione'. Il de-levering e' comunque implicito nel vol-target,") + print(" e le tabelle di fase riportano DD e Sharpe INSIEME (un k<1 abbassa entrambi).") + + print(f"\n sensibilita' al COSTO (il rischio #1 di uno sleeve a 13 gambe alt):") + for fs in (0.0005, 0.001, 0.002, 0.003): + x = alive(xsec_engine(p13, phase=0, fee_side=fs)) + print(f" {fs*1e4:5.0f} bps/lato: FULL Sh {sh(x):+5.2f} | " + f"PRE-2024 {sh(win(x, None, '2024-01-01')):+5.2f} | " + f"2024+ {sh(win(x, '2024-01-01')):+5.2f}") + + # ------------------------------------------------- 9. impatto sul LIBRO + print("\n[9] IMPATTO SUL LIBRO β€” TP01+SKH01 (75/25 live) con e senza XS01, su 2021+") + from src.portfolio.sleeves import _tp01_returns, _skyhook_returns + tp = A._to_daily(_tp01_returns()); sk = A._to_daily(_skyhook_returns()) + for xs_tag, xs in (("XS01 storia lunga (U13-FIX)", s13), ("XS01 ufficiale (HL 2024+)", off)): + J = pd.concat({"tp": tp, "sk": sk, "xs": A._to_daily(xs)}, axis=1, join="inner").dropna() + if len(J) < 200: + continue + base = 0.75 * J["tp"] + 0.25 * J["sk"] + for w in (0.15, 0.25, 0.50): + mix = (1 - w) * base + w * J["xs"] + print(f" [{xs_tag}] {J.index[0].date()}..{J.index[-1].date()} n={len(J)} " + f"w={w:.2f}: Sh {sh(base):+.2f} -> {sh(mix):+.2f} " + f"({sh(mix)-sh(base):+.2f}) maxDD {dd(base)*100:.1f}% -> {dd(mix)*100:.1f}% " + f"corr(XS,libro) {J['xs'].corr(base):+.3f}") + # e il numero che il PROP-ALLOC usa davvero: il libro 2021+ contiene il 2022 + pre = J[J.index < DISCOVERY] + if len(pre) > 200: + bpre = 0.75 * pre["tp"] + 0.25 * pre["sk"] + print(f" -> SOLO FUORI CAMPIONE ({pre.index[0].date()}..{pre.index[-1].date()}, " + f"n={len(pre)}), sweep del peso di XS01:") + for w in (0.15, 0.25, 0.33, 0.50): + mpre = (1 - w) * bpre + w * pre["xs"] + print(f" w={w:.2f}: libro Sh {sh(bpre):+.2f} -> {sh(mpre):+.2f} " + f"({sh(mpre)-sh(bpre):+.2f}) maxDD {dd(bpre)*100:.1f}% -> " + f"{dd(mpre)*100:.1f}% corr {pre['xs'].corr(bpre):+.3f}") + print(" ⚠️ PROP-ALLOC mette XS01 al 50%: su un conto a BARRIERA cio' che si") + print(" compra e' il maxDD, quindi la riga da guardare e' quella, non lo Sharpe.") + + # ---------------------------------------------------- 10. eseguibilita' + print("\n[10] ESEGUIBILITA' β€” la domanda del canale funded e' se il libro STA su $100k") + det: dict = {} + xsec_engine(p13, phase=0, detail=det) + W, sc = det["W"], det["scale"] + dW = np.abs(np.diff(W, axis=0)); rb = np.flatnonzero(dW.sum(axis=1) > 1e-12) + print(f" {len(U13)} gambe, {len(rb)} ribilanciamenti, gross medio " + f"{np.abs(W).sum(axis=1).mean():.2f}, leva media (vol-target) {np.mean(sc[sc > 0]):.2f}x") + print(f" {'capitale':>10} {'ticket mediano':>16} {'ticket p10':>12} {'<$5':>7} {'<$10':>7}") + for cap in (600, 5000, 20000, 100000): + parts = [dW[i][dW[i] > 1e-12] * sc[i + 1] * cap for i in rb if sc[i + 1] > 0] + tk = np.concatenate(parts) if parts else np.array([]) + if not tk.size: + print(" (nessun ticket calcolabile β€” controlla il motore)"); break + print(f" {cap:>10,d} {np.median(tk):>15.2f}$ {np.percentile(tk, 10):>11.2f}$ " + f"{(tk < 5).mean()*100:>6.1f}% {(tk < 10).mean()*100:>6.1f}%") + print(" ⚠️ Approssimazione DICHIARATA: il modello congelato applica il vol-target alla") + print(" SERIE dei rendimenti, non alle posizioni -> qui il ticket e' calcolato sui soli") + print(" delta di RIBILANCIAMENTO (ogni 10 giorni), ignorando il micro-turnover") + print(" giornaliero che una implementazione vera del vol-target genererebbe. E' un") + print(" LIMITE INFERIORE del numero di ordini, quindi ottimistico sul lato costi.") + print(" ⚠️ E NON rimette in discussione il ~$20k citato dal progetto: quella soglia e' sui") + print(" 19 alt Hyperliquid (min-order e liquidita' del venue), non su questi 13.") + + print(f"\n[fine] {time.time()-t0:.1f}s") + + +if __name__ == "__main__": + main()