Files
PythagorasGoal/tests/test_cb_chain_vrp.py
T
Adriano Dal Pastro d55eb13533 feat(chain): assorbita la raccolta catena opzioni, cerbero-bite dismesso
cerbero-bite viene eliminato. L'unica sua parte irreversibile e' il DATO:
una catena opzioni non si ricostruisce a posteriori (Deribit non serve book
storici, non c'e' un secondo venue). Il codice si riscrive; le ore non
raccolte no.

ASSORBITO
- scripts/live/collect_chain.py + scripts/cron_chain.sh (cron 25 * * * *):
  raccolta propria, ~570 strumenti/giro, ~3 min.
- scripts/analysis/import_cb_archive.py: archivio 1.23M righe (2026-05-01+)
  + market_snapshots 17.402 righe (2026-03-26+: dealer gamma, gamma flip,
  rischio liquidazioni, funding cross — dati che non abbiamo altrove).
- snapshot sqlite integrale in /opt/docker/backups/manual/ (SHA256).

NON ASSORBITO, con motivo: motore credit-spread ETH (regola "niente
short-vol da modello in deploy", conto a $52 contro minimo $720), GUI, kill
switch/dead-man/audit (abbiamo venue_watch/edge_watch/monitor_health/
fee_watch), dvol_history (fetch_dvol.py ha storia PIU' LUNGA: 2020+ contro
2026-05), decisions/positions (0 posizioni).

TRE DIFETTI DI BITE NON REPLICATI, tutti misurati il 30/07:
1. una chiamata per strumento invece di due (get_order_book?depth=3 da' gia'
   quote+greche+IV+OI+book+underlying) + prefiltro OI in una chiamata sola:
   551 -> ~290 chiamate per asset;
2. pacing invece di raffica. Il carico non e' mai stato il problema: 570
   chiamate/ora = 0.16/s DISTRIBUITE; bite le sparava in 26s (~44/s) e si
   auto-saturava il rate limit per-IP (12.186 risposte 429 in 26h, 96% al
   minuto :00). Primo giro reale: 574 chiamate, 0 risposte 429. Il minuto :25
   e' scelto: :00 era la raffica, :07 e' cron_book (feed 5m di SKH01).
3. quote_status esplicito {ok, no_quote, error} e book_depth NULL su errore
   mai 0. "Book vuoto" e "chiamata fallita" sono cose diverse: e' per questo
   che il guasto del 29/07 (50% di quote perse, 38 ore) non produsse alcun
   segnale. Le righe ereditate restano 'unknown': bite non lo registrava e a
   posteriori non e' ricostruibile.

Battuta di cuore in data/chain_collect/runs.jsonl anche a giro fallito,
sorvegliata da monitor_health (1h, max_age 3h): un collettore fermo non
produce niente, e il niente si legge come "nessun dato quel giorno".

Difetto trovato per strada: due formati ISO nella stessa colonna (92 righe
di backfill senza microsecondi). pd.to_datetime senza `format` ne inferisce
uno solo e manda gli altri a NaT -> il dropna a valle li toglieva in
silenzio, e la serie di contesto perdeva 5 settimane slittando dal 26/03 al
01/05. Corretto con format="ISO8601" e scarto RUMOROSO.

Book, pesi, config, strategia INVARIATI. 537 test verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:14:34 +00:00

199 lines
8.5 KiB
Python

"""Test della catena opzioni reale (cerbero-bite) e della misura di f per VRP01.
Due famiglie:
A. RILEVATORI di difetto del dato (quote vuote / book incrociato / non monotonia). Ognuno ha il
suo CONTROLLO POSITIVO: un rilevatore che non segnala mai e' indistinguibile da uno rotto.
B. MISURA di f. Il punto non e' "calcola un rapporto" ma che il rapporto sia quello GIUSTO:
f dello spread, non della gamba corta — e' la distinzione che ha ribaltato la conclusione
del 30/07 rispetto alla calibrazione del 20/06.
Nessun test tocca il DB o la rete: tutte le fixture sono sintetiche.
"""
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" / "analysis"))
from cblib import bs_put, f_factors, pick_legs # noqa: E402
from certify_cb_chain import ( # noqa: E402
crossed_rate, hollow_rate, monotonicity_violations, worst_day_hollow,
)
TS = pd.Timestamp("2026-07-24T08:00:00Z")
EXP = pd.Timestamp("2026-07-31T08:00:00Z")
def _riga(strike, bid, ask, delta, mid=None, iv=50.0, ts=TS, tipo="P"):
return {
"ts": ts, "asset": "ETH", "instrument_name": f"ETH-31JUL26-{int(strike)}-{tipo}",
"strike": float(strike), "exp": EXP, "option_type": tipo,
"bid": bid, "ask": ask, "mid": (bid + ask) / 2 if mid is None and bid is not None else mid,
"iv": iv, "delta": delta, "book_depth_top3": 100, "source": "research",
}
def _catena() -> pd.DataFrame:
"""Catena put plausibile: premio crescente nello strike, delta piu' negativo vicino allo spot."""
return pd.DataFrame([
_riga(1500, 0.0020, 0.0025, -0.05),
_riga(1600, 0.0050, 0.0055, -0.10),
_riga(1700, 0.0110, 0.0120, -0.20),
_riga(1750, 0.0160, 0.0175, -0.28),
_riga(1800, 0.0240, 0.0260, -0.40),
])
# --------------------------------------------------------------- A. rilevatori
def test_quote_vuote_contate_e_non_confuse_con_righe_mancanti():
df = _catena()
df.loc[[0, 1], ["bid", "ask", "mid", "iv", "delta"]] = np.nan # ticker fallito: la riga RESTA
assert len(df) == 5, "una quota vuota non riduce il numero di righe — e' il punto del difetto"
assert hollow_rate(df) == pytest.approx(2 / 5)
def test_controllo_positivo_catena_sana_non_segnala():
df = _catena()
assert hollow_rate(df) == 0.0
assert crossed_rate(df) == 0.0
assert monotonicity_violations(df) == 0.0
def test_book_incrociato_segnalato():
df = _catena()
df.loc[2, "bid"] = df.loc[2, "ask"] + 0.01
assert crossed_rate(df) == pytest.approx(1 / 5)
def test_premio_non_monotono_nello_strike_segnalato():
"""Il premio di una put e' non-decrescente nello strike: e' il payoff, non un modello."""
df = _catena()
df.loc[3, "mid"] = 0.0001 # quota stantia/corrotta
v = monotonicity_violations(df)
assert v > 0, "una put che costa MENO di una piu' OTM e' un difetto, va segnalato"
def test_giorno_peggiore_non_si_lascia_diluire_dalla_media():
"""Il guasto del 29/07 mediato su 7 giorni si legge 13%: la statistica giusta e' il giorno."""
righe = []
for g in range(7):
ts = TS + pd.Timedelta(days=g)
vuote = 8 if g == 6 else 0 # guasto solo nell'ultimo giorno
for i in range(10):
r = _riga(1500 + 50 * i, 0.01, 0.011, -0.2, ts=ts)
if i < vuote:
r["bid"] = r["ask"] = r["mid"] = None
righe.append(r)
df = pd.DataFrame(righe)
media = hollow_rate(df)
peggiore, giorno = worst_day_hollow(df, days=7)
assert media == pytest.approx(8 / 70) # ~11%: il guasto sembra piccolo
assert peggiore == pytest.approx(0.8) # 80%: il guasto com'e' davvero
assert giorno == (TS + pd.Timedelta(days=6)).date()
# --------------------------------------------------------------- B. struttura e f
def test_pick_legs_prende_le_gambe_piu_vicine_ai_delta_bersaglio():
legs = pick_legs(_catena())
assert legs is not None
assert legs["k_short"] == 1750 and legs["d_short"] == pytest.approx(-0.28)
assert legs["k_long"] == 1600 and legs["d_long"] == pytest.approx(-0.10)
def test_una_gamba_sola_quotata_non_e_meta_struttura():
"""Con una gamba sola non esiste un credit spread: va scartato, non prezzato a meta'."""
df = _catena()
df.loc[df["strike"] != 1750, ["bid", "ask"]] = np.nan
assert pick_legs(df) is None
def test_gambe_incrociate_rifiutate():
"""Se la 'corta' ha strike sotto la 'lunga' la struttura e' invertita: None, non un credito."""
df = _catena().copy()
df["delta"] = [-0.28, -0.20, -0.10, -0.05, -0.02] # delta bersaglio sugli strike sbagliati
assert pick_legs(df) is None
def test_f_uguale_a_uno_quando_il_mercato_quota_esattamente_il_modello():
"""Controllo di round-trip: quote costruite DAL modello devono dare f = 1 su tutto."""
spot, dvol, dte = 1800.0, 0.50, 7.0
T = dte / 365.25
ks, kl = 1750.0, 1600.0
ps, pl = bs_put(spot, ks, T, dvol), bs_put(spot, kl, T, dvol)
legs = {"k_short": ks, "k_long": kl,
"bid_short": ps / spot, "ask_short": ps / spot,
"bid_long": pl / spot, "ask_long": pl / spot,
"mid_short": ps / spot, "mid_long": pl / spot}
f = f_factors(legs, spot, dvol, dte)
assert f["f_short"] == pytest.approx(1.0, rel=1e-9)
assert f["f_long"] == pytest.approx(1.0, rel=1e-9)
assert f["f_net"] == pytest.approx(1.0, rel=1e-9)
@pytest.mark.parametrize("k_long", [1700.0, 1650.0, 1600.0])
def test_il_f_della_gamba_corta_non_e_il_f_dello_spread(k_long):
"""IL test che vale il filone: con la sola ala comprata piu' cara del modello (skew), la gamba
corta resta a f~1 e il CREDITO NETTO si comprime. Misurare una gamba sola da' la risposta
sbagliata con segno rassicurante — e' cio' che era successo con la calibrazione del 20/06.
La compressione segue l'identita' f_net = (1 - f_long*k) / (1 - k), k = premio_lungo/premio_corto:
quanto piu' l'ala comprata pesa nel credito, tanto piu' il credito netto crolla a parita' di
f_long. Per questo il test gira su tre ali: una soglia sola sarebbe stata una fixture fortunata.
"""
spot, dvol, dte, f_long = 1800.0, 0.50, 7.0, 2.3
T = dte / 365.25
ks = 1750.0
ps, pl = bs_put(spot, ks, T, dvol), bs_put(spot, k_long, T, dvol)
legs = {"k_short": ks, "k_long": k_long,
"bid_short": ps / spot, "ask_short": ps / spot,
"bid_long": f_long * pl / spot, "ask_long": f_long * pl / spot,
"mid_short": ps / spot, "mid_long": f_long * pl / spot}
f = f_factors(legs, spot, dvol, dte)
k = pl / ps
assert f["f_short"] == pytest.approx(1.0, rel=1e-9), "la gamba venduta e' prezzata bene"
assert f["f_long"] == pytest.approx(f_long, rel=1e-9)
assert f["f_net"] == pytest.approx((1 - f_long * k) / (1 - k), rel=1e-9)
assert f["f_net"] < f["f_short"], "il f dello spread e' SEMPRE sotto quello della gamba corta"
assert f["f_net"] < 1.0
def test_fill_conservativo_non_e_mai_migliore_del_mid():
"""Attraversare lo spread non puo' aumentare il credito: guardia di verso, non di valore."""
legs = pick_legs(_catena())
f = f_factors(legs, spot=1800.0, dvol_frac=0.50, dte_days=7.0)
assert f["cred_real"] <= f["cred_mid"] + 1e-12
assert f["f_net"] <= f["f_net_mid"] + 1e-12
# --------------------------------------------------------------- C. guardia sulla convenzione
def test_nel_sleeve_f_moltiplica_il_credito_NETTO():
"""Tutta l'aritmetica del 30/07 poggia su questa convenzione: `f` scala il credito NETTO
(`net_prem = (bs(Ks) - bs(Kl)) * f`). Se qualcuno cambia dove entra `f`, il f misurato sulle
quote reali non e' piu' il parametro giusto da sostituire e le conclusioni del diario
2026-07-30 vanno rifatte. Questo test deve rompersi PRIMA che questo accada."""
import inspect
from src.portfolio import sleeves as SL
src = inspect.getsource(SL._vrp_weekly_asset)
righe = [r.strip() for r in src.splitlines() if "net_prem" in r and "cfg[\"f\"]" in r]
assert righe, "non trovo piu' `net_prem = (...) * cfg['f']` in _vrp_weekly_asset"
r = righe[0]
assert r.count("_bs_put") == 2 and r.index("cfg[\"f\"]") > r.rindex("_bs_put"), (
"`f` deve moltiplicare la DIFFERENZA dei due premi, non una gamba sola"
)
assert SL.VRP_CFG["f"] == 1.0, (
"il sleeve gira ancora a f=1.0: se cambia, aggiornare il diario 2026-07-30 e CLAUDE.md"
)