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
+34 -8
View File
@@ -1667,12 +1667,38 @@ df = load_data("BTC", "1h") # OK. load_data("SOL", ...) -> FileNotFoundError (
< 365g reali (es. **AXS 83% sintetico → fuori**), (3) gata i gap vol=0 interni. Universo certificato < 365g reali (es. **AXS 83% sintetico → fuori**), (3) gata i gap vol=0 interni. Universo certificato
= **51** (era 52). I **19 major di XS01 hanno 0 backfill → invariati** (strategia live non toccata). = **51** (era 52). I **19 major di XS01 hanno 0 backfill → invariati** (strategia live non toccata).
Verificato direttamente su cerbero MCP. Diario `2026-06-20-cerbero-backfill-fix.md`. Verificato direttamente su cerbero MCP. Diario `2026-06-20-cerbero-backfill-fix.md`.
- **CATENA OPZIONI REALE (cerbero-bite mainnet) — integrata e certificata 2026-07-30.** - **CATENA OPZIONI REALE — RACCOLTA PROPRIA dal 2026-07-30 (cerbero-bite ASSORBITO e dismesso).**
`/opt/docker/cerbero-bite` accumula dal 2026-06-09 la catena Deribit **mainnet** (BTC+ETH, `/opt/docker/cerbero-bite` (progetto separato) accumulava dal 2026-06-09 la catena Deribit
entrambe le ali, scadenze 1g→3 mesi, ORARIA, con `book_depth_top3`). Estrazione+certificazione: **mainnet** BTC+ETH; **viene eliminato, e la raccolta è passata dentro PythagorasGoal**:
`scripts/analysis/fetch_cb_chain.py``data/raw/cb_chain.parquet` (gitignored, ~1.23M righe); - **raccolta:** `scripts/live/collect_chain.py`, cron **`25 * * * *`** (`scripts/cron_chain.sh`) →
harness `scripts/research/cblib.py`. **È l'unica fonte di prezzi opzioni VERI del progetto** e `data/raw/cb_chain/YYYY-MM-DD.parquet`. Entrambe le ali, scadenze ≤95g, OI≥100, ~570 strumenti
serve a sostituire il premio modellato BS-su-DVOL (vedi il f di VRP01, misurato il 30/07). a giro, ~3 min. **Minuto :25 scelto, non arbitrario:** :00 era la raffica di bite, :07 è
`cron_book` (feed 5m di SKH01, la cosa che non deve trovare l'IP occupato).
- **archivio ereditato:** `scripts/analysis/import_cb_archive.py` (una-tantum) →
`cb_chain/bite_archive.parquet` (1.23M righe, 2026-05-01+) e `cb_market_snapshots.parquet`
(17.402 righe, 2026-03-26+: DVOL, RV30, funding perp e cross, **dealer net gamma**, gamma flip,
**rischio liquidazioni**, giorni all'evento macro — dati che il progetto non ha altrove).
Snapshot sqlite integrale in `/opt/docker/backups/manual/cerbero-bite-20260730/` (SHA256).
- **certificazione:** `scripts/analysis/certify_cb_chain.py`; **harness** `scripts/research/cblib.py`
(`load_chain()` unisce archivio + raccolta, dedup su `(ts, strumento)`).
- **battuta di cuore:** ogni giro scrive `data/chain_collect/runs.jsonl`, sorvegliato da
`monitor_health` (cadenza 1h, `max_age_h=3`) — **un collettore fermo non produce niente, e il
niente si legge come "nessun dato quel giorno"**.
**TRE DIFETTI DI BITE NON REPLICATI** (misurati il 30/07): (a) **una chiamata per strumento**
`get_order_book?depth=3` dà già quote+greche+IV+OI+book+underlying, bite ne faceva due con
rischio di disallineamento; (b) **pacing** (token bucket 4/s + backoff) invece della raffica —
il carico non è mai stato il problema (~570 chiamate/ora = 0.16/s **distribuite**), bite le
sparava in ~26s (~44/s) auto-saturandosi il rate limit per-IP; misurato sul nostro giro:
**574 chiamate, 0 risposte 429**; (c) **`quote_status` esplicito** in {`ok`, `no_quote`, `error`}
— "book vuoto" (fatto di mercato) e "chiamata fallita" (fatto di infrastruttura) sono cose
diverse, e `book_depth_top3` è **NULL su errore, mai 0**. ⚠️ Le righe ereditate da bite hanno
`quote_status='unknown'`: bite non registrava il perché, e si dichiara l'ignoranza invece di
inventare uno stato.
**NON assorbito, e perché:** il motore credit-spread ETH (il progetto ha già la regola "niente
short-vol da modello in deploy"), la GUI, kill switch/dead-man/audit (PythagorasGoal ha
`venue_watch`/`edge_watch`/`monitor_health`/`fee_watch`), `dvol_history` (`fetch_dvol.py` ha
storia **più lunga**: 2020+ contro 2026-05), `decisions`/`positions` (59 righe a capitale $52,
0 posizioni). Test `tests/test_collect_chain.py` (11) + `tests/test_cb_chain_vrp.py` (13).
**Perché si MEMORIZZA invece di interrogarla:** una catena opzioni non è ricostruibile a **Perché si MEMORIZZA invece di interrogarla:** una catena opzioni non è ricostruibile a
posteriori — Deribit non serve book storici, un'ora non raccolta è persa per sempre, e non c'è un posteriori — Deribit non serve book storici, un'ora non raccolta è persa per sempre, e non c'è un
secondo venue da cui recuperarla. secondo venue da cui recuperarla.
@@ -1684,8 +1710,8 @@ df = load_data("BTC", "1h") # OK. load_data("SOL", ...) -> FileNotFoundError (
ogni giro, **96% al minuto :00**, generate dalla spazzata full-chain che si auto-satura) → ogni giro, **96% al minuto :00**, generate dalla spazzata full-chain che si auto-satura) →
`bid`/`ask`/`iv`/`delta` NULL e **conteggio righe INVARIATO** (13k/giorno prima e dopo). Tasso di `bid`/`ask`/`iv`/`delta` NULL e **conteggio righe INVARIATO** (13k/giorno prima e dopo). Tasso di
quote vuote per settimana: 0.4·0.7·2.2·1.5·0.5·0.4·0.3 → **22%**; giorno peggiore **51.7% BTC / quote vuote per settimana: 0.4·0.7·2.2·1.5·0.5·0.4·0.3 → **22%**; giorno peggiore **51.7% BTC /
30.6% ETH**. Il fix lato bite (cron `:00``:20`) è **scritto ma non attivo** (`strategy.yaml` è 30.6% ETH**. **Risolto dal cambio di collettore** (30/07): la raccolta propria è paced e ha
copiato nell'immagine, serve rebuild). **REGOLA: una riga presente non è un dato presente** fatto 574 chiamate con **0 risposte 429**. **REGOLA: una riga presente non è un dato presente**
contare ciò che è QUOTATO, non ciò che è SCRITTO (3ª occorrenza dopo `paper_dvolspread` e contare ciò che è QUOTATO, non ciò che è SCRITTO (3ª occorrenza dopo `paper_dvolspread` e
`fresh_5m`). **REGOLA: un guasto IN CORSO si misura al GIORNO PEGGIORE, non in media** — la prima `fresh_5m`). **REGOLA: un guasto IN CORSO si misura al GIORNO PEGGIORE, non in media** — la prima
stesura confrontava "tutto" con "ultimi 7g" e diluiva un guasto di 2 giorni al 13.5%, commettendo stesura confrontava "tutto" con "ultimi 7g" e diluiva un guasto di 2 giorni al 13.5%, commettendo
+1
View File
@@ -0,0 +1 @@
{"ts": 1785442011000, "righe": 570, "ok": 570, "no_quote": 0, "error": 0, "chiamate": 574, "rate_limited": 0}
@@ -0,0 +1,91 @@
# 2026-07-30 (2º filone) — cerbero-bite viene eliminato: assorbita la raccolta, non il motore
**Decisione dell'operatore:** `/opt/docker/cerbero-bite` viene smantellato e il suo lavoro passa a
PythagorasGoal. **Esito:** book, pesi, config e strategia INVARIATI. Cambia solo *chi* raccoglie
la catena opzioni — e cambia in meglio su tre assi misurati.
## Cosa di bite valeva la pena, e cosa no
L'unica parte **irreversibile** è il dato: una catena opzioni non si ricostruisce a posteriori
(Deribit non serve book storici, non esiste un secondo venue). Tutto il resto è codice, e il codice
si riscrive.
| parte | assorbita? | perché |
|---|---|---|
| `option_chain_snapshots` (1.23M righe) | **sì** | ha appena falsificato il *f* di VRP01; irrecuperabile |
| `market_snapshots` (17.402 righe, 26/03+) | **sì** | dealer net gamma, gamma flip, rischio liquidazioni, funding cross: dati che il progetto non ha altrove |
| raccolta continua | **sì, riscritta** | senza, l'archivio si congela e "aspettare il crash" muore |
| `dvol_history` | no | `fetch_dvol.py` ha storia **più lunga** (2020+ vs 2026-05): copiarla sarebbe una seconda copia peggiore |
| motore credit-spread ETH | no | il progetto ha già la regola *niente short-vol da modello in deploy*; e il conto era a $52 contro un minimo di $720 |
| GUI, kill switch, dead-man, audit chain | no | PythagorasGoal ha già `venue_watch`, `edge_watch`, `monitor_health`, `fee_watch` |
| `decisions` / `positions` | no | 59 valutazioni d'ingresso, 0 posizioni |
Prima di toccare qualsiasi cosa: snapshot `VACUUM INTO` del DB (461 MB) + `audit.log` + config in
`/opt/docker/backups/manual/cerbero-bite-20260730/`, con SHA256.
## Il collettore nuovo — tre difetti di bite non replicati
`scripts/live/collect_chain.py`, cron **`25 * * * *`**.
**1. Una chiamata per strumento invece di due.** `public/get_order_book?depth=3` restituisce già
quote, greche, IV, open interest, volume, book **e** `underlying_price`. Bite chiamava ticker e
orderbook separatamente: doppio costo, e i due potevano riferirsi a istanti diversi.
Un `get_book_summary_by_currency` iniziale dà l'OI di tutta la catena in **una** chiamata e
prefiltra sotto soglia: 551 → **~290 chiamate per asset**.
**2. Pacing invece di raffica.** ⚠️ **Il carico non è mai stato il problema.** ~570 chiamate/ora
sono **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, il 96% nel minuto `:00`. Token bucket a 4/s +
backoff: primo giro reale **574 chiamate, 0 risposte 429**.
Il minuto `:25` è una scelta, non un default: `:00` era la raffica di bite, `:07` è `cron_book`
(da lì passa il feed 5m di SKH01, la cosa che non deve trovare l'IP occupato).
**3. Stato esplicito della quota.** `quote_status` ∈ {`ok`, `no_quote`, `error`}:
- `no_quote` = il venue ha risposto, il book è vuoto → **fatto di mercato**
- `error` = la chiamata è fallita → **fatto di infrastruttura**
Bite li faceva collassare entrambi su "riga con bid/ask NULL", ed è per questo che il guasto del
29/07 (50% di quote perse per 38 ore) non ha prodotto **nessun** segnale: il conteggio righe non
cambiava. Per lo stesso motivo `book_depth_top3` ora è **NULL su errore, mai 0** — bite scriveva 0,
e "chiamata fallita" diventava indistinguibile da "book vuoto".
⚠️ Le righe ereditate portano `quote_status='unknown'`. Bite non registrava il perché e **a
posteriori non è ricostruibile**: si dichiara l'ignoranza invece di inventare uno stato.
## Il silenzio, di nuovo
Un collettore fermo non produce niente, e il niente si legge come "nessun dato quel giorno" invece
che come "raccolta rotta" — su una serie irrecuperabile è il modo più caro di sbagliare. Ogni giro
scrive una riga in `data/chain_collect/runs.jsonl`, **anche quando fallisce**, e
`monitor_health` la sorveglia con cadenza 1h e `max_age_h=3` (due giri persi). È la stessa lezione
di `paper_dvolspread` e di `fresh_5m`, applicata prima che serva invece che dopo.
## Un difetto trovato per strada: due formati ISO nella stessa colonna
L'import del contesto restituiva **17.310 righe dal 2026-05-01** contro le 17.402 dal **2026-03-26**
del database. 92 righe di backfill sono scritte senza microsecondi
(`2026-03-26T12:00:00+00:00`), le altre con (`2026-05-01T15:45:00.062918+00:00`).
`pd.to_datetime` senza `format` **inferisce un formato solo** dal primo elemento e manda gli altri
a `NaT`, che il `dropna` a valle rimuoveva **in silenzio**: la serie perdeva i suoi 5 settimane più
vecchi e la data d'inizio slittava di un mese senza un messaggio.
Corretto con `format="ISO8601"` **e** con uno scarto rumoroso (`_drop_unparsed` stampa quante righe
cadono e perché). **REGOLA: `dropna` dopo un parsing è un rilevatore di difetti travestito da
pulizia — se toglie righe deve dirlo.** Un import silenzioso non è un import pulito, è un import
che non sai se ha funzionato.
## Cosa resta da fare fuori dal repo
Il container di bite è **ancora acceso** mentre scrivo: la sovrapposizione fra le due raccolte è
voluta (nessun buco alla consegna). Quando verrà spento, l'unica cosa che si ferma è la raffica
al minuto `:00`.
## File
- `scripts/live/collect_chain.py` + `scripts/cron_chain.sh` — la raccolta (cron `25 * * * *`)
- `scripts/analysis/import_cb_archive.py` — import una-tantum dell'archivio
- `scripts/analysis/certify_cb_chain.py` — certificazione dello store (era `fetch_cb_chain.py`)
- `scripts/research/cblib.py``load_chain()` ora unisce archivio + raccolta
- `src/live/monitor_health.py` — nuovo spec `collect_chain`
- `tests/test_collect_chain.py` (11 casi)
@@ -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`, Storia: nato il 30/07 come `fetch_cb_chain.py` per estrarre la catena dal container cerbero-bite.
f=1.0). L'unico modo per sapere se quel prezzo e' quello del mercato e' confrontarlo con quote Con lo smantellamento di bite l'estrazione e' diventata un import una-tantum
vere; cerbero-bite (/opt/docker/cerbero-bite) accumula la catena Deribit mainnet ora per ora, (`import_cb_archive.py`) e la raccolta e' passata a `scripts/live/collect_chain.py`; qui resta
entrambe le ali, scadenze 1g..3mesi. Una catena opzioni NON e' ricostruibile a posteriori cio' che serve per sempre: **il giudizio sul dato**.
(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.
CERTIFICAZIONE quattro difetti, ognuno con la sua diagnostica: CERTIFICAZIONE quattro difetti, ognuno con la sua diagnostica:
1. QUOTE VUOTE bid/ask NULL con lo strumento presente: il collettore persiste la riga anche 1. QUOTE VUOTE la riga c'e' ma la quota no. E' il difetto che il 29/07 ha portato il tasso da
quando il ticker fallisce (rate-limit) -> il CONTEGGIO RIGHE resta identico e qualunque ~0.4% a ~50% per 38 ore senza un segnale: il CONTEGGIO RIGHE resta identico, quindi ogni
controllo di copertura basato sul numero di righe dice "tutto bene". E' il difetto che il controllo di copertura basato sulle righe dice "tutto bene". Sulle righe raccolte da
29/07 ha portato il tasso da ~0.4% a ~50% senza che nulla lo segnalasse. `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). 2. BOOK INCROCIATO bid > ask (quote incoerenti).
3. MONOTONIA a parita' di scadenza/istante il premio di una put deve essere non-decrescente 3. MONOTONIA a parita' di scadenza/istante il premio di una put e' non-decrescente nello
nello strike; una violazione e' una quota stantia o corrotta. strike: e' il payoff, non un modello -> una violazione e' un difetto di dato.
4. PROFONDITA' ZERO book_depth_top3 == 0 e' AMBIGUO by design (chiamata fallita e book 4. PROFONDITA' ZERO nell'archivio bite `book_depth_top3 == 0` e' AMBIGUO by design (chiamata
davvero vuoto danno lo stesso valore) -> si riporta, non si ripara. 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/certify_cb_chain.py
uv run python scripts/analysis/fetch_cb_chain.py --db /percorso/state.sqlite uv run python scripts/analysis/certify_cb_chain.py --db /percorso/state.sqlite # legacy
""" """
from __future__ import annotations from __future__ import annotations
@@ -38,8 +36,8 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
RAW = PROJECT_ROOT / "data" / "raw" RAW = PROJECT_ROOT / "data" / "raw"
OUT = RAW / "cb_chain.parquet" STORE = RAW / "cb_chain"
CONTAINER = "cerbero-bite-cerbero-bite-1" CONTAINER = "cerbero-bite-cerbero-bite-1" # legacy: il container non esiste piu'
DB_IN_CONTAINER = "/app/data/state.sqlite" DB_IN_CONTAINER = "/app/data/state.sqlite"
# --- soglie di certificazione (decise, non default) --- # --- soglie di certificazione (decise, non default) ---
@@ -70,10 +68,16 @@ def read_chain(db: Path) -> pd.DataFrame:
con.close() con.close()
for c in ("strike", "bid", "ask", "mid", "iv", "delta", "gamma", "theta", "vega"): for c in ("strike", "bid", "ask", "mid", "iv", "delta", "gamma", "theta", "vega"):
df[c] = pd.to_numeric(df[c], errors="coerce") df[c] = pd.to_numeric(df[c], errors="coerce")
df["ts"] = pd.to_datetime(df["timestamp"], utc=True, errors="coerce") # formati ISO MISTI nella stessa colonna (con e senza microsecondi): senza `format` pandas
df["exp"] = pd.to_datetime(df["expiry"], utc=True, errors="coerce") # 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 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 # ------------------------------------------------------------------ certificazione
@@ -161,20 +165,21 @@ def certify(df: pd.DataFrame) -> list[dict]:
def main() -> int: def main() -> int:
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--db", type=Path, default=None, help="state.sqlite (default: docker cp)") ap.add_argument("--db", type=Path, default=None,
ap.add_argument("--out", type=Path, default=OUT) help="legacy: certifica direttamente uno state.sqlite di bite invece dello store")
args = ap.parse_args() args = ap.parse_args()
tmp = None if args.db is not None:
db = args.db df = read_chain(args.db)
if db is None: titolo = f"catena da {args.db}"
tmp = Path(tempfile.mkdtemp()) / "state.sqlite" else:
print(f" docker cp {CONTAINER}:{DB_IN_CONTAINER} ...") sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
db = _pull_db(tmp) from cblib import load_chain
df = read_chain(db) df = load_chain()
titolo = f"store {STORE}"
print("=" * 100) print("=" * 100)
print(" CATENA OPZIONI cerbero-bite — certificazione") print(f" CATENA OPZIONI — certificazione ({titolo})")
print("=" * 100) print("=" * 100)
rep = certify(df) rep = certify(df)
for r in rep: 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"| ultimi {RECENT_DAYS}g {100*r[f'vuote_{RECENT_DAYS}g']:.1f}% "
f"| GIORNO PEGGIORE {100*r['vuote_giorno_peggiore']:.1f}% ({r['giorno_peggiore']})") 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 " 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']}") print(f" STATUS: {r['status']}")
args.out.parent.mkdir(parents=True, exist_ok=True) if "quote_status" in df.columns:
df.to_parquet(args.out, index=False) print("\n stato dichiarato della quota (solo le righe raccolte da noi lo hanno):")
print(f"\n scritto {args.out} ({len(df):,} righe)") 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): 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.") 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 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] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
RAW = ROOT / "data" / "raw" 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 # 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 DTE_LO, DTE_HI = 4.0, 10.0
@@ -38,14 +38,26 @@ SHORT_DELTA, LONG_DELTA = -0.28, -0.10
@lru_cache(maxsize=4) @lru_cache(maxsize=4)
def load_chain() -> pd.DataFrame: 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( 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["ts"] = pd.to_datetime(df["ts"], utc=True)
df["exp"] = pd.to_datetime(df["exp"], 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: def puts(asset: str, df: pd.DataFrame | None = None) -> pd.DataFrame:
+5
View File
@@ -74,6 +74,11 @@ MONITORS: tuple[MonitorSpec, ...] = (
# calendario di BORSA: le gambe GTAA vengono da IB, il weekend non e' un guasto. # calendario di BORSA: le gambe GTAA vengono da IB, il weekend non e' un guasto.
MonitorSpec("paper_combo", "paper_combo", "equity.csv", 24.0, MonitorSpec("paper_combo", "paper_combo", "equity.csv", 24.0,
calendar="equity", max_age_h=120.0), calendar="equity", max_age_h=120.0),
# Raccolta catena opzioni (successore di cerbero-bite): ORARIA. Non alimenta un gate a data
# fissa ma una serie IRRECUPERABILE — un'ora non raccolta non si recupera da nessuna fonte,
# quindi la soglia d'eta' e' stretta (3h = due giri persi).
MonitorSpec("collect_chain", "chain_collect", "runs.jsonl", 1.0, max_age_h=3.0,
gate="serie irrecuperabile — la catena opzioni non si ricostruisce a posteriori"),
) )
+1 -1
View File
@@ -24,7 +24,7 @@ sys.path.insert(0, str(ROOT / "scripts" / "research"))
sys.path.insert(0, str(ROOT / "scripts" / "analysis")) sys.path.insert(0, str(ROOT / "scripts" / "analysis"))
from cblib import bs_put, f_factors, pick_legs # noqa: E402 from cblib import bs_put, f_factors, pick_legs # noqa: E402
from fetch_cb_chain import ( # noqa: E402 from certify_cb_chain import ( # noqa: E402
crossed_rate, hollow_rate, monotonicity_violations, worst_day_hollow, crossed_rate, hollow_rate, monotonicity_violations, worst_day_hollow,
) )
+139
View File
@@ -0,0 +1,139 @@
"""Test del collettore catena opzioni (successore di cerbero-bite).
Il collettore esiste per NON ripetere tre difetti misurati su bite il 2026-07-30. I test
sorvegliano esattamente quei tre, perche' sono difetti SILENZIOSI: nessuno di loro fa fallire un
giro, tutti e tre corrompono una serie irrecuperabile.
1. una chiamata fallita non deve diventare una riga che sembra un dato;
2. "book vuoto" e "chiamata fallita" non devono collassare sullo stesso valore;
3. il pacing deve esserci davvero (bite si auto-saturava il rate limit per-IP).
Nessun test tocca la rete.
"""
from __future__ import annotations
import sys
import time
from datetime import UTC, datetime
from pathlib import Path
import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "live"))
from collect_chain import Budget, _depth_top3, snapshot_row, write # noqa: E402
TS = datetime(2026, 7, 30, 20, 0, tzinfo=UTC)
INST = {
"instrument_name": "ETH-31JUL26-1750-P", "base_currency": "ETH",
"strike": 1750.0, "option_type": "put",
"expiration_timestamp": int(pd.Timestamp("2026-07-31T08:00:00Z").timestamp() * 1000),
}
def _ob(bid=0.016, ask=0.0175, bids=((0.016, 10.0),), asks=((0.0175, 12.0),)):
return {
"best_bid_price": bid, "best_ask_price": ask, "mark_iv": 52.3,
"greeks": {"delta": -0.28, "gamma": 0.001, "theta": -1.2, "vega": 0.9},
"open_interest": 500.0, "stats": {"volume": 31.0},
"bids": [list(b) for b in bids], "asks": [list(a) for a in asks],
"underlying_price": 1923.4, "index_price": 1922.7,
}
# ------------------------------------------------- 1. una chiamata fallita non e' un dato
def test_chiamata_fallita_marcata_error_e_senza_quota():
r = snapshot_row(INST, None, TS)
assert r["quote_status"] == "error"
assert r["bid"] is None and r["ask"] is None and r["iv"] is None
assert r["instrument_name"] == INST["instrument_name"], (
"la riga si scrive lo stesso: cio' che cambia e' che dichiara di non avere il dato"
)
def test_profondita_e_NULL_su_errore_mai_zero():
"""Il difetto di bite: depth 0 su chiamata fallita = indistinguibile da 'book vuoto'.
Qui su errore la profondita' non esiste, e un book davvero vuoto vale 0."""
assert snapshot_row(INST, None, TS)["book_depth_top3"] is None
vuoto = snapshot_row(INST, _ob(bid=0.0, ask=0.0, bids=(), asks=()), TS)
assert vuoto["book_depth_top3"] == 0.0
assert vuoto["quote_status"] == "no_quote"
def test_book_vuoto_e_chiamata_fallita_sono_stati_DIVERSI():
"""Controllo positivo dell'idea stessa del collettore: se questi due collassassero, la
diagnostica del 29/07 (50% di quote perse per rate-limit) sarebbe di nuovo impossibile."""
fallita = snapshot_row(INST, None, TS)["quote_status"]
vuoto = snapshot_row(INST, _ob(bid=0.0, ask=0.0, bids=(), asks=()), TS)["quote_status"]
assert fallita == "error" and vuoto == "no_quote" and fallita != vuoto
def test_un_solo_lato_quotato_e_comunque_ok():
"""Un book con solo il bid e' un mercato reale (illiquido), non un guasto."""
r = snapshot_row(INST, _ob(ask=0.0, asks=()), TS)
assert r["quote_status"] == "ok"
assert r["bid"] is not None and r["ask"] is None
assert r["mid"] is None, "senza un lato il mid non esiste: non si inventa"
def test_campi_valorizzati_quando_la_quota_c_e():
r = snapshot_row(INST, _ob(), TS)
assert r["quote_status"] == "ok"
assert r["mid"] == pytest.approx((0.016 + 0.0175) / 2)
assert r["delta"] == pytest.approx(-0.28) and r["iv"] == pytest.approx(52.3)
assert r["book_depth_top3"] == pytest.approx(22.0)
assert r["underlying_price"] == pytest.approx(1923.4)
assert r["option_type"] == "P"
def test_depth_top3_somma_solo_i_primi_tre_livelli():
assert _depth_top3([[1, 5], [2, 5], [3, 5], [4, 100]]) == pytest.approx(15.0)
assert _depth_top3([]) == 0.0
assert _depth_top3(None) is None
# ------------------------------------------------- 3. il pacing deve esistere
def test_il_budget_impone_davvero_una_cadenza():
"""Bite non aveva pacing: ~44 chiamate/s per 26s. Qui la cadenza e' una proprieta' misurabile."""
b = Budget(rps=20.0)
t0 = time.monotonic()
for _ in range(6):
b.wait()
dur = time.monotonic() - t0
assert dur >= 5 / 20.0 * 0.8, f"6 chiamate a 20/s non possono durare {dur:.3f}s"
def test_il_budget_conta_i_rate_limit_e_gli_errori():
b = Budget(rps=100.0)
b.note_error("boom")
b.rate_limited += 2
assert b.errors == 1 and b.rate_limited == 2 and b.err_samples == ["boom"]
# ------------------------------------------------- scrittura
def test_write_deduplica_e_tiene_l_ultima_osservazione(tmp_path, monkeypatch):
import collect_chain as CC
monkeypatch.setattr(CC, "STORE", tmp_path)
a = pd.DataFrame([snapshot_row(INST, _ob(bid=0.010), TS)])
b = pd.DataFrame([snapshot_row(INST, _ob(bid=0.020), TS)]) # stesso (ts, strumento)
CC.write(a, TS)
p = CC.write(b, TS)
out = pd.read_parquet(p)
assert len(out) == 1, "stesso istante e stesso strumento = una riga sola"
assert out.iloc[0]["bid"] == pytest.approx(0.020), "vince l'osservazione piu' recente"
def test_write_separa_i_giorni(tmp_path, monkeypatch):
import collect_chain as CC
monkeypatch.setattr(CC, "STORE", tmp_path)
CC.write(pd.DataFrame([snapshot_row(INST, _ob(), TS)]), TS)
dopo = TS.replace(day=31)
CC.write(pd.DataFrame([snapshot_row(INST, _ob(), dopo)]), dopo)
assert sorted(p.name for p in tmp_path.glob("*.parquet")) == \
["2026-07-30.parquet", "2026-07-31.parquet"]
+4 -1
View File
@@ -21,6 +21,7 @@ sys.path.insert(0, str(ROOT))
from src.live.monitor_health import ( # noqa: E402 from src.live.monitor_health import ( # noqa: E402
MIN_COVERAGE, MIN_COVERAGE,
MONITORS,
MonitorSpec, MonitorSpec,
alerts_from, alerts_from,
assess, assess,
@@ -170,6 +171,8 @@ def test_i_monitor_reali_sono_leggibili_e_giudicati():
"""Non asserisce che siano sani (dipende da quando gira il test): asserisce che la guardia """Non asserisce che siano sani (dipende da quando gira il test): asserisce che la guardia
li trova e si pronuncia su ognuno il fallimento silenzioso sarebbe una lista vuota.""" li trova e si pronuncia su ognuno il fallimento silenzioso sarebbe una lista vuota."""
rows = check_all(ROOT, now=NOW) rows = check_all(ROOT, now=NOW)
assert len(rows) == 6 # legato al REGISTRO, non a una costante: aggiungere un monitor e' normale (30/07:
# collect_chain), dimenticarne uno per strada no.
assert len(rows) == len(MONITORS) >= 6
assert all(r["status"] in ("OK", "NUOVO", "FERMO", "BUCATO", "ASSENTE") for r in rows) assert all(r["status"] in ("OK", "NUOVO", "FERMO", "BUCATO", "ASSENTE") for r in rows)
assert {"paper_xsr", "paper_statarb", "paper_dvolspread"} <= {r["name"] for r in rows} assert {"paper_xsr", "paper_statarb", "paper_dvolspread"} <= {r["name"] for r in rows}