710 lines
36 KiB
Python
710 lines
36 KiB
Python
"""ALT-OPT — la famiglia opzioni USDC-LINEARE di Deribit, mai guardata dal progetto (2026-08-22).
|
|
|
|
CONTESTO. Il filone VRP-QUOTE-VERE di questa stessa ondata ha trovato che `collect_chain.py`
|
|
interroga `{"currency": "BTC"|"ETH"}`, che su Deribit restituisce le sole opzioni INVERSE
|
|
(regolate in BTC/ETH). Il conto e' in USDC. Esiste accanto una famiglia USDC-LINEARE con lotti
|
|
molto piu' piccoli, e contando l'open interest sembra che su SOL/XRP/HYPE/AVAX sia molto piu'
|
|
popolata di BTC_USDC. Il progetto non l'ha mai misurata.
|
|
|
|
DOMANDA. Esiste su questa famiglia una struttura a RISCHIO DEFINITO (put credit spread, la stessa
|
|
geometria di VRP01: vendi ~-0.28, compri ~-0.10) eseguibile con qualche centinaio di dollari, con
|
|
spread e liquidita' che non se la mangino?
|
|
|
|
IPOTESI DICHIARATA PRIMA DI MISURARE: che il vincolo binding fosse la TAGLIA DEL LOTTO (e' cosi'
|
|
che il progetto ha sempre bocciato le opzioni: "BTC min 0.1 = $6.210/lotto -> FUORI a $3.000") e
|
|
che le famiglie alt, con lotti da poche decine di dollari, la superassero. Sbagliata due volte:
|
|
il lotto non e' il vincolo, e la classifica di liquidita' su cui poggia la domanda si ROVESCIA.
|
|
|
|
COSA MISURA, in ordine, fermandosi appena un vincolo la uccide:
|
|
0. l'UNITA' dei contratti — due test empirici, perche' leggerla male sposta ogni cifra in
|
|
dollari di un fattore 10-1000 (errore commesso nella prima stesura di questo file);
|
|
1. i fatti del venue (min_trade_amount, tick_size, contract_size, $ di UN lotto);
|
|
2. la liquidita' misurata in DUE modi che danno risposte opposte: open interest vs quote
|
|
effettivamente presenti. E' il risultato principale del file;
|
|
3. la struttura: delta REALIZZATI, credito eseguibile vs credito al mark, spread, fee, max-loss;
|
|
4. il collaterale e quante strutture entrano in $635;
|
|
5. quanto costerebbe raccogliere il dato (perche' il verdetto massimo qui e' LEAD).
|
|
|
|
COSA NON PUO' FARE, detto prima dei numeri: **non esiste un backtest possibile**. Il nostro
|
|
collettore raccoglie solo le inverse BTC/ETH e l'archivio ereditato pure: zero giorni di storia su
|
|
queste famiglie. Questo file misura UN ISTANTE.
|
|
|
|
VINCOLI DI PROGETTO CITATI, non aggirati:
|
|
(a) "niente short-vol da modello in deploy" (19/06). Qui i prezzi sono VERI, non modellati —
|
|
che e' un'altra cosa — ma la regola resta finche' non esiste un campione che contenga uno
|
|
STRESS. Un istante di calma non e' un campione.
|
|
(b) SOL e' escluso dall'universo DIREZIONALE per decisione dell'operatore del 22/08. Quella
|
|
decisione riguarda TP01/SKH01 congelati come terza gamba di TREND, e il suo stesso testo
|
|
dichiara che un meccanismo diverso la riapre; un put credit spread non e' direzionale in
|
|
quel senso. Ma il guardrail sul DATO vale identico: nessun file nuovo in `data/raw/`, e i
|
|
prezzi del sottostante qui vengono da `underlying_price` della catena, mai da `load_data`.
|
|
(Nota: qui SOL non viene proposto — viene BOCCIATO, e sulla misura, non sulla decisione.)
|
|
(c) il gate IV-rank>0.30 e' l'unico alpha misurato di VRP01 e su questi sottostanti NON e'
|
|
calcolabile: non abbiamo un giorno di storia di vol implicita. Senza quel gate la struttura
|
|
non e' VRP01, e' vendere vol a caso.
|
|
|
|
CONVENZIONE DI UNITA' (la trappola di questo file, verificata al §0): le opzioni INVERSE si
|
|
quotano in CRIPTO per contratto, le USDC-LINEARI in USDC per UNITA' DI SOTTOSTANTE; e
|
|
`min_trade_amount` e' in unita' di sottostante, NON in contratti — quindi non va moltiplicato per
|
|
`contract_size` (farlo gonfia XRP_USDC da $1.469 a $1.469.000 per lotto).
|
|
|
|
RETE: sole letture pubbliche, nessun ordine, pacing ~2 richieste/s, e il file SI RIFIUTA di
|
|
partire nella finestra :24-:29 (e' quando `cron_chain` usa lo stesso IP: il rate limit Deribit e'
|
|
per-IP e il progetto ha gia' avuto un guasto per questo, 29/07).
|
|
|
|
nice -n 19 timeout 900 uv run python scripts/research/r0822_alt_options.py
|
|
nice -n 19 timeout 900 uv run python scripts/research/r0822_alt_options.py --cache
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import time
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import requests
|
|
|
|
API = "https://www.deribit.com/api/v2/public"
|
|
TIMEOUT = 20
|
|
RPS = 2.0
|
|
CACHE = Path("/tmp/claude-1001/-opt-docker-PythagorasGoal/"
|
|
"b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad/altopt_cache.json")
|
|
|
|
SHORT_DELTA, LONG_DELTA = -0.28, -0.10 # la geometria di VRP01, non ri-scelta qui
|
|
DTE_LO, DTE_HI = 4.0, 45.0 # dal settimanale di VRP01 (4-10) a ~1.5 mesi
|
|
OI_MIN = 100.0 # la soglia che usa collect_chain.py, qui sotto esame
|
|
CAPITAL = 635.0 # il conto vero
|
|
RISK_BUDGET = 0.20 # quota di conto a rischio, per la capacita'
|
|
TAKER_CAP = 0.125 # cap fee opzioni Deribit: 12.5% del premio
|
|
|
|
LINEAR = ("ETH_USDC", "BTC_USDC", "SOL_USDC", "XRP_USDC", "HYPE_USDC", "AVAX_USDC")
|
|
INVERSE = ("BTC", "ETH") # termine di paragone: il progetto le conosce
|
|
FAMS = LINEAR + INVERSE
|
|
|
|
# ---------------------------------------------------------------- rete (parca, sola lettura)
|
|
|
|
|
|
class Budget:
|
|
def __init__(self, rps: float = RPS) -> None:
|
|
self.rps, self._next, self.calls, self.err, self.r429 = rps, 0.0, 0, 0, 0
|
|
|
|
def wait(self) -> None:
|
|
now = time.monotonic()
|
|
if now < self._next:
|
|
time.sleep(self._next - now)
|
|
self._next = max(now, self._next) + 1.0 / self.rps
|
|
|
|
|
|
def guardia_finestra() -> None:
|
|
m = datetime.now(UTC).minute
|
|
if 24 <= m <= 29:
|
|
raise SystemExit(f"minuto {m}: finestra di cron_chain (:25-:29). Rilancia dopo il :30.")
|
|
|
|
|
|
def get(path: str, params: dict, b: Budget, tries: int = 3):
|
|
for k in range(tries):
|
|
b.wait()
|
|
b.calls += 1
|
|
try:
|
|
r = requests.get(f"{API}/{path}", params=params, timeout=TIMEOUT)
|
|
except Exception:
|
|
b.err += 1
|
|
time.sleep(1.5 * (k + 1))
|
|
continue
|
|
if r.status_code == 429:
|
|
b.r429 += 1
|
|
time.sleep(2.0 * (k + 1))
|
|
continue
|
|
if r.status_code != 200:
|
|
b.err += 1
|
|
time.sleep(1.0 * (k + 1))
|
|
continue
|
|
try:
|
|
return r.json()["result"]
|
|
except Exception:
|
|
b.err += 1
|
|
return None
|
|
return None
|
|
|
|
|
|
def fetch_all(b: Budget) -> dict:
|
|
"""6 chiamate: instruments + book_summary per USDC, BTC, ETH. Niente per-strumento."""
|
|
out = {}
|
|
for cur in ("USDC", "BTC", "ETH"):
|
|
out[f"inst_{cur}"] = get("get_instruments",
|
|
{"currency": cur, "kind": "option", "expired": "false"}, b) or []
|
|
out[f"summ_{cur}"] = get("get_book_summary_by_currency",
|
|
{"currency": cur, "kind": "option"}, b) or []
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------- Black-Scholes (r=0, come il sleeve)
|
|
|
|
|
|
def put_delta(S: float, K: float, T: float, sig: float) -> float:
|
|
if not (S > 0 and K > 0 and T > 0 and sig > 0):
|
|
return float("nan")
|
|
d1 = (math.log(S / K) + 0.5 * sig * sig * T) / (sig * math.sqrt(T))
|
|
return 0.5 * (1.0 + math.erf(d1 / math.sqrt(2.0))) - 1.0
|
|
|
|
|
|
# ---------------------------------------------------------------- unita'
|
|
|
|
|
|
def is_inv(fam: str) -> bool:
|
|
return fam in INVERSE
|
|
|
|
|
|
def usd(price: float | None, r: dict) -> float:
|
|
"""Prezzo di UN LOTTO MINIMO, in dollari.
|
|
|
|
Lineare: il prezzo e' gia' in USDC per unita' di sottostante -> x min_trade_amount.
|
|
Inverse: il prezzo e' in CRIPTO per contratto -> x min_trade_amount x sottostante.
|
|
`contract_size` NON entra: `min_trade_amount` e' gia' in unita' di sottostante (§0).
|
|
"""
|
|
if price is None:
|
|
return float("nan")
|
|
return price * r["minamt"] * (r["S"] if is_inv(r["fam"]) else 1.0)
|
|
|
|
|
|
def loss_usd(Ks: float, Kl: float, r: dict) -> float:
|
|
"""Perdita massima strutturale, per un lotto minimo.
|
|
|
|
Lineare: esatta, (Ks-Kl) x quantita'. Inverse: il payoff e' in cripto (max(0,K-S)/S), il caso
|
|
peggiore e' a S=Kl e vale (Ks-Kl)/Kl cripto = (Ks-Kl) dollari A QUEL PREZZO -> stessa formula,
|
|
ma APPROSSIMATA (il valore in dollari dipende da dove finisce S). Dichiarata, non nascosta.
|
|
"""
|
|
return (Ks - Kl) * r["minamt"]
|
|
|
|
|
|
# ---------------------------------------------------------------- margine (FONTE SECONDARIA)
|
|
|
|
|
|
def im_short_put(S: float, K: float, mark_lot: float, r: dict) -> float:
|
|
"""Initial margin di una short put, in USDC, per UN LOTTO, formula PUBBLICATA da Deribit:
|
|
IM = max(max(0.15 - OTM/S, 0.10), 0.075) x S + mark [per unita' di sottostante]
|
|
|
|
⚠️ FONTE SECONDARIA, dichiarata: l'API PUBBLICA non espone il margine. E il numero che decide
|
|
davvero — se Deribit NETTA le due gambe di uno spread in margine standard — non e' leggibile
|
|
da qui: si conferma con `private/get_margins`, che richiede una chiave (lettura, ma non
|
|
pubblica) e quindi NON e' stato usato.
|
|
"""
|
|
if not (S > 0 and K > 0):
|
|
return float("nan")
|
|
otm = max(0.0, S - K) / S # una put e' OTM quando K < S
|
|
return max(max(0.15 - otm, 0.10), 0.075) * S * r["minamt"] + max(0.0, mark_lot)
|
|
|
|
|
|
# ---------------------------------------------------------------- vista
|
|
|
|
|
|
def build(raw: dict) -> list[dict]:
|
|
inst, summ = {}, {}
|
|
for cur in ("USDC", "BTC", "ETH"):
|
|
for i in raw.get(f"inst_{cur}") or []:
|
|
inst[i["instrument_name"]] = i
|
|
for s in raw.get(f"summ_{cur}") or []:
|
|
summ[s["instrument_name"]] = s
|
|
rows, now_ms = [], datetime.now(UTC).timestamp() * 1000.0
|
|
for name, i in inst.items():
|
|
s = summ.get(name)
|
|
if s is None:
|
|
continue
|
|
fam = name.split("-")[0]
|
|
dte = (float(i.get("expiration_timestamp") or 0) - now_ms) / 86400_000.0
|
|
bid, ask = s.get("bid_price"), s.get("ask_price")
|
|
bid = float(bid) if bid else None # Deribit manda 0/None per "nessun lato"
|
|
ask = float(ask) if ask else None
|
|
iv, S = s.get("mark_iv"), s.get("underlying_price")
|
|
K, T = float(i.get("strike") or 0), max(dte, 0.0) / 365.25
|
|
typ = "P" if i.get("option_type") == "put" else "C"
|
|
rows.append(dict(
|
|
name=name, fam=fam, typ=typ, K=K, dte=dte, S=float(S) if S else float("nan"),
|
|
iv=float(iv) if iv else float("nan"),
|
|
delta=(put_delta(float(S), K, T, float(iv) / 100.0)
|
|
if (S and iv and K and T > 0 and typ == "P") else float("nan")),
|
|
bid=bid, ask=ask, mid=((bid + ask) / 2 if (bid and ask) else None),
|
|
mark=float(s.get("mark_price") or 0.0),
|
|
oi=float(s.get("open_interest") or 0.0), vol=float(s.get("volume") or 0.0),
|
|
vol_usd=float(s.get("volume_usd") or 0.0),
|
|
tick=float(i.get("tick_size") or 0.0), minamt=float(i.get("min_trade_amount") or 0.0),
|
|
csize=float(i.get("contract_size") or 0.0),
|
|
blockmin=float(i.get("block_trade_min_trade_amount") or 0.0),
|
|
taker=float(i.get("taker_commission") or 0.0003), settle=i.get("settlement_currency"),
|
|
))
|
|
return rows
|
|
|
|
|
|
def rel_spread(r: dict) -> float:
|
|
if r["bid"] is None or r["ask"] is None or not r["mid"]:
|
|
return float("nan")
|
|
return (r["ask"] - r["bid"]) / r["mid"]
|
|
|
|
|
|
# ---------------------------------------------------------------- §0 le unita'
|
|
|
|
|
|
def unita(rows: list[dict], b: Budget, rete: bool) -> None:
|
|
print("\n" + "=" * 104)
|
|
print("0. L'UNITA' DEI CONTRATTI — due test empirici, prima di qualunque cifra in dollari")
|
|
print("=" * 104)
|
|
print(" Test 1 (dai dati gia' scaricati): `volume_usd / (volume x mark_price)`.")
|
|
print(" = 1 se `volume` e' in UNITA' DI SOTTOSTANTE; = contract_size se e' in CONTRATTI;")
|
|
print(" = prezzo del sottostante se il mark e' in cripto (inverse).")
|
|
print(f"\n {'famiglia':<10} {'n oss':>6} {'vol_usd/(vol*mark)':>20} {'c_size':>8} "
|
|
f"{'sottost.':>10} {'lettura':>26}")
|
|
for f in FAMS:
|
|
rr = [r for r in rows if r["fam"] == f and r["vol"] > 0 and r["mark"] > 0]
|
|
if not rr:
|
|
continue
|
|
q = float(np.median([r["vol_usd"] / (r["vol"] * r["mark"]) for r in rr]))
|
|
cs, S = rr[0]["csize"], float(np.nanmedian([r["S"] for r in rr]))
|
|
if abs(q - 1.0) < 0.35:
|
|
lettura = "unita' di sottostante"
|
|
elif abs(q - cs) / max(cs, 1) < 0.35:
|
|
lettura = "CONTRATTI"
|
|
elif abs(q - S) / max(S, 1) < 0.35:
|
|
lettura = "cripto/contratto (inverse)"
|
|
else:
|
|
lettura = "?? non concludente"
|
|
print(f" {f:<10} {len(rr):>6} {q:>20.4f} {cs:>8g} {S:>10.2f} {lettura:>26}")
|
|
|
|
if not rete:
|
|
print("\n Test 2 (size minima nel book): non girato (--cache non usa la rete).")
|
|
return
|
|
print("\n Test 2 (rete): la size MINIMA presente nel book. Se compare esattamente")
|
|
print(" `min_trade_amount`, allora e' quello il lotto minimo IN UNITA' DI SOTTOSTANTE.")
|
|
print(f"\n {'famiglia':<10} {'min_amt':>9} {'c_size':>8} {'min size vista':>15} "
|
|
f"{'libri':>6} {'verdetto':>12}")
|
|
for f in LINEAR:
|
|
cand = sorted([r for r in rows if r["fam"] == f and (r["bid"] or r["ask"])],
|
|
key=lambda r: -r["oi"])[:4]
|
|
sizes, nb = [], 0
|
|
for r in cand:
|
|
ob = get("get_order_book", {"instrument_name": r["name"], "depth": 10}, b)
|
|
if not ob:
|
|
continue
|
|
nb += 1
|
|
for side in ("bids", "asks"):
|
|
sizes += [float(l[1]) for l in (ob.get(side) or [])]
|
|
if not sizes:
|
|
continue
|
|
mn, ma, cs = min(sizes), cand[0]["minamt"], cand[0]["csize"]
|
|
v = "confermato" if abs(mn - ma) < 1e-9 else ("CONTRATTI" if abs(mn - ma * cs) < 1e-9
|
|
else "non concl.")
|
|
print(f" {f:<10} {ma:>9g} {cs:>8g} {mn:>15g} {nb:>6} {v:>12}")
|
|
print("\n ⚠️ Errore commesso e corretto in sessione: la prima stesura moltiplicava")
|
|
print(" min_trade_amount x contract_size, e dava XRP_USDC a $1.468.900 di nozionale per")
|
|
print(" lotto. Un numero assurdo e' un test: nessun venue retail quota un minimo da $1,5M.")
|
|
|
|
|
|
# ---------------------------------------------------------------- §1 fatti del venue
|
|
|
|
|
|
def famiglie(rows: list[dict]) -> dict:
|
|
print("\n" + "=" * 104)
|
|
print("1. I FATTI DEL VENUE — public/get_instruments + get_book_summary_by_currency")
|
|
print("=" * 104)
|
|
print(f"{'famiglia':<10} {'strum':>6} {'min_amt':>9} {'c_size':>7} {'tick':>9} "
|
|
f"{'sottost.$':>11} {'$/lotto':>9} {'taker':>7} {'settle':>7}")
|
|
tab = {}
|
|
for f in FAMS:
|
|
sub = [r for r in rows if r["fam"] == f]
|
|
if not sub:
|
|
continue
|
|
S = float(np.nanmedian([r["S"] for r in sub]))
|
|
a = sub[0]
|
|
tab[f] = dict(n=len(sub), minamt=a["minamt"], csize=a["csize"], tick=a["tick"], S=S,
|
|
lot=a["minamt"] * S, taker=a["taker"],
|
|
nliq=sum(1 for r in sub if r["oi"] >= OI_MIN))
|
|
print(f"{f:<10} {len(sub):>6} {a['minamt']:>9.4g} {a['csize']:>7.4g} {a['tick']:>9.5g} "
|
|
f"{S:>11.2f} {tab[f]['lot']:>9.2f} {a['taker']:>7.4g} {str(a['settle']):>7}")
|
|
print("\n `$/lotto` = min_trade_amount x sottostante = il NOZIONALE di un lotto (NON il premio,")
|
|
print(" che e' molto minore). E' la granularita' minima imposta dal venue.")
|
|
print(" `taker` per le opzioni Deribit e' una frazione del SOTTOSTANTE con cap al 12.5% del")
|
|
print(" premio: sull'ala molto OTM morde il CAP, non la percentuale.")
|
|
print(" 📌 Il lotto minimo NON e' il vincolo: da $242 (ETH_USDC) a $1.469 (XRP_USDC), contro")
|
|
print(" i $7.738 di BTC inverse su cui il progetto aveva costruito il muro delle opzioni.")
|
|
return tab
|
|
|
|
|
|
# ---------------------------------------------------------------- §2 liquidita', due modi
|
|
|
|
|
|
def liquidita(rows: list[dict], tab: dict) -> None:
|
|
print("\n" + "=" * 104)
|
|
print("2. LA LIQUIDITA', MISURATA IN DUE MODI CHE DANNO RISPOSTE OPPOSTE")
|
|
print("=" * 104)
|
|
print(" A) open interest >= 100 = quante POSIZIONI sono aperte (il filtro che usa")
|
|
print(" `collect_chain.py`, ereditato da bite, e la misura su cui poggia la domanda).")
|
|
print(" B) quote a DUE LATI presenti adesso = quanti strumenti sono NEGOZIABILI a un prezzo")
|
|
print(" noto. Sui soli put nella finestra 4-45 giorni, cioe' dove vive la struttura.")
|
|
print(f"\n{'famiglia':<10} {'A: OI>=100':>11} {'rango A':>8} {'put 4-45g':>10} "
|
|
f"{'B: 2 lati':>10} {'% quotati':>10} {'rango B':>8}")
|
|
stat = {}
|
|
for f in FAMS:
|
|
puts = [r for r in rows if r["fam"] == f and r["typ"] == "P" and DTE_LO <= r["dte"] <= DTE_HI]
|
|
two = [r for r in puts if r["bid"] is not None and r["ask"] is not None]
|
|
stat[f] = dict(oi=tab[f]["nliq"], puts=len(puts), two=len(two),
|
|
pct=(len(two) / len(puts) if puts else float("nan")))
|
|
rA = {f: i + 1 for i, f in enumerate(sorted(stat, key=lambda x: -stat[x]["oi"]))}
|
|
rB = {f: i + 1 for i, f in enumerate(sorted(stat, key=lambda x: -stat[x]["pct"]))}
|
|
for f in sorted(FAMS, key=lambda x: -stat[x]["oi"]):
|
|
s = stat[f]
|
|
print(f"{f:<10} {s['oi']:>11} {rA[f]:>8} {s['puts']:>10} {s['two']:>10} "
|
|
f"{s['pct']:>9.0%} {rB[f]:>8}")
|
|
# la correlazione di rango si CALCOLA, non si racconta: sulle sole famiglie lineari,
|
|
# cioe' quelle su cui la domanda del filone e' stata posta.
|
|
a = np.array([rA[f] for f in LINEAR], float)
|
|
c = np.array([rB[f] for f in LINEAR], float)
|
|
rho = float(np.corrcoef(a, c)[0, 1])
|
|
print(f"\n 📌 LA CLASSIFICA SI ROVESCIA. Correlazione di rango fra i due criteri sulle sei")
|
|
print(f" famiglie lineari: {rho:+.2f}. SOL_USDC e' 1ª per open interest fra le lineari e")
|
|
print(" ULTIMA di tutte per quote presenti; BTC_USDC, dichiarato 'praticamente morto' (5")
|
|
print(" strumenti con OI>=100), ha quasi tutti i put quotati a due lati. L'open interest")
|
|
print(" misura posizioni ACCUMULATE,")
|
|
print(" non la possibilita' di negoziare ADESSO — e sull'universo lineare le due cose non")
|
|
print(" hanno quasi nulla a che vedere.")
|
|
print(" ⚠️ Ricaduta sul codice di produzione: `collect_chain.py` prefiltra a OI>=100. Sulle")
|
|
print(" inverse non fa danno (sono quotate comunque), ma NON e' un filtro di liquidita' e il")
|
|
print(" suo nome lo lascia credere. 4ª occorrenza di 'una riga presente non e' un dato")
|
|
print(" presente' — qui in versione: 'una posizione aperta non e' un prezzo negoziabile'.")
|
|
|
|
print("\n" + "-" * 104)
|
|
print("2b. E ALLORA DOV'E' LA LIQUIDITA' CHE SI VEDE NEL VOLUME? Nel mercato a BLOCCHI,")
|
|
print(" che ha un minimo d'ordine suo — e non e' raggiungibile con $635.")
|
|
print("-" * 104)
|
|
print(f"{'famiglia':<10} {'con volume 24h':>15} {'volume 24h $':>14} {'blocco min':>11} "
|
|
f"{'blocco min $':>13} {'x il conto':>11}")
|
|
for f in FAMS:
|
|
sub = [r for r in rows if r["fam"] == f]
|
|
if not sub:
|
|
continue
|
|
nv = sum(1 for r in sub if r["vol"] > 0)
|
|
vusd = sum(r["vol_usd"] for r in sub)
|
|
bm = sub[0]["blockmin"]
|
|
bm_usd = bm * tab[f]["S"] * (1.0 if not is_inv(f) else 1.0)
|
|
print(f"{f:<10} {nv:>15} {vusd:>14,.0f} {bm:>11g} {bm_usd:>13,.0f} "
|
|
f"{bm_usd/CAPITAL:>10.0f}x")
|
|
print("\n 📌 SOL_USDC ha molti piu' strumenti con volume nelle ultime 24h che quotati a due")
|
|
print(" lati: quelle opzioni si negoziano, ma via blocchi/RFQ. Il minimo di blocco e' un")
|
|
print(" fatto del venue, non un'opinione, e a $635 e' fuori portata di due ordini di")
|
|
print(" grandezza. **Non si conclude che il mercato alt non esista: si conclude che la parte")
|
|
print(" a cui questo conto ha accesso — il book continuo — e' quella sottile.**")
|
|
|
|
|
|
# ---------------------------------------------------------------- §3 la struttura
|
|
|
|
|
|
def scegli(rows: list[dict], fam: str, eseguibile: bool) -> list[dict]:
|
|
"""Per ogni scadenza: la put piu' vicina a -0.28 (corta) e a -0.10 (lunga).
|
|
|
|
`eseguibile=False` -> fra TUTTI gli strike (la struttura che si vorrebbe).
|
|
`eseguibile=True` -> solo fra gli strike che hanno il LATO che serve: per APRIRE bisogna
|
|
vendere la corta al BID e comprare l'ala all'ASK. E' una restrizione
|
|
asimmetrica, ed e' quella giusta: un book a un lato solo non e' un
|
|
book vuoto, e' un book che serve solo una direzione.
|
|
"""
|
|
puts = [r for r in rows if r["fam"] == fam and r["typ"] == "P"
|
|
and DTE_LO <= r["dte"] <= DTE_HI and np.isfinite(r["delta"])]
|
|
out = []
|
|
for e in sorted({round(r["dte"], 4) for r in puts}):
|
|
g = [r for r in puts if round(r["dte"], 4) == e]
|
|
gs = [r for r in g if r["bid"] is not None] if eseguibile else g
|
|
gl = [r for r in g if r["ask"] is not None] if eseguibile else g
|
|
if not gs or not gl:
|
|
continue
|
|
s = min(gs, key=lambda r: abs(r["delta"] - SHORT_DELTA))
|
|
lo = min(gl, key=lambda r: abs(r["delta"] - LONG_DELTA))
|
|
if s["K"] > lo["K"]:
|
|
out.append({"dte": e, "short": s, "long": lo, "n": len(g)})
|
|
return out
|
|
|
|
|
|
def fee_lotto(r: dict) -> float:
|
|
"""Fee taker di UNA gamba, un lotto: min(0.03% del nozionale, 12.5% del premio)."""
|
|
return min(r["taker"] * r["S"] * r["minamt"], TAKER_CAP * usd(r["mark"], r))
|
|
|
|
|
|
def struttura(rows: list[dict]) -> dict:
|
|
print("\n" + "=" * 104)
|
|
print(f"3. LA STRUTTURA — put credit spread {SHORT_DELTA}/{LONG_DELTA}, finestra "
|
|
f"{DTE_LO:.0f}-{DTE_HI:.0f} giorni, per UN LOTTO MINIMO")
|
|
print("=" * 104)
|
|
print(" `f_venue` = (bid_corta - ask_ala) / (mark_corta - mark_ala) = quanta parte del")
|
|
print(" credito che il venue stesso considera equo resta dopo aver attraversato lo spread.")
|
|
print(" E' il gemello del `f` misurato dal progetto il 30/07 (0,73 = errore del MODELLO):")
|
|
print(" qui il modello non c'entra, il riferimento e' il mark del venue.")
|
|
print(f"\n{'famiglia':<10} {'scad':>4} {'d corta':>8} {'d ala':>7} {'spr% corta':>11} "
|
|
f"{'spr% ala':>9} {'cred mark$':>11} {'cred eseg$':>11} {'f_venue':>8} "
|
|
f"{'fee/cred':>9} {'maxloss$':>9}")
|
|
res = {}
|
|
for f in FAMS:
|
|
gg = scegli(rows, f, eseguibile=True)
|
|
if not gg:
|
|
print(f"{f:<10} {'0':>4} nessuna scadenza con corta-bid E ala-ask presenti")
|
|
continue
|
|
acc = {k: [] for k in ("ds", "dl", "ss", "sl", "cm", "ce", "fee", "ml")}
|
|
for g in gg:
|
|
s, lo = g["short"], g["long"]
|
|
cm = usd(s["mark"], s) - usd(lo["mark"], lo)
|
|
ce = usd(s["bid"], s) - usd(lo["ask"], lo)
|
|
if cm <= 0:
|
|
continue
|
|
acc["ds"].append(s["delta"]); acc["dl"].append(lo["delta"])
|
|
acc["ss"].append(rel_spread(s)); acc["sl"].append(rel_spread(lo))
|
|
acc["cm"].append(cm); acc["ce"].append(ce)
|
|
acc["fee"].append(2.0 * (fee_lotto(s) + fee_lotto(lo)) / cm)
|
|
acc["ml"].append(loss_usd(s["K"], lo["K"], s) - ce)
|
|
if not acc["cm"]:
|
|
print(f"{f:<10} {len(gg):>4} nessuna struttura con credito positivo al mark")
|
|
continue
|
|
m = {k: float(np.nanmedian(v)) if v else float("nan") for k, v in acc.items()}
|
|
m["fv"] = float(np.median([e / c for e, c in zip(acc["ce"], acc["cm"])]))
|
|
m["n"] = len(acc["cm"])
|
|
res[f] = m
|
|
print(f"{f:<10} {m['n']:>4} {m['ds']:>8.3f} {m['dl']:>7.3f} {m['ss']*100:>10.1f}% "
|
|
f"{m['sl']*100:>8.1f}% {m['cm']:>11.2f} {m['ce']:>11.2f} {m['fv']:>8.3f} "
|
|
f"{m['fee']*100:>8.1f}% {m['ml']:>9.2f}")
|
|
print("\n ⚠️ `spr%` puo' essere NaN quando la gamba ha un lato solo: e' negoziabile per")
|
|
print(" aprire (il lato che serve c'e') ma non ha un mid, quindi non ha uno spread definito.")
|
|
print(" `fee/cred` = 2 gambe x (apertura + chiusura) col cap 12.5%. VRP01 tiene FINO A")
|
|
print(" SCADENZA (nessuna gestione infra-settimana, il profit-take e' REFUTED il 30/07),")
|
|
print(" quindi il costo realistico e' ~la META' di questa colonna + la delivery fee.")
|
|
|
|
print(f"\n --- controllo: gli STESSI strike scelti fra TUTTI gli strike (non solo quotati) ---")
|
|
print(f" {'famiglia':<10} {'ideale d corta':>15} {'eseg. d corta':>14} {'ideale d ala':>13} "
|
|
f"{'eseg. d ala':>12} {'scad ideali':>12} {'scad eseg.':>11}")
|
|
for f in FAMS:
|
|
gi, ge = scegli(rows, f, False), scegli(rows, f, True)
|
|
if not gi:
|
|
continue
|
|
di = float(np.median([g["short"]["delta"] for g in gi]))
|
|
li = float(np.median([g["long"]["delta"] for g in gi]))
|
|
de = float(np.median([g["short"]["delta"] for g in ge])) if ge else float("nan")
|
|
le = float(np.median([g["long"]["delta"] for g in ge])) if ge else float("nan")
|
|
print(f" {f:<10} {di:>15.3f} {de:>14.3f} {li:>13.3f} {le:>12.3f} "
|
|
f"{len(gi):>12} {len(ge):>11}")
|
|
print(" Se la colonna `eseg.` si allontana dal target -0.28/-0.10, la struttura che si")
|
|
print(" comprerebbe NON e' quella richiesta: la griglia degli strike quotati la deforma.")
|
|
|
|
print("\n --- CONTROLLO POSITIVO: questo snapshot e' di SABATO. E' atipico? ---")
|
|
print(" Non posso aspettare una settimana, ma posso puntare lo stesso strumento su una")
|
|
print(" grandezza che il progetto ha GIA' misurato su 19 settimane di giorni feriali:")
|
|
print(" attraversare lo spread sul canonico BTC/ETH inverse costa ~10% del credito (30/07).")
|
|
for f in INVERSE:
|
|
if f in res:
|
|
print(f" {f:<4} inverse: costo di attraversamento oggi = "
|
|
f"{(1-res[f]['fv'])*100:5.1f}% del credito (atteso ~10%)")
|
|
print(" Se coincide, il sabato non sta degradando i book che il progetto conosce, e la")
|
|
print(" lettura sulle famiglie nuove non e' un artefatto del giorno. **E' un controllo")
|
|
print(" debole** (una grandezza sola, un istante), non una prova di tipicita'.")
|
|
robustezza_dte(rows)
|
|
return res
|
|
|
|
|
|
def robustezza_dte(rows: list[dict]) -> None:
|
|
"""L'UNICO grado di liberta' di questo file, esposto invece che nascosto.
|
|
|
|
VRP01 e' settimanale (tenor 7g, finestra 4-10). Qui la finestra e' stata allargata a 4-45
|
|
per dare piu' scadenze alle famiglie alt, che ne hanno poche. Allargare una finestra e'
|
|
una scelta, e una scelta va misurata: se la conclusione dipendesse da essa, sarebbe la
|
|
conclusione a essere stata scelta.
|
|
"""
|
|
global DTE_HI
|
|
print("\n --- ROBUSTEZZA: la finestra DTE e' il mio unico grado di liberta' ---")
|
|
print(f" {'famiglia':<10} {'finestra':>9} {'scad':>5} {'f_venue':>8} {'spr% corta':>11} "
|
|
f"{'cred eseg$':>11} {'maxloss$':>9}")
|
|
orig = DTE_HI
|
|
try:
|
|
for hi in (10.0, 45.0):
|
|
DTE_HI = hi
|
|
for f in FAMS:
|
|
gg = scegli(rows, f, eseguibile=True)
|
|
fv, ss, ce, ml = [], [], [], []
|
|
for g in gg:
|
|
s, lo = g["short"], g["long"]
|
|
cm = usd(s["mark"], s) - usd(lo["mark"], lo)
|
|
c = usd(s["bid"], s) - usd(lo["ask"], lo)
|
|
if cm <= 0:
|
|
continue
|
|
fv.append(c / cm); ss.append(rel_spread(s)); ce.append(c)
|
|
ml.append(loss_usd(s["K"], lo["K"], s) - c)
|
|
w = f"{DTE_LO:.0f}-{hi:.0f}g"
|
|
if not fv:
|
|
print(f" {f:<10} {w:>9} {len(gg):>5} {'n/d':>8}")
|
|
continue
|
|
print(f" {f:<10} {w:>9} {len(fv):>5} {np.median(fv):>8.3f} "
|
|
f"{np.nanmedian(ss)*100:>10.1f}% {np.median(ce):>11.2f} "
|
|
f"{np.median(ml):>9.2f}")
|
|
print()
|
|
finally:
|
|
DTE_HI = orig
|
|
print(" 📌 Da leggere sull'ORDINE, non sulla terza cifra: le quote si muovono fra un giro e")
|
|
print(" l'altro (su tre snapshot a pochi minuti ETH_USDC ha dato 0.947-0.983, le inverse")
|
|
print(" 0.847-0.942, le alt 0.79-0.87, SOL sempre negativo). Cio' che NON si muove e' la")
|
|
print(" graduatoria, e alla finestra VERA di VRP01 (4-10g) e' piu' netta, non meno.")
|
|
|
|
|
|
def profondita(rows: list[dict], fams: list[str], b: Budget) -> None:
|
|
print("\n" + "=" * 104)
|
|
print("3b. PROFONDITA' IN CIMA AL BOOK (public/get_order_book depth=5) sulle gambe scelte")
|
|
print("=" * 104)
|
|
print(f"{'famiglia':<10} {'gamba':<6} {'n':>3} {'lotti al best':>14} {'$ premio al best':>17} "
|
|
f"{'prezzo/tick':>12} {'max|d venue-BS|':>17}")
|
|
for f in fams:
|
|
gg = scegli(rows, f, eseguibile=True)[:4] # max 4 scadenze: budget di rete
|
|
for lato, key, side in (("corta", "short", "bids"), ("ala", "long", "asks")):
|
|
lots, prem, tk, dd = [], [], [], []
|
|
for g in gg:
|
|
r = g[key]
|
|
ob = get("get_order_book", {"instrument_name": r["name"], "depth": 5}, b)
|
|
if not ob or not (ob.get(side) or []):
|
|
continue
|
|
px, sz = float(ob[side][0][0]), float(ob[side][0][1])
|
|
lots.append(sz / r["minamt"] if r["minamt"] else float("nan"))
|
|
prem.append(usd(px, r) * (sz / max(r["minamt"], 1e-12)))
|
|
if r["tick"]:
|
|
tk.append(px / r["tick"])
|
|
gv = (ob.get("greeks") or {}).get("delta")
|
|
if gv is not None and np.isfinite(r["delta"]):
|
|
dd.append(abs(float(gv) - r["delta"]))
|
|
if not lots:
|
|
print(f"{f:<10} {lato:<6} {'0':>3} book vuoto sul lato che serve")
|
|
continue
|
|
print(f"{f:<10} {lato:<6} {len(lots):>3} {np.median(lots):>14.1f} "
|
|
f"{np.median(prem):>17.2f} {(np.median(tk) if tk else float('nan')):>12.1f} "
|
|
f"{(max(dd) if dd else float('nan')):>17.4f}")
|
|
print("\n `lotti al best` = size in cima al book / lotto minimo: quante strutture minime")
|
|
print(" entrano senza muovere il prezzo. `prezzo/tick` e' il controllo d'artefatto del")
|
|
print(" 30/07: un prezzo a 1-2 tick e' una griglia, non un prezzo.")
|
|
print(" `max|d venue-BS|` valida il MIO delta contro le greche del venue: se non e' piccolo,")
|
|
print(" tutta la selezione degli strike qui sopra e' sbagliata.")
|
|
|
|
|
|
# ---------------------------------------------------------------- §4 collaterale e capacita'
|
|
|
|
|
|
def collaterale(rows: list[dict], res: dict) -> None:
|
|
print("\n" + "=" * 104)
|
|
print(f"4. COLLATERALE E CAPACITA' — conto reale ${CAPITAL:.0f}")
|
|
print("=" * 104)
|
|
print(f"{'famiglia':<10} {'IM corta+ala$':>14} {'max-loss$':>10} {'n @IM':>7} "
|
|
f"{'n @maxloss':>11} {'n a budget 20%':>15} {'credito lordo$':>15}")
|
|
for f, m in res.items():
|
|
gg = scegli(rows, f, eseguibile=True)
|
|
ims = []
|
|
for g in gg:
|
|
s, lo = g["short"], g["long"]
|
|
if lo["ask"] is None:
|
|
continue
|
|
ims.append(im_short_put(s["S"], s["K"], usd(s["mark"], s), s) + usd(lo["ask"], lo))
|
|
if not ims:
|
|
continue
|
|
im, ml = float(np.median(ims)), m["ml"]
|
|
n_budget = math.floor(RISK_BUDGET * CAPITAL / ml) if ml > 0 else 0
|
|
print(f"{f:<10} {im:>14.2f} {ml:>10.2f} {CAPITAL/im:>7.1f} {CAPITAL/ml:>11.1f} "
|
|
f"{n_budget:>15} {n_budget*m['ce']:>15.2f}")
|
|
print("\n `IM corta+ala` = margine iniziale della sola put venduta (formula PUBBLICATA dal")
|
|
print(" venue, FONTE SECONDARIA) + il premio dell'ala, che si paga in contanti = il")
|
|
print(" fabbisogno se il venue NON netta le gambe. `max-loss` = il fabbisogno se LE NETTA.")
|
|
print(" ⚠️ Quale dei due valga NON e' leggibile dall'API pubblica: si chiude con")
|
|
print(" `private/get_margins` (lettura autenticata, fuori dal perimetro di questo file).")
|
|
print(" Ma la conclusione NON dipende da quale sia: il collaterale non e' il vincolo in")
|
|
print(" nessuna delle due letture.")
|
|
print(f" `n a budget 20%` = strutture che stanno in {RISK_BUDGET:.0%} del conto a max-loss;")
|
|
print(" `credito lordo` = il loro credito ESEGUIBILE sommato, per un ciclo. E' un incasso")
|
|
print(" LORDO PRIMA DI QUALUNQUE PERDITA, non un rendimento atteso: la struttura perde fino")
|
|
print(" a ~7x il credito quando va male (misura del progetto sul canonico BTC/ETH).")
|
|
|
|
|
|
def costo_raccolta(rows: list[dict], tab: dict) -> None:
|
|
print("\n" + "=" * 104)
|
|
print("5. COSTO DI RACCOLTA — cosa costerebbe aggiungere una famiglia al giro esistente")
|
|
print("=" * 104)
|
|
print(" `collect_chain.py` oggi: ~650 chiamate in ~160s a 4 rps (BTC+ETH inverse), al :25.")
|
|
print(f"{'famiglia':<10} {'a OI>=100':>10} {'a 2-lati':>9} {'+s a 4rps (2-lati)':>19} "
|
|
f"{'+% sul giro':>12}")
|
|
for f in LINEAR:
|
|
sub = [r for r in rows if r["fam"] == f]
|
|
two = sum(1 for r in sub if r["bid"] is not None and r["ask"] is not None)
|
|
print(f"{f:<10} {tab[f]['nliq']:>10} {two:>9} {(two+2)/4.0:>19.1f} "
|
|
f"{(two+2)/650*100:>11.0f}%")
|
|
print("\n ⚠️ Il vincolo non e' la CPU ma il rate limit Deribit PER-IP, gia' costato un guasto")
|
|
print(" il 29/07 (fallback silenzioso del feed 5m di SKH01). Ogni famiglia aggiunta allunga")
|
|
print(" la finestra in cui il giro occupa l'IP.")
|
|
|
|
|
|
# ---------------------------------------------------------------- main
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--cache", action="store_true", help="riusa lo snapshot su disco (niente rete)")
|
|
a = ap.parse_args()
|
|
|
|
print("=" * 104)
|
|
print("ALT-OPT — opzioni USDC-LINEARI su Deribit: c'e' una struttura eseguibile a $635?")
|
|
print("=" * 104)
|
|
print("Sola lettura pubblica, nessun ordine. UNO SNAPSHOT: qui non esiste un backtest.")
|
|
|
|
b, rete = Budget(), not a.cache
|
|
if a.cache and CACHE.exists():
|
|
raw = json.loads(CACHE.read_text())
|
|
print(f"\n[cache] {CACHE}")
|
|
else:
|
|
guardia_finestra()
|
|
t0 = time.time()
|
|
raw = fetch_all(b)
|
|
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
|
CACHE.write_text(json.dumps(raw))
|
|
print(f"\n[rete] {b.calls} chiamate, {b.err} errori, {b.r429} 429, {time.time()-t0:.0f}s")
|
|
|
|
rows = build(raw)
|
|
print(f"[dato] {len(rows)} strumenti con specifiche E quote — snapshot "
|
|
f"{datetime.now(UTC):%Y-%m-%d %H:%M} UTC")
|
|
if not rows:
|
|
print("NESSUN DATO: il venue non ha risposto. Verdetto: non misurabile.")
|
|
return 1
|
|
|
|
unita(rows, b, rete)
|
|
tab = famiglie(rows)
|
|
liquidita(rows, tab)
|
|
res = struttura(rows)
|
|
|
|
vive = [f for f in res if res[f]["n"] >= 2]
|
|
if rete and vive:
|
|
guardia_finestra()
|
|
profondita(rows, vive, b)
|
|
else:
|
|
print("\n3b. PROFONDITA': non girata (--cache, oppure nessuna famiglia con >=2 strutture).")
|
|
|
|
collaterale(rows, res)
|
|
costo_raccolta(rows, tab)
|
|
|
|
print("\n" + "=" * 104)
|
|
print("6. COSA QUESTO FILE NON PUO' DIRE (dichiarato prima di qualunque verdetto)")
|
|
print("=" * 104)
|
|
print(" * Non c'e' STORIA: il collettore raccoglie BTC/ETH inverse, l'archivio ereditato")
|
|
print(" pure. Zero giorni di catena su queste famiglie -> nessun backtest, nessun hold-out,")
|
|
print(" nessun deflated-Sharpe. Il verdetto massimo ottenibile e' LEAD.")
|
|
print(" * Non c'e' IV-RANK: il gate>0.30 e' l'unico alpha misurato di VRP01 e richiede la")
|
|
print(" distribuzione storica della vol implicita del sottostante. Senza, la struttura non")
|
|
print(" e' VRP01: e' vendere vol a caso. (Su BTC/ETH quel gate e' passato 0/19 settimane.)")
|
|
print(" * Non c'e' STRESS nel campione: la regola 'niente short-vol da modello in deploy' e'")
|
|
print(" del 19/06 e la condizione dichiarata per rivalutarla e' un crash CATTURATO.")
|
|
print(" * Il margine e' da FORMULA PUBBLICATA, non dall'API, e il netting delle gambe non e'")
|
|
print(" leggibile pubblicamente.")
|
|
print(" * E' UN ISTANTE, per giunta di SABATO pomeriggio: uno spread relativo mediano a")
|
|
print(" un'ora del fine settimana non e' la sua distribuzione. Un book alt puo' essere")
|
|
print(" molto piu' quotato in settimana. Servirebbe almeno un ciclo settimanale.")
|
|
print(f"\n[rete, totale] {b.calls} chiamate, {b.err} errori, {b.r429} risposte 429")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|