17b0f2cb47
Misura nuova: a $6.050 lo split CON GTAA01 e' impossibile (servirebbe il 50% del conto per i $3.000 di gamba minima), ma quello in LIQUIDITA' non ha soglie. Due correzioni prima dei numeri: - la configurazione vera e' 5.000 + 500/mese (dal commento in config/live.json del 26/07), non i 250/mese di tutte le tabelle. Nessuna azione di config: il cap e' gia' armato e vale min($3.000, equity_osservata x 0.5) = leva lorda <=1x. - a $6.050 non si sblocca nulla (GTAA01/XS01/XSR01 tutti fuori portata o sotto gate): il book resta TP01+SKH01. Split in liquidita' (p=1%, 20a): fuori 10% -> P(perso tutto) 18.4% -> 3.5% (0.0% con cassa in banca), salvati $6.818; 25% -> salvati $17.045, al prezzo di 0.9pp di P(arrivare) e circa un anno di ritardo mediano. P(perso tutto) SATURA a qualunque quota > 0: la protezione binaria si compra col fatto di avere un secondo conto. Cio' che distingue le quote e' il salvataggio. ERRORE CORRETTO IN SESSIONE: la colonna del salvataggio riportava prima il capitale a 20 anni condizionato al fallimento ($77k al 10% = 11x il vero), gonfiato dalla convenzione ereditata dal 26/07 per cui i versamenti si dirottano ai superstiti -> attribuiva allo split il valore di continuare a versare, che si ottiene comunque aprendo un altro conto. Ora misura il salvataggio istantaneo, congelato in un test. simulate() accetta un hazard PER VENUE (una cassa in banca non fallisce come un exchange); la replica esatta dei numeri del 26/07 e' preservata e testata. Test: 4 nuovi (508 totali), tutti verdi. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
194 lines
8.8 KiB
Python
194 lines
8.8 KiB
Python
"""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())
|