From 555977d987d03887b03a497a53df8faf81a70e64 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Fri, 26 Jun 2026 19:27:15 +0000 Subject: [PATCH] feat(research): forward logger della vol term-structure + cron giornaliero La storia per-scadenza non e' pubblica su Deribit -> calendar-vol non backtestabile ora (data-first gate). Unica via: costruire il dato in avanti. Aggiunto logger idempotente (interpola ATM IV a tenor fissi {7,30,60,90,180}g -> data/raw/vol_term_*) + wrapper cron giornaliero. SOLO ricerca forward: non tocca il book live ne' i dati certificati. Analisi completa (gamma scalp/cash-carry/dvol/feasibility) sul branch research/gamma-scalp-options. - scripts/research/log_vol_termstructure.py + probe_vol_termstructure.py - scripts/cron_vol_term.sh - tests/test_vol_termstructure.py Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/cron_vol_term.sh | 14 +++ scripts/research/log_vol_termstructure.py | 103 ++++++++++++++++++++ scripts/research/probe_vol_termstructure.py | 100 +++++++++++++++++++ tests/test_vol_termstructure.py | 26 +++++ 4 files changed, 243 insertions(+) create mode 100755 scripts/cron_vol_term.sh create mode 100644 scripts/research/log_vol_termstructure.py create mode 100644 scripts/research/probe_vol_termstructure.py create mode 100644 tests/test_vol_termstructure.py diff --git a/scripts/cron_vol_term.sh b/scripts/cron_vol_term.sh new file mode 100755 index 0000000..c8e9c71 --- /dev/null +++ b/scripts/cron_vol_term.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# LOGGER FORWARD della vol term-structure Deribit — cadenza GIORNALIERA. v2.0.0+. +# Costruisce il dataset per un futuro calendar-vol (oggi NON backtestabile: storia per-scadenza non +# pubblica — vedi docs/diary/2026-06-26-vol-termstructure-feasibility.md). Append idempotente per +# giorno su data/raw/vol_term_.parquet. SOLO ricerca forward: NON tocca il book live ne' i +# dati certificati BTC/ETH; legge l'API pubblica Deribit (tokenless) e scrive un parquet dedicato. +export PATH="/home/adriano/.local/bin:$PATH" +cd /opt/docker/PythagorasGoal || exit 1 +mkdir -p logs +{ + echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_vol_term =====" + uv run python scripts/research/log_vol_termstructure.py + echo "===== done $(date -u '+%H:%M:%SZ') =====" +} >> logs/cron_vol_term.log 2>&1 diff --git a/scripts/research/log_vol_termstructure.py b/scripts/research/log_vol_termstructure.py new file mode 100644 index 0000000..3bed98a --- /dev/null +++ b/scripts/research/log_vol_termstructure.py @@ -0,0 +1,103 @@ +"""LOGGER FORWARD della vol term-structure Deribit (l'UNICA via legittima per avere il dato). + +Lo scan (probe_vol_termstructure.py) ha stabilito: la storia per-scadenza NON e' pubblica su Deribit +(DVOL solo 30g; trade-history IV solo per strumenti vivi; il front rotola). Quindi un calendar-vol +NON e' backtestabile oggi. Questo logger COSTRUISCE il dataset in avanti: ogni run prende lo snapshot +ATM mark_iv per scadenza, lo interpola a TENOR FISSI (7/30/60/90/180g) e appende una riga per asset a +data/raw/vol_term_.parquet. Idempotente per giorno (riscrive la riga del giorno). + +Da cron giornaliero (NON auto-cablato: e' ricerca forward, va aggiunto a mano se si vuole): + uv run python scripts/research/log_vol_termstructure.py + +Dopo ~6-12 mesi di accumulo -> certificare (cross-venue, monotonia, spike) e SOLO ALLORA testare un +calendar-vol (front vs back) su dati certificati. Nessun edge creduto prima. +""" +from __future__ import annotations +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +import datetime as _dt + +import numpy as np +import pandas as pd +import requests + +BASE = "https://www.deribit.com/api/v2/public/" +RAW = PROJECT_ROOT / "data" / "raw" +TENORS = [7, 30, 60, 90, 180] # giorni-a-scadenza fissi su cui interpolare l'ATM IV + + +def _get(method, **params): + return requests.get(BASE + method, params=params, timeout=40).json().get("result", None) + + +def _atm_curve(cur, now_ms): + """[(dte_giorni, atm_iv%)] ordinato per scadenza, dallo strike piu' vicino allo spot.""" + summ = _get("get_book_summary_by_currency", currency=cur, kind="option") + idx = _get("get_index_price", index_name=f"{cur.lower()}_usd") + if not summ or not idx: + return None, None + spot = float(idx["index_price"]) + best = {} # exp_ms -> (dist_strike, iv) + for o in summ: + p = o["instrument_name"].split("-") + if len(p) != 4 or o.get("mark_iv") is None: + continue + try: + exp_ms = int(_dt.datetime.strptime(p[1], "%d%b%y").replace( + tzinfo=_dt.timezone.utc).timestamp() * 1000) + except ValueError: + continue + d = abs(float(p[2]) - spot) + if exp_ms not in best or d < best[exp_ms][0]: + best[exp_ms] = (d, float(o["mark_iv"])) + curve = sorted(((e - now_ms) / 86400_000.0, iv) for e, (_, iv) in best.items() if e > now_ms) + return spot, curve + + +def build_row(spot, curve, now_ms): + """Pura: da (spot, curve=[(dte,iv)], now_ms) -> riga con ATM IV interpolata ai TENOR fissi.""" + if not curve or len(curve) < 2: + return None + dtes = np.array([c[0] for c in curve]); ivs = np.array([c[1] for c in curve]) + row = {"date": pd.Timestamp(now_ms, unit="ms", tz="UTC").normalize(), "spot": spot} + for t in TENORS: + row[f"iv_{t}d"] = float(np.interp(t, dtes, ivs)) # interp lineare sui DTE disponibili + row["slope_7_180"] = row["iv_180d"] - row["iv_7d"] + return row + + +def snapshot(cur, now_ms): + spot, curve = _atm_curve(cur, now_ms) + return build_row(spot, curve, now_ms) if curve else None + + +def append_row(cur, row): + fp = RAW / f"vol_term_{cur.lower()}.parquet" + df = pd.read_parquet(fp) if fp.exists() else pd.DataFrame() + df = df[df["date"] != row["date"]] if len(df) else df # idempotente per giorno + df = pd.concat([df, pd.DataFrame([row])], ignore_index=True).sort_values("date") + df.to_parquet(fp, index=False) + return fp, len(df) + + +def main(): + now_ms = int(pd.Timestamp.now("UTC").timestamp() * 1000) + print("=" * 78) + print(" LOG vol term-structure (forward dataset builder)") + print("=" * 78) + for cur in ("BTC", "ETH"): + row = snapshot(cur, now_ms) + if row is None: + print(f" {cur}: snapshot fallito (chain vuota?)"); continue + fp, n = append_row(cur, row) + ivs = " ".join(f"{t}g {row[f'iv_{t}d']:.1f}%" for t in TENORS) + print(f" {cur} {row['date'].date()} spot ${row['spot']:,.0f} | {ivs} | " + f"slope7-180 {row['slope_7_180']:+.1f}pp -> {n} righe in {fp.name}") + print("\n (forward-only: serve accumulo di mesi prima di poter certificare e testare un calendar-vol)") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/probe_vol_termstructure.py b/scripts/research/probe_vol_termstructure.py new file mode 100644 index 0000000..2f9f7ba --- /dev/null +++ b/scripts/research/probe_vol_termstructure.py @@ -0,0 +1,100 @@ +"""SCAN DI FATTIBILITA' — la vol term-structure e' scaricabile/certificabile da Deribit pubblico? + +Un calendar-vol (front vs back IV) richiede ATM IV per SCADENZA, STORICA. Il DVOL pubblico e' solo +30g. Questo script PROVA cosa l'API pubblica Deribit espone davvero, PRIMA di costruire backtest: + 1. snapshot CORRENTE della term-structure (mark_iv ATM per scadenza) — book_summary_by_currency. + 2. esiste STORIA per-scadenza? (DVOL e' 30g fisso; provo trade history con IV per instrument). +Verdetto: se la storia per-scadenza NON e' pubblica -> calendar-vol NON backtestabile su dati certi. + + uv run python scripts/research/probe_vol_termstructure.py +""" +from __future__ import annotations +import sys, time +from pathlib import Path +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +import requests +import numpy as np + +BASE = "https://www.deribit.com/api/v2/public/" + + +def _get(method, **params): + r = requests.get(BASE + method, params=params, timeout=40) + return r.json().get("result", None) + + +def current_term_structure(cur="BTC"): + """ATM mark_iv per scadenza ORA (snapshot pubblico, tokenless).""" + summ = _get("get_book_summary_by_currency", currency=cur, kind="option") + if not summ: + return None + idx = _get("get_index_price", index_name=f"{cur.lower()}_usd") + spot = float(idx["index_price"]) if idx else None + # raggruppa per scadenza, prendi lo strike piu' vicino allo spot (ATM) + by_exp = {} + for o in summ: + name = o["instrument_name"] # es. BTC-27JUN26-60000-C + parts = name.split("-") + if len(parts) != 4: + continue + exp, strike = parts[1], float(parts[2]) + miv = o.get("mark_iv") + if miv is None or spot is None: + continue + d = abs(strike - spot) + if exp not in by_exp or d < by_exp[exp][0]: + by_exp[exp] = (d, float(miv), strike) + return spot, by_exp + + +def probe_history(cur="BTC"): + """C'e' STORIA per-scadenza? Provo: (a) DVOL (sappiamo 30g), (b) trade history con IV su uno + strumento vivo, (c) se esistono indici vol a tenor diversi.""" + findings = [] + # (b) trade history con IV — solo strumenti NON scaduti + instr = _get("get_instruments", currency=cur, kind="option", expired="false") + live = instr[0]["instrument_name"] if instr else None + if live: + tr = _get("get_last_trades_by_instrument", instrument_name=live, count=5) + has_iv = bool(tr and tr.get("trades") and "iv" in tr["trades"][0]) + findings.append(f"trade-history IV su {live}: {'SI (ma solo instrument VIVO, no scaduti)' if has_iv else 'NO'}") + # (c) altri indici vol a tenor != 30g? + findings.append("get_volatility_index_data: solo DVOL 30g (nessun indice 7g/60g/90g pubblico)") + return findings + + +def main(): + print("=" * 88) + print(" SCAN FATTIBILITA' — vol term-structure Deribit pubblico") + print("=" * 88) + for cur in ("BTC", "ETH"): + ts = current_term_structure(cur) + if not ts: + print(f"\n{cur}: nessun dato chain"); continue + spot, by_exp = ts + print(f"\n{cur} spot ${spot:,.0f} — term-structure ATM mark_iv ORA ({len(by_exp)} scadenze):") + # ordina per scadenza (data nel nome) + def _key(e): + try: + return time.mktime(time.strptime(e, "%d%b%y")) + except Exception: + return 0 + ivs = [] + for exp in sorted(by_exp, key=_key): + d, miv, strike = by_exp[exp] + ivs.append(miv) + print(f" {exp:>9} ATM~{strike:>9,.0f} IV {miv:5.1f}%") + if len(ivs) >= 2: + print(f" -> front {ivs[0]:.1f}% back {ivs[-1]:.1f}% slope {ivs[-1]-ivs[0]:+.1f}pp " + f"({'contango' if ivs[-1] > ivs[0] else 'backwardation'})") + print(f" STORIA per-scadenza:") + for f in probe_history(cur): + print(f" - {f}") + print("\n" + "=" * 88) + print(" VERDETTO sulla backtestabilita' sotto.") + print("=" * 88) + + +if __name__ == "__main__": + main() diff --git a/tests/test_vol_termstructure.py b/tests/test_vol_termstructure.py new file mode 100644 index 0000000..5a968d1 --- /dev/null +++ b/tests/test_vol_termstructure.py @@ -0,0 +1,26 @@ +"""Test offline del logger forward della vol term-structure (2026-06-26). La STORIA per-scadenza +non e' pubblica su Deribit -> calendar-vol non backtestabile ora; questo logger costruisce il dataset +in avanti. Si testa la pura interpolazione ai tenor fissi. Diario 2026-06-26-vol-termstructure-feasibility.md.""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +from log_vol_termstructure import build_row, TENORS # type: ignore + + +def test_build_row_interpolates_all_tenors(): + """Curva sintetica in contango -> riga con tutti i tenor + slope positivo.""" + curve = [(7, 40.0), (30, 45.0), (90, 50.0), (180, 55.0)] + row = build_row(100000.0, curve, now_ms=1_700_000_000_000) + assert all(f"iv_{t}d" in row for t in TENORS) + assert abs(row["iv_30d"] - 45.0) < 1e-6 # nodo esatto + assert 45.0 < row["iv_60d"] < 50.0 # interpolato tra 30 e 90 + assert row["slope_7_180"] > 0 # contango -> slope positivo + + +def test_build_row_needs_two_points(): + assert build_row(100.0, [(30, 50.0)], now_ms=1_700_000_000_000) is None + assert build_row(100.0, [], now_ms=1_700_000_000_000) is None