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())