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
325 lines
15 KiB
Python
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']}")
|