research(capitale): il lump-sum vale 2.45x, e la protezione dalla rovina non passa da GTAA01
Quattro filoni chiesti dall'operatore ("proposte"). Book, pesi, config: INVARIATI.
1. LUMP-SUM + VENUE (r0727_lumpsum_split.py). Tutte le traiettorie del 25-26/07 avevano
START=600 cablato: mai misurato un versamento iniziale, mentre ~10k EUR stanno fermi
altrove. Macchineria validata: con lump 0 riproduce IDENTICI i numeri del 26/07.
- 10k EUR oggi e mai piu' nulla -> traguardo 17.2a, P 62%, rendita 61.58 EUR/g
- equivalenza onesta: +154 EUR/mese per 13 anni = 24.523 EUR, cioe' 2.45x
(la prima stesura misurava i versamenti risparmiati: numero giusto, domanda sbagliata)
- col rischio venue: a 11.500$ lo split e' possibile (quota IB 26%, non 25%) e taglia
P(perso tutto) da 18.4% a 3.5% a p=1%, costando 1.9-2.6pp di P(arrivare)
- SPLIT-CASSA: seconda gamba ferma costa altri 0.6-0.8pp e protegge IDENTICO
-> la protezione non e' bloccata dal PRIIPs: serve un CONTO, non uno sleeve
2. FEE WATCH (scripts/live/fee_watch.py). Nuovo schema Deribit dal 1 agosto senza numeri
pubblicati -> sorvegliante invece di promemoria. Legge il tier base dall'endpoint
pubblico (oggi taker 5.00 bps), applica la regola congelata e allerta sui cambiamenti.
3. MONITOR HEALTH (src/live/monitor_health.py). Tre gate pre-registrati si decidono su
serie forward di cui una sola era sorvegliata. Misura coda E buchi interni: una serie
bucata ma fresca passa qualunque guardia di freschezza.
4. BANDA GTAA01 25% VALIDATA (r0727_gtaa_band_gate.py). 30 celle, 29.9 anni, dpy=252.
Non e' selection-on-holdout (4/30 IS, 5/30 OOS), DSR 0.999, tracking OK ma AL BORDO.
Il modo di fallire non e' il de-levering (la vol non scende) ma la perdita di tracking.
Impatto sul book: zero -> REBAL_BAND_USD non toccato, si applica al deploy.
Aggiunto anche il bullet edge_watch, cablato il 26/07 e mai finito in CLAUDE.md.
Test: 56 nuovi, 504/504 verdi.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python
|
||||
"""r0727_gtaa_band_gate.py — la banda al 25% della gamba passa i gate, o e' una cella scelta bene?
|
||||
|
||||
CONTESTO. Il 27/07 e' stata proposta una configurazione d'esecuzione nuova per GTAA01 — banda =
|
||||
**25% della gamba** invece dei $50 assoluti, cadenza settimanale invariata — perche' la banda in
|
||||
dollari assoluti degenera al variare del capitale (a $3.000 una banda da $400 e' l'80% della
|
||||
gamba: 3 ordini l'anno e a mercato il 45% del tempo invece del 66%). La proposta e' stata
|
||||
dichiarata esplicitamente **non validata**: "tarata su questa finestra, non passata per
|
||||
`study_family_honest` ne' per un deflated-Sharpe".
|
||||
|
||||
Questo script fa quella validazione. NON e' un gate sui pesi (quello resta `weights_tilt_null`):
|
||||
la banda e' un parametro di **esecuzione**, non di allocazione. Ma un parametro d'esecuzione
|
||||
scelto guardando il risultato e' selezione come qualunque altra, e va trattato come tale.
|
||||
|
||||
TRE GATE, e uno e' specifico di questo tipo di parametro:
|
||||
(A) **SELEZIONE IN-SAMPLE** — la cella si sceglie con i soli dati PRE-2015 e si legge sul
|
||||
2015+, l'hold-out equity documentato di GTAA01 (validazione 2026-06-22) e indipendente
|
||||
dall'hold-out crypto. Se la cella scelta al buio non e' quella proposta, la proposta e'
|
||||
selection-on-holdout.
|
||||
(B) **DEFLATED SHARPE** su TUTTA la griglia cercata (Bailey & Lopez de Prado, PASS >= 0.95),
|
||||
con `dpy=252`: e' una serie su giorni di BORSA, e passarla a 365 gonfia lo Sharpe di
|
||||
×1.20 (l'errore di annualizzazione del 25/07).
|
||||
(C) **NULL DE-LEVERING**, che qui indossa la veste "meno costi" invece di "meno drawdown":
|
||||
una banda larga puo' migliorare i numeri semplicemente smettendo di seguire il target.
|
||||
Il controllo non e' lo Sharpe ma quanto la serie bandata **assomiglia ancora** a quella
|
||||
non bandata (correlazione e rapporto di volatilita' contro il riferimento banda=0,
|
||||
cadenza=1). E' la 4a occorrenza dello stesso null in questo progetto.
|
||||
|
||||
⚠️ Il controllo (C) qui e' in coordinate diverse da quello del 27/07 (che misurava la quota di
|
||||
tempo a mercato sulla finestra UCITS corta, 66% vs 45%): la API pubblica di `gtaa.py` non espone
|
||||
le posizioni tenute, e la correlazione col riferimento misura la stessa cosa — se lo sleeve si
|
||||
congela, smette di somigliare a se' stesso. Le due letture sono complementari, non alternative.
|
||||
|
||||
uv run python scripts/research/r0727_gtaa_band_gate.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" / "alt"))
|
||||
|
||||
from altlib import deflated_sharpe # noqa: E402
|
||||
from src.portfolio.gtaa import ( # noqa: E402
|
||||
EQ_UNIVERSE, GTAA_MIN_CAPITAL, REBAL_BAND_USD, REBAL_EVERY, gtaa_returns,
|
||||
)
|
||||
|
||||
DPY = 252.0 # giorni di BORSA: annualizzare a 365 gonfia Sharpe ×1.20
|
||||
HOLDOUT = "2015-01-01" # hold-out equity documentato di GTAA01 (validazione 22/06)
|
||||
CAPITALS = (3_000.0, 10_000.0, 50_000.0)
|
||||
CAP_REF = 10_000.0
|
||||
EVERY_GRID = (1, 5, 10, 21, 42)
|
||||
FRAC_GRID = (0.0, 0.05, 0.10, 0.25, 0.40, 0.60)
|
||||
PROPOSTA = (5, 0.25) # cadenza settimanale, banda = 25% della gamba
|
||||
CANONICO = (REBAL_EVERY, REBAL_BAND_USD)
|
||||
MIN_CORR = 0.95 # sotto: la banda non filtra piu', congela
|
||||
|
||||
|
||||
def met(s: pd.Series) -> dict:
|
||||
v = s.dropna().values.astype(float)
|
||||
if len(v) < 30 or v.std() == 0:
|
||||
return dict(sharpe=float("nan"), cagr=float("nan"), maxdd=float("nan"), vol=float("nan"))
|
||||
eq = (1.0 + v).cumprod()
|
||||
return dict(sharpe=float(v.mean() / v.std() * np.sqrt(DPY)),
|
||||
cagr=float(eq[-1] ** (DPY / len(v)) - 1.0),
|
||||
maxdd=float((1.0 - eq / np.maximum.accumulate(eq)).max()),
|
||||
vol=float(v.std() * np.sqrt(DPY)))
|
||||
|
||||
|
||||
def run_cell(every: int, frac: float, capital: float) -> pd.Series:
|
||||
"""La banda e' una FRAZIONE della gamba: cosi' il parametro non dipende dal capitale."""
|
||||
band = frac * capital / len(EQ_UNIVERSE)
|
||||
return gtaa_returns(capital=capital, band_usd=band, every=every)
|
||||
|
||||
|
||||
def orders_per_year(s: pd.Series, capital: float, every: int, frac: float) -> float:
|
||||
"""Ordini l'anno stimati: il conteggio esatto vive dentro `_gated_returns`, qui si usa il
|
||||
piano pubblico su una griglia di controlli — proporzionale e sufficiente al confronto."""
|
||||
from src.portfolio.gtaa import _close, _exposure
|
||||
band = frac * capital / len(EQ_UNIVERSE)
|
||||
n = 0
|
||||
for sym in EQ_UNIVERSE:
|
||||
e = _exposure(_close(sym)).dropna()
|
||||
held = 0.0
|
||||
for i, tgt in enumerate(e.values):
|
||||
if i % every:
|
||||
continue
|
||||
if abs(tgt - held) * (capital / len(EQ_UNIVERSE)) >= max(band, 1.0):
|
||||
held = float(tgt)
|
||||
n += 1
|
||||
span = max((s.index[-1] - s.index[0]).days / 365.25, 1e-9)
|
||||
return n / span
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 104)
|
||||
print(" r0727 — GATE ONESTO SULLA BANDA GTAA01 (proposta: 25% della gamba, settimanale)")
|
||||
print("=" * 104)
|
||||
ref = run_cell(1, 0.0, CAP_REF) # riferimento: nessuna banda, controllo giornaliero
|
||||
print(f" storia {ref.index[0].date()} -> {ref.index[-1].date()} "
|
||||
f"({len(ref) / DPY:.1f} anni di borsa) · annualizzazione √{DPY:.0f} · "
|
||||
f"capitale di riferimento ${CAP_REF:,.0f}")
|
||||
print(f" canonico in produzione: ogni {CANONICO[0]} barre, banda ${CANONICO[1]:.0f} "
|
||||
f"(= {CANONICO[1] / (CAP_REF / 6):.0%} della gamba a ${CAP_REF:,.0f})")
|
||||
|
||||
# ---------------------------------------------------------------- griglia
|
||||
cells: dict[tuple[int, float], pd.Series] = {}
|
||||
rows = []
|
||||
for every in EVERY_GRID:
|
||||
for frac in FRAC_GRID:
|
||||
s = run_cell(every, frac, CAP_REF)
|
||||
cells[(every, frac)] = s
|
||||
full = met(s)
|
||||
ins = met(s.loc[: HOLDOUT])
|
||||
oos = met(s.loc[HOLDOUT:])
|
||||
j = pd.concat([s, ref], axis=1, join="inner").dropna()
|
||||
corr = float(j.iloc[:, 0].corr(j.iloc[:, 1]))
|
||||
rows.append(dict(every=every, frac=frac, sh_full=full["sharpe"], sh_in=ins["sharpe"],
|
||||
sh_oos=oos["sharpe"], cagr=full["cagr"], vol=full["vol"],
|
||||
corr=corr, volratio=full["vol"] / met(ref)["vol"]))
|
||||
G = pd.DataFrame(rows)
|
||||
|
||||
print("\n" + "=" * 104)
|
||||
print(" GRIGLIA — Sharpe in-sample (pre-2015) / hold-out (2015+) / full, e somiglianza al riferimento")
|
||||
print("=" * 104)
|
||||
print(f"\n {'cadenza':>8}{'banda%':>8}{'Sh IS':>8}{'Sh OOS':>9}{'Sh FULL':>9}"
|
||||
f"{'CAGR':>8}{'vol':>7}{'corr rif':>10}{'vol/rif':>9}")
|
||||
for _, r in G.iterrows():
|
||||
star = " <- proposta" if (r['every'], r['frac']) == PROPOSTA else ""
|
||||
print(f" {int(r['every']):>8}{r['frac']:>8.0%}{r['sh_in']:>8.2f}{r['sh_oos']:>9.2f}{r['sh_full']:>9.2f}"
|
||||
f"{r['cagr']:>8.2%}{r['vol']:>7.1%}{r['corr']:>10.3f}{r['volratio']:>9.2f}{star}")
|
||||
|
||||
# ---------------------------------------------------------------- (A) selezione in-sample
|
||||
print("\n" + "=" * 104)
|
||||
print(" (A) SELEZIONE IN-SAMPLE — quale cella si sceglie guardando SOLO il pre-2015?")
|
||||
print("=" * 104)
|
||||
best = G.sort_values("sh_in", ascending=False).iloc[0]
|
||||
prop = G[(G["every"] == PROPOSTA[0]) & (G["frac"] == PROPOSTA[1])].iloc[0]
|
||||
print(f"\n cella scelta al buio : cadenza {int(best['every'])}, banda {best['frac']:.0%} "
|
||||
f"-> IS {best['sh_in']:.2f} · OOS {best['sh_oos']:.2f} · FULL {best['sh_full']:.2f}")
|
||||
print(f" cella proposta 27/07 : cadenza {PROPOSTA[0]}, banda {PROPOSTA[1]:.0%} "
|
||||
f"-> IS {prop['sh_in']:.2f} · OOS {prop['sh_oos']:.2f} · FULL {prop['sh_full']:.2f}")
|
||||
rank_in = int((G["sh_in"] > prop['sh_in']).sum()) + 1
|
||||
rank_oos = int((G["sh_oos"] > prop['sh_oos']).sum()) + 1
|
||||
print(f" rango della proposta : {rank_in}/{len(G)} in-sample · {rank_oos}/{len(G)} sull'hold-out")
|
||||
if rank_oos < rank_in:
|
||||
print(" ⚠️ La proposta sta MEGLIO sull'hold-out che in-sample: e' la firma della")
|
||||
print(" selezione-sull'hold-out. Il gate si giudica su cosa succede scegliendo al buio.")
|
||||
print(f"\n Costo di scegliere al buio invece che a posteriori (hold-out): "
|
||||
f"{best['sh_oos'] - prop['sh_oos']:+.2f} di Sharpe")
|
||||
n_best = orders_per_year(cells[(int(best["every"]), float(best["frac"]))], CAP_REF,
|
||||
int(best["every"]), float(best["frac"]))
|
||||
n_prop = orders_per_year(cells[PROPOSTA], CAP_REF, *PROPOSTA)
|
||||
print(f"\n ⚠️ La cella scelta al buio controlla il target OGNI GIORNO ({n_best:.0f} ordini/anno)")
|
||||
print(f" contro la cadenza settimanale della proposta ({n_prop:.0f} ordini/anno). Il conto")
|
||||
print(f" di esecuzione (Degiro/Revolut) NON ha API: 250 controlli manuali l'anno non sono")
|
||||
print(f" una configurazione, sono un'ipotesi. La proposta costa "
|
||||
f"{prop['sh_full'] - best['sh_full']:+.2f} di Sharpe FULL e")
|
||||
print(" compra una cadenza eseguibile a mano. E' un vincolo dichiarato, non una scoperta.")
|
||||
|
||||
# ---------------------------------------------------------------- (B) deflated Sharpe
|
||||
print("\n" + "=" * 104)
|
||||
print(f" (B) DEFLATED SHARPE sulla griglia intera ({len(G)} celle), dpy={DPY:.0f}")
|
||||
print("=" * 104)
|
||||
all_sr = list(G["sh_full"].values)
|
||||
for label, cell in (("proposta 27/07", PROPOSTA),
|
||||
("scelta in-sample", (int(best['every']), float(best['frac'])))):
|
||||
s = cells[cell]
|
||||
dsr, null_max = deflated_sharpe(met(s)["sharpe"], all_sr, s.dropna().values, dpy=DPY)
|
||||
print(f"\n {label:<18} cadenza {cell[0]}, banda {cell[1]:.0%} · "
|
||||
f"Sharpe {met(s)['sharpe']:.2f} vs massimo atteso sotto il nullo {null_max:.2f}")
|
||||
print(f" {'':<18} DSR = {dsr:.3f} -> {'PASS' if dsr >= 0.95 else 'FAIL'} (soglia 0.95)")
|
||||
|
||||
# ---------------------------------------------------------------- (C) null de-levering
|
||||
print("\n" + "=" * 104)
|
||||
print(" (C) NULL DE-LEVERING — la banda filtra, o congela?")
|
||||
print("=" * 104)
|
||||
print(f"\n Riferimento = banda 0%, controllo giornaliero. Una banda che FILTRA lascia la")
|
||||
print(f" serie quasi identica (corr >= {MIN_CORR}); una che CONGELA la stacca.")
|
||||
print(f"\n {'banda%':>8}{'corr rif':>11}{'vol/rif':>10}{'Sh FULL':>10} verdetto")
|
||||
for frac in FRAC_GRID:
|
||||
r = G[(G["every"] == PROPOSTA[0]) & (G["frac"] == frac)].iloc[0]
|
||||
v = "OK" if r['corr'] >= MIN_CORR else "❌ FUORI TRACCIA"
|
||||
print(f" {frac:>8.0%}{r['corr']:>11.3f}{r['volratio']:>10.2f}{r['sh_full']:>10.2f} {v}")
|
||||
print("\n ⚠️ Il modo di fallire NON e' quello atteso, e va detto: allargando la banda la")
|
||||
print(" volatilita' NON scende (vol/rif resta 0.99-1.10), quindi qui il null de-levering")
|
||||
print(" classico non morde. Cio' che si rompe e' il TRACKING: la serie smette di")
|
||||
print(" somigliare a se' stessa perche' tiene posizioni vecchie. E i due segnali")
|
||||
print(" CONCORDANO — oltre il 25% la correlazione cala E lo Sharpe smette di migliorare")
|
||||
print(" (0.70 -> 0.67 -> 0.59): non c'e' una zona in cui il numero premia il congelamento.")
|
||||
print(f" Ma il 25% e' AL BORDO della regione (corr {G[(G['every'] == 5) & (G['frac'] == 0.25)].iloc[0]['corr']:.3f} "
|
||||
f"contro una soglia di {MIN_CORR}): non e' un plateau centrale.")
|
||||
|
||||
# ---------------------------------------------------------------- invarianza
|
||||
print("\n" + "=" * 104)
|
||||
print(" INVARIANZA AL CAPITALE — il motivo per cui la proposta esiste")
|
||||
print("=" * 104)
|
||||
print(f"\n {'capitale':>10}{'banda 25%':>12}{'ord/anno':>11}{'Sharpe':>9}{'corr rif':>10}"
|
||||
f" | {'banda $50 fissa':>16}{'ord/anno':>11}{'Sharpe':>9}")
|
||||
for cap in CAPITALS:
|
||||
s_f = run_cell(PROPOSTA[0], PROPOSTA[1], cap)
|
||||
s_a = gtaa_returns(capital=cap, band_usd=CANONICO[1], every=CANONICO[0])
|
||||
n_f = orders_per_year(s_f, cap, PROPOSTA[0], PROPOSTA[1])
|
||||
n_a = orders_per_year(s_a, cap, CANONICO[0], CANONICO[1] / (cap / 6))
|
||||
j = pd.concat([s_f, ref], axis=1, join="inner").dropna()
|
||||
print(f" ${cap:>9,.0f}{PROPOSTA[1] * cap / 6:>11,.0f}${n_f:>11.0f}{met(s_f)['sharpe']:>9.2f}"
|
||||
f"{float(j.iloc[:, 0].corr(j.iloc[:, 1])):>10.3f} | "
|
||||
f"{CANONICO[1]:>15,.0f}${n_a:>11.0f}{met(s_a)['sharpe']:>9.2f}")
|
||||
print(f"\n (sotto ${GTAA_MIN_CAPITAL:,.0f} lo sleeve non e' deployabile comunque: "
|
||||
"GTAA_MIN_CAPITAL)")
|
||||
|
||||
# ---------------------------------------------------------------- impatto sul book
|
||||
print("\n" + "=" * 104)
|
||||
print(" IMPATTO SUL BOOK — il numero che decide se toccare la produzione")
|
||||
print("=" * 104)
|
||||
from src.portfolio.portfolio import combine_outer
|
||||
from src.portfolio.sleeves import GTAA_BOOK_ACTIVATION, active_sleeves
|
||||
|
||||
def as_book(gtaa_series: pd.Series | None) -> pd.Series:
|
||||
cols, w = {}, {}
|
||||
for sl in active_sleeves():
|
||||
s = sl.daily()
|
||||
if sl.name == "GTAA01_eq_trend" and gtaa_series is not None:
|
||||
g = gtaa_series.dropna().sort_index()
|
||||
if g.index.tz is None:
|
||||
g.index = g.index.tz_localize("UTC")
|
||||
days = pd.date_range(g.index.min().normalize(), g.index.max().normalize(),
|
||||
freq="1D", tz="UTC")
|
||||
s = g.reindex(days).fillna(0.0)
|
||||
s = s[s.index >= GTAA_BOOK_ACTIVATION]
|
||||
cols[sl.name], w[sl.name] = s, sl.weight
|
||||
return combine_outer(cols, w)
|
||||
|
||||
def bk(s: pd.Series) -> dict:
|
||||
v = s.dropna().values.astype(float)
|
||||
eq = (1.0 + v).cumprod()
|
||||
return dict(sharpe=float(v.mean() / v.std() * np.sqrt(365.0)),
|
||||
maxdd=float((1.0 - eq / np.maximum.accumulate(eq)).max()))
|
||||
|
||||
b_can = bk(as_book(None))
|
||||
b_new = bk(as_book(run_cell(PROPOSTA[0], PROPOSTA[1], CAP_REF)))
|
||||
print(f"\n book 5-sleeve, banda CANONICA $50 : Sharpe FULL {b_can['sharpe']:.3f} · "
|
||||
f"maxDD {b_can['maxdd']:.2%}")
|
||||
print(f" book 5-sleeve, banda PROPOSTA 25% : Sharpe FULL {b_new['sharpe']:.3f} · "
|
||||
f"maxDD {b_new['maxdd']:.2%}")
|
||||
print(f" delta : {b_new['sharpe'] - b_can['sharpe']:+.3f} Sharpe · "
|
||||
f"{(b_new['maxdd'] - b_can['maxdd']) * 100:+.2f} pt di maxDD")
|
||||
print("\n ⚠️ Il book si annualizza a 365 (griglia giornaliera piena, weekend = 0), lo sleeve")
|
||||
print(" a 252 (giorni di borsa): i due Sharpe NON sono confrontabili fra loro, solo")
|
||||
print(" ciascuno con se' stesso fra le due configurazioni.")
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python
|
||||
"""r0727_lumpsum_split.py — il capitale che e' GIA' fermo, e cosa fa alla decisione di venue.
|
||||
|
||||
LA DOMANDA. Sul conto che gira ci sono **$600**. Fermi altrove ce ne sono ~**€10.000** (Revolut:
|
||||
€6.043 in XEON, che rende ~0% reale netto dopo inflazione e tasse, piu' VWCE/SPYM/PPFB) e €329 su
|
||||
Degiro. Tutte le traiettorie pubblicate (25/07 e 26/07) partono da `START = 600.0` **cablato**: il
|
||||
progetto ha misurato con cura il CALENDARIO dei versamenti (fattore 6) e non ha mai misurato un
|
||||
**versamento iniziale**.
|
||||
|
||||
PERCHE' LE DUE DOMANDE SI MISURANO INSIEME. Un lump-sum e' il front-loading estremo, e il 26/07
|
||||
aveva gia' misurato che il front-loading *mette piu' capitale sull'exchange prima* — cioe' proprio
|
||||
dove vive il rischio di venue. E la decisione del 26/07 ("100% Deribit fino a $20k") e' stata
|
||||
presa con **$600 sul conto, dove lo split e' impossibile per costruzione**. CLAUDE.md registra
|
||||
l'impegno esplicito: *"Se cambia il piano (orizzonte, importo dei versamenti) si riapre PRIMA."*
|
||||
Un lump-sum da €5-10k e' esattamente quel cambiamento. Misurare il lump-sum senza il rischio di
|
||||
venue risponderebbe alla meta' comoda della domanda.
|
||||
|
||||
COSA NON DECIDE QUESTO SCRIPT. Quanto di quei €6.043 sia un vero fondo d'emergenza e quanto
|
||||
capitale in attesa: e' una decisione dell'operatore, e un fondo d'emergenza NON e' capitale
|
||||
disponibile. Qui si misura **cosa compra ogni euro che entra**, per ogni taglia, cosi' che la
|
||||
decisione si prenda sul prezzo giusto.
|
||||
|
||||
ASSUNZIONI DICHIARATE
|
||||
* Il costo-opportunita' del lump-sum e' ~0% reale (XEON: ~2% lordo, ~0 netto dopo inflazione e
|
||||
26%). Tutti i numeri sono NOMINALI in USD, come ovunque nel progetto: il confronto e' quindi
|
||||
fra "fermo a ~0 reale" e "dentro il book", non fra due investimenti rischiosi.
|
||||
* Book = quello LIVE (TP01+SKH01 su Deribit), de-luckato ×0.89 (fattore MISURATO,
|
||||
r0726_deluck_factor). E' lo stesso book di ogni traiettoria pubblicata -> i numeri sono
|
||||
confrontabili con la tabella dei versamenti del 26/07.
|
||||
* Bersaglio = $272.061, il muro del 26/07 (rendita perpetua a €50/g netti). Identico per CONC e
|
||||
SPLIT: e' CONSERVATIVO contro lo split, che avrebbe un muro leggermente diverso.
|
||||
* `p` (probabilita' annua di fallimento di un venue) NON e' stimata: e' una sensibilita'.
|
||||
La sceglie l'operatore.
|
||||
|
||||
uv run python scripts/research/r0727_lumpsum_split.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
import r0725_capcurve as CC # noqa: E402
|
||||
import r0726_capwall_refresh as WR # noqa: E402
|
||||
import r0726_venue_risk as VR # noqa: E402
|
||||
from src.portfolio.gtaa import GTAA_MIN_CAPITAL # noqa: E402
|
||||
|
||||
DELUCK = 0.89
|
||||
START = 600.0 # il conto Deribit di oggi
|
||||
YEARS = 20
|
||||
N_PATHS = 4000
|
||||
BLOCK = 20
|
||||
SEED = 726 # lo stesso di r0726_venue_risk: consente la replica esatta
|
||||
TARGET = VR.TARGET # $272.061
|
||||
LUMPS_EUR = (0.0, 2_000.0, 5_000.0, 10_000.0)
|
||||
P_GRID = (0.0, 0.005, 0.01, 0.02, 0.05)
|
||||
|
||||
# Split: la quota su IB parte dal 25% raccomandato da r0725_ib10k (oltre, la rendita cala:
|
||||
# 0% -> €2.16/g, 50% -> €1.54/g su $11.5k). Ma la quota MINIMA e' imposta dall'eseguibilita'
|
||||
# della gamba equity: sotto GTAA_MIN_CAPITAL non c'e' uno sleeve, c'e' cash su un secondo conto.
|
||||
# Si accetta di salire fino a IB_SHARE_MAX pur di rendere lo split possibile; oltre, no.
|
||||
CONC = {"Deribit": 1.0}
|
||||
IB_SHARE = 0.25
|
||||
IB_SHARE_MAX = 0.35
|
||||
|
||||
# Haircut sulla gamba IB nella variante realistica: costi a taglia piccola (CAGR 3.8% -> 3.0% a
|
||||
# $3k, misurato il 25/07) + drag del veicolo UCITS (+0.10%/anno, misurato il 26/07).
|
||||
IB_HAIRCUT_YR = 0.008 + 0.001
|
||||
|
||||
|
||||
def simulate(cfg: dict, series: dict, p_annual: float, lump_usd: float = 0.0,
|
||||
dep_eur: float = 250.0, n_paths: int = N_PATHS, years: int = YEARS,
|
||||
seed: int = SEED) -> dict:
|
||||
"""Accumulo con capitale iniziale `START + lump_usd` e jump di venue.
|
||||
|
||||
Generalizza `r0726_venue_risk.simulate` sul solo capitale iniziale e sul deposito: la
|
||||
struttura (ordine di consumo dell'RNG compreso) e' identica, cosi' che con lump=0 e
|
||||
dep=250 i numeri debbano coincidere ESATTAMENTE con quelli pubblicati il 26/07.
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
n_days = years * 365
|
||||
names = list(cfg)
|
||||
J = pd.concat({k: series[k] for k in names}, axis=1, join="inner").fillna(0.0)
|
||||
R = J.values.astype(float)
|
||||
idx = CC._boot_paths(np.arange(len(R), dtype=float), n_paths, n_days, BLOCK, rng).astype(int)
|
||||
dep = dep_eur * CC.EURUSD
|
||||
|
||||
w0 = np.array([cfg[k] for k in names], float)
|
||||
cap = np.outer(np.full(n_paths, START + lump_usd), w0)
|
||||
alive = np.ones((n_paths, len(names)), bool)
|
||||
haz = 1.0 - (1.0 - p_annual) ** (1.0 / 365.0)
|
||||
hit = np.zeros(n_paths, bool)
|
||||
hit_day = np.full(n_paths, n_days + 1, int)
|
||||
for t in range(n_days):
|
||||
cap *= (1.0 + R[idx[:, t], :])
|
||||
if haz > 0:
|
||||
dead = (rng.random((n_paths, len(names))) < haz) & alive
|
||||
cap = np.where(dead, 0.0, cap)
|
||||
alive &= ~dead
|
||||
if t % 30 == 0 and t > 0 and dep > 0:
|
||||
wa = alive * w0
|
||||
tot = wa.sum(axis=1, keepdims=True)
|
||||
share = np.divide(wa, tot, out=np.zeros_like(wa), where=tot > 0)
|
||||
cap += dep * share
|
||||
tot_cap = cap.sum(axis=1)
|
||||
newly = (~hit) & (tot_cap >= TARGET)
|
||||
hit_day[newly] = t
|
||||
hit |= newly
|
||||
yrs = np.where(hit, hit_day / 365.0, np.nan)
|
||||
fin = yrs[~np.isnan(yrs)]
|
||||
return dict(p_reach=float(hit.mean()),
|
||||
median_yrs=float(np.median(fin)) if len(fin) > 50 else float("nan"),
|
||||
p_all_dead=float((~alive.any(axis=1)).mean()),
|
||||
median_end=float(np.median(cap.sum(axis=1))),
|
||||
paid=START + lump_usd + dep * (n_days // 30))
|
||||
|
||||
|
||||
def haircut(s: pd.Series, per_year: float) -> pd.Series:
|
||||
"""Abbassa il drift di una serie giornaliera di `per_year` all'anno (costi non modellati)."""
|
||||
return s - per_year / 365.0
|
||||
|
||||
|
||||
def split_config(total_usd: float) -> dict | None:
|
||||
"""Configurazione di split possibile a quel capitale, o None.
|
||||
|
||||
La quota IB non e' libera: dal basso la vincola l'ESEGUIBILITA' (sotto GTAA_MIN_CAPITAL la
|
||||
gamba equity non esiste, e lo 'split' sarebbe cash fermo su un secondo conto), dall'alto la
|
||||
vincola la RENDITA (r0725_ib10k: oltre il 25% si paga, oltre il 50% si dimezza). Fra i due
|
||||
vincoli si prende la quota minima che rende la gamba eseguibile — mai meno del 25%
|
||||
raccomandato, mai piu' di IB_SHARE_MAX.
|
||||
"""
|
||||
need = GTAA_MIN_CAPITAL / total_usd if total_usd > 0 else float("inf")
|
||||
share = max(IB_SHARE, need)
|
||||
if share > IB_SHARE_MAX:
|
||||
return None
|
||||
return {"Deribit": 1.0 - share, "IB": share}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 104)
|
||||
print(" r0727 — IL CAPITALE GIA' FERMO: cosa compra un versamento iniziale, e cosa fa al venue")
|
||||
print("=" * 104)
|
||||
|
||||
series = VR.venue_series()
|
||||
r_deribit = series["Deribit"].values.astype(float)
|
||||
gross = CC.TARGET_EUR_DAY * 365 * CC.EURUSD / (1 - CC.TAX_RATE)
|
||||
_, perp, wall = WR.perp_and_wall(r_deribit, 1.0, gross)
|
||||
print(f" book live de-luckato ×{DELUCK} | rendita perpetua {perp:.2%} | bersaglio ${TARGET:,.0f}")
|
||||
print(f" {N_PATHS} path, blocchi {BLOCK}g, orizzonte {YEARS}a, conto di partenza ${START:.0f}, "
|
||||
f"EURUSD {CC.EURUSD}")
|
||||
|
||||
# ---------------------------------------------------------------- validazione
|
||||
print("\n" + "-" * 104)
|
||||
print(" VALIDAZIONE — con lump 0 e €250/mese si devono riprodurre i numeri del 26/07")
|
||||
print("-" * 104)
|
||||
for p in (0.0, 0.01):
|
||||
mine = simulate(CONC, series, p, lump_usd=0.0, dep_eur=250.0)
|
||||
theirs = VR.simulate({"Deribit": 1.0}, series, p, n_paths=N_PATHS, years=YEARS, seed=SEED)
|
||||
ok = (abs(mine["p_reach"] - theirs["p_reach"]) < 1e-12
|
||||
and abs(mine["p_all_dead"] - theirs["p_all_dead"]) < 1e-12)
|
||||
print(f" p={p:.1%} P(arrivare) {mine['p_reach']:.4f} vs {theirs['p_reach']:.4f} · "
|
||||
f"P(perso tutto) {mine['p_all_dead']:.4f} vs {theirs['p_all_dead']:.4f} "
|
||||
f"-> {'IDENTICO' if ok else 'DIVERGE (la generalizzazione ha cambiato qualcosa)'}")
|
||||
|
||||
# ---------------------------------------------------------------- (1) cosa compra il lump
|
||||
print("\n" + "=" * 104)
|
||||
print(" (1) COSA COMPRA UN VERSAMENTO INIZIALE — senza rischio di venue (p=0), 100% Deribit")
|
||||
print("=" * 104)
|
||||
print(f"\n {'lump':>8}{'dep/mese':>10}{'tot versato':>14}{'anni al muro':>14}"
|
||||
f"{'P(entro 20a)':>14}{'cap. mediano':>15}{'rendita a 20a':>16}")
|
||||
grid = {}
|
||||
for dep in (0.0, 250.0):
|
||||
for L in LUMPS_EUR:
|
||||
r = simulate(CONC, series, 0.0, lump_usd=L * CC.EURUSD, dep_eur=dep)
|
||||
grid[(L, dep)] = r
|
||||
rent = r["median_end"] * perp * (1 - CC.TAX_RATE) / 365.0 / CC.EURUSD
|
||||
yrs = f"{r['median_yrs']:.1f}a" if not np.isnan(r["median_yrs"]) else "mai"
|
||||
print(f" €{L:>7,.0f}{dep:>10,.0f}{r['paid']:>13,.0f}${yrs:>14}"
|
||||
f"{r['p_reach']:>13.1%}{r['median_end']:>14,.0f}${rent:>13.2f} €/g")
|
||||
|
||||
# equivalenza: a quale versamento mensile equivale il lump, a parita' di traguardo mediano.
|
||||
# NB: NON si confronta il lump col totale dei versamenti risparmiati — sarebbe la domanda
|
||||
# sbagliata (il valore e' arrivare prima, non versare meno), e darebbe un rapporto <1 che
|
||||
# invita alla conclusione opposta.
|
||||
print("\n EQUIVALENZA — a quanti €/mese IN PIU', per sempre, equivale il lump-sum?")
|
||||
base = grid[(0.0, 250.0)]["median_yrs"]
|
||||
for L in LUMPS_EUR[1:]:
|
||||
got = grid[(L, 250.0)]["median_yrs"]
|
||||
lo, hi = 250.0, 3_000.0
|
||||
for _ in range(12): # bisezione sul deposito a pari traguardo
|
||||
mid = 0.5 * (lo + hi)
|
||||
y = simulate(CONC, series, 0.0, lump_usd=0.0, dep_eur=mid)["median_yrs"]
|
||||
if np.isnan(y) or y > got:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
eq = 0.5 * (lo + hi)
|
||||
print(f" €{L:>6,.0f} oggi -> traguardo {got:.1f}a (invece di {base:.1f}a) "
|
||||
f"= come versare €{eq:,.0f}/mese invece di €250, cioe' €{eq - 250:,.0f}/mese in piu' "
|
||||
f"per {got:.0f} anni (€{(eq - 250) * 12 * got:,.0f})")
|
||||
|
||||
# ---------------------------------------------------------------- (2) venue
|
||||
print("\n" + "=" * 104)
|
||||
print(" (2) LO STESSO LUMP, COL RISCHIO DI VENUE DENTRO — concentrato vs split")
|
||||
print(f" SPLIT = Deribit / IB, quota IB scelta dal CAPITALE (minimo eseguibile "
|
||||
f"${GTAA_MIN_CAPITAL:,.0f}, tetto {IB_SHARE_MAX:.0%})")
|
||||
print("=" * 104)
|
||||
ib_real = {**series, "IB": haircut(series["IB"], IB_HAIRCUT_YR)}
|
||||
for L in LUMPS_EUR:
|
||||
tot = START + L * CC.EURUSD
|
||||
cfg = split_config(tot)
|
||||
if cfg:
|
||||
why = (f"POSSIBILE a quota IB {cfg['IB']:.0%} (${tot * cfg['IB']:,.0f} sulla gamba equity; "
|
||||
f"il minimo eseguibile e' ${GTAA_MIN_CAPITAL:,.0f})")
|
||||
else:
|
||||
why = (f"IMPOSSIBILE: servirebbe una quota IB del {GTAA_MIN_CAPITAL / tot:.0%} "
|
||||
f"per fare ${GTAA_MIN_CAPITAL:,.0f}, oltre il tetto {IB_SHARE_MAX:.0%}")
|
||||
print(f"\n lump €{L:,.0f} -> capitale iniziale ${tot:,.0f} · split {why}")
|
||||
print(f" {'p annua':>9}{'P(arrivare) CONC':>19}{'SPLIT':>9}{'SPLIT+costi':>13}"
|
||||
f"{'P(perso tutto) CONC':>22}{'SPLIT':>9}")
|
||||
for p in P_GRID:
|
||||
c = simulate(CONC, series, p, lump_usd=L * CC.EURUSD)
|
||||
if cfg:
|
||||
s = simulate(cfg, series, p, lump_usd=L * CC.EURUSD)
|
||||
sr = simulate(cfg, ib_real, p, lump_usd=L * CC.EURUSD)
|
||||
print(f" {p:>8.1%}{c['p_reach']:>18.1%}{s['p_reach']:>9.1%}{sr['p_reach']:>13.1%}"
|
||||
f"{c['p_all_dead']:>21.1%}{s['p_all_dead']:>9.1%}")
|
||||
else:
|
||||
print(f" {p:>8.1%}{c['p_reach']:>18.1%}{'-':>9}{'-':>13}"
|
||||
f"{c['p_all_dead']:>21.1%}{'-':>9}")
|
||||
# --- lo split che si puo' fare OGGI: la gamba equity e' bloccata dal PRIIPs -------------
|
||||
print("\n" + "-" * 104)
|
||||
print(" (2-bis) E SE LA SECONDA GAMBA FOSSE SOLO LIQUIDITA'?")
|
||||
print(" GTAA01 oggi NON e' deployabile (blocco PRIIPs sugli ETF USA; la via UCITS su Degiro")
|
||||
print(" e' verificata ma in preparazione). Lo split disponibile SUBITO e' Deribit + un conto")
|
||||
print(" fermo (XEON o simile, ~0% reale). Quanta protezione viene dalla DIVERSIFICAZIONE DI")
|
||||
print(" VENUE e quanta dai ritorni della gamba equity?")
|
||||
print("-" * 104)
|
||||
L = 10_000.0
|
||||
tot = START + L * CC.EURUSD
|
||||
cfg = split_config(tot)
|
||||
zero = {**series, "IB": series["IB"] * 0.0} # stessa griglia temporale, drift nullo
|
||||
print(f"\n lump €{L:,.0f} (${tot:,.0f}), quota seconda gamba {cfg['IB']:.0%}")
|
||||
print(f" {'p annua':>9}{'P(arrivare) CONC':>19}{'SPLIT-GTAA':>13}{'SPLIT-CASSA':>13}"
|
||||
f"{'P(perso tutto) CONC':>22}{'SPLIT':>9}")
|
||||
for p in P_GRID:
|
||||
c = simulate(CONC, series, p, lump_usd=L * CC.EURUSD)
|
||||
g = simulate(cfg, series, p, lump_usd=L * CC.EURUSD)
|
||||
z = simulate(cfg, zero, p, lump_usd=L * CC.EURUSD)
|
||||
print(f" {p:>8.1%}{c['p_reach']:>18.1%}{g['p_reach']:>13.1%}{z['p_reach']:>13.1%}"
|
||||
f"{c['p_all_dead']:>21.1%}{g['p_all_dead']:>9.1%}")
|
||||
print("\n (P(perso tutto) e' identica fra SPLIT-GTAA e SPLIT-CASSA: dipende da QUANTI conti")
|
||||
print(" falliscono, non da cosa ci sta sopra. La differenza fra le due colonne di")
|
||||
print(" P(arrivare) e' il prezzo di tenere ferma la seconda gamba invece di investirla.)")
|
||||
|
||||
print("\n ⚠️ La colonna P(perso tutto) sotto CONC non dipende dal lump: con UN conto solo,")
|
||||
print(" 'almeno un fallimento' COINCIDE con 'perso tutto', e quella probabilita' e' una")
|
||||
print(" proprieta' del tempo di esposizione, non del capitale. Il lump non la peggiora —")
|
||||
print(" ma moltiplica cio' che quella probabilita' porta via.")
|
||||
|
||||
# ---------------------------------------------------------------- (3) la soglia
|
||||
print("\n" + "=" * 104)
|
||||
print(" (3) DA QUALE CAPITALE LO SPLIT E' POSSIBILE SENZA DISTORCERE I PESI")
|
||||
print("=" * 104)
|
||||
ideale = GTAA_MIN_CAPITAL / IB_SHARE
|
||||
minimo = GTAA_MIN_CAPITAL / IB_SHARE_MAX
|
||||
tot10 = START + 10_000 * CC.EURUSD
|
||||
print(f"\n soglia di eseguibilita' della gamba equity (GTAA_MIN_CAPITAL): ${GTAA_MIN_CAPITAL:,.0f}")
|
||||
print(f" quota IB raccomandata da r0725_ib10k (oltre, cala la rendita): {IB_SHARE:.0%}")
|
||||
print(f" -> split a quota RACCOMANDATA possibile da : ${ideale:,.0f}")
|
||||
print(f" -> split possibile forzando la quota fino al {IB_SHARE_MAX:.0%} da : ${minimo:,.0f}")
|
||||
print(f"\n Con un lump da €10.000 il conto sarebbe ${tot10:,.0f}: "
|
||||
f"{'SOPRA' if tot10 >= ideale else 'SOTTO'} la soglia a quota")
|
||||
print(f" raccomandata (${ideale:,.0f}) e {'sopra' if tot10 >= minimo else 'sotto'} "
|
||||
f"quella a quota forzata (${minimo:,.0f}) -> lo split e' possibile, ma a")
|
||||
print(f" quota IB {max(IB_SHARE, GTAA_MIN_CAPITAL / tot10):.0%}, non {IB_SHARE:.0%}: "
|
||||
"e' il capitale a scegliere la quota, non la preferenza.")
|
||||
print(f"\n La riapertura della decisione di venue e' fissata a ${20_000:,.0f}. Un lump da")
|
||||
print(f" €10k porta il conto a ${tot10:,.0f} — sotto quella data ma sopra la fattibilita'")
|
||||
print(" tecnica, ed e' un cambiamento del piano: CLAUDE.md dice di riaprire PRIMA in")
|
||||
print(" questo caso, e questa tabella e' il materiale per farlo.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user