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:
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user