bb40e87d13
Quattro misure (r0909_*), nessun cambio a libro/pesi/config: - libro nei crolli: −0,48%/g nei 160 giorni ≤ −5%, positivo per finestra dal 2022 per la gamba short di SKH01 (108% dei guadagni); beta 0,0769 riprodotto (§46); il peso di SKH01 resta chiuso dal gate (in-sample). - crollo catturato 1-5/06/2026 a quote vere: f_net 0,74 = rally; put δ−0,10 1,92× il modello; a vol bassa e fuori dal gate di VRP01 → §3 non si riapre. - XRP terza gamba (harness r0822_sol_leg): hold-out −0,169 in 0/24, un anno buono. - universo Deribit: liquidi solo BTC/ETH/XRP/SOL; XS01 13/19; BTCDVOL future non negoziabile; PAXG non misurato. Revisione fable: due conclusioni smontate (MTM 2,6× era un artefatto di quote; «8/12 guadagna» era l'ordine delle classi). Debito §5.18: cblib.spot_series + asof guarda un'ora avanti (feed 1h etichettato all'apertura). Test Opus: 92 nuovi, suite 1008/1008; due difetti del verdetto XRP corretti. Docs: diario, RESULTS §74-77, CLAUDE.md, memoria 20/50, README; journal 07-08/09. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017zJjHUS7mf4pnGE6pq9RTt
509 lines
27 KiB
Python
509 lines
27 KiB
Python
"""Test di `scripts/research/r0909_crash_catturato.py` (2026-09-09) — il `f` di stress su quote vere.
|
||
|
||
Questo script porta l'UNICA condizione che riapre la decisione vincolante del 19/06 ("niente
|
||
short-vol da modello in deploy"): un `f` di stress reale misurato su un crash catturato. Un
|
||
numero con quel peso ha bisogno di guardie sul MECCANISMO, non solo sull'esito:
|
||
|
||
* il prezzatore vettorizzato dev'essere *lo stesso* di `cblib.bs_put`, elemento per elemento e
|
||
nei casi degeneri (T≤0, sig=0 → intrinseco): se diverge, il denominatore di ogni `f` e' un
|
||
altro modello e il rapporto non significa piu' niente;
|
||
* **lo spot dev'essere CAUSALE.** Il feed 1h e' etichettato all'APERTURA: la barra 20:00 chiude
|
||
alle 21:00, quindi `S.asof(ts)` guardava un'ora AVANTI. `spot_causale` sposta l'indice di
|
||
+1h. E' un look-ahead di un'ora dentro una misura di stress — piccolo e letale;
|
||
* **il costo di chiusura si TAGLIA alla larghezza.** Uno spread a rischio definito non costa
|
||
mai piu' della larghezza per chiudersi: si lascia regolare. Senza il taglio il "peggior MTM"
|
||
era un artefatto di quote (2,6× il modello; col taglio 1,08);
|
||
* la geometria fuori tolleranza va MARCATA, non aggiustata: `pick_legs` prende sempre la delta
|
||
piu' vicina, quindi senza `in_geom` una griglia rada produrrebbe silenziosamente un'altra
|
||
struttura (e' cio' che ha reso BTC non misurabile);
|
||
* un `f` fra un reale negativo e un modello positivo NON e' un `f`: dev'essere NaN (P12).
|
||
|
||
I test sintetici costruiscono quote a mano, dove ogni numero atteso si calcola a matita. Il test
|
||
d'integrazione (~11 s) riproduce la misura pubblicata su ETH.
|
||
"""
|
||
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 cblib as CB # noqa: E402
|
||
import r0909_crash_catturato as C # noqa: E402
|
||
|
||
INS_S = "ETH-19JUN26-1900-P"
|
||
INS_L = "ETH-19JUN26-1600-P"
|
||
K_S, K_L = 1900.0, 1600.0
|
||
LARGHEZZA = K_S - K_L
|
||
TS0 = pd.Timestamp("2026-06-05 00:00", tz="UTC")
|
||
ORE = pd.date_range(TS0, periods=4, freq="h")
|
||
|
||
|
||
# ------------------------------------------------------------------ impalcature sintetiche
|
||
|
||
def _serie_spot(entrata: float = 2000.0, scadenza: float | None = None) -> pd.Series:
|
||
"""Spot orario dal 01/06 a EXP: `entrata` fino a 12h dopo l'ingresso, poi `scadenza`
|
||
(cosi' `S.asof(EXP)` e' pilotabile senza toccare le quote della finestra d'ingresso)."""
|
||
idx = pd.date_range(pd.Timestamp("2026-06-01", tz="UTC"), C.EXP, freq="h")
|
||
fine = TS0 + pd.Timedelta(hours=12)
|
||
v = np.where(idx <= fine, entrata, entrata if scadenza is None else scadenza)
|
||
return pd.Series(v.astype(float), index=idx)
|
||
|
||
|
||
def _serie_dvol(dvol: float = 50.0) -> pd.Series:
|
||
idx = pd.date_range(pd.Timestamp("2026-06-01", tz="UTC"), C.EXP, freq="h")
|
||
return pd.Series(float(dvol), index=idx)
|
||
|
||
|
||
def _seg(bid_s=0.030, ask_s=0.032, bid_l=0.010, ask_l=0.012,
|
||
ore=ORE, salta_lunga=()) -> pd.DataFrame:
|
||
"""Tabella di quote (ts × strumento). `salta_lunga` toglie la gamba LUNGA a quelle ore:
|
||
e' il caso "una gamba sola", che non e' meta' struttura ma un'altra strategia."""
|
||
righe = []
|
||
for k, t in enumerate(ore):
|
||
righe.append(dict(ts=t, instrument_name=INS_S, bid=bid_s, ask=ask_s, iv=48.0 + k))
|
||
if k not in salta_lunga:
|
||
righe.append(dict(ts=t, instrument_name=INS_L, bid=bid_l, ask=ask_l, iv=57.0 + k))
|
||
return pd.DataFrame(righe)
|
||
|
||
|
||
def _quote(bid_s=0.030, ask_s=0.032, bid_l=0.010, ask_l=0.012, salta_lunga=()) -> C.Quote:
|
||
return C.Quote(_seg(bid_s, ask_s, bid_l, ask_l, salta_lunga=salta_lunga),
|
||
_serie_spot(), _serie_dvol())
|
||
|
||
|
||
def _snap(d_short: float, d_long: float, bid_s=0.030, ask_s=0.032,
|
||
bid_l=0.010, ask_l=0.012) -> pd.DataFrame:
|
||
return pd.DataFrame([
|
||
dict(instrument_name=INS_S, strike=K_S, delta=d_short,
|
||
bid=bid_s, ask=ask_s, iv=48.0, mid=(bid_s + ask_s) / 2),
|
||
dict(instrument_name=INS_L, strike=K_L, delta=d_long,
|
||
bid=bid_l, ask=ask_l, iv=57.0, mid=(bid_l + ask_l) / 2),
|
||
])
|
||
|
||
|
||
def _trade(d_short: float, d_long: float, bid_s=0.030, ask_s=0.032, bid_l=0.010,
|
||
ask_l=0.012, spot=2000.0, ST=None) -> dict | None:
|
||
S, V = _serie_spot(spot, ST), _serie_dvol()
|
||
Q = C.Quote(_seg(bid_s, ask_s, bid_l, ask_l), S, V)
|
||
return C.trade("ETH", TS0, _snap(d_short, d_long, bid_s, ask_s, bid_l, ask_l), Q, S, V)
|
||
|
||
|
||
# ===========================================================================================
|
||
# spot_causale — il look-ahead di un'ora
|
||
# ===========================================================================================
|
||
|
||
def test_spot_causale_restituisce_l_ultima_chiusura_CONOSCIUTA():
|
||
"""🚨 Il feed 1h e' etichettato all'APERTURA: la barra 01:00 chiude alle 02:00. Senza lo
|
||
spostamento, `asof(02:00)` restituiva la chiusura delle 02:00 — cioe' un'ora di futuro
|
||
dentro un ingresso. DEVE FALLIRE SE: qualcuno "semplifica" togliendo lo shift.
|
||
|
||
⚠️ `cblib.spot_series` ha ancora il difetto (e quindi il f 0,714 di §11): dichiarato nel
|
||
docstring dello script, non toccato qui."""
|
||
idx = pd.date_range("2026-06-01", periods=5, freq="h", tz="UTC")
|
||
S = pd.Series([10.0, 11.0, 12.0, 13.0, 14.0], index=idx)
|
||
Sc = C.spot_causale(S)
|
||
|
||
assert list(Sc.index) == list(idx + pd.Timedelta(hours=1))
|
||
assert Sc.asof(idx[2]) == 11.0, "a 02:00 e' nota la chiusura della barra 01:00"
|
||
assert S.asof(idx[2]) == 12.0, "il difetto che la funzione ripara (controllo positivo)"
|
||
assert np.isnan(Sc.asof(idx[0])), "prima della prima chiusura nota non c'e' spot, non c'e' 10.0"
|
||
assert Sc.iloc[0] == 10.0 and S.iloc[0] == 10.0, "i VALORI non si toccano, solo le etichette"
|
||
|
||
|
||
# ===========================================================================================
|
||
# bs_put_vec == cblib.bs_put, elemento per elemento
|
||
# ===========================================================================================
|
||
|
||
def test_bs_put_vec_e_IDENTICA_a_cblib_bs_put_elemento_per_elemento():
|
||
"""DEVE FALLIRE SE: la vettorizzata devia dal prezzatore del sleeve — il denominatore di
|
||
ogni `f` smetterebbe di essere "lo stesso trade nel modello"."""
|
||
S = np.array([2400.0, 2000.0, 1900.0, 1800.0, 1500.0, 900.0])
|
||
T = np.array([0.10, 0.0383, 0.0192, 0.0027, 0.0027, 0.20])
|
||
sig = np.array([0.35, 0.50, 0.65, 1.10, 0.50, 0.05])
|
||
for K in (K_S, K_L, 2200.0):
|
||
v = C.bs_put_vec(S, K, T, sig)
|
||
atteso = [CB.bs_put(float(s), K, float(t), float(g)) for s, t, g in zip(S, T, sig)]
|
||
assert v == pytest.approx(atteso, rel=0, abs=1e-10), K
|
||
|
||
|
||
def test_bs_put_vec_a_T_o_sigma_nulli_da_l_INTRINSECO():
|
||
"""I casi degeneri entrano nel conto: all'ora di scadenza T=0 e il valore e' il payoff, non
|
||
un prezzo. DEVE FALLIRE SE: qualcuno toglie la maschera `ok` e lascia passare log/0 o NaN
|
||
dentro `mod_spread`."""
|
||
S = np.array([2400.0, 1900.0, 1500.0, 1000.0])
|
||
for T, sig in ((np.zeros(4), np.full(4, 0.5)),
|
||
(np.full(4, -1.0), np.full(4, 0.5)),
|
||
(np.full(4, 0.05), np.zeros(4))):
|
||
v = C.bs_put_vec(S, K_S, T, sig)
|
||
assert v == pytest.approx(np.maximum(K_S - S, 0.0), abs=1e-12)
|
||
assert np.isfinite(v).all()
|
||
|
||
|
||
# ===========================================================================================
|
||
# Quote + percorso su quote sintetiche
|
||
# ===========================================================================================
|
||
|
||
def test_percorso_costa_lo_spread_nel_VERSO_GIUSTO():
|
||
"""chiudi_short = (ASK corta − BID lunga)·spot: si ricompra cio' che si e' venduto e si
|
||
rivende cio' che si e' comprato, cioe' si attraversa lo spread due volte.
|
||
liquida_long = (BID corta − ASK lunga)·spot e' lo specchio.
|
||
DEVE FALLIRE SE: qualcuno "corregge" i lati — a mid il percorso costerebbe meno e il fatto
|
||
misurato si ammorbidirebbe da solo."""
|
||
Q = _quote(salta_lunga=(1,))
|
||
|
||
assert list(Q.bid.index) == list(ORE)
|
||
assert Q.spot.iloc[0] == 2000.0
|
||
assert Q.dvol.iloc[0] == pytest.approx(0.50), "il DVOL entra in frazione, non in punti"
|
||
|
||
p = C.percorso(Q, ORE[0] - pd.Timedelta(hours=1), INS_S, INS_L, K_S, K_L)
|
||
assert len(p) == 3, "l'ora con la sola gamba corta dev'essere ESCLUSA"
|
||
assert list(p["ts"]) == [ORE[0], ORE[2], ORE[3]]
|
||
assert p["ts"].is_monotonic_increasing
|
||
|
||
assert p["chiudi_grezzo"].tolist() == pytest.approx([(0.032 - 0.010) * 2000.0] * 3)
|
||
assert p["chiudi_short"].tolist() == pytest.approx([(0.032 - 0.010) * 2000.0] * 3), (
|
||
"dentro la larghezza il taglio non deve mordere: sarebbe un costo inventato")
|
||
assert p["liquida_long"].tolist() == pytest.approx([(0.030 - 0.012) * 2000.0] * 3)
|
||
assert p["liquida_put"].tolist() == pytest.approx([0.010 * 2000.0] * 3)
|
||
assert p["iv_s"].tolist() == pytest.approx([48.0, 50.0, 51.0])
|
||
assert p["iv_l"].tolist() == pytest.approx([57.0, 59.0, 60.0])
|
||
assert p["spot"].tolist() == pytest.approx([2000.0] * 3)
|
||
|
||
# il valore MODELLATO e' quello del sleeve, agli stessi strike e alla stessa ora
|
||
for i, t in enumerate([ORE[0], ORE[2], ORE[3]]):
|
||
T = max((C.EXP - t).total_seconds(), 0.0) / 86400.0 / 365.25
|
||
atteso = CB.bs_put(2000.0, K_S, T, 0.5) - CB.bs_put(2000.0, K_L, T, 0.5)
|
||
assert p["mod_spread"].iloc[i] == pytest.approx(atteso, abs=1e-10)
|
||
assert p["mod_put"].iloc[i] == pytest.approx(CB.bs_put(2000.0, K_L, T, 0.5), abs=1e-10)
|
||
|
||
|
||
def test_percorso_TAGLIA_il_costo_di_chiusura_alla_LARGHEZZA():
|
||
"""🚨 Uno spread a rischio definito non puo' costare piu' della larghezza per chiudersi: si
|
||
lascia regolare. A T→0 le quote arrivano al 158% di spread relativo, e senza il taglio il
|
||
"peggior MTM" era un artefatto di book (2,6× il modello, corretto in revisione a 1,08).
|
||
`chiudi_grezzo` resta accanto perche' il taglio va CONTATO, non nascosto (P12)."""
|
||
Q = _quote(bid_s=0.005, ask_s=0.200, bid_l=0.010, ask_l=0.012)
|
||
p = C.percorso(Q, ORE[0] - pd.Timedelta(hours=1), INS_S, INS_L, K_S, K_L)
|
||
|
||
assert p["chiudi_grezzo"].tolist() == pytest.approx([(0.200 - 0.010) * 2000.0] * 4) # 380
|
||
assert p["chiudi_short"].tolist() == pytest.approx([LARGHEZZA] * 4) # 300
|
||
assert (p["chiudi_short"] <= LARGHEZZA + 1e-12).all()
|
||
assert (p["chiudi_grezzo"] > p["chiudi_short"]).all(), "il grezzo deve restare visibile"
|
||
# la gamba lunga liquidata non puo' valere meno di zero
|
||
assert p["liquida_long"].tolist() == pytest.approx([0.0] * 4)
|
||
|
||
|
||
def test_percorso_taglia_liquida_long_anche_in_ALTO():
|
||
"""L'altro estremo della stessa regola: un debit spread non vale piu' della sua larghezza."""
|
||
Q = _quote(bid_s=0.200, ask_s=0.210, bid_l=0.005, ask_l=0.010)
|
||
p = C.percorso(Q, ORE[0] - pd.Timedelta(hours=1), INS_S, INS_L, K_S, K_L)
|
||
assert p["liquida_long"].tolist() == pytest.approx([LARGHEZZA] * 4)
|
||
|
||
|
||
def test_percorso_parte_DOPO_l_ingresso_e_non_lo_include():
|
||
"""Il mark-to-market comincia all'ora successiva: includere l'ora d'ingresso metterebbe
|
||
dentro un P&L noto a priori (il mezzo spread pagato entrando)."""
|
||
Q = _quote()
|
||
p = C.percorso(Q, ORE[0], INS_S, INS_L, K_S, K_L)
|
||
assert list(p["ts"]) == [ORE[1], ORE[2], ORE[3]]
|
||
assert C.percorso(Q, ORE[-1], INS_S, INS_L, K_S, K_L).empty
|
||
|
||
|
||
def test_percorso_e_vuoto_se_uno_strumento_non_esiste_nella_tabella():
|
||
"""P5: "non vedo" ha uno stato suo (DataFrame vuoto → `trade` ritorna None), non un valore
|
||
inventato."""
|
||
Q = _quote()
|
||
assert C.percorso(Q, ORE[0], INS_S, "ETH-19JUN26-9999-P", K_S, 9999.0).empty
|
||
assert C.percorso(Q, ORE[0], "ETH-19JUN26-9999-P", INS_L, 9999.0, K_L).empty
|
||
|
||
|
||
def test_Quote_scarta_le_quote_NON_quotate():
|
||
"""bid o ask a zero (o assenti) non sono prezzi: entrano come "non quotato", non come 0."""
|
||
seg = _seg()
|
||
seg.loc[seg["instrument_name"] == INS_L, "bid"] = 0.0 # gamba lunga senza bid
|
||
Q = C.Quote(seg, _serie_spot(), _serie_dvol())
|
||
assert INS_L not in Q.bid.columns
|
||
assert C.percorso(Q, ORE[0], INS_S, INS_L, K_S, K_L).empty
|
||
|
||
|
||
# ===========================================================================================
|
||
# la guardia di geometria in_geom (TOL_D)
|
||
# ===========================================================================================
|
||
|
||
def test_in_geom_e_FALSO_quando_le_delta_disponibili_sono_lontane():
|
||
"""`pick_legs` prende SEMPRE la delta piu' vicina: su una griglia rada restituisce una
|
||
struttura che non e' VRP01 e non lo dice. `in_geom` e' il pezzo che lo dice.
|
||
DEVE FALLIRE SE: qualcuno allarga TOL_D o toglie il flag — i 78 snapshot BTC scartati
|
||
il 09/09 sono esattamente quelli che quel flag ha fermato."""
|
||
t = _trade(-0.50, -0.01)
|
||
assert t is not None
|
||
assert t["in_geom"] is False
|
||
assert t["d_short"] == -0.50 and t["d_long"] == -0.01
|
||
|
||
|
||
def test_in_geom_e_VERO_sulla_geometria_di_VRP01():
|
||
"""Controllo POSITIVO (M15): un rilevatore tarato per non segnalare va provato su un caso
|
||
che DEVE passare, altrimenti "nessun trade fuori geometria" non significa niente."""
|
||
t = _trade(C.SHORT_D, C.LONG_D)
|
||
assert t["in_geom"] is True
|
||
assert t["d_short"] == pytest.approx(C.SHORT_D) and t["d_long"] == pytest.approx(C.LONG_D)
|
||
|
||
|
||
def test_in_geom_il_confine_e_esattamente_TOL_D():
|
||
"""I due confini si DERIVANO da `TOL_D`/`SHORT_D`/`LONG_D`, non si riscrivono (P1).
|
||
|
||
⚠️ Il confine ESATTO non e' asseribile in virgola mobile: `-0.28 - 0.08` vale
|
||
-0.36000000000000004, quindi la delta "sul confine" cade FUORI. Si prova un micron dentro
|
||
e un micron fuori — e' il confine che conta, non il suo ultimo bit."""
|
||
dentro_s, fuori_s = C.SHORT_D - C.TOL_D[0] + 1e-6, C.SHORT_D - C.TOL_D[0] - 1e-6
|
||
dentro_l, fuori_l = C.LONG_D - C.TOL_D[1] + 1e-6, C.LONG_D - C.TOL_D[1] - 1e-6
|
||
assert _trade(dentro_s, dentro_l)["in_geom"] is True, "un micron dentro deve passare"
|
||
assert _trade(fuori_s, dentro_l)["in_geom"] is False, "gamba corta appena fuori"
|
||
assert _trade(dentro_s, fuori_l)["in_geom"] is False, "gamba lunga appena fuori"
|
||
|
||
|
||
def test_P1_le_delta_bersaglio_vengono_dal_SLEEVE():
|
||
"""DEVE FALLIRE SE: qualcuno ridichiara −0,28/−0,10 nello script — la misura punterebbe su
|
||
una geometria diversa da quella del sleeve che dovrebbe descrivere."""
|
||
from src.portfolio.sleeves import VRP_CFG
|
||
assert C.SHORT_D is VRP_CFG["short_delta"]
|
||
assert C.LONG_D is VRP_CFG["long_delta"]
|
||
assert C.FEE_FRAC is VRP_CFG["fee_frac"]
|
||
|
||
|
||
# ===========================================================================================
|
||
# trade() — i conti, e cio' che si RIFIUTA di calcolare
|
||
# ===========================================================================================
|
||
|
||
def test_f_exp_e_f_mtm_sono_NAN_a_SEGNI_DISCORDI():
|
||
"""Un rapporto fra un reale NEGATIVO e un modello POSITIVO non e' un fattore di stress:
|
||
e' due fatti diversi messi in frazione. Qui il credito reale (0,2 $) sta sotto le fee
|
||
d'ingresso (1,2 $) mentre il credito modellato e' pieno → pnl_real < 0 < pnl_mod.
|
||
DEVE FALLIRE SE: qualcuno toglie le condizioni di segno e pubblica un `f` negativo."""
|
||
t = _trade(C.SHORT_D, C.LONG_D, bid_s=0.0101, ask_s=0.0102, bid_l=0.009, ask_l=0.010)
|
||
assert t["pnl_real"] < 0 < t["pnl_mod"], (t["pnl_real"], t["pnl_mod"])
|
||
assert np.isnan(t["f_exp"])
|
||
assert t["mtm_real_min"] < 0 < t["mtm_mod_min"], (t["mtm_real_min"], t["mtm_mod_min"])
|
||
assert np.isnan(t["f_mtm"])
|
||
|
||
|
||
def test_f_exp_e_UN_NUMERO_quando_perdono_entrambi():
|
||
"""Controllo POSITIVO del test qui sopra: con lo spot a 1500 a scadenza perdono sia il
|
||
reale sia il modello, e allora `f_exp` esiste ed e' esattamente il rapporto delle perdite."""
|
||
t = _trade(C.SHORT_D, C.LONG_D, bid_s=0.0101, ask_s=0.0102, bid_l=0.009, ask_l=0.010,
|
||
ST=1500.0)
|
||
assert t["pnl_real"] < 0 and t["pnl_mod"] < 0
|
||
assert t["f_exp"] == pytest.approx(t["pnl_real"] / t["pnl_mod"], abs=1e-12)
|
||
assert np.isfinite(t["f_exp"]) and t["f_exp"] > 0
|
||
|
||
|
||
def test_itm_pieno_dichiara_quando_f_exp_e_TAUTOLOGICO():
|
||
"""Se ENTRAMBE le gambe finiscono ITM il payoff e' la larghezza per il reale e per il
|
||
modello: `f_exp` si riduce a una funzione di `f_net` e non e' piu' un'informazione nuova.
|
||
Il flag esiste per poterlo DIRE (l'83% dei trade ETH del 09/09), non per correggerlo."""
|
||
assert _trade(C.SHORT_D, C.LONG_D, ST=1500.0)["itm_pieno"] is True # 1500 <= K_L
|
||
assert _trade(C.SHORT_D, C.LONG_D, ST=2000.0)["itm_pieno"] is False
|
||
assert _trade(C.SHORT_D, C.LONG_D, ST=K_L)["itm_pieno"] is True # confine incluso
|
||
assert _trade(C.SHORT_D, C.LONG_D, ST=K_L + 1.0)["itm_pieno"] is False
|
||
|
||
|
||
def test_trade_CONTA_le_ore_tagliate_invece_di_nasconderle():
|
||
"""P12/D5: una riparazione silenziosa e' un'invenzione. Il taglio alla larghezza cambia il
|
||
peggior MTM, quindi le ore in cui morde vanno contate e stampate accanto al numero."""
|
||
normale = _trade(C.SHORT_D, C.LONG_D)
|
||
assert normale["n_ore"] == 3 and normale["n_tagli"] == 0
|
||
|
||
tagliato = _trade(C.SHORT_D, C.LONG_D, ask_s=0.200) # (0,200 − 0,010)·2000 = 380 > 300
|
||
assert tagliato["n_ore"] == 3 and tagliato["n_tagli"] == 3
|
||
assert tagliato["mtm_real_min"] == pytest.approx(tagliato["cred_real"] - LARGHEZZA)
|
||
|
||
|
||
def test_f_net_e_il_credito_reale_su_quello_modellato():
|
||
"""Il credito reale si incassa al BID della corta e si paga all'ASK della lunga: e' la
|
||
convenzione conservativa di `cblib.f_factors`, e vale 0,74 su ETH nel crollo."""
|
||
t = _trade(C.SHORT_D, C.LONG_D)
|
||
assert t["cred_real"] == pytest.approx((0.030 - 0.012) * 2000.0)
|
||
T = (C.EXP - TS0).total_seconds() / 86400.0 / 365.25
|
||
assert t["cred_mod"] == pytest.approx(
|
||
CB.bs_put(2000.0, K_S, T, 0.5) - CB.bs_put(2000.0, K_L, T, 0.5), abs=1e-10)
|
||
assert t["f_net"] == pytest.approx(t["cred_real"] / t["cred_mod"], abs=1e-12)
|
||
assert t["risk"] == pytest.approx(LARGHEZZA - t["cred_real"])
|
||
|
||
|
||
def test_trade_e_None_se_il_credito_non_e_positivo():
|
||
"""Un credit spread a credito ≤ 0 non e' la struttura che si sta studiando."""
|
||
assert _trade(C.SHORT_D, C.LONG_D, bid_s=0.010, ask_s=0.011, bid_l=0.020, ask_l=0.030) is None
|
||
|
||
|
||
# ===========================================================================================
|
||
# verdetto() — i rami
|
||
# ===========================================================================================
|
||
|
||
ESITO = ("IL CROLLO E' CATTURATO MA A VOL BASSA E FUORI DAL GATE DI VRP01: il f di stress che "
|
||
"la regola del 19/06 aspettava NON e' misurato nella sostanza — cio' che e' misurato "
|
||
"e' che il rapporto di credito f_net non cambia col regime")
|
||
|
||
|
||
def _r(f_exp=1.04, f_mtm=1.08, n=569, n_pre=286, n_fuori=2, f_net=0.74, q_itm=0.83,
|
||
n_strutture=4, n_tagli=8779, n_ore=213477, gate=0.0,
|
||
dur_f_net=0.71, dur_q_perde=0.46) -> dict:
|
||
return dict(n=n, n_pre=n_pre, n_fuori=n_fuori, f_net=f_net, f_exp=f_exp, f_mtm=f_mtm,
|
||
q_itm_pieno=q_itm, n_strutture=n_strutture, n_tagli=n_tagli, n_ore=n_ore,
|
||
gate_aperto=gate, durante=dict(f_net=dur_f_net, q_perde=dur_q_perde))
|
||
|
||
|
||
def test_verdetto_l_esito_e_una_CONCLUSIONE_non_un_esito_numerico():
|
||
"""La conclusione del 09/09 non e' "f vale x": e' che il crollo catturato sta a vol BASSA e
|
||
fuori dal gate, quindi il `f` di stress che la regola del 19/06 aspetta non e' misurato
|
||
nella sostanza. DEVE FALLIRE SE: qualcuno rimette un verdetto che dipende dal solo numero —
|
||
e' la lettura che il numero da solo autorizzava e che la revisione ha tolto."""
|
||
for s in ({"BTC": _r(), "ETH": _r()},
|
||
{"BTC": _r(f_exp=3.0), "ETH": _r(f_exp=0.2)},
|
||
{"BTC": None, "ETH": None}):
|
||
v = C.verdetto(s)
|
||
assert v.startswith(ESITO)
|
||
assert v.endswith("— n = 1 crollo, 1 scadenza per asset, famiglia INVERSE")
|
||
|
||
|
||
def test_verdetto_NON_MISURABILE_quando_un_asset_non_ha_snapshot_in_geometria():
|
||
"""P5: lo stato "non vedo" ha un ramo suo e nomina la causa (griglia di strike rada)."""
|
||
v = C.verdetto({"BTC": dict(n=0, n_fuori=78), "ETH": None})
|
||
assert "BTC: NON misurabile — 78 snapshot tutti FUORI geometria (griglia di strike rada)" in v
|
||
assert "ETH: NON misurabile — 0 snapshot tutti FUORI geometria" in v
|
||
|
||
|
||
def test_verdetto_distingue_ZERO_SNAPSHOT_da_NESSUN_INGRESSO_PRE_CROLLO():
|
||
"""Due guasti diversi con la stessa azione (P5): "tutti fuori geometria" e "in geometria ma
|
||
nessun ingresso PRIMA del crollo" sono cause diverse e vanno dette diverse — e' esattamente
|
||
il caso di BTC il 09/09, dove resta solo la lente DURANTE."""
|
||
v = C.verdetto({"BTC": _r(f_exp=float("nan"), n=247, n_pre=0, n_fuori=78,
|
||
dur_f_net=0.79, dur_q_perde=0.3724696356275304),
|
||
"ETH": _r()})
|
||
assert "BTC: nessun ingresso pre-crollo in geometria (solo DURANTE: f_net 0.79, perde il 37%)" in v
|
||
assert "FUORI geometria" not in v.split("BTC:")[1].split(";")[0]
|
||
|
||
|
||
def test_verdetto_dichiara_che_il_f_a_scadenza_e_TAUTOLOGICO():
|
||
"""M13/§2: un numero si cita con la sua risoluzione. 286 snapshot su 4 strutture non sono
|
||
286 osservazioni, e a gambe entrambe ITM il `f` a scadenza e' una funzione di `f_net`.
|
||
Senza queste due note la riga "f 1,04" verrebbe letta come una misura indipendente."""
|
||
v = C.verdetto({"BTC": _r(), "ETH": _r()})
|
||
assert "a scadenza f 1.04 ma TAUTOLOGICO nel 83% dei trade" in v
|
||
assert "4 strutture, non 286 osservazioni" in v
|
||
assert "peggior MTM tagliato alla larghezza f 1.08" in v
|
||
assert "quote oltre la larghezza nel 4.1% delle ore" in v
|
||
|
||
|
||
def test_verdetto_dice_se_il_gate_di_VRP01_era_APERTO():
|
||
"""E' il fatto che decide se la misura parla del sleeve o di un altro trade: col gate chiuso
|
||
VRP01 quel trade NON lo avrebbe fatto."""
|
||
assert "gate IV-rank CHIUSO nel 0% delle ore" in C.verdetto({"BTC": _r(), "ETH": _r()})
|
||
assert "gate IV-rank APERTO nel 60% delle ore" in C.verdetto(
|
||
{"BTC": _r(gate=0.6), "ETH": _r(gate=0.6)})
|
||
# confine: 0,5 esatto non e' "aperto" (il confronto e' `>`)
|
||
assert "gate IV-rank CHIUSO nel 50% delle ore" in C.verdetto(
|
||
{"BTC": _r(gate=0.5), "ETH": _r(gate=0.5)})
|
||
|
||
|
||
def test_verdetto_non_divide_per_zero_quando_non_ci_sono_ore():
|
||
"""Un campione vuoto non deve far esplodere la riga: la quota di ore tagliate va a 0,0%."""
|
||
v = C.verdetto({"BTC": _r(n_tagli=0, n_ore=0), "ETH": _r()})
|
||
assert "quote oltre la larghezza nel 0.0% delle ore" in v
|
||
|
||
|
||
def test_verdetto_riproduce_ALLA_LETTERA_la_riga_pubblicata_il_09_09():
|
||
"""M23: la macchina deve ristampare il numero vecchio. Sono i valori esatti usciti da
|
||
`sintesi()` il 09/09 — se il testo cambia, cambia anche cio' che il diario cita, e va
|
||
cambiato nei due posti insieme."""
|
||
s = {"BTC": _r(f_exp=float("nan"), f_mtm=float("nan"), n=247, n_pre=0, n_fuori=78,
|
||
f_net=0.7941019699391642, q_itm=float("nan"), n_strutture=0, n_tagli=0,
|
||
n_ore=0, gate=0.0, dur_f_net=0.7941019699391642,
|
||
dur_q_perde=0.3724696356275304),
|
||
"ETH": _r(f_exp=1.0383032287675409, f_mtm=1.0790935015712533, n=569, n_pre=286,
|
||
n_fuori=2, f_net=0.7405178198702741, q_itm=0.8286713286713286,
|
||
n_strutture=4, n_tagli=8779, n_ore=213477, gate=0.0)}
|
||
assert C.verdetto(s) == (
|
||
ESITO + " — "
|
||
"BTC: nessun ingresso pre-crollo in geometria (solo DURANTE: f_net 0.79, perde il 37%); "
|
||
"ETH: f_net 0.74 (22/08 in rally 0,714); a scadenza f 1.04 ma TAUTOLOGICO nel 83% dei "
|
||
"trade (entrambe le gambe ITM ⇒ dipende solo da f_net; 4 strutture, non 286 "
|
||
"osservazioni); peggior MTM tagliato alla larghezza f 1.08 (quote oltre la larghezza "
|
||
"nel 4.1% delle ore); gate IV-rank CHIUSO nel 0% delle ore "
|
||
"— n = 1 crollo, 1 scadenza per asset, famiglia INVERSE")
|
||
|
||
|
||
# ===========================================================================================
|
||
# carica() e l'integrazione sull'archivio VERO
|
||
# ===========================================================================================
|
||
|
||
def test_carica_tiene_solo_put_una_scadenza_la_finestra_e_nessun_duplicato():
|
||
"""L'archivio ereditato copre molte scadenze e molti tipi: se il filtro si allentasse, il
|
||
`f` verrebbe misurato su una struttura diversa da quella dichiarata nel docstring."""
|
||
if not C.ARCHIVIO.exists():
|
||
pytest.skip(f"archivio ereditato assente: {C.ARCHIVIO} (data/raw e' gitignored)")
|
||
a = C.carica()
|
||
assert len(a) > 0
|
||
assert set(pd.unique(a["option_type"])) == {"P"}, "call rimaste dentro"
|
||
assert (a["exp"] == C.EXP).all(), "piu' di una scadenza"
|
||
assert a["ts"].min() >= C.INGRESSI[0], "quote prima della finestra dichiarata"
|
||
assert a["ts"].max() <= C.EXP, "quote dopo la scadenza"
|
||
assert not a.duplicated(subset=["ts", "instrument_name"]).any()
|
||
assert (a["dte"] >= 0).all()
|
||
assert a["dte"].max() <= (C.EXP - C.INGRESSI[0]).total_seconds() / 86400.0 + 1e-9
|
||
assert {"BTC", "ETH"} <= set(pd.unique(a["asset"]))
|
||
|
||
|
||
def test_integrazione_la_misura_del_09_09_si_riproduce():
|
||
"""~11 s. Riproduce cio' su cui poggia la (non) riapertura della decisione del 19/06.
|
||
|
||
I fatti che decidono, e che il test tiene fermi:
|
||
* il gate IV-rank di VRP01 e' CHIUSO nel 100% delle ore → il sleeve questo trade non lo
|
||
avrebbe fatto, quindi il `f` misurato non e' il suo `f` di stress;
|
||
* il `f` a scadenza (1,04) e' TAUTOLOGICO nella grande maggioranza dei trade;
|
||
* 286 snapshot stanno su POCHE strutture: la risoluzione non e' 286 (M13);
|
||
* BTC resta NON misurabile pre-crollo;
|
||
* col taglio alla larghezza il peggior MTM e' vicino a 1, non 2,6 (che era book, non
|
||
rischio) — qui si controlla la TAGLIA, non la terza cifra."""
|
||
if not C.ARCHIVIO.exists():
|
||
pytest.skip(f"archivio ereditato assente: {C.ARCHIVIO}")
|
||
s = C.sintesi(C.misura())
|
||
|
||
eth = s["ETH"]
|
||
assert eth["n_pre"] > 200, "il campione pre-crollo si e' svuotato"
|
||
assert eth["f_net"] == pytest.approx(0.74, abs=0.03)
|
||
assert eth["f_exp"] == pytest.approx(1.04, abs=0.03)
|
||
assert eth["q_itm_pieno"] == pytest.approx(0.83, abs=0.10), (
|
||
"la quota di trade a f_exp tautologico e' cambiata: cambia la lettura del 1,04")
|
||
assert eth["n_strutture"] <= 10, (
|
||
f"{eth['n_pre']} snapshot su {eth['n_strutture']} strutture: se le strutture "
|
||
"diventassero tante, la nota sulla risoluzione andrebbe riscritta")
|
||
assert eth["gate_aperto"] == pytest.approx(0.0, abs=1e-12), (
|
||
"il gate IV-rank di VRP01 risulta APERTO: cambia il senso della misura, che oggi dice "
|
||
"'lo sleeve questo trade non lo avrebbe fatto'")
|
||
assert eth["perdita_su_risk"] == pytest.approx(1.01, abs=0.05)
|
||
assert 0.9 < eth["f_mtm"] < 1.5, (
|
||
f"peggior MTM {eth['f_mtm']:.2f}: col taglio alla larghezza sta attorno a 1; sopra 1,5 "
|
||
"vuol dire che il taglio non morde piu' e il numero torna a essere book, non rischio")
|
||
assert 0 < eth["n_tagli"] < eth["n_ore"] * 0.20, (
|
||
"il taglio deve mordere su una minoranza di ore: se e' zero non serve, se e' la "
|
||
"maggioranza il campione e' un altro")
|
||
|
||
btc = s["BTC"]
|
||
assert btc["n_pre"] == 0 and np.isnan(btc["f_exp"]), (
|
||
"BTC era NON misurabile pre-crollo: se ora lo e', il verdetto cambia e va riscritto, "
|
||
"non aggiustato")
|
||
assert btc["durante"]["n"] > 0 and 0.0 < btc["durante"]["q_perde"] < 1.0
|
||
|
||
v = C.verdetto(s)
|
||
assert v.startswith(ESITO)
|
||
assert "BTC: nessun ingresso pre-crollo in geometria" in v
|
||
assert "gate IV-rank CHIUSO nel 0% delle ore" in v
|
||
assert v.endswith("famiglia INVERSE")
|