"""r0825_capitale_fermo.py — il capitale che il libro NON usa: occasione o illusione? DOMANDA (operatore, 2026-08-25). Il libro live sta **flat** buona parte del tempo (28,0% dei giorni sull'intera storia; **75,6% nel 2026** e 77,0% nel 2022). Il 07/08 il progetto aveva gia' scelto un **50/50 book/ETF** per *rimpianto minimo*, su base statistica. La domanda nuova e' meccanica: **mettere l'ETF proprio nei giorni in cui il libro e' flat** batte il 50/50 statico? ⚠️ ATTENZIONE A COSA SI STA MISURANDO. "ETF quando TP01 e' flat" e' a tutti gli effetti una **strategia di timing sull'azionario guidata da un segnale cripto**. Non c'e' nessuna ragione a priori perche' funzioni, e il rischio e' di misurare due cose diverse credendo di misurarne una: (a) **UTILIZZO DEL CAPITALE** — stare nell'ETF invece che fermi aggiunge rendimento a prescindere da QUANDO lo si fa. E' aritmetica, non informazione. (b) **TIMING** — la flat-ness di TP01 predice i rendimenti azionari. Il confronto ingenuo (dinamico vs 50/50) li somma. Per separarli serve il **null a maschera casuale**: si rifa' la stessa strategia con una maschera flat FINTA della stessa lunghezza e della stessa struttura a blocchi. Se il dinamico vero non batte quella nuvola, di (b) non c'e' niente e resta solo (a) — che e' comunque un risultato, ma **un altro** risultato. REGOLE APPLICATE: * **M6/M5** — il confronto e' a **ISO-RISCHIO** (ogni linea riscalata alla stessa vol bersaglio), mai a iso-nozionale: un veicolo a CAGR piu' basso ma vol piu' bassa perde sempre a iso-nozionale per costruzione. Il de-levering e' il PRIMO test, non l'ultimo. * **M14** — il null e' a **struttura preservata** (blocchi), non i.i.d.: la flat-ness del libro e' fortemente autocorrelata (interi mesi), e un null i.i.d. sarebbe banale da battere. * **⚠️ finestra comune** — un mix esiste solo dove esistono ENTRAMBE le serie. L'errore gia' commesso e corretto il 07/08 (dichiarava 30 anni e ne usava 7) qui non si ripete: si taglia sull'**intersezione** e la si stampa. * **convenzione GTAA01** per l'azionario: ritorni su griglia di calendario con **0.0 a borsa chiusa** (senza, lo Sharpe azionario sale del 20% — lezione 25/07). uv run python scripts/research/r0825_capitale_fermo.py """ from __future__ import annotations import sys from pathlib import Path import numpy as np import pandas as pd ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "scripts" / "research")) import eqlib as EQ # noqa: E402 import r0807_asset_compare as AC # noqa: E402 import r0822d_piano_vero as PV # noqa: E402 VOL_TARGET = 0.11 # ~la vol del libro: la lente iso-rischio del confronto N_NULL = 400 # estrazioni della maschera finta BLOCK = 20 # giorni: la stessa taglia di blocco del resto del progetto SEED = 20260825 def metriche(r: pd.Series | np.ndarray, nome: str = "") -> dict: r = np.asarray(r, float) mu, sd = r.mean() * 365.0, r.std() * 365.0 ** 0.5 eq = np.cumprod(1.0 + r) dd = float((eq / np.maximum.accumulate(eq) - 1.0).min()) return dict(nome=nome, drift=mu, vol=sd, sharpe=mu / sd if sd > 0 else np.nan, maxdd=dd) def iso(r: np.ndarray, vol_target: float = VOL_TARGET) -> np.ndarray: """Riscala la serie alla vol bersaglio. E' il de-levering di M6: senza, un veicolo a vol bassa perde per costruzione e il confronto non dice niente.""" sd = r.std() * 365.0 ** 0.5 return r * (vol_target / sd) if sd > 0 else r def maschera_a_blocchi(n: int, quota: float, rng: np.random.Generator, block: int = BLOCK) -> np.ndarray: """Maschera booleana casuale con ~`quota` di True, in BLOCCHI di `block` giorni. La flat-ness vera del libro dura mesi interi: un null i.i.d. avrebbe una struttura temporale completamente diversa e sarebbe facile da battere per la ragione sbagliata.""" nb = int(np.ceil(n / block)) scelti = rng.random(nb) < quota return np.repeat(scelti, block)[:n] def main() -> None: print("=" * 100) print(" r0825 — IL CAPITALE FERMO: l'ETF nei giorni in cui il libro e' flat") print("=" * 100) # --- le due serie, sulla FINESTRA COMUNE ------------------------------------------------- B = PV.costruisci_serie() ix = pd.to_datetime(B.index).tz_localize(None).normalize() # ⚠️ LA MASCHERA FLAT SI PRENDE DALLA SERIE GREZZA, MAI DA QUELLA DE-LUCKATA. # `deluck` sottrae una COSTANTE (r - (1-0.89)*r.mean()): un giorno a rendimento esattamente # zero diventa un numero piccolo ma non nullo, e `abs(r) < 1e-12` non trova piu' NIENTE. # Prima versione di questo script: maschera VUOTA, dinamico identico al book, e un p-value # stampato lo stesso. Se ne e' accorta solo la riga che stampa la quota — che e' il motivo # per cui la si stampa. grezzo = pd.Series(B["fund"].values.astype(float), index=ix) book = pd.Series(PV.deluck(B["fund"].values.astype(float)), index=ix) grezzo = grezzo[~grezzo.index.duplicated(keep="last")] book = book[~book.index.duplicated(keep="last")] spy = AC._calendar_daily(EQ.load_eq("SPY")["close"]) idx = book.index.intersection(spy.index) book, spy, grezzo = book.reindex(idx), spy.reindex(idx), grezzo.reindex(idx) flat = (grezzo.abs() < 1e-12).values assert flat.sum() > 0, "maschera flat vuota: e' stata presa dalla serie de-luckata?" print(f"\n finestra COMUNE: {idx[0].date()} -> {idx[-1].date()} ({len(idx)} giorni, " f"{len(idx)/365.25:.1f} anni)") print(f" giorni in cui il LIBRO e' flat: {flat.sum()} = {flat.mean():.1%}") print(f" quota per anno: " + " · ".join( f"{y}: {g.mean():.0%}" for y, g in pd.Series(flat, index=idx).groupby(idx.year))) b, s = book.values, spy.values linee = { "BOOK da solo": b, "ETF da solo": s, "50/50 statico": 0.5 * b + 0.5 * s, "DINAMICO (ETF se il libro e' flat)": np.where(flat, s, b), } # --- (1) confronto a ISO-NOZIONALE (come lo si guarderebbe d'istinto) -------------------- print(f"\n{'='*100}\n (1) ISO-NOZIONALE — come lo si guarda d'istinto. ⚠️ NON e' il test." f"\n{'='*100}") print(f" {'linea':<38}{'drift':>9}{'vol':>9}{'Sharpe':>9}{'maxDD':>9}") for nome, r in linee.items(): m = metriche(r, nome) print(f" {nome:<38}{m['drift']:>8.2%}{m['vol']:>9.2%}{m['sharpe']:>9.2f}{m['maxdd']:>9.1%}") # --- (2) ISO-RISCHIO: il test vero (M6) -------------------------------------------------- print(f"\n{'='*100}\n (2) ISO-RISCHIO a vol {VOL_TARGET:.0%} — il confronto che conta (M6)" f"\n{'='*100}") print(f" {'linea':<38}{'drift':>9}{'vol':>9}{'Sharpe':>9}{'maxDD':>9}{'leva impl.':>12}") iso_m = {} for nome, r in linee.items(): k = VOL_TARGET / (r.std() * 365 ** 0.5) m = metriche(iso(r), nome) iso_m[nome] = m print(f" {nome:<38}{m['drift']:>8.2%}{m['vol']:>9.2%}{m['sharpe']:>9.2f}" f"{m['maxdd']:>9.1%}{k:>11.2f}x") # --- (3) IL NULL: timing o solo utilizzo del capitale? ----------------------------------- print(f"\n{'='*100}\n (3) NULL A MASCHERA CASUALE — separa il TIMING dall'UTILIZZO" f"\n{'='*100}") print(f" {N_NULL} maschere finte, stessa quota ({flat.mean():.1%}) e stessa struttura " f"a blocchi di {BLOCK}g.") rng = np.random.default_rng(SEED) nulli = [] for _ in range(N_NULL): fk = maschera_a_blocchi(len(b), flat.mean(), rng) nulli.append(metriche(iso(np.where(fk, s, b)))["sharpe"]) nulli = np.array(nulli) vero = iso_m["DINAMICO (ETF se il libro e' flat)"]["sharpe"] pct = float((nulli < vero).mean()) print(f" Sharpe iso-rischio del DINAMICO vero : {vero:.3f}") print(f" nuvola del null: mediana {np.median(nulli):.3f} · " f"p5 {np.percentile(nulli,5):.3f} · p95 {np.percentile(nulli,95):.3f}") print(f" percentile del vero dentro il null : {pct:.1%}") print(f" p-value one-sided (vero <= null) : {1-pct:.3f}") # --- (3b) IL "MECCANISMO" SI SCOMPONE PER ANNO PRIMA DI CREDERCI (M9) -------------------- print(f"\n{'='*100}\n (3b) L'azionario nei giorni flat — e la scomposizione che la smonta" f"\n{'='*100}") sf, sn = spy.values[flat], spy.values[~flat] print(f" aggregato: SPY|flat {sf.mean()*365:>7.2%} (vol {sf.std()*365**0.5:.2%}) · " f"SPY|dentro {sn.mean()*365:>7.2%} (vol {sn.std()*365**0.5:.2%}) · " f"differenza {(sf.mean()-sn.mean())*365:>+7.2%}") print(f" ⚠️ Sembra un meccanismo («il libro va flat quando l'azionario soffre»). Non lo e':") print(f" {'anno':>6}{'gg flat':>9}{'differenza SPY (flat - dentro)':>34}") segni = [] for y, m in spy.groupby(spy.index.year): mk = flat[spy.index.year == y] a, b = m.values[mk], m.values[~mk] if len(a) < 15 or len(b) < 15: print(f" {y:>6}{len(a):>9}{'campione insufficiente':>34}") continue d = (a.mean() - b.mean()) * 365 segni.append(np.sign(d)) print(f" {y:>6}{len(a):>9}{d:>+33.1%}") su, giu = int(sum(1 for x in segni if x > 0)), int(sum(1 for x in segni if x < 0)) print(f"\n => il segno ALTERNA: {su} anni 'flat meglio', {giu} anni 'flat peggio'.") print(f" L'aggregato e' un artefatto di COMPOSIZIONE: i giorni flat sono concentrati") print(f" negli anni a basso rendimento azionario, non nei GIORNI a basso rendimento.") print(f" Dentro ogni anno l'effetto non c'e'. (M9: un contributo si scompone per anno") print(f" prima di crederci — '24/24 ancore positive' puo' essere un anno solo.)") # --- (4) VERDETTO calcolato a runtime (N11) ---------------------------------------------- print(f"\n{'='*100}\n VERDETTO (calcolato, non scritto a mano)\n{'='*100}") din = iso_m["DINAMICO (ETF se il libro e' flat)"]["sharpe"] sta = iso_m["50/50 statico"]["sharpe"] bk = iso_m["BOOK da solo"]["sharpe"] print(f" a iso-rischio: BOOK {bk:.3f} · 50/50 {sta:.3f} · DINAMICO {din:.3f}") if din <= sta: print(f" (a) Il dinamico NON batte il 50/50 statico ({din:.3f} <= {sta:.3f}): " f"la commutazione non aggiunge nulla che il mix non dia gia'.") else: print(f" (a) Il dinamico batte il 50/50 statico di {din-sta:+.3f} di Sharpe.") if pct < 0.95: print(f" (b) E il TIMING non e' dimostrato: il vero sta al {pct:.0%} del null, " f"sotto la soglia del 95%.") print(f" Quel che si vede e' UTILIZZO DEL CAPITALE, non informazione: qualunque") print(f" maschera con la stessa quota fa altrettanto.") else: print(f" (b) Il timing SOPRAVVIVE al null ({pct:.0%} >= 95%): la flat-ness del libro") print(f" porta informazione sull'azionario. Da riprovare fuori campione.") print(f"\n NB: 'battere a iso-rischio' qui vuol dire leva implicita diversa per ogni linea") print(f" (colonna 'leva impl.'), e la leva NON e' autorizzata oggi (§3, k max 1.40).") if __name__ == "__main__": main()