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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -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())
|
||||||
Executable
+24
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user