"""Test del filone lump-sum + split di venue (scripts/research/r0727_lumpsum_split.py). Il test piu' importante e' `test_riproduce_esattamente_i_numeri_del_26_07`: la macchineria e' una GENERALIZZAZIONE di `r0726_venue_risk.simulate` (capitale iniziale e deposito diventano parametri). Se la generalizzazione avesse cambiato anche solo l'ordine di consumo dell'RNG, i numeri pubblicati e quelli nuovi non sarebbero piu' confrontabili — e la tabella nuova sembrerebbe comunque plausibile. Il secondo che conta e' `test_sotto_la_soglia_lo_split_non_esiste`: uno 'split' in cui la seconda gamba e' sotto il minimo eseguibile non e' uno split, e' cash su un secondo conto — che protegge dal fallimento del venue ma NON e' lo sleeve che i numeri assumono. """ from __future__ import annotations import sys from pathlib import Path import numpy as np import pandas as pd import pytest ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "scripts" / "research")) import r0727_lumpsum_split as LS # noqa: E402 @pytest.fixture(scope="module") def series(): """Serie sintetiche a drift noto: i test sulla struttura non devono dipendere dal feed.""" idx = pd.date_range("2020-01-01", periods=1200, freq="D", tz="UTC") rng = np.random.default_rng(7) return { "Deribit": pd.Series(rng.normal(0.0005, 0.008, len(idx)), index=idx), "IB": pd.Series(rng.normal(0.0002, 0.004, len(idx)), index=idx), "Hyperliquid": pd.Series(rng.normal(0.0003, 0.010, len(idx)), index=idx), } # =========================================================================== # la replica — senza questa, la tabella nuova non e' confrontabile con quella vecchia # =========================================================================== def test_riproduce_esattamente_i_numeri_del_26_07(series): import r0726_venue_risk as VR cfg = {"Deribit": 1.0} kw = dict(n_paths=200, years=3, seed=LS.SEED) mine = LS.simulate(cfg, series, 0.01, lump_usd=0.0, dep_eur=VR.DEP_EUR, **kw) theirs = VR.simulate(cfg, series, 0.01, **kw) assert mine["p_reach"] == theirs["p_reach"] assert mine["p_all_dead"] == theirs["p_all_dead"] assert mine["median_end"] == theirs["median_end"] def test_il_deposito_di_default_e_quello_delle_traiettorie_pubblicate(series): """€250/mese: la colonna con cui si confronta tutto il piano.""" import inspect import r0726_venue_risk as VR assert inspect.signature(LS.simulate).parameters["dep_eur"].default == VR.DEP_EUR # =========================================================================== # il lump-sum # =========================================================================== def test_un_lump_sum_non_puo_peggiorare_il_traguardo(series): kw = dict(n_paths=300, years=5, seed=1) senza = LS.simulate({"Deribit": 1.0}, series, 0.0, lump_usd=0.0, **kw) con = LS.simulate({"Deribit": 1.0}, series, 0.0, lump_usd=10_000.0, **kw) assert con["p_reach"] >= senza["p_reach"] assert con["median_end"] > senza["median_end"] def test_con_un_conto_solo_la_rovina_non_dipende_dal_capitale(series): """Fatto strutturale, non aneddoto: con UN venue 'almeno un fallimento' COINCIDE con 'perso tutto', e quella probabilita' e' una proprieta' del TEMPO di esposizione. Se un giorno questa uguaglianza si rompesse, il modello di rovina e' cambiato.""" kw = dict(n_paths=400, years=5, seed=3) a = LS.simulate({"Deribit": 1.0}, series, 0.02, lump_usd=0.0, **kw) b = LS.simulate({"Deribit": 1.0}, series, 0.02, lump_usd=20_000.0, **kw) assert a["p_all_dead"] == b["p_all_dead"] def test_senza_deposito_e_senza_lump_non_si_arriva(series): r = LS.simulate({"Deribit": 1.0}, series, 0.0, lump_usd=0.0, dep_eur=0.0, n_paths=200, years=5, seed=2) assert r["p_reach"] == 0.0 # =========================================================================== # lo split, e la soglia che lo rende reale # =========================================================================== def test_sotto_la_soglia_lo_split_non_esiste(): """A $6.050 servirebbe il 50% su IB per fare $3.000: oltre il tetto, quindi None. Dichiararlo 'possibile' vorrebbe dire modellare uno sleeve che non e' eseguibile.""" assert LS.split_config(600.0) is None assert LS.split_config(6_050.0) is None def test_sopra_la_soglia_e_il_capitale_a_scegliere_la_quota(): cfg = LS.split_config(11_500.0) assert cfg is not None assert cfg["IB"] == pytest.approx(3_000.0 / 11_500.0, rel=1e-9) # ~26%, non il 25% preferito assert cfg["Deribit"] + cfg["IB"] == pytest.approx(1.0) def test_a_capitale_ampio_si_torna_alla_quota_raccomandata(): cfg = LS.split_config(50_000.0) assert cfg["IB"] == LS.IB_SHARE def test_la_quota_ib_non_supera_mai_il_tetto(): for tot in (8_600.0, 10_000.0, 12_000.0, 100_000.0): cfg = LS.split_config(tot) if cfg: assert cfg["IB"] <= LS.IB_SHARE_MAX + 1e-12 def test_due_conti_riducono_la_rovina(series): kw = dict(n_paths=600, years=6, seed=5, lump_usd=10_900.0) conc = LS.simulate({"Deribit": 1.0}, series, 0.05, **kw) split = LS.simulate(LS.split_config(11_500.0), series, 0.05, **kw) assert split["p_all_dead"] < conc["p_all_dead"] def test_la_rovina_non_dipende_da_cosa_sta_sulla_seconda_gamba(series): """SPLIT-GTAA e SPLIT-CASSA devono avere la stessa P(perso tutto): la protezione viene dal numero di conti, non dai loro ritorni. E' la lettura centrale della sezione 2-bis.""" kw = dict(n_paths=500, years=6, seed=11, lump_usd=10_900.0) cfg = LS.split_config(11_500.0) zero = {**series, "IB": series["IB"] * 0.0} assert (LS.simulate(cfg, series, 0.03, **kw)["p_all_dead"] == LS.simulate(cfg, zero, 0.03, **kw)["p_all_dead"]) # =========================================================================== # costi dichiarati # =========================================================================== def test_haircut_toglie_il_drift_dichiarato(series): s = LS.haircut(series["IB"], 0.009) drift = (series["IB"].mean() - s.mean()) * 365 assert drift == pytest.approx(0.009, rel=1e-9) def test_il_haircut_ib_somma_taglia_piccola_e_veicolo_ucits(): """0.8pp (CAGR 3.8%->3.0% a $3k, misurato 25/07) + 0.1pp (drag UCITS, misurato 26/07).""" assert LS.IB_HAIRCUT_YR == pytest.approx(0.009) def test_il_bersaglio_e_lo_stesso_per_entrambi_i_bracci(): """Conservativo CONTRO lo split (che avrebbe un muro leggermente diverso): se un giorno si usassero due bersagli, il confronto smetterebbe di essere leggibile.""" import r0726_venue_risk as VR assert LS.TARGET == VR.TARGET # =========================================================================== # rischio per-venue: la cassa non e' un exchange # =========================================================================== def test_un_p_per_venue_non_cambia_il_caso_scalare(series): """La generalizzazione a hazard per-venue deve lasciare identico il caso gia' pubblicato, altrimenti la replica del 26/07 vale solo per la versione vecchia della funzione.""" kw = dict(n_paths=300, years=4, seed=9, lump_usd=5_000.0) cfg = {"Deribit": 0.75, "IB": 0.25} a = LS.simulate(cfg, series, 0.02, **kw) b = LS.simulate(cfg, series, {"Deribit": 0.02, "IB": 0.02}, **kw) assert a["p_reach"] == b["p_reach"] and a["p_all_dead"] == b["p_all_dead"] def test_una_cassa_che_non_fallisce_azzera_la_rovina(series): """P(perso tutto) richiede che cadano TUTTI i conti: se il secondo non puo' cadere, e' zero a qualunque quota. E' il risultato che rende la protezione indipendente da GTAA01.""" kw = dict(n_paths=400, years=6, seed=13, lump_usd=5_000.0) zero = {**series, "IB": series["IB"] * 0.0} r = LS.simulate({"Deribit": 0.9, "IB": 0.1}, zero, {"Deribit": 0.05, "IB": 0.0}, **kw) assert r["p_all_dead"] == 0.0 def test_il_salvataggio_scala_con_la_quota_tenuta_fuori(series): """P(perso tutto) SATURA a qualunque quota > 0: cio' che distingue il 10% dal 40% e' quanto si salva nel momento del fallimento, e deve crescere con la quota.""" kw = dict(n_paths=600, years=8, seed=17, lump_usd=5_000.0) zero = {**series, "IB": series["IB"] * 0.0} sv = [LS.simulate({"Deribit": 1 - q, "IB": q}, zero, {"Deribit": 0.05, "IB": 0.0}, **kw)["median_salvage"] for q in (0.10, 0.25, 0.40)] assert sv[0] < sv[1] < sv[2] def test_il_salvataggio_e_istantaneo_non_a_scadenza(series): """Deve misurare cio' che resta NEL MOMENTO del fallimento. Il capitale a 20 anni condizionato al fallimento sarebbe gonfiato dai versamenti dirottati ai superstiti — valore che si ottiene comunque aprendo un altro conto, non merito dello split.""" assert "SALVA nel momento" in LS.simulate.__doc__ or "salvataggio istantaneo" in ( Path(LS.__file__).read_text())