From 14c1a75a7eec11c088ca1a22e3bfa7bfdd612999 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sat, 22 Aug 2026 17:10:03 +0000 Subject: [PATCH] =?UTF-8?q?research(wave-0822):=20GROWTH-POLICY=20?= =?UTF-8?q?=E2=80=94=20il=20libro=20gira=20al=207%=20di=20Kelly,=20e=20ogn?= =?UTF-8?q?i=20gate=20del=20progetto=20e'=20cieco=20alla=20scala?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/research/RESULTS-0822.md | 24 +++++++ scripts/research/r0822_flow_squeeze.py | 45 ++++++++++--- scripts/research/r0822_oi_pin.py | 81 +++++++++++++++-------- scripts/research/r0822_prop_alloc.py | 38 ++++++++--- scripts/research/r0822_term_structure.py | 25 +++++-- scripts/research/r0822_vol_size.py | 51 ++++++++++---- scripts/research/r0822_vrp_real_quotes.py | 42 ++++++++++-- scripts/research/r0822_xs_lite.py | 58 ++++++++++------ 8 files changed, 275 insertions(+), 89 deletions(-) diff --git a/docs/research/RESULTS-0822.md b/docs/research/RESULTS-0822.md index f2ff662..450eb89 100644 --- a/docs/research/RESULTS-0822.md +++ b/docs/research/RESULTS-0822.md @@ -7,6 +7,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 || 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 | +| 2 | GROWTH-POLICY | **LEAD** (condizione, non data) | il libro gira al **7% di Kelly** e raccoglie il **15%** della crescita massima in log; il gradino eseguibile 1.00x->1.25-1.50x vale 14.7a -> 12.9-11.6a al muro. Ma un solo giorno -10% all'anno porta k* da ~10x a **2x**: non si decide su un conto vero con la lente close-only | ## Note che sopravvivono ai singoli filoni @@ -45,3 +46,26 @@ informazione. `gamma_flip_level` congelato nel **61%** dei passi su BTC. 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)**. + +### 2 — GROWTH-POLICY (r0822_growth_policy.py, 241 celle dichiarate) +✅ **Controllo di replica superato**: il motore riproduce `r0807_piano_netto` §2 al numero pubblicato +(EUR250/m -> 19,8a / P 52%; EUR500/m -> 14,7a / P 99%) con path diversi. +📌 **Il risultato strutturale: ogni gate del progetto e' INVARIANTE ALLA SCALA.** Lo Sharpe misura +1,31 a OGNI cella del knob di leva -> `deflated_sharpe` e `marginal_vs_tp01` non falliscono, **non +vedono la variabile**. E' per questo che la leva e' l'unica leva del libro mai esaminata in 2 mesi. +- Serie de-luckata x0,89: drift 17,11%/a, vol 11,37%, Sharpe 1,51, peggior giorno -3,95%. + Netto fee E netto fisco d'accumulo (33% + carry 4a + patrimoniale 0,2%). +- k* empirico **~10-14x** (banda 10x a 1500 path / 14x a 2500: la POSIZIONE non e' risolta), + Kelly gaussiano 13,2x. Libro a k=1 -> g 17,0%/a, maxDD mediano 18,1%, P(DD>30%) 1,4%. +- **Fragilita' che decide il verdetto:** k* e' lineare nell'errore del drift (stimato su 7,4 anni) — + -1 SE -> 10x, -2 SE -> 7x, drift dimezzato -> 7x, drift zero -> 0,5x. E allo **stress di coda** + un giorno -10% **all'anno** porta k* a **2x**, -15%/anno a **1x**. +- ⚠️ **Controllo di plausibilita' FALLITO dall'agente sul proprio output** (e riportato): capitale + mediano **$4,4e12** da $600 a k=14x -> il modello assume ritorno **indipendente dalla size**. + A k>2 i numeri sono aritmetica, non previsioni. +- **Gate pre-registrato (condizione, non data): nessuna proposta di k>1 prima di rifare la curva con + la lente WICK ACCOPPIATA** (`r0725_prop_coupled`) — a leva il breach si valuta sul MINIMO, e la + lente close-only usata qui e' quella misurata **cieca** il 25/07 (raddoppia i breach da daily-loss). +- 📌 **Sottoprodotto da verificare (tocca un'etichetta del libro live):** a `target_vol=20%` la vol + **realizzata** di TP01 e' **12,14%** — il target vale sulla posizione quando c'e', e TP01 e' + long-flat. **L'etichetta sovrastima il rischio preso di ~40%.** diff --git a/scripts/research/r0822_flow_squeeze.py b/scripts/research/r0822_flow_squeeze.py index 072c9fa..89e092f 100644 --- a/scripts/research/r0822_flow_squeeze.py +++ b/scripts/research/r0822_flow_squeeze.py @@ -254,7 +254,8 @@ def sezione2(snap: pd.DataFrame) -> pd.DataFrame: g["p_x24"] = [bboot_p(d[d.odec == k].x24.values, block=48) for k in g.index] print(g.round(3).to_string()) print(" av4/av24 = |ret| futuro medio: e' la gamba 'squeeze/cascata' dell'ipotesi") - print(" (piu' vol dopo un estremo di OI). E' PIATTA: nessuna cascata prevedibile.") + print(" (piu' vol dopo un estremo di OI). E' QUASI PIATTA (0.59-0.75%% a 4h, solo il decile 0") + print(" leggermente sopra): la 'cascata' prevedibile non c'e'.") print("\n--- CONTROLLO: e' solo 'il prezzo si e' appena mosso'? ---") z = lambda s: (s - s.mean()) / s.std() # noqa: E731 @@ -293,9 +294,25 @@ def sezione2(snap: pd.DataFrame) -> pd.DataFrame: for a in ASSETS: sub = d[(d.asset == a) & (d.odec == 0)] cond = (d[d.asset == a].odec == 0).values - ep = int(np.sum(cond[1:] & ~cond[:-1])) + int(cond[0]) - print(f" {a}: ore in decile0={len(sub)} episodi distinti={ep} " + # AMPIEZZA EFFETTIVA: il numero di ORE non e' la taglia campionaria se la + # condizione e' fatta di pochi episodi lunghi (lezione del filone gemello + # dealer-gamma della stessa ondata: 73 "episodi" di cui 6-8 all'80% del tempo). + lens, cur = [], 0 + for v in cond: + if v: + cur += 1 + elif cur: + lens.append(cur); cur = 0 + if cur: + lens.append(cur) + L = np.array(sorted(lens)[::-1]) + print(f" {a}: ore in decile0={len(sub)} episodi distinti={len(L)} " + f"durata mediana={np.median(L):.1f}h max={L.max()}h " + f"quota ore negli 8 episodi piu' lunghi={L[:8].sum()/L.sum():.2f} " f"y4={100*sub.y4.mean():+.3f}% y24={100*sub.y24.mean():+.3f}%") + print(" -> QUI l'ampiezza NON e' la patologia: 86-87 episodi corti (mediana 2h) e i") + print(" primi 8 coprono solo il 20-25% delle ore. Il vincolo binding e' la") + print(" LUNGHEZZA DEL CALENDARIO (90 giorni), non la concentrazione degli episodi.") print("\n scomposizione PER MESE (demean mensile) — il test che il progetto impone") print(" dopo il caso SOL ('un contributo positivo si scompone prima di crederci'):") tab = d[d.odec == 0].groupby("mese")[["y4", "y24"]].agg(["size", "mean"]) @@ -475,6 +492,9 @@ def sezione4(snap: pd.DataFrame) -> None: print(" -> il candidato NON e' la statica travestita: il timing porta qualcosa.") print("\n--- NULL 2: timing CASUALE a pari esposizione (shift circolare, 300 estrazioni) ---") + print(" e' il null LOCATION-MATCHED nella dimensione del tempo: stessa esposizione, stessa") + print(" struttura di episodi, stessa finestra — cambia SOLO QUANDO. Un null piu' ingenuo") + print(" (posizione casuale iid) direbbe di si' a qualunque cosa in una finestra con drift.") base_trig = {} for a in ASSETS: df = A.get(a, "1h") @@ -504,12 +524,19 @@ def sezione4(snap: pd.DataFrame) -> None: sr = float(cd.mean() / cd.std() * np.sqrt(365.25)) rep = A.marginal_vs_tp01(cd) print(f" candidate_daily : n={len(cd)} giorni, Sharpe={sr:.2f}") + n_hold = int((cd.index >= A.HOLDOUT).sum()) print(f" marginal_vs_tp01 : verdetto={rep.get('marginal_verdict')} " - f"corr_beta_tp01={rep.get('beta_to_tp01')} has_insample_edge={rep.get('has_insample_edge')} " + f"beta_to_tp01={rep.get('beta_to_tp01')} has_insample_edge={rep.get('has_insample_edge')} " f"is_hedge={rep.get('is_hedge')} robust_oos={rep.get('robust_oos')} " f"beats_noise_null={rep.get('beats_noise_null')} null_pctl_full={rep.get('null_pctl_full')}") - print(" (robust_oos=False non e' un difetto del candidato: con 90 giorni NON ESISTE il") - print(" multi-cut che il gate richiede. Il gate e' girato e il suo esito e' NEUTRAL.)") + print(f" ATTENZIONE: giorni totali={len(cd)}, giorni dopo HOLDOUT({A.HOLDOUT.date()})" + f"={n_hold} -> COINCIDONO") + print(f" (cand_full_sharpe={rep.get('cand_full_sharpe')} == " + f"cand_hold_sharpe={rep.get('cand_hold_sharpe')}). Il candidato vive INTERAMENTE") + print(" dentro l'hold-out del progetto: FULL e HOLD-OUT sono la STESSA finestra e la") + print(" meta' out-of-sample del gate e' strutturalmente INERTE (robust_oos=False non") + print(" giudica il candidato, dice che il multi-cut non esiste). Da leggere come") + print(" 'girato ma NON informativo sull'OOS', non come un NEUTRAL guadagnato sul campo.") all_sr = list(R.sharpe.values) d18 = A.deflated_sharpe(sr, all_sr, cd) d_all = A.deflated_sharpe(sr, all_sr * 7, cd) @@ -628,8 +655,10 @@ def main() -> None: 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; e il P&L e' per il 71% - di giugno e per il 60% concentrato in 3 giorni su 114. + Sharpe 5.4; implausible_sharpe=True; e il P&L e' per il 71% di giugno e per il 60% + concentrato in 3 giorni su 114. marginal_vs_tp01 gira e dice NEUTRAL, ma va dichiarato + NON INFORMATIVO SULL'OOS: il candidato sta interamente dopo HOLDOUT(2025-01-01), quindi + full e hold-out sono la stessa finestra (Sharpe identico) e il multi-cut non esiste. 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_oi_pin.py b/scripts/research/r0822_oi_pin.py index 87b1f52..47d73e6 100644 --- a/scripts/research/r0822_oi_pin.py +++ b/scripts/research/r0822_oi_pin.py @@ -60,7 +60,8 @@ FEE_SIDE_CONS = A.FEE_SIDE # 5 bps/lato = convenzione conservativa dei bac MAX_H = 50 # ore prima della scadenza da tenere in memoria HTE_GRID = (1, 2, 3, 6, 12, 24, 36, 48) # ore-alla-scadenza all'INGRESSO RULES = ("oi", "gex", "mp") # max OI · max gamma*OI · max pain -PLACEBOS = ("rand", "adj", "jit", "mid", "mr7") +PLACEBOS_GLOB = ("rand", "mid", "mr7") # non dipendono dalla regola +PLACEBOS_RULE = ("adj", "jit") # costruiti sulla distanza DELLA regola SEED = 20260822 RNG = np.random.default_rng(SEED) @@ -144,29 +145,39 @@ def levels_table(chain: pd.DataFrame, spot: dict[str, pd.Series]) -> pd.DataFram "gex": float(K[int(np.argmax(gx))]) if gx.max() > 0 else np.nan, "mp": _max_pain(K, oi_c, oi_p), } - # ---- placebo location-matched ------------------------------------------------- - # rand: permutare l'OI fra gli strike e riprendere l'argmax equivale ESATTAMENTE a - # estrarre uno strike uniforme dalla griglia (l'argmax di un vettore permutato - # e' uniforme sulle posizioni) -> lo si implementa cosi', ed e' esatto. - p_rand = float(K[RNG.integers(len(K))]) - # adj: lo strike ADIACENTE al max-OI (stesso quartiere, OI diverso) - j = i_oi + (1 if (i_oi == 0 or (i_oi < len(K) - 1 and RNG.random() < 0.5)) else -1) - p_adj = float(K[int(np.clip(j, 0, len(K) - 1))]) - # jit: strike arbitrario alla STESSA distanza dallo spot ("0.618 vs 0.58?") - dist = abs(K - S) - d0 = abs(lv["oi"] - S) - cand = np.where((dist >= 0.5 * d0) & (dist <= 1.5 * d0) & (K != lv["oi"]))[0] - p_jit = float(K[cand[RNG.integers(len(cand))]]) if len(cand) else np.nan - # mid: punto medio della griglia quotata = puro artefatto di copertura, zero info OI - p_mid = float(0.5 * (K[0] + K[-1])) + # ---- placebo LOCATION-MATCHED, uno per OGNI regola -------------------------------- + # ⚠ difetto trovato e corretto in sessione: la prima stesura costruiva `adj`/`jit` + # attorno al SOLO max-OI e poi li usava come null anche per max-pain e gamma. Ma + # |K_oi - S| mediano e' ~2,4% e |K_mp - S| ~1,1%: confrontare mp con un placebo + # tarato sulla distanza di oi NON e' location-matched, e' proprio l'errore che il + # null doveva prevenire. Ogni regola ha ora i placebo alla PROPRIA distanza. + dist = np.abs(K - S) + for nm, kv in list(lv.items()): + if not np.isfinite(kv): + out_adj = out_jit = np.nan + else: + i_r = int(np.argmin(np.abs(K - kv))) + j = i_r + (1 if (i_r == 0 or (i_r < len(K) - 1 and RNG.random() < 0.5)) else -1) + out_adj = float(K[int(np.clip(j, 0, len(K) - 1))]) + d0 = abs(kv - S) + cand = np.where((dist >= 0.5 * d0) & (dist <= 1.5 * d0) & (K != kv))[0] + out_jit = float(K[cand[RNG.integers(len(cand))]]) if len(cand) else np.nan + lv[f"adj_{nm}"] = out_adj + lv[f"jit_{nm}"] = out_jit + # placebo GLOBALI (non dipendono dalla regola) + # rand: permutare l'OI fra gli strike e riprenderne l'argmax equivale ESATTAMENTE a + # estrarre uno strike uniforme dalla griglia (l'argmax di un vettore permutato e' + # uniforme sulle posizioni) -> lo si implementa cosi', ed e' esatto, non simulato. + lv["rand"] = float(K[RNG.integers(len(K))]) + # mid: centro della griglia quotata = puro artefatto di copertura, zero informazione OI + lv["mid"] = float(0.5 * (K[0] + K[-1])) rows.append(dict( asset=asset, exp=exp, hh=hh, dte_h=float((exp - hh).total_seconds() / 3600.0), S=float(S), n_k=len(K), tot_oi=float(tot), - k_oi=lv["oi"], k_gex=lv["gex"], k_mp=lv["mp"], - p_rand=p_rand, p_adj=p_adj, p_jit=p_jit, p_mid=p_mid, top_share=float(oi[i_oi] / tot), hhi=float(((oi / tot) ** 2).sum()), k_step=float(np.median(np.diff(K))) if len(K) > 1 else np.nan, + **{f"L_{k}": v for k, v in lv.items()}, )) return pd.DataFrame(rows) @@ -198,9 +209,8 @@ def build_events(lev: pd.DataFrame, spot: dict[str, pd.Series]) -> pd.DataFrame: top_share=float(r["top_share"]), hhi=float(r["hhi"]), k_step=float(r["k_step"]), dow=int(pd.Timestamp(exp).dayofweek)) - lvls = {"oi": r["k_oi"], "gex": r["k_gex"], "mp": r["k_mp"], - "rand": r["p_rand"], "adj": r["p_adj"], "jit": r["p_jit"], - "mid": r["p_mid"], "mr7": float(mr7[a].asof(hh))} + lvls = {c[2:]: r[c] for c in r.index if str(c).startswith("L_")} + lvls["mr7"] = float(mr7[a].asof(hh)) for nm, K in lvls.items(): K = float(K) if K is not None else np.nan if not np.isfinite(K) or K <= 0: @@ -421,12 +431,12 @@ def main() -> None: P("\n LA STESSA STATISTICA SUI LIVELLI PLACEBO — se sale anche li', NON e' pinning:") P(f" {'hte':>4} " + " ".join(f"{('tw_' + n):>10}" for n in - ("oi", "mp", "rand", "jit", "mid", "mr7", "maj", "short"))) + ("oi", "mp", "rand", "jit_mp", "mid", "mr7", "maj", "short"))) for hte in HTE_GRID: e = ev[ev["hte"] == hte] P(f" {hte:>4} " + " ".join( f"{np.nanmean(e[f'tw_{n}']):>10.5f}" for n in - ("oi", "mp", "rand", "jit", "mid", "mr7", "maj", "short"))) + ("oi", "mp", "rand", "jit_mp", "mid", "mr7", "maj", "short"))) P("\n QUANTO SONO LO STESSO SEGNALE? accordo del SEGNO di (K-S) fra max-pain e i placebo:") for n in ("mid", "mr7", "oi", "maj"): agr = [float(np.nanmean(np.sign(ev.loc[ev.hte == h, "d_mp"]) @@ -446,22 +456,25 @@ def main() -> None: P(" placebo: rand=strike uniforme (== permutazione dell'OI, equivalenza esatta) · adj=strike") P(" adiacente al max-OI · jit=strike arbitrario a distanza 0.5-1.5x (analogo Fib±jitter) ·") P(" mid=centro della griglia quotata (zero info OI) · mr7=media 7g dello spot (mean-reversion).") - P(f" {'hte':>4} {'rule':>5} " + " ".join(f"{('Δ vs ' + p):>12}" for p in PLACEBOS)) + P(f" {'hte':>4} {'rule':>5} {'Δ vs adj_r':>12} {'Δ vs jit_r':>12}" + f" {'Δ vs rand':>12} {'Δ vs mid':>12} {'Δ vs mr7':>12}") npos = ntot = 0 for hte in HTE_GRID: e = ev[ev["hte"] == hte] for rl in RULES: cells = [] - for p in PLACEBOS: + for p in (f"adj_{rl}", f"jit_{rl}") + PLACEBOS_GLOB: dd = e[f"tw_{rl}"].to_numpy(float) - e[f"tw_{p}"].to_numpy(float) m, lo, hi, _ = cluster_boot(dd, e["exp"].to_numpy()) - mark = "*" if (np.isfinite(lo) and lo > 0) else (" ") - cells.append(f"{m:>11.5f}{mark}") + cells.append(f"{m:>11.5f}{'*' if (np.isfinite(lo) and lo > 0) else ' '}") ntot += 1 npos += int(np.isfinite(lo) and lo > 0) P(f" {hte:>4} {rl:>5} " + " ".join(cells)) P(f" celle (rule x hte x placebo) in cui lo SPECIALE batte il placebo con IC95>0:" f" {npos}/{ntot} (atteso per caso ~{0.025 * ntot:.0f})") + P(" LETTURA: `adj_r`/`jit_r` chiedono 'e' proprio QUELLO strike?'; `mid`/`mr7` chiedono") + P(" 'serve l'open interest, o basta il centro recente del prezzo?'. La seconda e' la") + P(" domanda che uccide o salva il filone.") # ---------------------------------------------------------------- 5. CONDIZIONAMENTO P("\n[5] CONDIZIONAMENTO — la concentrazione di OI e le scadenze grandi cambiano qualcosa?") @@ -581,7 +594,7 @@ def main() -> None: # DSR: conta TUTTI i trial al rialzo all_sr = [float(v) for v in C["sharpe"].to_numpy(float) if np.isfinite(v)] all_sr_wide = all_sr + [float(v) for v in N["sharpe"].to_numpy(float) if np.isfinite(v)] - n_fact = len(HTE_GRID) * len(RULES) * (1 + len(PLACEBOS)) + 3 * 4 # celle del fatto + condiz. + n_fact = len(HTE_GRID) * len(RULES) * (1 + len(PLACEBOS_GLOB) + len(PLACEBOS_RULE)) + 3 * 4 # celle del fatto + condiz. trials_tot = len(all_sr_wide) + n_fact dsr, sr0 = A.deflated_sharpe(mb["sharpe"], all_sr, dbest, dpy=365.25) dsr_w, _ = A.deflated_sharpe(mb["sharpe"], all_sr_wide, dbest, dpy=365.25) @@ -632,6 +645,18 @@ def main() -> None: f" · MEDIANA ONESTA {ab.get('median'):.2f} · banda [{ab.get('lo'):.2f},{ab.get('hi'):.2f}]" f" · positive {ab.get('frac_positive'):.0%} · gate_pass={ab.get('gate_pass')}") + # LA DOMANDA DECISIVA, sulla banda d'ancora: max-pain aggiunge qualcosa a un livello + # che NON usa opzioni? Mediana delle DIFFERENZE APPAIATE sui 8 hte (mai differenza + # delle mediane: lezione 26/07). + def _fn(rule): + return lambda h: strat_daily(ev, rule, int(h), (0.002, 0.03), "sign", "all", + FEE_SIDE_REAL)[0] + for other in ("mr7", "mid", "oi", "rand"): + dl = A.anchor_luck_delta(_fn("mp"), _fn(other), offsets=list(HTE_GRID)) + P(f" Δ appaiato mp-{other:<4}: mediana {dl.get('median_paired'):+.2f}" + f" · positiva in {dl.get('n_positive')}/{dl.get('n_anchors')} hte" + f" · banda [{dl.get('lo'):+.2f},{dl.get('hi'):+.2f}] · gate_pass={dl.get('gate_pass')}") + # eseguibilita' a $600 P(" eseguibilita' $600 :", end=" ") hc = [] diff --git a/scripts/research/r0822_prop_alloc.py b/scripts/research/r0822_prop_alloc.py index 7522441..b6eb00e 100644 --- a/scripts/research/r0822_prop_alloc.py +++ b/scripts/research/r0822_prop_alloc.py @@ -92,7 +92,7 @@ RAW = ROOT / "data" / "raw" DELUCK = 0.89 # fattore d'ancora MISURATO sul drift (26/07), non stimato a occhio START_WIN = "2024-01-01" # finestra comune ai 5 sleeve (XS01 nasce qui) BLOCK = 20 # blocco del bootstrap, in giorni -N_PATHS = 4000 +N_PATHS = 2500 SPLIT = pl.SPLIT # 80% al trader TARGET_EUR_DAY = 50.0 FIRMS = {f["name"]: f for f in pl.FIRMS} @@ -386,7 +386,15 @@ def hr(t: str = "") -> None: def main() -> None: + 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}") + print("=" * 104) print(" PROP-ALLOC — allocare per la BARRIERA (P(pass) x P(vivo 12m)) invece che per lo Sharpe") print("=" * 104) @@ -398,6 +406,7 @@ def main() -> None: print(f" de-luck sul drift x{DELUCK} (misurato 26/07) | bootstrap a blocchi di {BLOCK}g, " f"indici CONDIVISI fra configurazioni | {N_PATHS} percorsi") + tick("pannello costruito") off = {"TP01": _d(_tp01_returns()), "SKH01": _d(_skyhook_returns()), "XS01": _d(_xsec_returns()), "VRP01": _d(_vrp_combo_returns()), "GTAA01": _d(_gtaa_daily_returns())} rec = {"TP01": _crypto(1.0, 0.0)["R"], "SKH01": _crypto(0.0, 1.0)["R"], @@ -430,6 +439,7 @@ def main() -> None: print(" non per assenza di rischio (mark infra-settimana misurato il 30/07: mediana -6.9%,") print(" minimo -81.7% del capitale su un trade finito in UTILE).") + tick("A. profilo sleeve") hr("B. IL CONTO ANALITICO — perche' con leva LIBERA la barriera premia lo SHARPE, non la vol") tgt, dd, _dl = FIRMS["HYRO"]["ev"] print(f" P(toccare +{tgt:.0%} prima di -{dd:.0%}), gambler's ruin senza limite di tempo, " @@ -460,6 +470,7 @@ def main() -> None: f"= {len(grid)*len(LEVS)} celle, firm HYRO (eval +10%/-6%/-4%, funded -6%/-4%)") print(f" orizzonte eval {EV_H}g, funded {FU_H}g. Lente ACCOPPIATA. " f"J = P(pass) x P(vivo 12m).") + tick("B. conto analitico") t = time.time() G3 = grid_scan(grid, LEVS, "HYRO", ev_idx, fu_idx) print(f" ({time.time()-t:.0f}s)") @@ -513,14 +524,15 @@ def main() -> None: print(" lette insieme: la leva alta non aumenta la probabilita' di farcela, aumenta la FRETTA.") # ---------------------------------------------------------------- D. i null + tick("C. griglia + leva") hr("D. NULL 1 — l'argmax e' speciale, o e' solo il MASSIMO DI 45 ESTRAZIONI?") - rw = random_weights(300, CRYPTO_PROP, seed=8223) + rw = random_weights(200, CRYPTO_PROP, seed=8223) lev_star = float(best["lev"]) Jr = np.array([objective(w, lev_star, "HYRO", ev_idx, fu_idx)["J"] for w in rw]) pctl = float((Jr < best["J"]).mean() * 100.0) bestofk = 100.0 * len(grid) / (len(grid) + 1) print(f" J dell'argmax = {best['J']:.3f} alla leva {lev_star:g}") - print(f" null: 300 vettori di peso casuali uniformi sul simplesso (stesso campionatore di") + print(f" null: {len(rw)} vettori di peso casuali uniformi sul simplesso (stesso campionatore di") print(f" `portfolio.weights_tilt_null`), valutati alla STESSA leva") print(f" mediana {np.median(Jr):.3f} | p90 {np.percentile(Jr, 90):.3f} | " f"max {Jr.max():.3f} | frazione sopra il LIVE 75/25 = {(Jr > best_live['J']).mean():.0%}") @@ -531,6 +543,7 @@ def main() -> None: print(f" (la meta' bassa del null e' pero' informativa: il LIVE 75/25 e' battuto dal " f"{(Jr > best_live['J']).mean():.0%} dei pesi CASUALI)") + tick("D. null casuale") hr("D-bis. NULL 2 — selezione IN-SAMPLE-ONLY (la scelta guardando J e' selezione come ogni altra)") half = n_hist // 2 R_all = {} @@ -569,6 +582,7 @@ def main() -> None: print(" -> se rho e' alto la mappa e' stabile e la scelta si trasferisce; se e' ~0 l'argmax") print(" e' rumore e la decisione va presa sul MECCANISMO (la leva), non sulla cella.") + tick("D-bis. selezione in-sample") hr("D-ter. NULL DEL DE-LEVERING — la leva e' GIA' dentro l'ottimizzazione, ma va detto") print(" Il null classico ('esiste k<1 che da' lo stesso DD con Sharpe migliore?') qui e'") print(" strutturale: ogni configurazione e' valutata a OTTO leve, quindi nessuna puo' vincere") @@ -586,8 +600,11 @@ def main() -> None: print(" Ri-fare i 24x23x10 offset col recon MTM orario e' fuori budget. Si applica il fattore") print(" di drift MISURATO il 26/07 (x0.89 sul libro live) e si controlla cio' che conta: se") print(" l'ORDINE fra le configurazioni sopravvive alla banda 0.60 (stima 25/07, ri-misurata") - print(" troppo severa) - 1.00 (nessuna correzione).") + print(" troppo severa) - 1.00 (nessuna correzione). L'argmax e' ri-cercato sulle 12 celle-peso") + print(" migliori (non su tutte le 45: costo), il che e' conservativo per la STABILITA'.") cands = [best["w"], best_sh["w"], best_live["w"]] + top_keys = list(G3.sort_values("J", ascending=False).drop_duplicates("w")["w"].head(12)) + top_w = [dict(zip(CRYPTO_PROP, [int(x) / 100 for x in k.split("/")])) for k in top_keys] print(f"\n {'fattore':>9} " + " ".join(f"{('J ' + c):>14}" for c in dict.fromkeys(cands)) + f" {'argmax pesi':>14} {'argmax leva':>11}") for fct in (1.00, 0.89, 0.60): @@ -596,12 +613,13 @@ def main() -> None: wc = dict(zip(CRYPTO_PROP, [int(x) / 100 for x in c.split("/")])) sub = [objective(wc, lv, "HYRO", ev_idx, fu_idx, factor=fct)["J"] for lv in LEVS] cells.append(max(sub)) - g = grid_scan(grid, LEVS, "HYRO", ev_idx, fu_idx, factor=fct) + g = grid_scan(top_w, LEVS, "HYRO", ev_idx, fu_idx, factor=fct) b = g.loc[g["J"].idxmax()] print(f" {fct:>9.2f} " + " ".join(f"{v:>14.3f}" for v in cells) + f" {b['w']:>14} {b['lev']:>11.3f}") # ---------------------------------------------------------------- F. VRP01 / GTAA01 + tick("E. banda ancora") hr("F. E GLI SLEEVE 'INUTILI A $600'? — l'asimmetria del 25/07 §4 vale per XS01, non per tutti") print(" Su un conto funded da $100k gli sleeve fuori portata a $600 diventano ESEGUIBILI per") print(" TAGLIA. Ma la taglia non e' l'unico vincolo: una firm CRYPTO non lista opzioni Deribit") @@ -625,11 +643,12 @@ def main() -> None: print(" 6% verrebbe sfondata dal mark, non dal risultato.") # ---------------------------------------------------------------- G. FTMO + tick("F. VRP/GTAA") hr("G. LA BARRIERA CAMBIA LA RISPOSTA? — HYRO (-6% statico) vs FTMO (-10% statico)") print(f" {'firm':>6} {'argmax pesi':>16} {'leva':>6} {'J':>7} {'P(pass)':>8} {'P(vivo12m)':>11} " f"{'E[pay/a]':>10} | {'LIVE 75/25: leva':>17} {'J':>7}") for fm in ("HYRO", "FTMO"): - g = grid_scan(grid, LEVS, fm, ev_idx, fu_idx) + g = G3 if fm == "HYRO" else grid_scan(grid, LEVS, fm, ev_idx, fu_idx) b = g.loc[g["J"].idxmax()] lv2 = g[g["w"] == "75/25/0"] b2 = lv2.loc[lv2["J"].idxmax()] @@ -640,12 +659,13 @@ def main() -> None: print(" vincolo binding e' la regola di DD, non il capitale'), qui misurata sull'ottimo.") # ---------------------------------------------------------------- H. gate ufficiale sui pesi + tick("G. FTMO") hr("H. GATE UFFICIALE SUI PESI (`portfolio.weights_tilt_null`) — sullo SHARPE, come previsto") cols = {nm: off[nm] for nm in CRYPTO_PROP} try: res = weights_tilt_null(cols, {"TP01": 0.75, "SKH01": 0.25, "XS01": 0.0001}, {nm: max(w_best.get(nm, 0.0), 1e-4) for nm in CRYPTO_PROP}, - floor=0.0, n=300, k_seen=len(grid) * len(LEVS)) + floor=0.0, n=200, k_seen=len(grid) * len(LEVS)) print(f" proposta {best['w']} vs LIVE 75/25 : delta_insample {res['delta_insample']:+.4f} | " f"delta_hold {res['delta_hold']:+.4f} | pctl_hold {res['pctl_hold']:.1f}° " f"(best-of-k {res['bestofk_pctl']:.1f}°) | gate_pass = {res['gate_pass']}") @@ -656,6 +676,7 @@ def main() -> None: print(" e' il libro live e non passa da questo gate.") # ---------------------------------------------------------------- I. tavola operativa + tick("H. gate pesi") hr("I. TAVOLA OPERATIVA — le 3 configurazioni migliori, e quanti conti servono per 50 EUR/g") top = G3.sort_values("J", ascending=False).drop_duplicates("w").head(3) rows_ops = [] @@ -681,6 +702,7 @@ def main() -> None: print(f" -> al massimo {cap_tot/100_000:.0f} conti da $100k. Se ne servono di piu', servono " f"ALTRE FIRM (e ognuna aggiunge rischio di controparte).") + tick("I. tavola operativa") hr("I-bis. LA SCALA DI CONTI — macchineria del 25/07 (`r0725_prop_coupled.simulate`), " "de-luck portato a x0.89") print(f" 36 mesi, EUR {pl.START_EUR:.0f} di cassa iniziale, max {pl.MAX_CONCURRENT} conti, " @@ -698,7 +720,7 @@ def main() -> None: f"{'P(>=50/g)':>10} {'P(zero)':>8}") for pname, (cfgs, slots) in pols.items(): for lev in (float(best["lev"]), 0.75): - r = pc.simulate(cfgs, slots, n_paths=2000, lev=lev, seed=20260822, + r = pc.simulate(cfgs, slots, n_paths=1500, lev=lev, seed=20260822, lens="coupled", start=START_WIN) print(f" {pname:>44} {lev:>6.3f} {r['med']:>10.2f} {r['p90']:>8.2f} " f"{r['p_ge10']:>10.1%} {r['p_target']:>10.1%} {r['p_zero']:>8.1%}") diff --git a/scripts/research/r0822_term_structure.py b/scripts/research/r0822_term_structure.py index e5cb1f4..b85f3c3 100644 --- a/scripts/research/r0822_term_structure.py +++ b/scripts/research/r0822_term_structure.py @@ -115,7 +115,10 @@ def load_quoted_chain() -> pd.DataFrame: df = pd.concat(parts, ignore_index=True) del parts df = df.drop_duplicates(subset=["ts", "asset", "strike", "option_type", "exp"], keep="last") - df["hr"] = df["ts"].dt.floor("h") + # ⚠️ le quote di bite sono datetime64[us], il feed certificato e' [ns]: un join fra + # DatetimeIndex di unita' diverse degrada a Index generico e rompe ogni resample a valle. + df["hr"] = df["ts"].dt.floor("h").dt.as_unit("ns") + df["exp"] = df["exp"].dt.as_unit("ns") df["dte"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0 df["quoted"] = (df["bid"] > 0) & (df["ask"] > 0) & df["iv"].notna() & df["delta"].notna() return df @@ -290,7 +293,9 @@ def sezione1(curve: pd.DataFrame) -> dict: # SEZIONE 2 — Q1: LEAD-LAG. La pendenza precede o segue? # =========================================================================================== def slope_hourly(curve: pd.DataFrame, a: str) -> pd.Series: - s = curve[curve.asset == a].set_index("hr") + s = curve[curve.asset == a].copy() + s["hr"] = pd.to_datetime(s["hr"], utc=True).dt.as_unit("ns") + s = s.set_index("hr") return (s[f"iv_{BACK}"] - s[f"iv_{FRONT}"]).dropna().sort_index() @@ -356,7 +361,9 @@ def sezione2(curve: pd.DataFrame) -> dict: print("\n (c) IL CONFOUND DI QUESTO FILONE — la pendenza e' informazione NUOVA o RV riciclata?") rvp = (r1.rolling(24).std() * np.sqrt(24 * 365) * 100).reindex(idx) rvf = (r1.shift(-24).rolling(24).std().shift(-23) * np.sqrt(24 * 365) * 100).reindex(idx) - ivf = curve[curve.asset == a].set_index("hr")[f"iv_{FRONT}"].reindex(idx) + cf = curve[curve.asset == a].copy() + cf["hr"] = pd.to_datetime(cf["hr"], utc=True).dt.as_unit("ns") + ivf = cf.set_index("hr")[f"iv_{FRONT}"].reindex(idx) c_pp = float(j.slope.corr(rvp)) c_pf = float(j.slope.corr(rvf)) print(f" corr(pendenza, RV 24h PASSATA) {c_pp:+.3f} <-- quanto e' termometro") @@ -400,8 +407,14 @@ def sezione2(curve: pd.DataFrame) -> dict: x, y = sld[m].to_numpy(), fwd[m].to_numpy() c = float(np.corrcoef(x, y)[0, 1]) p = _block_pctl(x, y, c, L=max(3 * h, 7)) - print(f" h={h}g n={m.sum():3d} corr={c:+.3f} pctl vs null a blocchi={p:.3f}") - TRIALS.append((f"Q1 {a}: 3 orizzonti giornalieri", 3)) + c7 = float(np.corrcoef(x[:-7], y[:-7])[0, 1]) if len(x) > 20 else np.nan + c7b = float(np.corrcoef(x[7:], y[7:])[0, 1]) if len(x) > 20 else np.nan + print(f" h={h}g n={m.sum():3d} corr={c:+.3f} pctl vs null a blocchi={p:.3f}" + f" | senza ultima settimana {c7:+.3f} | senza prima settimana {c7b:+.3f}") + print(" ^ le due colonne di destra sono il test di RISOLUZIONE: se togliere 7") + print(" giorni su 74 sposta la correlazione di piu' di quanto la correlazione") + print(" valga, il campione non sta misurando, sta oscillando.") + TRIALS.append((f"Q1 {a}: 3 orizzonti giornalieri x 3 sottocampioni", 9)) return res @@ -600,6 +613,7 @@ def sezione4(curve: pd.DataFrame) -> None: sub("(b) ridondanza, per quel che il campione consente") for a in ASSETS: sl = slope_hourly(curve, a).resample("1D").last().dropna() + sl.index = pd.DatetimeIndex(sl.index).as_unit("ns") j = pd.concat({"slope": sl, "tp": pos[a]}, axis=1, join="inner").dropna() for qq in (0.10, 0.25, 0.40): th = j.slope.quantile(qq) @@ -704,6 +718,7 @@ def sezione5(curve: pd.DataFrame) -> None: sub("(a) quanto e' buono il proxy (misurato sulla finestra vera, non assunto)") for a in ASSETS: sl = slope_hourly(curve, a).resample("1D").last().dropna() + sl.index = pd.DatetimeIndex(sl.index).as_unit("ns") df = A.get(a, "1d") p = pd.Series(_proxy_slope(df, a), index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))).dropna() diff --git a/scripts/research/r0822_vol_size.py b/scripts/research/r0822_vol_size.py index d00ae9e..357274b 100644 --- a/scripts/research/r0822_vol_size.py +++ b/scripts/research/r0822_vol_size.py @@ -282,6 +282,17 @@ def leverage_series(base: pd.Series, tv: float | None, w: int, cap: float, return L.fillna(1.0) +def activity_L(base: pd.Series, w: int, cap: float) -> pd.Series: + """L_t da sola ATTIVITA': quante barre ATTIVE (trade chiusi) negli ultimi w giorni, riferita + alla mediana espandente causale. Serve a separare i DUE canali dentro `rv` calcolata su tutte + le barre di uno sleeve all'88% di zeri: la MAGNITUDINE degli esiti e la FREQUENZA dei trade. + Se il guadagno vive qui, la variante non e' 'vol-target' ma un filtro di frequenza.""" + act = (base != 0.0).astype(float) + cnt = act.rolling(w, min_periods=max(5, w // 3)).sum().shift(1).replace(0.0, np.nan) + ref = cnt.expanding(min_periods=VT_WARM).median() + return (ref / cnt).clip(lower=1.0 / cap, upper=cap).fillna(1.0) + + def sizes_from_L(ex: dict, L: pd.Series) -> np.ndarray: """La size di ogni trade = L al giorno d'INGRESSO. E' la versione CAUSALE del vol-target su uno sleeve a equity a gradino: L al giorno di CHIUSURA non era nota all'ingresso.""" @@ -389,11 +400,12 @@ def main() -> None: for w in (30, 90, 180): for cp in (2.0, 3.0): BVT[f"BOOKVT w{w} cap{cp:.0f}"] = dict(tv=0.10, w=w, cap=cp) + ACT = {f"ACT w{w}": dict(w=w, cap=3.0) for w in (90, 180)} NAIVE = {f"NAIVE-VT w{w}": dict(tv=0.20, w=w, cap=3.0, active_only=False) for w in (30, 90)} - ALL = list(SZ) + list(VTL) + list(BVT) + list(NAIVE) + ALL = list(SZ) + list(VTL) + list(ACT) + list(BVT) + list(NAIVE) print(f"\n GRIGLIA DICHIARATA: {len(SZ)} size per-trade + {len(VTL)} vol-target di gamba + " - f"{len(BVT)} vol-target di libro + {len(NAIVE)} controlli non-causali = {len(ALL)} celle " - f"(+ baseline).") + f"{len(ACT)} filtro-attivita' + {len(BVT)} vol-target di libro + {len(NAIVE)} controlli " + f"non-causali = {len(ALL)} celle (+ baseline).") # ------------------------------------------------------------------ serie TP = A.tp01_baseline_daily() @@ -420,6 +432,16 @@ def main() -> None: MTP[nm][o] = 1.0 if o == offs[0]: SIZEMED[nm] = float(np.median(np.concatenate(list(sz.values())))) + for nm, sp in ACT.items(): + SLE[nm], BK[nm], MTP[nm] = {}, {}, {} + for o in offs: + sz = {a: sizes_from_L(EX[o][a], activity_L(EX[o][a]["base_daily"], **sp)) + for a in ASSETS} + SLE[nm][o] = leg_daily(EX[o], sz) + BK[nm][o] = book(TP, SLE[nm][o]) + MTP[nm][o] = 1.0 + if o == offs[0]: + SIZEMED[nm] = float(np.median(np.concatenate(list(sz.values())))) for nm, sp in BVT.items(): SLE[nm], BK[nm], MTP[nm] = {}, {}, {} for o in offs: @@ -467,7 +489,7 @@ def main() -> None: f"{maxdd(s)*100:>8.1f}%{maxdd(iv)*100:>9.1f}%{cagr(iv)*100:>8.1f}%{m}") prow("BASELINE (size fissa)", base_s, 1.00) - for nm in list(SZ) + list(VTL) + list(NAIVE): + for nm in list(SZ) + list(VTL) + list(ACT) + list(NAIVE): prow(nm, SLE[nm][can], SIZEMED.get(nm)) # ------------------------------------------------------------------ §3 libro, banda appaiata @@ -592,7 +614,7 @@ def main() -> None: print("\n" + "-" * 112) print(" 7. GATE — marginal_vs_tp01 / implausible_sharpe / weights_tilt_null") print("-" * 112) - best_sleeve = max([n for n in list(SZ) + list(VTL)], key=lambda x: is_med[x]) + best_sleeve = max([n for n in list(SZ) + list(VTL) + list(ACT)], key=lambda x: is_med[x]) for nm in ("BASE", best_sleeve): m = A.marginal_vs_tp01(SLE[nm][can]) b25 = m.get("blends", {}).get("w25", {}) @@ -606,7 +628,7 @@ 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, best_sleeve] + list(BVT)[:2]: + for nm in dict.fromkeys([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, @@ -630,7 +652,7 @@ def main() -> None: no = ns = 0 for a in ASSETS: df1h = TO.get1h(a) - tgt = netted_target(a, EX[can][a], nm, SZ, VTL, BVT, SLE, TP, can, df1h) + tgt = netted_target(a, EX[can][a], nm, SZ, VTL, ACT, BVT, SLE, 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"] @@ -647,8 +669,8 @@ def main() -> None: 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)) + top = list(dict.fromkeys([best, best_iso, max(ACT, key=lambda x: RES[x]["iF"]), + max(BVT, key=lambda x: RES[x]["iF"])])) print(f" {'variante':<28}" + "".join(f"{y:>8}" for y in yrs)) for nm in top: cells = [] @@ -668,11 +690,12 @@ def main() -> None: 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: + elif nm in VTL or nm in ACT: + mk = ((lambda b: leverage_series(b, **VTL[nm])) if nm in VTL + else (lambda b: activity_L(b, **ACT[nm]))) szs, dys = [], [] for a in ASSETS: - szs.append(sizes_from_L(EX[can][a], - leverage_series(EX[can][a]["base_daily"], **VTL[nm]))) + szs.append(sizes_from_L(EX[can][a], mk(EX[can][a]["base_daily"]))) dys.append(EX[can][a]["day_ent"].values) szs = np.concatenate(szs); dys = pd.DatetimeIndex(np.concatenate(dys)) else: @@ -697,7 +720,7 @@ def main() -> None: print("=" * 112) -def netted_target(asset: str, ex: dict, nm, SZ, VTL, BVT, SLE, TP, can, df1h) -> np.ndarray: +def netted_target(asset: str, ex: dict, nm, SZ, VTL, ACT, BVT, SLE, TP, can, df1h) -> np.ndarray: """Peso NETTO per asset sul grid 1h: 0.75*target TP01 + 0.25*posizione SKH (dir*size), poi l'eventuale leva di libro sulla gamba TP01. E' cio' che il cron manda a Deribit come UNA posizione netta sullo stesso strumento.""" @@ -711,6 +734,8 @@ def netted_target(asset: str, ex: dict, nm, SZ, VTL, BVT, SLE, TP, can, df1h) -> sizes = SZ[nm](ex) elif nm in VTL: sizes = sizes_from_L(ex, leverage_series(ex["base_daily"], **VTL[nm])) + elif nm in ACT: + sizes = sizes_from_L(ex, activity_L(ex["base_daily"], **ACT[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 684fb04..f00bba1 100644 --- a/scripts/research/r0822_vrp_real_quotes.py +++ b/scripts/research/r0822_vrp_real_quotes.py @@ -326,11 +326,11 @@ def main() -> None: V = CB.dvol_series(a) win = V[(V.index >= puts["ts"].min()) & (V.index <= puts["ts"].max())] 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(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" @@ -368,6 +368,29 @@ def main() -> None: f"max {tr['ivrank'].max():.3f})") print(f" TOTALE: {passed}/{tot}. Il sleeve sarebbe stato FLAT per l'intero campione.") + print("\n QUANDO TORNERA' MISURABILE — il gate non e' chiuso per sempre, e la soglia e' un numero.") + print(" Regola del 19/06: «rivalutare quando la catena cattura un crash». Un criterio senza\n" + " numero non e' un criterio, quindi eccolo (IV-rank espandente causale, come nel sleeve):") + for a in ASSETS: + V = CB.dvol_series(a) + vals = V.to_numpy(float) + ivr = np.full(len(vals), np.nan) + for i in range(1, len(vals)): + ivr[i] = (vals[:i] < vals[i]).mean() + ser = pd.Series(ivr, index=V.index).dropna() + per_year = ser.groupby(ser.index.year).apply(lambda x: float((x > 0.30).mean())) + thr = float(np.quantile(vals[:-1], 0.30)) + last_open = ser[ser > 0.30] + since = (V.index[-1] - last_open.index[-1]).days if len(last_open) else None + print(f" {a}: quota di giorni col gate APERTO per anno — " + + " ".join(f"{y}:{v:.0%}" for y, v in per_year.items())) + print(f" DVOL oggi {vals[-1]:.1f} · serve DVOL > {thr:.1f} " + f"(+{thr / vals[-1] - 1:.0%}) per riaprire il gate · ultimo giorno aperto " + f"{last_open.index[-1]:%Y-%m-%d} ({since} giorni fa)") + print(" → il campione non e' 'sfortunato': cade dentro un regime di vol bassa lungo e\n" + " persistente. La misura giusta si potra' rifare quando quel numero sara' superato,\n" + " e da allora servira' comunque un anno di settimane per avere un campione.") + ret = {a: (canon[a]["ST"].iloc[-1] / canon[a]["spot"].iloc[0] - 1.0) for a in ASSETS} print(f"\n E il sottostante nel campione: BTC {ret['BTC']:+.1%}, ETH {ret['ETH']:+.1%} — " f"vol implicita bassa E rialzo forte\n = il regime IDEALE per vendere put. " @@ -582,6 +605,13 @@ def main() -> None: if not W.empty: print(f"\n Peggior mark infra-settimana: mediana {W['frac'].median():.1%} del rischio, " f"minimo {W['frac'].min():.1%}") + wmin = W.loc[W['frac'].idxmin()] + print(f" Denominatore, per non fabbricare una discrepanza: qui il rischio e' " + f"`width - credito`.\n" + f" CLAUDE.md cita «-81,7% del capitale» per la STESSA settimana " + f"({wmin['asset']} {wmin['exp']:%Y-%m-%d}):\n" + f" e' lo stesso mark (${wmin['worst']:.2f}) diviso per la width nuda. Due " + f"denominatori, un solo fatto.") wl = W[W["pnl"] > 0] print(f" Fra le sole settimane chiuse in UTILE ({len(wl)}/{len(W)}): mediana " f"{wl['frac'].median():.1%}, minimo {wl['frac'].min():.1%}") @@ -694,7 +724,7 @@ def main() -> None: 1. IL CAMPIONE NON CONTIENE LA STRATEGIA. 0 settimane su 19 passano il gate IV-rank>0.30 che E' l'alpha di VRP01. Il campione e' vol implicita al 24-28 percentile E un rialzo - del +25%/+57%: il regime migliore possibile per vendere put, non uno neutro. + forte del sottostante: il regime migliore possibile per vendere put, non uno neutro. Le 10 settimane per asset (non 16: prima del 2026-06-09 la finestra 4-10 DTE non esiste) danno 10/10 vincenti su BTC. La lente giusta non e' lo Sharpe, e' la regola del tre: 0 perdite su 10 lascia un tasso vero fino al 30%, su un payoff che perde diff --git a/scripts/research/r0822_xs_lite.py b/scripts/research/r0822_xs_lite.py index 107bbf0..dc3bb8a 100644 --- a/scripts/research/r0822_xs_lite.py +++ b/scripts/research/r0822_xs_lite.py @@ -375,11 +375,9 @@ def main() -> None: print(" su GTAA/ADJUSTED_LAST). NON l'ho inseguito: tutte le mie conclusioni sono confronti") print(" RELATIVI dentro UNA lente. Ma tocca un numero pubblicato e il gate del 23/10.") print(f" XSR01 dal 25/07 a oggi (forward, {len(xsr_fwd)} barre, stessa lente): " - f"ritorno {(np.prod(1+xsr_fwd.values)-1)*100:+.2f}% " - f"Sharpe {sh(xsr_fwd) if len(xsr_fwd) > 30 else float('nan'):.2f} " - f"{'(campione troppo corto per uno Sharpe: si legge il ritorno)' if len(xsr_fwd) <= 30 else ''}") - print(" ^ la differenza fra 1.82 e il numero FULL di oggi NON e' una lente diversa (le repliche") - print(" sono bit-exact): e' un mese in piu' di dati. Il gate pre-registrato resta il 23/10.") + f"ritorno {(np.prod(1+xsr_fwd.values)-1)*100:+.2f}%" + + (" (campione troppo corto per uno Sharpe: si legge il ritorno)" + if len(xsr_fwd) <= 30 else f" Sharpe {sh(xsr_fwd):.2f}")) print(f" finestra MAJ19: {C19.index[0].date()} -> {C19.index[-1].date()} ({len(C19)} barre); " f"in-sample = pre {HOLDOUT.date()} = {int((C19.index < HOLDOUT).sum())} barre") cov51 = np.isfinite(C51.values).sum(axis=1) @@ -535,24 +533,42 @@ 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}") + print(f" {'cella':<13}{'|dw| RIBILANCIO':>18}{'|dw| deriva':>14}{'cap sleeve min':>17}" + f"{'= book al 15%':>16}") + capmin_k5 = None 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.") + W = cell["W"] + Weff = xs_effective_weights(W, cell["scale"]) + dW = np.abs(np.diff(W, axis=0)) # W cambia alla riga j+1 + dE = np.abs(np.diff(Weff, axis=0)) # Weff[j+1]-Weff[j], Weff[i]=W[i-1]*scale[i] + # il cambio di W alla riga j+1 si vede su Weff alla riga j+2 -> confine da rispettare + rb, dr = [], [] + for j in range(len(dW)): + if j + 1 >= len(dE): + break + m = dW[j] > 1e-12 + if m.any(): + rb.extend(dE[j + 1][m].tolist()) # gambe DAVVERO ribilanciate + dr.extend(dE[j + 1][~m][dE[j + 1][~m] > 1e-12].tolist()) + else: + dr.extend(dE[j + 1][dE[j + 1] > 1e-12].tolist()) + med_rb = float(np.median(rb)) if rb else float("nan") + med_dr = float(np.median(dr)) if dr else float("nan") + capmin = MIN_ORDER_HL / med_rb if med_rb > 0 else float("nan") + if nm.startswith("MAJ19 k=5"): + capmin_k5 = capmin + print(f" {'XS ' + nm.split()[1]:<13}{med_rb:>18.4f}{med_dr:>14.4f}{capmin:>16.0f}$" + f"{capmin/0.15:>15.0f}$") + print(" ^ ARITMETICA: il ticket vale |dw|*capitale_sleeve, e le due colonne |dw| sono ordini") + print(" di grandezza diversi: il RIBILANCIO (ogni 10 giorni, porta il segnale) e la DERIVA") + print(" giornaliera del vol-target (non porta segnale). Il min-order taglia la seconda molto") + print(" prima della prima, ed e' per questo che la quota di turnover eseguito puo' essere del") + print(f" 4% con haircut ~0. Per il CANONICO k=5 il ribilancio smette di passare sotto " + f"~{capmin_k5:.0f}$") + print(f" di sleeve = ~{capmin_k5/0.15:.0f}$ di book al 15%. La soglia '$20k' del progetto viene da") + print(" una diagnostica di TURNOVER, non di Sharpe — stessa lezione gia' imparata su TP01") + print(" ('a $600 il min-order $5 e' GIA' la banda ottimale'), mai 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")