feat(chain): assorbita la raccolta catena opzioni, cerbero-bite dismesso

cerbero-bite viene eliminato. L'unica sua parte irreversibile e' il DATO:
una catena opzioni non si ricostruisce a posteriori (Deribit non serve book
storici, non c'e' un secondo venue). Il codice si riscrive; le ore non
raccolte no.

ASSORBITO
- scripts/live/collect_chain.py + scripts/cron_chain.sh (cron 25 * * * *):
  raccolta propria, ~570 strumenti/giro, ~3 min.
- scripts/analysis/import_cb_archive.py: archivio 1.23M righe (2026-05-01+)
  + market_snapshots 17.402 righe (2026-03-26+: dealer gamma, gamma flip,
  rischio liquidazioni, funding cross — dati che non abbiamo altrove).
- snapshot sqlite integrale in /opt/docker/backups/manual/ (SHA256).

NON ASSORBITO, con motivo: motore credit-spread ETH (regola "niente
short-vol da modello in deploy", conto a $52 contro minimo $720), GUI, kill
switch/dead-man/audit (abbiamo venue_watch/edge_watch/monitor_health/
fee_watch), dvol_history (fetch_dvol.py ha storia PIU' LUNGA: 2020+ contro
2026-05), decisions/positions (0 posizioni).

TRE DIFETTI DI BITE NON REPLICATI, tutti misurati il 30/07:
1. una chiamata per strumento invece di due (get_order_book?depth=3 da' gia'
   quote+greche+IV+OI+book+underlying) + prefiltro OI in una chiamata sola:
   551 -> ~290 chiamate per asset;
2. pacing invece di raffica. Il carico non e' mai stato il problema: 570
   chiamate/ora = 0.16/s DISTRIBUITE; bite le sparava in 26s (~44/s) e si
   auto-saturava il rate limit per-IP (12.186 risposte 429 in 26h, 96% al
   minuto :00). Primo giro reale: 574 chiamate, 0 risposte 429. Il minuto :25
   e' scelto: :00 era la raffica, :07 e' cron_book (feed 5m di SKH01).
3. quote_status esplicito {ok, no_quote, error} e book_depth NULL su errore
   mai 0. "Book vuoto" e "chiamata fallita" sono cose diverse: e' per questo
   che il guasto del 29/07 (50% di quote perse, 38 ore) non produsse alcun
   segnale. Le righe ereditate restano 'unknown': bite non lo registrava e a
   posteriori non e' ricostruibile.

Battuta di cuore in data/chain_collect/runs.jsonl anche a giro fallito,
sorvegliata da monitor_health (1h, max_age 3h): un collettore fermo non
produce niente, e il niente si legge come "nessun dato quel giorno".

Difetto trovato per strada: due formati ISO nella stessa colonna (92 righe
di backfill senza microsecondi). pd.to_datetime senza `format` ne inferisce
uno solo e manda gli altri a NaT -> il dropna a valle li toglieva in
silenzio, e la serie di contesto perdeva 5 settimane slittando dal 26/03 al
01/05. Corretto con format="ISO8601" e scarto RUMOROSO.

Book, pesi, config, strategia INVARIATI. 537 test verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-30 20:14:34 +00:00
parent 8c18e82f1a
commit d55eb13533
12 changed files with 755 additions and 56 deletions
@@ -1,27 +1,25 @@
"""FETCH + CERTIFY della catena opzioni REALE accumulata da cerbero-bite.
"""CERTIFICAZIONE della catena opzioni in `data/raw/cb_chain/` (archivio bite + raccolta propria).
Perche' esiste: VRP01 prezza le sue gambe con BS su DVOL ATM (`src/portfolio/sleeves.VRP_CFG`,
f=1.0). L'unico modo per sapere se quel prezzo e' quello del mercato e' confrontarlo con quote
vere; cerbero-bite (/opt/docker/cerbero-bite) accumula la catena Deribit mainnet ora per ora,
entrambe le ali, scadenze 1g..3mesi. Una catena opzioni NON e' ricostruibile a posteriori
(Deribit non serve book storici) -> il dato va estratto e conservato, non richiesto quando serve.
Come `fetch_hyperliquid.py`: estrae, CERTIFICA, e scrive solo se la certificazione ha un esito
dichiarato. Le soglie sono qui sotto e sono decisioni, non default.
Storia: nato il 30/07 come `fetch_cb_chain.py` per estrarre la catena dal container cerbero-bite.
Con lo smantellamento di bite l'estrazione e' diventata un import una-tantum
(`import_cb_archive.py`) e la raccolta e' passata a `scripts/live/collect_chain.py`; qui resta
cio' che serve per sempre: **il giudizio sul dato**.
CERTIFICAZIONE quattro difetti, ognuno con la sua diagnostica:
1. QUOTE VUOTE bid/ask NULL con lo strumento presente: il collettore persiste la riga anche
quando il ticker fallisce (rate-limit) -> il CONTEGGIO RIGHE resta identico e qualunque
controllo di copertura basato sul numero di righe dice "tutto bene". E' il difetto che il
29/07 ha portato il tasso da ~0.4% a ~50% senza che nulla lo segnalasse.
1. QUOTE VUOTE la riga c'e' ma la quota no. E' il difetto che il 29/07 ha portato il tasso da
~0.4% a ~50% per 38 ore senza un segnale: il CONTEGGIO RIGHE resta identico, quindi ogni
controllo di copertura basato sulle righe dice "tutto bene". Sulle righe raccolte da
`collect_chain.py` si legge anche il PERCHE' (`quote_status`: no_quote vs error); sulle righe
ereditate da bite no (`unknown`), perche' bite non lo registrava.
2. BOOK INCROCIATO bid > ask (quote incoerenti).
3. MONOTONIA a parita' di scadenza/istante il premio di una put deve essere non-decrescente
nello strike; una violazione e' una quota stantia o corrotta.
4. PROFONDITA' ZERO book_depth_top3 == 0 e' AMBIGUO by design (chiamata fallita e book
davvero vuoto danno lo stesso valore) -> si riporta, non si ripara.
3. MONOTONIA a parita' di scadenza/istante il premio di una put e' non-decrescente nello
strike: e' il payoff, non un modello -> una violazione e' un difetto di dato.
4. PROFONDITA' ZERO nell'archivio bite `book_depth_top3 == 0` e' AMBIGUO by design (chiamata
fallita e book vuoto danno lo stesso valore) -> si riporta, non si ripara. Nelle righe nuove
l'ambiguita' non esiste: su errore la profondita' e' NULL.
uv run python scripts/analysis/fetch_cb_chain.py # docker cp dal container
uv run python scripts/analysis/fetch_cb_chain.py --db /percorso/state.sqlite
uv run python scripts/analysis/certify_cb_chain.py
uv run python scripts/analysis/certify_cb_chain.py --db /percorso/state.sqlite # legacy
"""
from __future__ import annotations
@@ -38,8 +36,8 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
RAW = PROJECT_ROOT / "data" / "raw"
OUT = RAW / "cb_chain.parquet"
CONTAINER = "cerbero-bite-cerbero-bite-1"
STORE = RAW / "cb_chain"
CONTAINER = "cerbero-bite-cerbero-bite-1" # legacy: il container non esiste piu'
DB_IN_CONTAINER = "/app/data/state.sqlite"
# --- soglie di certificazione (decise, non default) ---
@@ -70,10 +68,16 @@ def read_chain(db: Path) -> pd.DataFrame:
con.close()
for c in ("strike", "bid", "ask", "mid", "iv", "delta", "gamma", "theta", "vega"):
df[c] = pd.to_numeric(df[c], errors="coerce")
df["ts"] = pd.to_datetime(df["timestamp"], utc=True, errors="coerce")
df["exp"] = pd.to_datetime(df["expiry"], utc=True, errors="coerce")
# formati ISO MISTI nella stessa colonna (con e senza microsecondi): senza `format` pandas
# ne inferisce uno solo e manda gli altri a NaT, che il dropna sotto toglierebbe in SILENZIO.
df["ts"] = pd.to_datetime(df["timestamp"], utc=True, format="ISO8601", errors="coerce")
df["exp"] = pd.to_datetime(df["expiry"], utc=True, format="ISO8601", errors="coerce")
df["dte"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0
return df.dropna(subset=["ts", "exp", "strike"]).drop(columns=["timestamp", "expiry"])
n0 = len(df)
df = df.dropna(subset=["ts", "exp", "strike"]).drop(columns=["timestamp", "expiry"])
if len(df) != n0:
print(f" ATTENZIONE: {n0 - len(df)} righe scartate (timestamp illeggibile) su {n0}")
return df
# ------------------------------------------------------------------ certificazione
@@ -161,20 +165,21 @@ def certify(df: pd.DataFrame) -> list[dict]:
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--db", type=Path, default=None, help="state.sqlite (default: docker cp)")
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--db", type=Path, default=None,
help="legacy: certifica direttamente uno state.sqlite di bite invece dello store")
args = ap.parse_args()
tmp = None
db = args.db
if db is None:
tmp = Path(tempfile.mkdtemp()) / "state.sqlite"
print(f" docker cp {CONTAINER}:{DB_IN_CONTAINER} ...")
db = _pull_db(tmp)
df = read_chain(db)
if args.db is not None:
df = read_chain(args.db)
titolo = f"catena da {args.db}"
else:
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
from cblib import load_chain
df = load_chain()
titolo = f"store {STORE}"
print("=" * 100)
print(" CATENA OPZIONI cerbero-bite — certificazione")
print(f" CATENA OPZIONI — certificazione ({titolo})")
print("=" * 100)
rep = certify(df)
for r in rep:
@@ -184,17 +189,17 @@ def main() -> int:
f"| ultimi {RECENT_DAYS}g {100*r[f'vuote_{RECENT_DAYS}g']:.1f}% "
f"| GIORNO PEGGIORE {100*r['vuote_giorno_peggiore']:.1f}% ({r['giorno_peggiore']})")
print(f" book incrociato {100*r['incrociate']:.2f}% | premio non monotono nello strike "
f"{100*r['non_monotone']:.2f}% | depth==0 {100*r['depth_zero']:.1f}% (ambiguo by design)")
f"{100*r['non_monotone']:.2f}% | depth==0 {100*r['depth_zero']:.1f}% (ambiguo solo "
f"nell'archivio bite)")
print(f" STATUS: {r['status']}")
args.out.parent.mkdir(parents=True, exist_ok=True)
df.to_parquet(args.out, index=False)
print(f"\n scritto {args.out} ({len(df):,} righe)")
if "quote_status" in df.columns:
print("\n stato dichiarato della quota (solo le righe raccolte da noi lo hanno):")
for k, v in df["quote_status"].value_counts().items():
print(f" {k:9s} {v:>9,}")
if any(r["status"] != "OK" for r in rep):
print(" ATTENZIONE: almeno un asset non e' OK — le quote vuote NON sono righe mancanti,")
print("\n ATTENZIONE: almeno un asset non e' OK — le quote vuote NON sono righe mancanti,")
print(" il conteggio righe resta identico. Ogni misura su questa finestra va pesata.")
if tmp is not None:
tmp.unlink(missing_ok=True)
return 0
+132
View File
@@ -0,0 +1,132 @@
"""IMPORT dell'archivio cerbero-bite dentro PythagorasGoal — una volta sola, prima della sua
eliminazione (2026-07-30).
cerbero-bite viene smantellato; la sua raccolta continua in `scripts/live/collect_chain.py`. Questo
script prende cio' che NON e' ricostruibile e lo porta dentro:
* `option_chain_snapshots` -> data/raw/cb_chain/bite_archive.parquet (1.23M righe, 2026-05-01+)
* `market_snapshots` -> data/raw/cb_market_snapshots.parquet (17k righe, 2026-03-26+:
spot, DVOL, RV30, IV-RV, funding perp e cross-exchange, dealer net gamma, gamma flip level,
OI delta 4h, rischio liquidazioni long/short, giorni all'evento macro)
NON importato e perche':
* `dvol_history` — il progetto ha gia' `fetch_dvol.py` con storia PIU' LUNGA (2020+ contro
2026-05): reimportarlo aggiungerebbe una seconda copia peggiore della prima.
* `decisions` / `positions` — 59 righe di valutazioni d'ingresso a capitale $52, 0 posizioni:
e' il log di una strategia che il progetto ha gia' deciso di non deployare (regola
"niente short-vol da modello in deploy").
* `audit.log` — conservato come file nel backup, non e' una serie storica.
⚠️ LO STATO DELLA QUOTA NELL'ARCHIVIO E' `unknown`, NON `ok`. Bite scriveva la riga anche quando
la chiamata falliva, con bid/ask NULL, senza registrare il perche': "book vuoto" e "chiamata
fallita" sono indistinguibili a posteriori. Il collettore nuovo li separa (`no_quote` / `error`);
per lo storico si dichiara l'ignoranza invece di inventare uno stato. Chi misura sull'archivio
deve sapere che le righe senza quota sono ~1.5% fino al 28/07 e ~50% dal 29/07 (guasto di
rate-limit, vedi diario 2026-07-30).
uv run python scripts/analysis/import_cb_archive.py --db /opt/docker/backups/manual/cerbero-bite-20260730/state.sqlite
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
from pathlib import Path
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
RAW = PROJECT_ROOT / "data" / "raw"
STORE = RAW / "cb_chain"
ARCHIVE = STORE / "bite_archive.parquet"
MARKET = RAW / "cb_market_snapshots.parquet"
DEFAULT_DB = Path("/opt/docker/backups/manual/cerbero-bite-20260730/state.sqlite")
def parse_ts(s: pd.Series) -> pd.Series:
"""Timestamp ISO con formati MISTI nella stessa colonna.
bite scrive sia '2026-03-26T12:00:00+00:00' (backfill) sia
'2026-05-01T15:45:00.062918+00:00' (runtime). `pd.to_datetime` senza `format` inferisce UN
formato dal primo elemento e manda gli altri a NaT: con un `dropna` a valle spariscono in
silenzio (qui: 92 righe, e la data d'inizio della serie slittava dal 26/03 al 01/05).
`format="ISO8601"` accetta entrambe le varianti.
"""
return pd.to_datetime(s, utc=True, format="ISO8601", errors="coerce")
def _drop_unparsed(df: pd.DataFrame, cols: list[str], etichetta: str) -> pd.DataFrame:
"""Scarta le righe con timestamp illeggibile DICHIARANDOLO. Uno scarto silenzioso in un
import e' indistinguibile da un dato che non e' mai esistito."""
before = len(df)
out = df.dropna(subset=cols)
if len(out) != before:
print(f" ATTENZIONE [{etichetta}]: {before - len(out)} righe scartate "
f"(timestamp illeggibile) su {before}")
return out
def import_chain(db: Path) -> pd.DataFrame:
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
try:
df = pd.read_sql_query(
"""select timestamp, asset, instrument_name, strike, expiry, option_type,
bid, ask, mid, iv, delta, gamma, theta, vega,
open_interest, volume_24h, book_depth_top3, source
from option_chain_snapshots""", con)
finally:
con.close()
for c in ("strike", "bid", "ask", "mid", "iv", "delta", "gamma", "theta", "vega",
"open_interest", "volume_24h", "book_depth_top3"):
df[c] = pd.to_numeric(df[c], errors="coerce")
df["ts"] = parse_ts(df["timestamp"])
df["exp"] = parse_ts(df["expiry"])
df = _drop_unparsed(df.drop(columns=["timestamp", "expiry"]), ["ts", "exp", "strike"], "catena")
# bite non distingue "book vuoto" da "chiamata fallita": si dichiara, non si indovina.
df["quote_status"] = df["bid"].notna().map({True: "ok", False: "unknown"})
df["underlying_price"] = pd.NA
df["index_price"] = pd.NA
df["source"] = "bite:" + df["source"].astype(str)
return df
def import_market(db: Path) -> pd.DataFrame:
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
try:
df = pd.read_sql_query("select * from market_snapshots", con)
finally:
con.close()
df["ts"] = parse_ts(df["timestamp"])
return _drop_unparsed(df.drop(columns=["timestamp"]), ["ts"], "contesto")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--db", type=Path, default=DEFAULT_DB)
args = ap.parse_args()
if not args.db.exists():
print(f" DB assente: {args.db}")
return 1
STORE.mkdir(parents=True, exist_ok=True)
ch = import_chain(args.db)
ch.to_parquet(ARCHIVE, index=False)
st = ch["quote_status"].value_counts().to_dict()
print(f" catena -> {ARCHIVE}")
print(f" {len(ch):,} righe {ch['ts'].min():%Y-%m-%d} -> {ch['ts'].max():%Y-%m-%d} "
f"| ok {st.get('ok', 0):,} unknown {st.get('unknown', 0):,} "
f"({100*st.get('unknown', 0)/len(ch):.1f}%)")
mk = import_market(args.db)
mk.to_parquet(MARKET, index=False)
print(f" contesto -> {MARKET}")
print(f" {len(mk):,} righe {mk['ts'].min():%Y-%m-%d} -> {mk['ts'].max():%Y-%m-%d} "
f"| colonne: {', '.join(c for c in mk.columns if c not in ('ts', 'asset'))[:110]}...")
print("\n NON importati (motivo nel docstring): dvol_history, decisions, positions, audit.log")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# RACCOLTA CATENA OPZIONI Deribit — oraria. Successore di cerbero-bite (smantellato il 2026-07-30).
#
# Perche' e' un cron e non un container: la raccolta e' un giro di ~3 minuti ogni ora, senza stato
# fra un giro e l'altro. cerbero-bite girava come servizio con scheduler interno; l'unica parte che
# serviva davvero era questa.
#
# MINUTO :25, e la scelta NON e' arbitraria:
# :00 cerbero-bite sparava la sua spazzata full-chain (~44 chiamate/s) e saturava il rate limit
# Deribit per-IP — 12.186 risposte 429 in 26 ore, il 96% nel minuto tondo.
# :07 cron_book.sh (esecuzione del book live). Il feed 5m di SKH01 passa da li': e' la cosa che
# NON deve trovare l'IP occupato.
# :25 nessun altro job. Il giro dura ~3 minuti a 4 chiamate/s, quindi finisce ben prima del :30.
#
# Il pacing e' nello script (token bucket + backoff sul 429): un giro lento non costa niente,
# una raffica costa il dato di tutti gli altri.
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_chain ====="
uv run python scripts/live/collect_chain.py
echo "===== done $(date -u '+%H:%M:%SZ') ====="
} >> logs/cron_chain.log 2>&1
+261
View File
@@ -0,0 +1,261 @@
"""COLLETTORE CATENA OPZIONI — successore di cerbero-bite, dentro PythagorasGoal (2026-07-30).
Perche' esiste: cerbero-bite viene eliminato, e con esso si fermerebbe l'unica raccolta di prezzi
opzioni REALI del progetto. Una catena non e' ricostruibile a posteriori (Deribit non serve book
storici, non c'e' un secondo venue) -> l'ora non raccolta e' persa per sempre. Il valore
dell'archivio sta negli eventi RARI: e' la rete stesa in attesa del regime di vol alta che
promuovera' o uccidera' VRP01 (criterio dichiarato il 19/06, gate IV-rank>0.30 mai attivo nella
finestra raccolta finora).
TRE DIFFERENZE DA BITE, tutte misurate il 30/07 e tutte deliberate:
1. UNA CHIAMATA PER STRUMENTO, non due. `public/get_order_book?depth=3` restituisce gia' quote,
greche, IV, open interest, volume, book E underlying_price. Bite chiamava ticker + orderbook
separatamente: doppio costo e possibilita' di disallineamento fra i due (quote di un istante,
book di un altro).
2. PACING, non raffica. Il carico non e' mai stato il problema: ~570 chiamate/ora = 0.16/s se
distribuite. Bite le sparava in ~26 secondi (~44/s) e si auto-saturava il rate limit per-IP
(12.186 risposte 429 in 26 ore, 96% nel minuto :00), con l'effetto collaterale di disturbare
il feed 5m del book live sulla stessa VPS. Qui: token bucket a `--rps` (default 4/s, ~2.5
minuti per giro) + backoff sul 429. Un giro lento non costa nulla; una raffica costa il dato.
3. STATO ESPLICITO DELLA QUOTA. Bite persisteva la riga anche quando la chiamata falliva, con
bid/ask NULL: il conteggio righe restava identico e nessun controllo di copertura se ne
accorgeva (il 29/07 il 50% delle quote e' diventato vuoto per 38 ore senza un segnale). Qui
ogni riga porta `quote_status` in {ok, no_quote, error}:
ok = il venue ha risposto e c'e' almeno un lato del book
no_quote = il venue ha risposto e il book e' vuoto da entrambi i lati (fatto di mercato)
error = la chiamata e' fallita (fatto di infrastruttura)
Sono cose diverse e non vanno mai confuse. Per lo stesso motivo `book_depth_top3` e' NULL su
errore, MAI 0: bite scriveva 0 e "chiamata fallita" diventava indistinguibile da "book vuoto".
uv run python scripts/live/collect_chain.py # un giro, entrambi gli asset
uv run python scripts/live/collect_chain.py --assets ETH --rps 8
uv run python scripts/live/collect_chain.py --dry-run # non scrive
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
import pandas as pd
import requests
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
API = "https://www.deribit.com/api/v2/public"
STORE = PROJECT_ROOT / "data" / "raw" / "cb_chain"
ASSETS = ("BTC", "ETH")
EXPIRY_MAX_DAYS = 95 # 1g..3mesi, come la finestra di bite (continuita' della serie)
OI_MIN = 100.0 # come bite: sotto questa soglia lo strumento e' rumore
DEFAULT_RPS = 4.0
TIMEOUT = 15
@dataclass
class Budget:
"""Token bucket + contabilita' del giro. Il 429 non e' un dettaglio: e' la cosa da non fare."""
rps: float
_next: float = 0.0
calls: int = 0
errors: int = 0
rate_limited: int = 0
waited_s: float = 0.0
err_samples: list[str] = field(default_factory=list)
def wait(self) -> None:
now = time.monotonic()
if now < self._next:
time.sleep(self._next - now)
self.waited_s += self._next - now
self._next = max(now, self._next) + 1.0 / self.rps
def note_error(self, msg: str) -> None:
self.errors += 1
if len(self.err_samples) < 5:
self.err_samples.append(msg[:160])
def _get(path: str, params: dict, budget: Budget, tries: int = 3) -> dict | None:
"""GET con pacing e backoff. Ritorna None se la chiamata non e' andata a buon fine."""
for k in range(tries):
budget.wait()
budget.calls += 1
try:
r = requests.get(f"{API}/{path}", params=params, timeout=TIMEOUT)
except Exception as exc: # rete: si registra QUI, non dopo
budget.note_error(f"{path}: {type(exc).__name__}: {exc}")
time.sleep(1.5 * (k + 1))
continue
if r.status_code == 429:
budget.rate_limited += 1
time.sleep(2.0 * (k + 1)) # backoff: il venue ha detto di rallentare
continue
if r.status_code != 200:
budget.note_error(f"{path}: HTTP {r.status_code}")
time.sleep(1.0 * (k + 1))
continue
try:
return r.json()["result"]
except Exception as exc:
budget.note_error(f"{path}: payload illeggibile: {exc}")
return None
return None
def instruments(asset: str, budget: Budget, now: datetime) -> list[dict]:
res = _get("get_instruments", {"currency": asset, "kind": "option", "expired": "false"}, budget)
if not res:
return []
horizon = now.timestamp() * 1000 + EXPIRY_MAX_DAYS * 86400_000
return [i for i in res if i.get("expiration_timestamp", 0) <= horizon]
def _depth_top3(side: list) -> float | None:
if side is None:
return None
return float(sum(row[1] for row in side[:3] if isinstance(row, (list, tuple)) and len(row) >= 2))
def snapshot_row(inst: dict, ob: dict | None, ts: datetime) -> dict:
"""Una riga per strumento — SEMPRE, ma con lo stato della quota dichiarato."""
name = inst["instrument_name"]
base = {
"ts": ts, "asset": inst["base_currency"], "instrument_name": name,
"strike": float(inst["strike"]), "option_type": "P" if inst["option_type"] == "put" else "C",
"exp": pd.Timestamp(inst["expiration_timestamp"], unit="ms", tz="UTC"),
"bid": None, "ask": None, "mid": None, "iv": None,
"delta": None, "gamma": None, "theta": None, "vega": None,
"open_interest": None, "volume_24h": None, "book_depth_top3": None,
"underlying_price": None, "index_price": None,
"quote_status": "error", "source": "pyg",
}
if ob is None:
return base # errore: depth resta NULL, mai 0
g = ob.get("greeks") or {}
stats = ob.get("stats") or {}
bid, ask = ob.get("best_bid_price"), ob.get("best_ask_price")
bid = float(bid) if bid else None # Deribit manda 0.0 per "nessun lato"
ask = float(ask) if ask else None
db, da = _depth_top3(ob.get("bids")), _depth_top3(ob.get("asks"))
base.update({
"bid": bid, "ask": ask,
"mid": (bid + ask) / 2 if (bid is not None and ask is not None) else None,
"iv": float(ob["mark_iv"]) if ob.get("mark_iv") is not None else None,
"delta": g.get("delta"), "gamma": g.get("gamma"),
"theta": g.get("theta"), "vega": g.get("vega"),
"open_interest": ob.get("open_interest"), "volume_24h": stats.get("volume"),
"book_depth_top3": (db or 0.0) + (da or 0.0),
"underlying_price": ob.get("underlying_price"), "index_price": ob.get("index_price"),
# il venue ha risposto: se non c'e' nessun lato e' un fatto di MERCATO, non un guasto
"quote_status": "ok" if (bid is not None or ask is not None) else "no_quote",
})
return base
def open_interest_map(asset: str, budget: Budget) -> dict[str, float] | None:
"""OI di TUTTA la catena in UNA chiamata (`get_book_summary_by_currency`).
Serve a non spendere una chiamata per scoprire che uno strumento e' sotto soglia: il prefiltro
dimezza il giro (551 -> ~300 chiamate su ETH). None = la chiamata e' fallita, e allora si
raccoglie TUTTO invece di filtrare su un dato che non si ha: un filtro su dati mancanti
scarterebbe strumenti buoni fingendo che fossero illiquidi.
"""
res = _get("get_book_summary_by_currency", {"currency": asset, "kind": "option"}, budget)
if not res:
return None
return {r["instrument_name"]: float(r.get("open_interest") or 0.0) for r in res}
def sweep(asset: str, budget: Budget, now: datetime) -> pd.DataFrame:
insts = [i for i in instruments(asset, budget, now) if float(i.get("strike") or 0) > 0]
oi = open_interest_map(asset, budget)
if oi is not None:
insts = [i for i in insts if oi.get(i["instrument_name"], 0.0) >= OI_MIN]
rows = []
for inst in insts:
ob = _get("get_order_book", {"instrument_name": inst["instrument_name"], "depth": 3}, budget)
rows.append(snapshot_row(inst, ob, now))
return pd.DataFrame(rows)
def heartbeat(now: datetime, df: pd.DataFrame, budget: Budget) -> None:
"""Una riga per giro in `data/chain_collect/runs.jsonl`, letta da `monitor_health`.
Serve perche' un collettore fermo non produce NIENTE, e il niente si legge come "nessun dato
quel giorno" invece che come "raccolta rotta" — con una serie irrecuperabile e' il modo piu'
caro di sbagliare. La battuta di cuore esiste anche quando il giro fallisce.
"""
d = PROJECT_ROOT / "data" / "chain_collect"
d.mkdir(parents=True, exist_ok=True)
st = df["quote_status"].value_counts().to_dict() if not df.empty else {}
riga = {"ts": int(now.timestamp() * 1000), "righe": int(len(df)),
"ok": int(st.get("ok", 0)), "no_quote": int(st.get("no_quote", 0)),
"error": int(st.get("error", 0)), "chiamate": budget.calls,
"rate_limited": budget.rate_limited}
with (d / "runs.jsonl").open("a") as fh:
fh.write(json.dumps(riga) + "\n")
def write(df: pd.DataFrame, day: datetime) -> Path:
"""Un parquet per giorno: append-friendly e nessun file che cresce senza fine."""
STORE.mkdir(parents=True, exist_ok=True)
p = STORE / f"{day:%Y-%m-%d}.parquet"
if p.exists():
df = pd.concat([pd.read_parquet(p), df], ignore_index=True)
df = df.drop_duplicates(subset=["ts", "instrument_name"], keep="last")
df.to_parquet(p, index=False)
return p
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--assets", nargs="+", default=list(ASSETS))
ap.add_argument("--rps", type=float, default=DEFAULT_RPS, help="chiamate/secondo (pacing)")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
now = datetime.now(UTC).replace(microsecond=0)
budget = Budget(rps=args.rps)
t0 = time.monotonic()
frames = []
for a in args.assets:
df = sweep(a, budget, now)
frames.append(df)
if df.empty:
print(f" {a}: NESSUNA riga — il giro e' fallito, non e' un mercato vuoto")
continue
st = df["quote_status"].value_counts().to_dict()
print(f" {a}: {len(df):4d} strumenti | ok {st.get('ok', 0)} | "
f"no_quote {st.get('no_quote', 0)} | error {st.get('error', 0)}")
out = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(columns=["quote_status"])
dur = time.monotonic() - t0
print(f" {budget.calls} chiamate in {dur:.0f}s ({budget.calls/max(dur,1):.1f}/s) | "
f"429: {budget.rate_limited} | errori: {budget.errors}")
for e in budget.err_samples:
print(f" errore: {e}")
if not args.dry_run:
heartbeat(now, out, budget) # anche a giro fallito: il silenzio non e' un dato
if out.empty:
print(" NIENTE DA SCRIVERE — giro fallito")
return 1
bad = float((out["quote_status"] == "error").mean())
if bad > 0.20:
print(f" ATTENZIONE: {100*bad:.0f}% di quote in ERRORE — la riga c'e' ma il dato no.")
if args.dry_run:
print(" --dry-run: non scrivo")
return 0
p = write(out, now)
print(f" scritto {p} ({len(out)} righe)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+17 -5
View File
@@ -27,7 +27,7 @@ from scipy.stats import norm
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
RAW = ROOT / "data" / "raw"
CHAIN = RAW / "cb_chain.parquet"
STORE = RAW / "cb_chain" # bite_archive.parquet (una-tantum) + YYYY-MM-DD.parquet (nostri)
# finestra "settimanale" di VRP01 (tenor_d=7): si accettano 4..10 DTE come in options_vrp_calibrate
DTE_LO, DTE_HI = 4.0, 10.0
@@ -38,14 +38,26 @@ SHORT_DELTA, LONG_DELTA = -0.28, -0.10
@lru_cache(maxsize=4)
def load_chain() -> pd.DataFrame:
if not CHAIN.exists():
"""Tutta la catena: archivio ereditato da cerbero-bite + raccolta propria, in una serie sola.
Le due fonti restano distinguibili dalla colonna `source` (`bite:live`, `bite:research`, `pyg`)
e da `quote_status` (`unknown` per l'archivio, che non registrava il perche' di una quota
assente). La deduplica su (ts, instrument_name) tiene l'ULTIMA occorrenza: se un giorno e'
coperto da entrambe le fonti vince la piu' recente, cioe' la nostra.
"""
if not STORE.exists():
raise FileNotFoundError(
f"{CHAIN} assente — estrai prima con scripts/analysis/fetch_cb_chain.py"
f"{STORE} assente — importa l'archivio con scripts/analysis/import_cb_archive.py "
f"e raccogli con scripts/live/collect_chain.py"
)
df = pd.read_parquet(CHAIN)
parts = sorted(STORE.glob("*.parquet"))
if not parts:
raise FileNotFoundError(f"{STORE} vuoto")
df = pd.concat([pd.read_parquet(p) for p in parts], ignore_index=True)
df["ts"] = pd.to_datetime(df["ts"], utc=True)
df["exp"] = pd.to_datetime(df["exp"], utc=True)
return df
df["dte"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0
return df.drop_duplicates(subset=["ts", "instrument_name"], keep="last").reset_index(drop=True)
def puts(asset: str, df: pd.DataFrame | None = None) -> pd.DataFrame: