diff --git a/docs/research/RESULTS-0822.md b/docs/research/RESULTS-0822.md index f90ed6c..f2ff662 100644 --- a/docs/research/RESULTS-0822.md +++ b/docs/research/RESULTS-0822.md @@ -6,7 +6,7 @@ null de-levering superato + eseguibilita' al capitale dichiarato. | # | filone | verdetto | in una riga | |---|---|---|---| -| 9 | ADAPTIVE-HORIZON | **SCARTATO** | il "vincitore adattivo" ha lookback incollato al bordo il 100% del tempo = TSMOM costante a 20g (corr 1.000, dSh 0.00 in 8/8 anni e 0/12 ancore); isolando le celle davvero adattive -> NEUTRAL, corr→TP01 0.90 = TP01 travestito | +| 9 | ADAPTIVE-HORIZON | **SCARTATO** | il "vincitore adattivo" ha lookback incollato al bordo il 100% del tempo = TSMOM costante a 20g (corr 1.000, dSh 0.00 in 8/8 anni e 0/12 ancore); isolando le celle davvero adattive -> NEUTRAL, corr→TP01 0.90 = TP01 travestito || 4 | DEALER-GAMMA | **SCARTATO** | la gamba tradeable e' INCOERENTE (BTC non separa, ETH separa al ROVESCIO); cio' che resta e' quasi tutto DVOL (corr 0.66), gia' refutato il 26/06. DSR 0.440 e la cella migliore sta SOTTO il massimo atteso dal rumore | ## Note che sopravvivono ai singoli filoni @@ -22,3 +22,26 @@ null de-levering superato + eseguibilita' al capitale dichiarato. una superficie frastagliata. **Nessun cambio a TP01.** - Riaprirebbe il filone: un rilevatore il cui L si muove DAVVERO che batta il miglior L costante (non TP01) in >=6/8 anni appaiati con corr→TP01 < 0.6 — es. BOCPD/PELT vero, non provato. + +### 4 — DEALER-GAMMA (r0822_dealer_gamma.py, 136 statistiche dichiarate) +⚠️ **DIFETTO DI DATO, il risultato piu' riusabile del filone: `dealer_net_gamma` e' il GEX col SEGNO +INVERTITO** — corr **-0.95 BTC / -0.89 ETH** contro la ricostruzione da manuale sulla catena +(962/1116 snapshot, ~280-316 strike), fit `dng ~ -0.7*GEX`. **Chi lo usasse col nome che porta +leggerebbe ogni regime al contrario.** Spiega anche la contraddizione interna del file (corr fra +`dng>0` e `spot>flip` = -0.28 / -0.92). +⚠️ **Il file dichiara piu' di quel che ha:** cadenza 15 min solo dal **2026-05-01**; i 37 giorni +prima sono **un punto al giorno** -> **90 giorni, non 4 mesi**. `liquidation_long_risk` e +`liquidation_short_risk` sono **COSTANTI** ('low' in 17.229 righe su 17.229 non nulle) = zero +informazione. `gamma_flip_level` congelato nel **61%** dei passi su BTC. +- Gate: **null de-levering FALLITO** (baseline x0.6 stesso maxDD con Sharpe migliore); **DSR 0.440** + su 64 trial, e nel modo piu' netto — la cella migliore (+4.98 lordo) sta **sotto** il massimo + atteso dal puro rumore (+5.12). `marginal_vs_tp01` inutilizzabile: su 90 giorni full e hold-out + COINCIDONO (`n_hold_days == n_days == 90`). +- **Null location-matched** (aggiunto dall'agente): `gamma_flip_level` bocciato — 96% di accordo con + `spot > SMA(720h)` su ETH; su BTC la SMA nuda da' uno spread MAGGIORE. +- **Ampiezza effettiva**: 73/59 episodi di regime ma 6-8 coprono il 78-86% del tempo -> **n_eff 53 + (BTC) / 24 (ETH) ore** su 2.130. Il regime coincide col MESE (maggio long, giugno short, luglio long). +- ✅ Ricostruibile oggi dalla NOSTRA catena, meglio dell'originale (518 ore dal 01/08, ~209-262 + strike/ora, quote_status ok ~100%, segno giusto) — **ma la soglia non e' portabile** (`dng=0` + corrisponde a `GEX=+5.8e6` su BTC): ricalibrarla sui 90 giorni sarebbe la selezione che il + progetto rifiuta. Riapertura non prima di **~30-40 episodi di regime = ~2 anni (meta' 2028)**. diff --git a/scripts/research/r0822_dealer_gamma.py b/scripts/research/r0822_dealer_gamma.py index e91bd44..bc6df2b 100644 --- a/scripts/research/r0822_dealer_gamma.py +++ b/scripts/research/r0822_dealer_gamma.py @@ -655,9 +655,11 @@ def main() -> None: print(" 3. `gamma_flip_level` e' una media mobile: 96% di accordo con spot>SMA(720h) su ETH; su") print(" BTC (accordo 0.68) la SMA nuda da' uno spread di vol MAGGIORE del livello stesso.") print(" Niente contenuto di opzioni isolabile. Trappola 2 del brief.") - print(" 4. Ampiezza effettiva: 6-8 episodi di regime, n_eff 24-53 ore su 2.130. Il regime") - print(" coincide col mese (maggio LONG, giugno SHORT, luglio LONG): lo spread di vol e' UN") - print(" confronto fra tre mesi, non 2.136 osservazioni.") + print(" 4. Ampiezza effettiva: 73 (BTC) / 59 (ETH) episodi di regime, ma SEI e OTTO di essi") + print(" coprono il 78-86% del tempo; n_eff 53 e 24 ore su 2.130. Il regime coincide col") + print(" MESE (maggio LONG, giugno SHORT, luglio LONG): lo spread di vol e' un confronto") + print(" fra tre mesi, non fra 2.136 osservazioni. Tolto giugno cala del 46% (BTC) e del") + print(" 71% (ETH) e resta poggiato su 134 / 366 ore.") print(" 5. Cio' che resta della gamba (A) e' quasi tutto DVOL (corr 0.66 su BTC), e il DVOL") print(" come modulatore di TP01 e' gia' stato refutato il 26/06: era de-levering.") print(" 6. Il gate misurato FALLISCE il null del de-levering (baseline x0.6 lo domina) su una") diff --git a/scripts/research/r0822_flow_squeeze.py b/scripts/research/r0822_flow_squeeze.py index 18e0e36..072c9fa 100644 --- a/scripts/research/r0822_flow_squeeze.py +++ b/scripts/research/r0822_flow_squeeze.py @@ -622,11 +622,14 @@ def main() -> None: debole. (Ed e' esattamente la parte che rendeva l'angolo NUOVO rispetto al filone funding.) 2. IL FATTO NON REGGE LA PROPRIA SOGLIA DI POTENZA, dichiarata prima di guardare: l'unico effetto e' il decile piu' basso di oi_delta (-0.40% a 24h) e la MDE a 24h e' 1.06%. - La pendenza lineare e' nulla (|t|<1.3), il |ritorno| futuro e' piatto fra i decili (la - 'cascata' non c'e'), e la scomposizione per mese mette quasi tutto in giugno. + La pendenza lineare e' nulla (|t|<1.3 in 4 specificazioni su 4); il |ritorno| futuro e' + quasi piatto fra i decili (0.59-0.75% a 4h, solo il decile 0 leggermente sopra) -> la + 'cascata' non c'e'; e il segno e' ASSENTE nel primo dei tre mesi (maggio y24=+0.02%, + giugno -0.64%, luglio -0.74%): 2 mesi su 3 non sono un plateau, sono due mesi. 3. IL SEGNALE E' UN MASSIMO DI 18 CELLE SU 114 GIORNI: Sharpe 3.6 con SE(Sharpe)=2.0, deflated-Sharpe 0.10 (18 celle) / 0.004 (118 trial) contro un massimo atteso PER CASO di - Sharpe 5.4; implausible_sharpe=True; marginal_vs_tp01 = NEUTRAL; 60% del P&L in 3 giorni. + Sharpe 5.4; implausible_sharpe=True; marginal_vs_tp01 = NEUTRAL; e il P&L e' per il 71% + di giugno e per il 60% concentrato in 3 giorni su 114. 4. ED E' COMUNQUE MORTO ALLA NASCITA: la colonna su cui poggia non e' ricostruibile con i dati che raccogliamo (l'OI della catena e' quello delle OPZIONI, rango ~0.05 con la colonna) e la sorgente e' stata dismessa il 2026-07-30. diff --git a/scripts/research/r0822_ortho_screen.py b/scripts/research/r0822_ortho_screen.py index b91e181..981ca76 100644 --- a/scripts/research/r0822_ortho_screen.py +++ b/scripts/research/r0822_ortho_screen.py @@ -630,6 +630,7 @@ def deep_dive(results) -> None: B = A.tp01_baseline_daily() C = r["C"] best = None + rows_ctl = [] for p in MOM_CONTROL_GRID: fnm = mk_mom(tf="1d", **p) Cm = A.candidate_daily(fnm, tf="1d") @@ -637,18 +638,27 @@ def deep_dive(results) -> None: JB = pd.concat({"B": B, "M": Cm}, axis=1, join="inner").dropna() up = A._sh((1 - W_BLEND) * JB["B"] + W_BLEND * JB["M"]) - A._sh(JB["B"]) row = dict(params=p, sharpe=round(A._sh(Cm), 3), + insample=round(A._sh(Cm[Cm.index < A.HOLDOUT]), 3), hold=round(A._sh(Cm[Cm.index >= A.HOLDOUT]), 3), corr_to_acc=round(float(J["A"].corr(J["M"])), 3), corr_to_tp01=round(float(JB["B"].corr(JB["M"])), 3), uplift_w25_full=round(up, 3)) - print(f" MOM {str(p):<28} Sh {row['sharpe']:+.3f} hold {row['hold']:+.3f} " - f"corr->{r['code']} {row['corr_to_acc']:+.3f} corr->TP01 {row['corr_to_tp01']:+.3f} " - f"uplift {row['uplift_w25_full']:+.3f}") + print(f" MOM {str(p):<28} Sh {row['sharpe']:+.3f} IS {row['insample']:+.3f} " + f"hold {row['hold']:+.3f} corr->{r['code']} {row['corr_to_acc']:+.3f} " + f"corr->TP01 {row['corr_to_tp01']:+.3f} uplift {row['uplift_w25_full']:+.3f}") + rows_ctl.append(row) if best is None or row["corr_to_acc"] > best["corr_to_acc"]: best = row print(f" livello piu' vicino all'accelerazione: {best['params']} corr {best['corr_to_acc']:+.3f}") print(" lettura: corr alta col LIVELLO => l'accelerazione non e' una dimensione nuova; " - "corr bassa => e' distinta ma resta da spiegare perche' non sopravvive al DSR.\n") + "corr bassa => e' distinta ma resta da spiegare perche' non sopravvive al DSR.") + bh = max(rows_ctl, key=lambda x: x["hold"]) + bi = max(rows_ctl, key=lambda x: x["insample"]) + print(f" TRAPPOLA DICHIARATA: la cella di controllo migliore SULL'HOLD-OUT e' {bh['params']} " + f"(hold {bh['hold']:+.3f}); quella scelta IN-SAMPLE-ONLY e' {bi['params']} " + f"(IS {bi['insample']:+.3f}, hold {bi['hold']:+.3f}). Se coincidono, il controllo e'" + " selezionabile; se no, guardare la colonna hold e' selezione-sull'hold-out (trappola 3)" + " e NON promuove nulla.\n") NOT_TESTED = [ diff --git a/scripts/research/r0822_term_structure.py b/scripts/research/r0822_term_structure.py index 67521b3..e5cb1f4 100644 --- a/scripts/research/r0822_term_structure.py +++ b/scripts/research/r0822_term_structure.py @@ -156,28 +156,62 @@ def sezione0(chain: pd.DataFrame) -> None: # =========================================================================================== # SEZIONE 1 — RICOSTRUZIONE DELLA CURVA + CERTIFICAZIONE # =========================================================================================== -def _atm_iv(g: pd.DataFrame) -> float: - """ATM IV di UNO snapshot+scadenza: interpolazione sul DELTA, mai estrapolazione.""" - vals = [] - for typ, tgt in (("C", 0.5), ("P", -0.5)): - s = g[g.option_type == typ] - if len(s) >= 2: - d = s["delta"].to_numpy() - if d.min() <= tgt <= d.max(): - o = np.argsort(d) - vals.append(float(np.interp(tgt, d[o], s["iv"].to_numpy()[o]))) - return float(np.mean(vals)) if vals else np.nan +def _interp_at(gid: np.ndarray, x: np.ndarray, y: np.ndarray, ngrp: int, + target: float) -> np.ndarray: + """Interpolazione lineare di y(x) a `target`, per ogni gruppo, VETTORIALE e senza + estrapolazione (NaN se il gruppo non racchiude il target). + + `gid` dev'essere ordinato crescente e (gid, x) lessicograficamente ordinati. + Equivalente a np.interp gruppo-per-gruppo: qui serve solo perche' i gruppi sono ~30.000 + e un ciclo python li paga a caro prezzo su una VPS a 2 core condivisa. + """ + n_tot = np.bincount(gid, minlength=ngrp) + start = np.concatenate([[0], np.cumsum(n_tot)[:-1]]) + n_below = np.bincount(gid[x <= target], minlength=ngrp) + ok = (n_below > 0) & (n_below < n_tot) + out = np.full(ngrp, np.nan) + if not ok.any(): + return out + lo = (start + n_below - 1)[ok] + hi = lo + 1 + x0, x1, y0, y1 = x[lo], x[hi], y[lo], y[hi] + w = np.where(x1 != x0, (target - x0) / np.where(x1 != x0, x1 - x0, 1.0), 0.0) + out[ok] = y0 + w * (y1 - y0) + return out def build_atm_by_expiry(chain: pd.DataFrame) -> pd.DataFrame: + """ATM IV per (asset, ora, scadenza) = IV interpolata sul DELTA a 0.5 (call) / -0.5 (put). + + Perche' il delta e non "lo strike piu' vicino allo spot": il delta referenzia il FORWARD. + A 90 giorni il forward sta lontano dallo spot, quindi lo strike ATM-spot e' un'opzione OTM + e la sua IV porta dentro lo SKEW — che e' un altro filone e falserebbe la pendenza in modo + sistematico e crescente col tenore. Nessuna estrapolazione: se il delta 0.5 non e' racchiuso + dai quotati, quella scadenza a quell'ora non ha ATM e viene scartata. + """ f = CACHE / "atm_by_expiry.parquet" if f.exists(): return pd.read_parquet(f) - q = chain[chain.quoted & (chain.dte > DTE_MIN)] - out = (q.groupby(["asset", "hr", "exp"]) - .apply(lambda g: pd.Series({"iv": _atm_iv(g), "dte": g["dte"].median()}), - include_groups=False) - .reset_index().dropna(subset=["iv"])) + q = chain[chain.quoted & (chain.dte > DTE_MIN)].copy() + keys = ["asset", "hr", "exp"] + q["gid"] = q.groupby(keys, sort=True).ngroup() + ngrp = int(q["gid"].max()) + 1 + ivs = [] + for typ, tgt in (("C", 0.5), ("P", -0.5)): + s = q[q.option_type == typ].sort_values(["gid", "delta"]) + v = np.full(ngrp, np.nan) + if len(s): + g2 = s["gid"].to_numpy() + uniq, inv = np.unique(g2, return_inverse=True) + v[uniq] = _interp_at(inv, s["delta"].to_numpy(), s["iv"].to_numpy(), len(uniq), tgt) + ivs.append(v) + M = np.vstack(ivs) + with np.errstate(invalid="ignore"): + cnt = np.sum(np.isfinite(M), axis=0) + iv = np.where(cnt > 0, np.nansum(np.where(np.isfinite(M), M, 0.0), axis=0) / np.maximum(cnt, 1), np.nan) + out = (q.groupby(keys, sort=True)["dte"].median().reset_index()) + out["iv"] = iv + out = out.dropna(subset=["iv"]).reset_index(drop=True) CACHE.mkdir(parents=True, exist_ok=True) out.to_parquet(f) return out @@ -266,7 +300,7 @@ def px_hourly(a: str) -> pd.Series: index=pd.DatetimeIndex(pd.to_datetime(d["datetime"], utc=True))) -def _block_pctl(x: np.ndarray, y: np.ndarray, stat: float, L: int, n: int = 800, +def _block_pctl(x: np.ndarray, y: np.ndarray, stat: float, L: int, n: int = 400, seed: int = 20260822) -> float: """Percentile della correlazione osservata contro il null a BLOCCHI (y ricampionata da blocchi non allineati a x): conserva l'autocorrelazione e rompe solo l'accoppiamento.""" @@ -400,6 +434,51 @@ def build_atm_cost(chain: pd.DataFrame) -> pd.DataFrame: return atm + +def _roll_table(r: pd.DataFrame, H: int) -> pd.DataFrame: + """Roll IMPLICITO vs REALIZZATO su orizzonte H ore, appaiato per (scadenza, ISTANTE D'INGRESSO). + + Appaiato sull'INGRESSO e non sull'uscita: e' l'uscita che le due letture spostano, e un + inner-join sulla data d'uscita terrebbe solo i casi in cui non e' successo niente (trappola + incontrata 3 volte nel progetto: venue-watch, GTAA, VRP profit-take). + impl = iv_curva(T-H) al tempo t - iv(T) al tempo t (se la curva non si muovesse) + real = iv(T-H) al tempo t+H - iv(T) al tempo t (cio' che e' successo davvero) + """ + r = r[["hr", "exp", "iv", "dte"]].dropna() + nxt = r[["hr", "exp", "iv"]].copy() + nxt["hr"] = nxt["hr"] - pd.Timedelta(hours=H) + j = r.merge(nxt.rename(columns={"iv": "iv_fut"}), on=["hr", "exp"], how="inner") + j["T2"] = j["dte"] - H / 24.0 + j = j[j["T2"] > DTE_MIN] + if j.empty: + return pd.DataFrame(columns=["dte", "impl", "real"]) + # curva dell'ora t, ordinata: chiave monotona globale gid*1000 + dte -> searchsorted unico + c = r.sort_values(["hr", "dte"]).copy() + c["gid"] = c.groupby("hr", sort=True).ngroup() + key = c["gid"].to_numpy() * 1000.0 + c["dte"].to_numpy() + w = (c["iv"].to_numpy() / 100.0) ** 2 * c["dte"].to_numpy() # varianza totale + gmap = dict(zip(c["hr"].to_numpy(), c["gid"].to_numpy())) + n_tot = np.bincount(c["gid"].to_numpy()) + start = np.concatenate([[0], np.cumsum(n_tot)[:-1]]) + gq = np.array([gmap[h] for h in j["hr"].to_numpy()]) + pos = np.searchsorted(key, gq * 1000.0 + j["T2"].to_numpy(), side="left") + lo, hi = pos - 1, pos + ok = (lo >= start[gq]) & (hi < start[gq] + n_tot[gq]) + ivT2 = np.full(len(j), np.nan) + if ok.any(): + l, h2 = lo[ok], hi[ok] + x0 = key[l] - gq[ok] * 1000.0 + x1 = key[h2] - gq[ok] * 1000.0 + t2 = j["T2"].to_numpy()[ok] + ww = np.where(x1 != x0, (t2 - x0) / np.where(x1 != x0, x1 - x0, 1.0), 0.0) + var = w[l] + ww * (w[h2] - w[l]) + ivT2[ok] = np.sqrt(np.maximum(var, 0.0) / t2) * 100.0 + j = j.assign(ivT2=ivT2).dropna(subset=["ivT2"]) + return pd.DataFrame({"dte": j["dte"].to_numpy(), + "impl": j["ivT2"].to_numpy() - j["iv"].to_numpy(), + "real": j["iv_fut"].to_numpy() - j["iv"].to_numpy()}) + + def sezione3(chain: pd.DataFrame, atm_exp: pd.DataFrame, curve: pd.DataFrame) -> None: hr("SEZIONE 3 — Q2: il CARRY di vol (roll-down) e' incassabile al bid-ask reale?") print(" Definizione senza modello: un'opzione a tenore T, dopo dt, sta a T-dt. Se la curva non") @@ -412,25 +491,7 @@ def sezione3(chain: pd.DataFrame, atm_exp: pd.DataFrame, curve: pd.DataFrame) -> roll_day = {} for H in (24, 168): for a in ASSETS: - r = atm_exp[atm_exp.asset == a] - by_hr = {h: g.sort_values("dte") for h, g in r.groupby("hr")} - rows = [] - for _, g in r.set_index(["exp", "hr"]).sort_index().groupby(level=0): - g = g.droplevel(0).sort_index() - fut = g["iv"].reindex(g.index + pd.Timedelta(hours=H)) - for t, iv0, d0, iv1 in zip(g.index, g["iv"].to_numpy(), - g["dte"].to_numpy(), fut.to_numpy()): - T2 = d0 - H / 24.0 - gg = by_hr.get(t) - if not np.isfinite(iv1) or T2 <= DTE_MIN or gg is None or len(gg) < 2: - continue - td, ivd = gg["dte"].to_numpy(), gg["iv"].to_numpy() - if td.min() > T2 or td.max() < T2: - continue - w = (ivd / 100.0) ** 2 * td - ivT2 = np.sqrt(np.interp(T2, td, w) / T2) * 100.0 - rows.append((d0, ivT2 - iv0, iv1 - iv0)) - d = pd.DataFrame(rows, columns=["dte", "impl", "real"]) + d = _roll_table(atm_exp[atm_exp.asset == a], H) d = d[(d.dte >= 4) & (d.dte <= 45)] if len(d) < 50: print(f" {a} dt={H}h: campione insufficiente ({len(d)})") diff --git a/scripts/research/r0822_vol_size.py b/scripts/research/r0822_vol_size.py index dc9850a..d00ae9e 100644 --- a/scripts/research/r0822_vol_size.py +++ b/scripts/research/r0822_vol_size.py @@ -182,6 +182,7 @@ def extract(asset: str, off: int) -> dict: ts_close=ltf["timestamp"].values.astype(np.int64) + LTF_MIN * 60_000, day_ent=idx[idxE].floor("D") if len(idxE) else pd.DatetimeIndex([], tz="UTC"), ) + out["base_daily"] = equity_daily(out, size_flat(out)) # riusata da VTL/§9: calcolarla 1 volta R._CACHE.clear() # ent = 18k dict per chiave: non accumulare return out @@ -413,8 +414,7 @@ def main() -> None: for o in offs: sz = {} for a in ASSETS: - base_a = equity_daily(EX[o][a], size_flat(EX[o][a])) - sz[a] = sizes_from_L(EX[o][a], leverage_series(base_a, **sp)) + sz[a] = sizes_from_L(EX[o][a], leverage_series(EX[o][a]["base_daily"], **sp)) SLE[nm][o] = leg_daily(EX[o], sz) BK[nm][o] = book(TP, SLE[nm][o]) MTP[nm][o] = 1.0 @@ -546,6 +546,13 @@ def main() -> None: print(f" {n:<28} IS {is_med[n]:>7.3f} HOLD {ho_med[n]:>7.3f} FULL {fu_med[n]:>7.3f}") best = max(CAUSAL, key=lambda x: is_med[x]) print(f"\n cella scelta AL BUIO: {best} (IS {is_med[best]:.3f} vs baseline {base_is:.3f})") + iso_is = {n: float(np.median([sh(ins(BK[n][o])) - sh(ins(CTRL[n][o])) for o in offs])) + for n in CAUSAL} + best_iso = max(CAUSAL, key=lambda x: iso_is[x]) + print(f" cella scelta AL BUIO **a ISO-PESO**: {best_iso} (dIS iso {iso_is[best_iso]:+.3f})") + print(f" la stessa cella a iso-peso: dShFULL {RES[best]['iF']:+.3f} " + f"({RES[best]['niF']}/{len(offs)}), dShHOLD {RES[best]['iH']:+.3f} " + f"({RES[best]['niH']}/{len(offs)})") trials = [fu_med[n] for n in CAUSAL] + [med_F] dsr, sr0 = A.deflated_sharpe(fu_med[best], trials, BK[best][can]) dsr_b, _ = A.deflated_sharpe(med_F, trials, base_b[can]) @@ -599,9 +606,9 @@ def main() -> None: 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}") cols = {"TP01": TP, "SKH01": SLE["BASE"][can]} - for nm in [best_sleeve, list(BVT)[0]]: - m = vol(SLE[nm][can]) / vol(SLE["BASE"][can]) - wp = {"TP01": W_TP, "SKH01": W_SKH * m} + for nm in [best, best_sleeve] + list(BVT)[:2]: + m_s = vol(SLE[nm][can]) / vol(SLE["BASE"][can]) + wp = {"TP01": W_TP * MTP[nm][can], "SKH01": W_SKH * m_s} g = weights_tilt_null(cols, {"TP01": W_TP, "SKH01": W_SKH}, wp, caps={"SKH01": 0.5}, floor=0.05, n=300, k_seen=len(ALL)) print(f" tilt-null {nm:<26} peso equiv SKH {wp['SKH01']/sum(wp.values()):.3f} " @@ -634,6 +641,57 @@ def main() -> None: print(f" {label:<28}{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.") + # ------------------------------------------------------------------ §9 per anno + deriva + print("\n" + "-" * 112) + print(" 9. SCOMPOSIZIONE PER ANNO del dSharpe a ISO-PESO (regola codificata dopo SOL, 22/08:") + print(" un contributo positivo si scompone per ANNO prima di crederci) + deriva della size") + print("-" * 112) + yrs = sorted({int(y) for y in BK["BASE"][can].index.year}) + top = [best, best_iso] + [max(BVT, key=lambda x: RES[x]["iF"])] + top = list(dict.fromkeys(top)) + print(f" {'variante':<28}" + "".join(f"{y:>8}" for y in yrs)) + for nm in top: + cells = [] + for y in yrs: + v = [] + for o in offs: + a_ = BK[nm][o][BK[nm][o].index.year == y] + c_ = CTRL[nm][o][CTRL[nm][o].index.year == y] + if len(a_) > 20: + v.append(sh(a_) - sh(c_)) + cells.append(float(np.median(v)) if v else float("nan")) + npos = int(np.nansum(np.asarray(cells) > 0)) + print(f" {nm:<28}" + "".join(f"{c:>+8.2f}" for c in cells) + + f" anni positivi {npos}/{len(yrs)}") + print() + for nm in top: + if nm in SZ: + szs = np.concatenate([SZ[nm](EX[can][a]) for a in ASSETS]) + dys = pd.DatetimeIndex(np.concatenate([EX[can][a]["day_ent"].values for a in ASSETS])) + elif nm in VTL: + szs, dys = [], [] + for a in ASSETS: + szs.append(sizes_from_L(EX[can][a], + leverage_series(EX[can][a]["base_daily"], **VTL[nm]))) + dys.append(EX[can][a]["day_ent"].values) + szs = np.concatenate(szs); dys = pd.DatetimeIndex(np.concatenate(dys)) + else: + L = leverage_series(BK["BASE"][can], BVT[nm]["tv"], BVT[nm]["w"], BVT[nm]["cap"]) + szs, dys = [], [] + for a in ASSETS: + szs.append(sizes_from_L(EX[can][a], L)) + dys.append(EX[can][a]["day_ent"].values) + szs = np.concatenate(szs); dys = pd.DatetimeIndex(np.concatenate(dys)) + s = pd.Series(szs, index=dys) + med = s.groupby(s.index.year).median() + rho = float(np.corrcoef(np.arange(len(med)), med.values)[0, 1]) if len(med) > 2 else float("nan") + print(f" size mediana per anno — {nm:<22}" + + "".join(f"{med.get(y, float('nan')):>8.2f}" for y in yrs) + + f" corr col tempo {rho:+.2f}") + print(" ⚠️ una size che DERIVA col tempo non e' controllo del rischio: e' un tilt temporale") + print(" (la vol crypto e' scesa lungo il campione -> qualunque 1/vol pesa di piu' il") + print(" periodo recente, che e' anche l'hold-out). La riga 'corr col tempo' la misura.") + print("\n" + "=" * 112) print(f" fatto in {time.time()-t0:.0f}s") print("=" * 112) @@ -652,8 +710,7 @@ def netted_target(asset: str, ex: dict, nm, SZ, VTL, BVT, SLE, TP, can, df1h) -> elif nm in SZ: sizes = SZ[nm](ex) elif nm in VTL: - base_a = equity_daily(ex, size_flat(ex)) - sizes = sizes_from_L(ex, leverage_series(base_a, **VTL[nm])) + sizes = sizes_from_L(ex, leverage_series(ex["base_daily"], **VTL[nm])) else: # BOOKVT L = leverage_series(book(TP, SLE["BASE"][can]), BVT[nm]["tv"], BVT[nm]["w"], BVT[nm]["cap"]) sizes = sizes_from_L(ex, L) diff --git a/scripts/research/r0822_vrp_real_quotes.py b/scripts/research/r0822_vrp_real_quotes.py index d53e924..684fb04 100644 --- a/scripts/research/r0822_vrp_real_quotes.py +++ b/scripts/research/r0822_vrp_real_quotes.py @@ -325,9 +325,16 @@ def main() -> None: for a in ASSETS: V = CB.dvol_series(a) win = V[(V.index >= puts["ts"].min()) & (V.index <= puts["ts"].max())] - pct = float((V[V.index < win.index[0]].values[:, None] < win.values).mean()) - print(f" {a}: DVOL nel campione min {win.min():.1f} / mediana {win.median():.1f} / " - f"max {win.max():.1f} → percentile mediano vs storia 2021+ = {pct:.0%}") + hist = V[V.index < win.index[0]].to_numpy(float) + print(f" {a}: DVOL nel campione min {win.min().1f} / mediana {win.median().1f} / " + f"max {win.max().1f}") + print(f" vs storia 2021+ ({len(hist)} giorni) la MEDIANA del campione sta al " + f"{float((hist < win.median()).mean()).0%} pctl, il suo MASSIMO al " + f"{float((hist < win.max()).mean()).0%} pctl") + print("\n \u26a0\ufe0f Due statistiche diverse, da tenere distinte: CLAUDE.md cita «DVOL max al 24\u00b0/28\u00b0\n" + " pctl» dal campione del 30/07. Un MASSIMO cresce con la finestra — e' una statistica\n" + " di ESTREMO, non di livello. Per dire in che regime siamo la lente e' la MEDIANA, ed\n" + " e' bassissima.") # ------------------------------------------------------------ §2 CAMPIONE hr("§2 IL CAMPIONE — quante settimane ci sono DAVVERO") @@ -446,8 +453,16 @@ def main() -> None: print(f" {a:6s} {lot:>7.2f} {wid:>10,.0f} {wid * lot - tr['cred'].median() * lot:>11,.0f}$ " f"{tr['k_short'].median() * lot:>12,.0f}$ {tr['cred'].median() * lot:>12,.2f}$") print(" Sul rischio DEFINITO un lotto minimo inverse sta dentro $600. Sul cash-secured no.\n" - " Quale dei due valga dipende dal regime di margine del conto, che NON e' leggibile\n" - " da dati pubblici: si legge sul conto. E' un limite di questa misura, non un dettaglio.") + " Quale dei due valga dipende dal regime di margine del conto (standard vs portfolio),\n" + " che NON e' leggibile da dati pubblici.") + print(" ⚠️ E c'e' una domanda PRIMA di quella, che nessun dato pubblico chiude: un saldo in\n" + " USDC puo' marginare un'opzione INVERSE (collaterale in BTC/ETH)? Se no, la famiglia\n" + " con 3 mesi di storia non e' negoziabile da questo conto a NESSUN capitale.\n" + " Precedente esatto: il 26/07 GTAA01 aveva 5 settimane di misure sopra l'assunzione\n" + " che i 6 ETF fossero comprabili — il blocco PRIIPs si scopri' con UN ordine di prova.\n" + " La regola che ne usci' («la negoziabilita' sul conto REALE si verifica quando lo\n" + " sleeve entra in RICERCA, non quando entra nel book») si applica qui identica, e\n" + " questo script NON puo' eseguirla: richiede il conto, non i dati.") # ------------------------------------------------------------ §4 BACKTEST hr("§4 BACKTEST SU QUOTE REALI — griglia dichiarata") diff --git a/scripts/research/r0822_xs_lite.py b/scripts/research/r0822_xs_lite.py index 605af8b..107bbf0 100644 --- a/scripts/research/r0822_xs_lite.py +++ b/scripts/research/r0822_xs_lite.py @@ -535,6 +535,25 @@ def main() -> None: f"{dg['med_ticket']:>11.0f}{dg['orders_py']:>10.0f}" f"{trk:>12.2f}{' ⚠' if trk < 0.8 else ' '}") print() + print(f" {'cella':<13}{'|dw| mediano al ribil.':>24}{'capitale sleeve minimo':>25}{' = book al 15%':>17}") + for nm in ("MAJ19 k=1", "MAJ19 k=2", "MAJ19 k=3", "MAJ19 k=5*"): + cell = xs_cells[nm] + Weff = xs_effective_weights(cell["W"], cell["scale"]) + chg = np.abs(np.diff(cell["W"], axis=0)).sum(axis=1) > 1e-12 # giorni di ribilanciamento + rows = np.where(chg)[0] + 1 + d = np.abs(np.diff(Weff, axis=0))[rows - 1] + med = float(np.median(d[d > 1e-9])) if np.any(d > 1e-9) else float("nan") + capmin = MIN_ORDER_HL / med if med > 0 else float("nan") + print(f" {'XS ' + nm.split()[1]:<13}{med:>24.4f}{capmin:>24.0f}$" + f"{capmin/0.15:>16.0f}$") + print(" ^ ARITMETICA: il ticket di un ribilanciamento e' |dw|*capitale_sleeve. Il min-order") + print(" morde sotto il 'capitale sleeve minimo'. Per il CANONICO k=5 quel numero e' ~150$") + print(" di sleeve = ~1.000$ di book al 15%, non 20.000$. La soglia dichiarata dal progetto") + print(" veniva da una diagnostica di TURNOVER (quota di turnover sopra il min-order), che a") + print(" capitale piccolo e' bassa perche' salta la deriva GIORNALIERA del vol-target — la") + print(" quale non porta segnale. Stessa lezione gia' imparata su TP01 ('a $600 il min-order") + print(" $5 e' GIA' la banda ottimale'), non applicata a XS01.") + print() print(" 'gambe eseg' = quota delle VARIAZIONI di posizione sopra il min-order (la diagnostica") print(" su cui si basa la soglia '$20k'). 'haircut' = quanto ne costa in Sharpe. Sono cose") print(" DIVERSE: il turnover saltato e' la deriva del vol-target, non il segnale.") @@ -710,6 +729,40 @@ def main() -> None: print(f" break-even di costo per gamba: XS k=2 {_be(be.get('XS MAJ19 k=2'))}, " f"XS k=5 {_be(be.get('XS MAJ19 k=5*'))}, XSR k=2 {_be(be.get('XSR ALL50 k=2'))}, " f"XSR pieno {_be(be.get('XSR ALL50 k=pieno*'))}") + print("-" * 108) + print(" 1. CONCENTRARE NON COMPRA RENDIMENTO, COMPRA ESEGUIBILITA'. Il null del de-levering") + print(" boccia ogni k tranne i due che sono fortuna d'ancora (XS k=4: fase0 al 90° pctl,") + print(" mediana 1.10 < 1.15 del canonico; XSR k=6: +0.03 su 1.65). Nessuna concentrazione") + print(" batte il proprio originale a PARI maxDD.") + print(" 2. MA IL COSTO E' MOLTO PIU' BASSO DEL PREVISTO, e la mia ipotesi (a) era sbagliata:") + print(" mi aspettavo Sh ~ sqrt(ampiezza) e quindi il 40-60% dell'edge a k=2-3. Misurato:") + print(" XS-LITE k=2 conserva il 98% del canonico A MEDIANA DI FASE con 4 gambe invece di 10,") + print(" e N_hhi scende 6.9 -> 3.0. La radice dell'ampiezza NON descrive questa curva: la") + print(" dispersione cross-section e' concentrata negli ESTREMI del ranking, e le gambe 3-5") + print(" per lato aggiungono rischio quanto segnale. Il collasso vero e' solo a k=1 (41%).") + print(" 3. IL MURO NON E' IL MIN-ORDER, E' IL PESO DELLO SLEEVE. Con $600 DEDICATI, XS01 gira") + print(" gia' oggi a k=5 (tracking 1.04, haircut -0.04). E' al 15% del book che muore: a") + print(" sleeve $90 il canonico esegue il 4% delle variazioni e il lordo scende al 13% del") + print(" target. A quel capitale k=2 e' l'unica cella CON EDGE che traccia (1.01: k=1 traccia") + print(" ma non ha edge, Sh 0.13). Il '~$20k' e'") + print(" quindi il prodotto di due scelte (peso 15% + k=5), non una proprieta' del segnale.") + print(" 4. XSR01 NON SI CONCENTRA: k<=3 perde l'edge IN-SAMPLE (IS 0.96 -> 0.28) e il gate") + print(" marginale lo declassa ad ADDS -> NOISE (has_insample_edge=False). Ipotesi (c)") + print(" CONFERMATA: li' l'ampiezza E' l'edge. E muore a ~28 bps/gamba di costo totale a") + print(" OGNI k, contro >50 bps di XS-LITE: concentrare non ripara il rischio #1 di XSR01.") + print(" 5. ⛔ NIENTE DI QUESTO E' DEPLOYABILE OGGI, e non per una ragione misurata qui:") + print(" XS01/XSR01 vivono su HYPERLIQUID, e la decisione dell'operatore del 26/07 tiene") + print(" il 100% del capitale su Deribit fino a $20k. A $20k il canonico e' eseguibile da") + print(" solo. XS-LITE serve SE e QUANDO si apre HL sotto quella soglia — non prima.") + print("-" * 108) + print(" GATE PRE-REGISTRATO (dichiarato ORA, prima di qualunque dato nuovo):") + print(" XS-LITE k=2 su MAJ19 si riapre SOLO se si apre un conto Hyperliquid con >= $300") + print(" dedicati allo sleeve, e in quel caso si deploya il k CANONICO (5) se il capitale") + print(" dello sleeve e' >= $750, k=2 sotto. Nessun nuovo backtest serve per questa scelta:") + print(" e' gia' decisa dalla tabella (5) e non va ri-ottimizzata guardando i dati nuovi.") + print(" Come CANDIDATO a sleeve XS-LITE resta bocciato: DSR FAIL a ogni conteggio e null") + print(" del de-levering non superato. Non si ri-testa prima del 2027-06 (3.5 anni di") + print(" storia HL, di cui >=1.5 fuori dal campione di scoperta di XS01).") print("=" * 108)