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:
@@ -24,7 +24,7 @@ sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "analysis"))
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
MIN_COVERAGE,
|
||||
MONITORS,
|
||||
MonitorSpec,
|
||||
alerts_from,
|
||||
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
|
||||
li trova e si pronuncia su ognuno — il fallimento silenzioso sarebbe una lista vuota."""
|
||||
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 {"paper_xsr", "paper_statarb", "paper_dvolspread"} <= {r["name"] for r in rows}
|
||||
|
||||
Reference in New Issue
Block a user