"""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']}")