Files
PythagorasGoal/tests/test_r0909_deribit_universo.py
T
Adriano Dal Pastro bb40e87d13 crolli: il libro perde nel giorno e chiude > 0 in 8/8 finestre dal 2022 (SKH01 short); crollo catturato a vol bassa, XRP diluisce come SOL
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
2026-09-09 16:33:54 +00:00

325 lines
15 KiB
Python

"""Test di `scripts/research/r0909_deribit_universo.py` (2026-09-09) — l'universo Deribit letto oggi.
Lo script e' il filtro che decide COSA vale la pena misurare (C6): se sbaglia a leggere il venue,
sbaglia l'elenco delle monete candidate, e la misura successiva parte dalla domanda sbagliata.
Qui si congelano i tre pezzi che possono sbagliare in silenzio:
* i FILTRI — un perpetual `state != "open"` non e' negoziabile e non deve comparire in una
tabella intitolata "aperti"; un future datato non e' un perpetual;
* lo SPREAD assente — senza bid o senza ask lo spread e' NaN, non zero (P5: "non vedo" non e'
"spread perfetto", e uno zero stampato in quella colonna direbbe il contrario del vero);
* l'universo di XS01 — si IMPORTA da `sleeves.XS_UNIVERSE` (P1). Una lista ricopiata qui
risponderebbe "13/19 quotate" anche dopo che qualcuno ha cambiato le gambe del sleeve.
⚠️ NESSUN test tocca la rete e nessuno scrive in `data/`: la cache viene deviata in `tmp_path`
e `get()` e' sostituita da una che fallisce se qualcuno la chiama.
"""
from __future__ import annotations
import inspect
import json
import sys
import time
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import r0909_deribit_universo as U # noqa: E402
LETTO_A = "2026-09-09T15:05:43+00:00"
def _ms(giorno: str) -> int:
return int(time.mktime(time.strptime(giorno, "%Y-%m-%d")) * 1000) + int(
time.timezone * 1000)
def _instr(coin: str, nome: str, stato: str, listato: str, min_amt: float) -> dict:
# creation_timestamp e' letto con time.gmtime -> si costruisce in UTC puro
import calendar
ts = calendar.timegm(time.strptime(listato + " 12:00", "%Y-%m-%d %H:%M")) * 1000
return dict(base_currency=coin, instrument_name=nome, state=stato,
creation_timestamp=ts, min_trade_amount=min_amt)
def _bs(nome: str, bid, ask, mark, vol, oi=0.0, funding=0.0) -> dict:
return dict(instrument_name=nome, bid_price=bid, ask_price=ask, mark_price=mark,
volume_usd=vol, open_interest=oi, funding_8h=funding)
def _lettura() -> dict:
"""Una lettura sintetica del venue: 4 perpetual aperti, 1 INATTIVO, 2 future datati (di cui
uno sul DVOL — l'unico strumento long-vol diretto del venue)."""
fut = [
_instr("XRP", "XRP_USDC-PERPETUAL", "open", "2022-03-16", 1.0),
_instr("ETH", "ETH_USDC-PERPETUAL", "open", "2022-03-14", 0.01),
_instr("ADA", "ADA_USDC-PERPETUAL", "open", "2022-03-21", 10.0),
_instr("PAXG", "PAXG_USDC-PERPETUAL", "open", "2024-12-02", 0.001),
_instr("APT", "APT_USDC-PERPETUAL", "inactive", "2022-11-02", 1.0),
_instr("BTC", "BTC_USDC-27JUN26", "open", "2022-03-09", 0.001),
_instr("BTCDVOL", "BTCDVOL_USDC-30SEP26", "open", "2026-06-01", 1.0),
]
bs = [
_bs("XRP_USDC-PERPETUAL", 2.0, 2.002, 2.0, 38.13e6, oi=3.0e6, funding=4.0e-5),
_bs("ETH_USDC-PERPETUAL", 3000.0, 3000.3, 3000.0, 32.72e6, oi=8.0e3, funding=1.05e-4),
_bs("ADA_USDC-PERPETUAL", None, 0.6, 0.6, 3.33e6, oi=1.0e6, funding=None),
_bs("PAXG_USDC-PERPETUAL", 0.0, 3600.0, 3600.0, 0.18e6, oi=2.0e3, funding=0.0),
_bs("APT_USDC-PERPETUAL", 1.0, 1.1, 1.0, 999e6, oi=1.0, funding=0.0),
_bs("BTC_USDC-27JUN26", 100000.0, 100001.0, 100000.0, 500e6, oi=1.0, funding=0.0),
_bs("BTCDVOL_USDC-30SEP26", 30.0, 60.0, 45.0, 0.0, oi=12.0, funding=None),
]
return dict(letto_a=LETTO_A, fut=fut, bs=bs,
opt_usdc=[dict(base_currency="SOL")] * 3 + [dict(base_currency="BTC")] * 2,
opt_inv={"BTC": 912, "ETH": 790})
# ===========================================================================================
# (a) tabella / sintesi / verdetto su una lettura SINTETICA
# ===========================================================================================
def test_tabella_esclude_i_perpetual_NON_APERTI():
"""Un perpetual `state != "open"` non e' negoziabile: se entrasse, sarebbe pure il piu'
liquido della tabella (999 M$ di volume stantio) e comparirebbe in cima all'elenco dei
candidati. DEVE FALLIRE SE: qualcuno toglie il filtro sullo stato."""
d = _lettura()
rows = U.tabella(d)
assert "APT" not in {r["coin"] for r in rows}
s = U.sintesi(rows, d)
assert s["inattivi"] == ["APT_USDC-PERPETUAL"], (
"un perpetual inattivo non va nella tabella, ma va DETTO: e' la differenza fra "
"'non esiste' e 'esiste e non si puo' negoziare' (P5)")
def test_un_perpetual_INATTIVO_conta_come_gamba_XS01_MANCANTE():
"""APT e' in XS01 ed e' listato sul venue, ma inattivo: la riga "N/19 quotate" deve
contarlo fra i mancanti, o il filtro di eseguibilita' direbbe il falso (C6)."""
d = _lettura()
s = U.sintesi(U.tabella(d), d)
assert "APT" in s["xs01_mancanti"] and "APT" not in s["xs01_listate"]
def test_tabella_esclude_i_FUTURE_DATATI_ma_la_sintesi_li_CONTA():
"""`kind=future` su Deribit contiene anche i future con scadenza: la domanda della tabella
e' sui PERPETUAL, e un future datato non e' una gamba direzionale continua. Restano contati
(`n_datati`) perche' e' li' dentro che vive il future sul DVOL."""
d = _lettura()
rows = U.tabella(d)
assert {r["coin"] for r in rows} == {"XRP", "ETH", "ADA", "PAXG"}
assert len(rows) == 4
assert U.sintesi(rows, d)["n_datati"] == 2
def test_il_future_sul_DVOL_viene_isolato_coi_suoi_numeri_di_negoziabilita():
"""C5/C6: e' l'unico strumento long-vol DIRETTO del venue, quindi va guardato — e va
guardato con bid/ask e OI accanto, perche' e' li' che si vede che non e' negoziabile
(bid 30 / ask 60 = 67% di spread). Un elenco di nomi non basterebbe."""
d = _lettura()
s = U.sintesi(U.tabella(d), d)
assert [f["nome"] for f in s["dvol_fut"]] == ["BTCDVOL_USDC-30SEP26"], (
"il future datato SENZA 'DVOL' nel nome non deve entrare qui")
f = s["dvol_fut"][0]
assert (f["stato"], f["min_amt"], f["bid"], f["ask"], f["oi"]) == ("open", 1.0, 30.0, 60.0, 12.0)
assert f["vol_usd"] == 0.0
def test_tabella_e_ORDINATA_per_volume_decrescente():
rows = U.tabella(_lettura())
vol = [r["vol_usd"] for r in rows]
assert vol == sorted(vol, reverse=True)
assert [r["coin"] for r in rows] == ["XRP", "ETH", "ADA", "PAXG"]
def test_lo_spread_e_NAN_senza_bid_o_senza_ask():
"""P5: senza un lato del book lo spread non e' 0 bps (che vorrebbe dire "perfetto"), e'
"non misurabile". DEVE FALLIRE SE: qualcuno mette un `or 0.0` anche su bid/ask."""
rows = {r["coin"]: r for r in U.tabella(_lettura())}
assert np.isnan(rows["ADA"]["spread_bps"]), "bid assente -> spread non misurabile"
assert np.isnan(rows["PAXG"]["spread_bps"]), "bid a zero -> spread non misurabile"
assert rows["XRP"]["spread_bps"] == pytest.approx((2.002 - 2.0) / 2.001 * 1e4)
assert rows["ETH"]["spread_bps"] == pytest.approx(0.3 / 3000.15 * 1e4)
def test_il_lotto_minimo_e_in_DOLLARI_non_in_unita():
"""C3: prima di misurare il rendimento a un capitale dato si misura il lotto minimo del
venue — e il confronto col pavimento $5 si fa in dollari, non in unita' della moneta."""
rows = {r["coin"]: r for r in U.tabella(_lettura())}
assert rows["XRP"]["min_usd"] == pytest.approx(1.0 * 2.0)
assert rows["ADA"]["min_usd"] == pytest.approx(10.0 * 0.6)
assert rows["ETH"]["min_amt"] == 0.01
def test_la_data_di_listing_e_quella_del_venue():
rows = {r["coin"]: r for r in U.tabella(_lettura())}
assert rows["XRP"]["listato"] == "2022-03-16"
assert rows["PAXG"]["listato"] == "2024-12-02"
def test_P1_le_gambe_di_XS01_si_IMPORTANO_dal_sleeve():
"""*Un controllo puntato su una configurazione diversa da quella che gira passa sempre.*
Se l'elenco fosse ricopiato nello script, "13/19 quotate" resterebbe vero anche dopo un
cambio di universo del sleeve — cioe' smetterebbe di controllare qualcosa."""
from src.portfolio import sleeves
assert U.XS_UNIVERSE is sleeves.XS_UNIVERSE
src = inspect.getsource(U)
assert "from src.portfolio.sleeves import XS_UNIVERSE" in src
assert "XS_UNIVERSE = [" not in src, "universo XS01 ridichiarato nello script"
def test_le_gambe_XS01_mancanti_si_CALCOLANO_dall_universo():
"""La riga "mancano [...]" e' l'elenco delle gambe di XS01 non quotate sul venue: si
ricava dall'universo importato, quindi si aggiorna da sola se il sleeve cambia."""
from src.portfolio.sleeves import XS_UNIVERSE
rows = U.tabella(_lettura())
s = U.sintesi(rows, _lettura())
quotate = {"XRP", "ETH", "ADA"} # PAXG non e' in XS01
assert s["xs01_listate"] == sorted(c for c in XS_UNIVERSE if c in quotate)
assert s["xs01_mancanti"] == [c for c in XS_UNIVERSE if c not in quotate]
assert set(s["xs01_listate"]) | set(s["xs01_mancanti"]) == set(XS_UNIVERSE)
assert {r["coin"] for r in rows if r["xs01"]} == quotate
def test_liquidi_usa_la_soglia_dichiarata_e_il_confine_e_incluso():
"""La soglia $10M/giorno e' dichiarata nel modulo: il test la DERIVA, non la ricopia."""
d = _lettura()
for b in d["bs"]:
if b["instrument_name"] == "ADA_USDC-PERPETUAL":
b["volume_usd"] = U.SOGLIA_LIQUIDO_USD # esattamente in soglia -> liquido
if b["instrument_name"] == "PAXG_USDC-PERPETUAL":
b["volume_usd"] = U.SOGLIA_LIQUIDO_USD - 1.0 # un dollaro sotto -> no
s = U.sintesi(U.tabella(d), d)
assert s["liquidi"] == ["XRP", "ETH", "ADA"]
def test_sintesi_riporta_le_famiglie_di_opzioni_e_l_ora_della_lettura():
"""N10/§7: un numero letto dal venue vale con la sua ora. Senza `letto_a` la riga
diventerebbe una verita' senza data."""
d = _lettura()
s = U.sintesi(U.tabella(d), d)
assert s["letto_a"] == LETTO_A
assert s["opt_inv"] == {"BTC": 912, "ETH": 790}
assert s["opt_usdc"] == [("SOL", 3), ("BTC", 2)]
assert s["n_perp"] == 4
def test_verdetto_su_una_lettura_sintetica():
d = _lettura()
s = U.sintesi(U.tabella(d), d)
v = U.verdetto(s)
assert v.startswith("4 perpetual USDC; LIQUIDI (≥$10M/g): XRP, ETH; ")
assert "XS01 3/19 quotate" in v
assert "opzioni USDC-lineari su 2 sottostanti" in v
assert f"lettura del {LETTO_A}" in v
# ===========================================================================================
# (b) leggi() — la cache, e la garanzia che NESSUN test vada in rete
# ===========================================================================================
@pytest.fixture()
def cache_in_tmp(tmp_path, monkeypatch):
"""Devia la cache fuori da `data/_cache/` (debito §5.12: la suite non scrive nei file vivi)."""
p = tmp_path / "r0909_deribit_universo.json"
monkeypatch.setattr(U, "CACHE", p)
return p
def test_leggi_con_cache_presente_NON_tocca_la_rete(cache_in_tmp, monkeypatch):
"""DEVE FALLIRE SE: qualcuno rende la lettura sempre fresca — la suite comincerebbe a
dipendere dalla rete e dal venue, e un test rosso non direbbe piu' niente sul codice."""
def _vietato(path):
raise AssertionError(f"chiamata di rete durante un test: {path}")
monkeypatch.setattr(U, "get", _vietato)
atteso = _lettura()
cache_in_tmp.write_text(json.dumps(atteso))
d = U.leggi(usa_cache=True)
assert d["letto_a"] == LETTO_A
assert len(d["fut"]) == len(atteso["fut"])
# (i NaN dello spread non sono confrontabili con ==: si confronta il resto)
chiave = lambda rows: [(r["coin"], r["listato"], r["vol_usd"], r["min_usd"], r["xs01"])
for r in rows]
assert chiave(U.tabella(d)) == chiave(U.tabella(atteso))
def test_leggi_senza_cache_interroga_il_venue_UNA_volta_e_la_scrive(cache_in_tmp, monkeypatch):
"""La cache non e' un'ottimizzazione: e' la FONTE del numero stampato (una lettura e' un
istante). Deve finire su disco con la sua ora, o la riga del diario non ha piu' fonte."""
chiamate = []
def _finto(path):
chiamate.append(path)
if "get_book_summary" in path:
return _lettura()["bs"]
if "kind=option" in path and "currency=USDC" in path:
return _lettura()["opt_usdc"]
if "kind=option" in path:
return [0] * 912
return _lettura()["fut"]
monkeypatch.setattr(U, "get", _finto)
assert not cache_in_tmp.exists()
d = U.leggi(usa_cache=True)
assert cache_in_tmp.exists(), "la lettura non e' stata salvata: il numero resta senza fonte"
assert json.loads(cache_in_tmp.read_text())["letto_a"] == d["letto_a"]
assert d["letto_a"].endswith("+00:00"), "l'ora della lettura dev'essere in UTC dichiarato"
assert d["opt_inv"] == {"BTC": 912, "ETH": 912}
assert len(chiamate) == 5, "una chiamata per famiglia: fut, book summary, opzioni USDC, BTC, ETH"
# e una seconda lettura con cache NON ripete le chiamate
prima = len(chiamate)
U.leggi(usa_cache=True)
assert len(chiamate) == prima
def test_fresh_ignora_la_cache(cache_in_tmp, monkeypatch):
"""`--fresh` deve rileggere davvero: e' l'unico modo di aggiornare una tabella che per
costruzione invecchia (i perpetual del 18/08/2026 non c'erano un mese prima)."""
cache_in_tmp.write_text(json.dumps(_lettura()))
chiamate = []
monkeypatch.setattr(U, "get", lambda path: chiamate.append(path) or [])
U.leggi(usa_cache=False)
assert chiamate, "con usa_cache=False la cache non va usata"
def test_il_flag_fresh_e_DOCUMENTATO_e_cablato():
"""P15: una regola operativa va provata contro il codice che dovrebbe eseguirla. Qui:
il flag esiste nel `main`, e' l'unico modo di bypassare la cache, ed e' scritto nell'uso
che lo script stampa (chi legge la tabella deve sapere che puo' essere vecchia)."""
src = inspect.getsource(U)
assert 'leggi(usa_cache="--fresh" not in sys.argv)' in src, "il flag non e' cablato"
assert "--fresh per rileggere" in src, (
"il flag non e' documentato nell'uso che lo script stampa: chi legge la tabella non "
"sa come aggiornarla, e la lettura in cache invecchia in silenzio")
def test_lo_script_NON_ha_credenziali_ne_ordini():
"""L'API interrogata e' quella PUBBLICA: nessun token, nessun endpoint privato, nessun
ordine. Se un giorno servisse il conto, e' una decisione (debito §5.11), non un refactor."""
src = inspect.getsource(U)
assert U.API == "https://www.deribit.com/api/v2/public/"
for vietato in ("private/", "buy", "sell", "CERBERO", "api_key", "client_id"):
assert vietato not in src, f"lo script pubblico contiene '{vietato}'"
# ===========================================================================================
# la lettura VERA in cache (se c'e'): il file su cui poggia la riga del 09/09
# ===========================================================================================
def test_la_cache_vera_se_c_e_regge_la_tabella_pubblicata():
"""Non e' un test di rete: legge il JSON che lo script ha gia' salvato. Se manca, si salta."""
if not U.CACHE.exists():
pytest.skip(f"nessuna lettura in cache ({U.CACHE}): il test non va in rete")
d = json.loads(U.CACHE.read_text())
rows = U.tabella(d)
s = U.sintesi(rows, d)
assert s["n_perp"] == len(rows) > 0
assert [r["vol_usd"] for r in rows] == sorted([r["vol_usd"] for r in rows], reverse=True)
assert {"BTC", "ETH"} <= {r["coin"] for r in rows}, "il venue non quota piu' BTC/ETH?"
assert set(s["xs01_listate"]) | set(s["xs01_mancanti"]) == set(U.XS_UNIVERSE)
assert U.verdetto(s).endswith(f"lettura del {d['letto_a']}")