326229188a
Domanda dell'operatore: "tutto su Deribit?". Ha scoperto un buco, non un dettaglio. IL BUCO. Il progetto ha prezzato con ossessione fee, slippage, min-order, pavimento IB, haircut small-cap, fortuna d'ancora, degrado d'esecuzione, look-ahead, backfill, split non aggiustati — e MAI la probabilita' che l'exchange sparisca col saldo dentro. E TP01+SKH01+VRP01 stanno tutti sullo stesso conto: tre sleeve quasi-ortogonali sui ritorni, PERFETTAMENTE CORRELATI sul fallimento del venue. La matrice di correlazione del book non lo vede per costruzione. LIMITE DEI MURI DEL 25-26/07 (dichiarato): book_series gira a alloc=$600 col book a 2 sleeve, quindi portarlo a $272k assumeva gia' "tutto su Deribit" senza dirlo — e assumeva anche di girare il book da $600 a $272k (falso: a $3k GTAA01, $5k XSR01, $20k XS01 -> il muro vero e' piu' basso). LA MISURA (accumulo da $600, 250 EUR/m, 20a, bersaglio $272k, x0.89, jump di venue; CONC = 100% Deribit vs SPLIT = Deribit 65 / HL 15 / IB 20; bersaglio identico -> conservativo CONTRO lo split): p annua P(arrivare) CONC / SPLIT P(perso TUTTO) CONC / SPLIT 0.5% 87% / 90% 10% / 0% 1.0% 81% / 83% 18% / 0% 2.0% 69% / 71% 34% / 4% 5.0% 42% / 45% 64% / 27% Capitale mediano CONC a 20a: $544k (p=0) -> $0 (p=5%). LA COLONNA CHE CONTA NON E' LA PRIMA. Sulla probabilita' di ARRIVARE la concentrazione costa 1-3pp; sulla ROVINA fino a 64pp. Motivo strutturale: con un conto solo "almeno un fallimento" COINCIDE con "perso tutto". Lo SPLIT viene colpito 2.5x piu' spesso ed e' molto piu' sicuro -> "quante volte vieni colpito" non e' una misura di rischio. ONESTA': p NON e' stimato (sensibilita', non previsione); i fallimenti sono assunti indipendenti, ottimistico per Deribit-HL -> la parte solida dello split e' IB, altra classe di rischio; a $600 lo split e' impossibile, la concentrazione e' forzata. RISPOSTA: no, ma la domanda ha una DATA. Prima soglia vera ~$3k (GTAA01 su IB). E converge col 25/07: "max 25% su IB, COSTA ~0.08 EUR/g" era detto su basi di solo rendimento; sull'asse della rovina quello stesso 25% e' la mossa principale — non e' il prezzo di un peggioramento, e' il premio di un'assicurazione. Incluse le aggiunte a r0726_capwall_refresh: drill-down 250 EUR/m e solve_deposit (10 anni @P=90% = 1.178 EUR/m, $155.923 versati su $272k -> il rendimento fa il 43%, contro il 77% a 20 anni). Bug catturato: il contatore dei versamenti si congelava al traguardo mentre il capitale continuava a riceverli -> rapporto gonfiato (46x vs 25x a 30 anni); test di regressione cablato. Book, pesi, cron, config INVARIATI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
124 lines
5.0 KiB
Python
124 lines
5.0 KiB
Python
"""Test per r0726_venue_risk.py e per le aggiunte a r0726_capwall_refresh.py.
|
|
|
|
Due invarianti che, se rotte, darebbero numeri plausibili e sbagliati:
|
|
* l'hazard GIORNALIERO deve comporre esattamente alla probabilita' ANNUA dichiarata —
|
|
un errore qui sposta l'intera tabella di sensibilita' senza che nulla sembri storto;
|
|
* il contatore dei versamenti deve seguire il capitale (bug catturato in sessione: `paid`
|
|
si congelava al traguardo mentre `cap` continuava a ricevere i depositi -> rapporto
|
|
capitale/versato gonfiato del 70-80% a 25-30 anni).
|
|
"""
|
|
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"))
|
|
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
|
|
|
|
|
def _flat(vals, start="2020-01-01"):
|
|
return pd.Series(np.asarray(vals, float),
|
|
index=pd.date_range(start, periods=len(vals), freq="1D", tz="UTC"))
|
|
|
|
|
|
# ===========================================================================
|
|
# hazard: giornaliero -> annuo
|
|
# ===========================================================================
|
|
@pytest.mark.parametrize("p", [0.005, 0.01, 0.02, 0.05, 0.2])
|
|
def test_hazard_giornaliero_compone_alla_probabilita_annua(p):
|
|
"""(1-(1-p)^(1/365))^ -> su 365 giorni la sopravvivenza deve tornare (1-p)."""
|
|
haz = 1.0 - (1.0 - p) ** (1.0 / 365.0)
|
|
assert (1.0 - haz) ** 365 == pytest.approx(1.0 - p, rel=1e-9)
|
|
|
|
|
|
def test_p_zero_non_uccide_nessun_venue():
|
|
import r0726_venue_risk as VR
|
|
|
|
S = {"A": _flat([0.001] * 400), "B": _flat([0.001] * 400)}
|
|
r = VR.simulate({"A": 0.5, "B": 0.5}, S, 0.0, n_paths=200, years=1)
|
|
assert r["p_any_fail"] == 0.0
|
|
assert r["p_all_dead"] == 0.0
|
|
|
|
|
|
def test_p_certo_uccide_tutto_e_azzera_il_capitale():
|
|
"""Controllo POSITIVO: se il gate non mordesse mai, il test a p=0 passerebbe comunque."""
|
|
import r0726_venue_risk as VR
|
|
|
|
S = {"A": _flat([0.001] * 400), "B": _flat([0.001] * 400)}
|
|
r = VR.simulate({"A": 0.5, "B": 0.5}, S, 1.0, n_paths=200, years=1)
|
|
assert r["p_all_dead"] == 1.0
|
|
assert r["median_end"] == 0.0
|
|
|
|
|
|
def test_concentrato_perde_tutto_piu_spesso_del_diversificato():
|
|
"""L'asimmetria che motiva l'intero studio: a parita' di p, 'perso TUTTO' e' molto piu'
|
|
probabile con un solo venue, anche se 'almeno un fallimento' e' piu' probabile con tre."""
|
|
import r0726_venue_risk as VR
|
|
|
|
S = {"A": _flat([0.0005] * 800), "B": _flat([0.0005] * 800), "C": _flat([0.0005] * 800)}
|
|
conc = VR.simulate({"A": 1.0}, S, 0.05, n_paths=800, years=10)
|
|
split = VR.simulate({"A": 0.34, "B": 0.33, "C": 0.33}, S, 0.05, n_paths=800, years=10)
|
|
assert conc["p_all_dead"] > split["p_all_dead"]
|
|
assert split["p_any_fail"] > conc["p_any_fail"]
|
|
|
|
|
|
def test_venue_series_applica_il_deluck_sul_drift():
|
|
import r0726_venue_risk as VR
|
|
|
|
S = VR.venue_series()
|
|
assert set(S) == {"Deribit", "Hyperliquid", "IB"}
|
|
for v in S.values():
|
|
assert isinstance(v.index, pd.DatetimeIndex) and len(v) > 100
|
|
|
|
|
|
# ===========================================================================
|
|
# capwall: il contatore dei versamenti
|
|
# ===========================================================================
|
|
def test_focus_versato_segue_il_capitale():
|
|
"""`paid` deve valere 600 + dep*n_versamenti, deterministico e indipendente dal path.
|
|
Se si congelasse al traguardo (il bug), i path che arrivano presto mostrerebbero un
|
|
versato piu' basso e il rapporto capitale/versato uscirebbe gonfiato."""
|
|
import r0725_capcurve as CC
|
|
import r0726_capwall_refresh as CW
|
|
|
|
r = np.full(2000, 0.0004)
|
|
F = CW.focus(r, cap_needed=1e12, dep_eur=250.0, n_paths=50, years=5, seed=1)
|
|
for y, paid in F["snap_paid"].items():
|
|
n_dep = (y * 365) // 30
|
|
atteso = 600.0 + 250.0 * CC.EURUSD * n_dep
|
|
assert np.allclose(paid, atteso, rtol=0, atol=1e-6), f"anno {y}: {paid[0]} != {atteso}"
|
|
|
|
|
|
def test_focus_bersaglio_irraggiungibile_non_produce_traguardi():
|
|
import r0726_capwall_refresh as CW
|
|
|
|
F = CW.focus(np.full(2000, 0.0001), cap_needed=1e15, dep_eur=100.0,
|
|
n_paths=50, years=3, seed=2)
|
|
assert len(F["fin"]) == 0
|
|
assert len(F["paid_at_hit"]) == 0
|
|
|
|
|
|
def test_solve_deposit_e_monotono_nella_confidenza():
|
|
"""Piu' certezza -> piu' soldi. Se la ricerca binaria riusasse campioni diversi a ogni
|
|
passo, la funzione non sarebbe monotona e questo test lo direbbe."""
|
|
import r0726_capwall_refresh as CW
|
|
|
|
r = np.random.default_rng(3).normal(0.0006, 0.006, 1500)
|
|
d50, _ = CW.solve_deposit(r, 100_000.0, 10, 0.50, n_paths=400, seed=5)
|
|
d90, _ = CW.solve_deposit(r, 100_000.0, 10, 0.90, n_paths=400, seed=5)
|
|
assert d90 >= d50
|
|
|
|
|
|
def test_solve_deposit_ritorna_nan_se_irraggiungibile():
|
|
import r0726_capwall_refresh as CW
|
|
|
|
r = np.full(1500, -0.01) # book che perde l'1% al giorno
|
|
dep, versato = CW.solve_deposit(r, 1e12, 5, 0.5, n_paths=200, seed=6)
|
|
assert np.isnan(dep) and np.isnan(versato)
|