research(vol): calendar-vol non backtestabile — data-first gate + forward logger
Angolo term-structure DVOL / calendar-vol. Scan di fattibilita' PRIMA del backtest:
la storia per-scadenza NON e' pubblica su Deribit (DVOL solo 30g; trade-history IV
solo per strumenti vivi; il front rotola/espira -> serie continua front-vs-back
irricostruibile). Per la metodologia (niente edge senza OOS su dati certi) -> STOP,
niente backtest su uno snapshot.
Unica via legittima: costruire il dato IN AVANTI. Aggiunto logger forward idempotente
che interpola l'ATM IV a tenor fissi {7,30,60,90,180}g e accumula data/raw/vol_term_*.
Seminati i primi snapshot (oggi: contango lieve su BTC/ETH). Non auto-cablato in cron.
- scripts/research/probe_vol_termstructure.py (scan fattibilita')
- scripts/research/log_vol_termstructure.py (forward dataset builder)
- tests/test_vol_termstructure.py (interpolazione offline)
- docs/diary/2026-06-26-vol-termstructure-feasibility.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
# 2026-06-26 — Calendar-vol / term-structure DVOL: il DATO STORICO NON ESISTE (data-first gate)
|
||||||
|
|
||||||
|
**Goal utente:** "continua" — angolo scelto: scaricare e certificare la term-structure DVOL per
|
||||||
|
testare un calendar-vol (front IV vs back IV). **Esito: bloccato a monte dal gate "il dato prima
|
||||||
|
della strategia" (lezione v2.0.0).** Branch `research/gamma-scalp-options`.
|
||||||
|
Script: `scripts/research/probe_vol_termstructure.py` (scan) + `log_vol_termstructure.py` (forward).
|
||||||
|
|
||||||
|
## Scan di fattibilità (prima di costruire qualunque backtest)
|
||||||
|
|
||||||
|
Cosa l'API pubblica Deribit espone davvero per la vol per-scadenza:
|
||||||
|
|
||||||
|
| dato | disponibile? |
|
||||||
|
|---|---|
|
||||||
|
| Snapshot CORRENTE della term-structure ATM (mark_iv per scadenza) | **SÌ** (book_summary_by_currency, tokenless) |
|
||||||
|
| DVOL storico | solo **30g** fisso — nessun indice 7g/60g/90g pubblico |
|
||||||
|
| Trade-history IV per strumento | solo per strumenti **VIVI** (gli scaduti spariscono) |
|
||||||
|
| **Serie storica continua front-vs-back IV** | **NO** — il front-month rotola/espira → irricostruibile |
|
||||||
|
|
||||||
|
**Snapshot oggi (2026-06-26), per riferimento:**
|
||||||
|
- BTC spot $59.9k: 7g 45.6% · 30g 43.2% · 60g 42.7% · 90g 42.9% · 180g 44.5% — **contango** lieve.
|
||||||
|
- ETH spot $1.58k: 7g 57.6% · 30g 56.4% · 90g 57.5% · 180g 59.2% — contango (+1.6pp slope).
|
||||||
|
- (I valori sono interpolati a tenor fissi: il front a 1-giorno grezzo, 38%/54%, è rumore escluso.)
|
||||||
|
|
||||||
|
## Verdetto
|
||||||
|
|
||||||
|
**Un calendar-vol NON è backtestabile ora su dati certificati.** La storia per-scadenza non è
|
||||||
|
pubblica su Deribit (la nostra fonte di verità), e — coerentemente con la metodologia — **nessun
|
||||||
|
edge va creduto senza backtest OOS su dati certificati**. Costruire un calendar-vol su uno snapshot
|
||||||
|
singolo o su una storia ricostruita-a-pezzi sarebbe esattamente il tipo di scorciatoia che ha
|
||||||
|
prodotto la libreria fasulla v2.0.0. Quindi: **stop, niente backtest.**
|
||||||
|
|
||||||
|
## L'unica via legittima: costruire il dato in avanti
|
||||||
|
|
||||||
|
`scripts/research/log_vol_termstructure.py` — logger forward idempotente: ogni run prende lo snapshot
|
||||||
|
ATM, lo interpola ai tenor fissi {7,30,60,90,180}g e appende una riga/asset a
|
||||||
|
`data/raw/vol_term_<asset>.parquet`. Seminate le prime righe BTC/ETH oggi. Test offline
|
||||||
|
`tests/test_vol_termstructure.py` (interpolazione pura).
|
||||||
|
|
||||||
|
**Non auto-cablato in cron** (è ricerca forward, e non tocco il cron live senza ok). Per accumulare:
|
||||||
|
aggiungere una riga giornaliera al crontab. Dopo **~6-12 mesi** → certificare (monotonia, spike,
|
||||||
|
cross-venue) e SOLO ALLORA testare front-vs-back. Prima, nulla.
|
||||||
|
|
||||||
|
## Lezione
|
||||||
|
|
||||||
|
Il data-first gate ha funzionato: ha fermato un backtest su un dato che non esiste, invece di
|
||||||
|
produrre un numero non falsificabile. La term-structure è in **contango** oggi (regime calmo) —
|
||||||
|
osservazione da forward-monitor, non un edge. Nessun impatto sul book live (branch separato).
|
||||||
@@ -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_<asset>.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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user