diff --git a/docs/superpowers/specs/2026-05-12-data-quality-audit-design.md b/docs/superpowers/specs/2026-05-12-data-quality-audit-design.md index 27b0037..14d4d1c 100644 --- a/docs/superpowers/specs/2026-05-12-data-quality-audit-design.md +++ b/docs/superpowers/specs/2026-05-12-data-quality-audit-design.md @@ -76,6 +76,7 @@ class ChainAuditReport: quotes_per_snap_p10: int quotes_per_snap_p90: int bid_gt_ask_count: int + ask_zero_count: int iv_null_count: int iv_null_pct: Decimal @@ -92,7 +93,8 @@ def audit_option_chain(conn, *, asset, since, now) -> ChainAuditReport: ... | market_snapshots | NULL rate per colonna | > 10% nella finestra | una metrica con >10% NULL non è affidabile per backtest | | option_chain_snapshots | snap mancanti | qualsiasi (count visibile) | cron `*/15`, ogni miss è significativo | | option_chain_snapshots | quote/snap < 50% mediana 24h | qualsiasi | rilevatore di chain truncate (mismatch with width filter) | -| option_chain_snapshots | bid > ask | qualsiasi | dato corrotto, da indagare | +| option_chain_snapshots | bid > ask (con ask > 0) | qualsiasi | dato corrotto, da indagare | +| option_chain_snapshots | ask ≤ 0 | qualsiasi | missing ask side: best ask vuoto al momento della query (≠ cross BBO) | | option_chain_snapshots | IV null/non-parseable | conteggio + % | IV è chiave per BS skew calibration | > **Nota:** il check `book_depth_top3 = 0` originariamente previsto è @@ -144,6 +146,7 @@ l'umano. Far diventare l'audit un gate CI è out of scope. snapshots: 672 expected: 672 coverage: 100.0% quotes/snap: median 55 p10 50 p90 60 bid > ask: 0 + ask <= 0: 37 (missing ask side) IV null: 12 quotes (0.03%) === BTC — ... diff --git a/src/cerbero_bite/analysis/data_audit.py b/src/cerbero_bite/analysis/data_audit.py index d024c7c..00b7122 100644 --- a/src/cerbero_bite/analysis/data_audit.py +++ b/src/cerbero_bite/analysis/data_audit.py @@ -91,6 +91,7 @@ class ChainAuditReport: quotes_per_snap_p10: int = 0 quotes_per_snap_p90: int = 0 bid_gt_ask_count: int = 0 + ask_zero_count: int = 0 iv_null_count: int = 0 iv_null_pct: Decimal = Decimal("0") @@ -286,12 +287,23 @@ def audit_option_chain( ) bid_gt_ask = 0 + ask_zero = 0 iv_null = 0 for r in quote_rows: bid_s, ask_s, iv_s = r["bid"], r["ask"], r["iv"] - if bid_s is not None and ask_s is not None: + ask_d: Decimal | None = None + if ask_s is not None: try: - if Decimal(bid_s) > Decimal(ask_s): + ask_d = Decimal(ask_s) + except (ValueError, ArithmeticError): + ask_d = None + if ask_d is not None and ask_d <= 0: + # Su Deribit ask=0 significa "nessun ordine in vendita", + # non bid>ask reale: contiamolo a parte. + ask_zero += 1 + elif ask_d is not None and bid_s is not None: + try: + if Decimal(bid_s) > ask_d: bid_gt_ask += 1 except (ValueError, ArithmeticError): pass @@ -318,6 +330,7 @@ def audit_option_chain( quotes_per_snap_p10=p10_q, quotes_per_snap_p90=p90_q, bid_gt_ask_count=bid_gt_ask, + ask_zero_count=ask_zero, iv_null_count=iv_null, iv_null_pct=iv_null_pct, ) diff --git a/src/cerbero_bite/cli.py b/src/cerbero_bite/cli.py index b769317..ddc4164 100644 --- a/src/cerbero_bite/cli.py +++ b/src/cerbero_bite/cli.py @@ -941,6 +941,7 @@ def _chain_to_dict(r: ChainAuditReport) -> dict[str, Any]: "quotes_per_snap_p10": r.quotes_per_snap_p10, "quotes_per_snap_p90": r.quotes_per_snap_p90, "bid_gt_ask_count": r.bid_gt_ask_count, + "ask_zero_count": r.ask_zero_count, "iv_null_count": r.iv_null_count, "iv_null_pct": str(r.iv_null_pct), } @@ -985,6 +986,7 @@ def _render_chain_report(asset: str, r: ChainAuditReport) -> None: f"p10 {r.quotes_per_snap_p10} p90 {r.quotes_per_snap_p90}" ) console.print(f" bid > ask: {r.bid_gt_ask_count}") + console.print(f" ask <= 0: {r.ask_zero_count} (missing ask side)") console.print( f" IV null: {r.iv_null_count} quotes ({r.iv_null_pct}%)" ) diff --git a/tests/unit/test_data_audit.py b/tests/unit/test_data_audit.py index 0ca2760..61c369f 100644 --- a/tests/unit/test_data_audit.py +++ b/tests/unit/test_data_audit.py @@ -303,6 +303,7 @@ def test_audit_chain_full_coverage(tmp_path: Path) -> None: assert report.quotes_per_snap_p10 == 10 assert report.quotes_per_snap_p90 == 10 assert report.bid_gt_ask_count == 0 + assert report.ask_zero_count == 0 assert report.iv_null_count == 0 assert report.iv_null_pct == Decimal("0") @@ -346,11 +347,65 @@ def test_audit_chain_detects_bid_gt_ask_and_iv_null(tmp_path: Path) -> None: finally: conn.close() assert report.bid_gt_ask_count == 1 + assert report.ask_zero_count == 0 assert report.iv_null_count == 1 # 1 of 3 quotes → 33.33% assert report.iv_null_pct == Decimal("33.33") +def test_audit_chain_ask_zero_counted_separately(tmp_path: Path) -> None: + conn = _make_conn(tmp_path) + since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC) + until = datetime(2026, 5, 12, 12, 30, tzinfo=UTC) + try: + with transaction(conn): + ts = since.replace(minute=0) + # ask=0 → conta in ask_zero, NON in bid_gt_ask + _seed_chain_row( + conn, + asset="ETH", + ts=ts, + instrument="ETH-A", + bid="0.05", + ask="0", + ) + # ask negativo (impossibile economicamente) → ask_zero + _seed_chain_row( + conn, + asset="ETH", + ts=ts, + instrument="ETH-B", + strike="3050", + bid="0.05", + ask="-0.01", + ) + # Crossed BBO reale (ask>0) → bid_gt_ask + _seed_chain_row( + conn, + asset="ETH", + ts=ts, + instrument="ETH-C", + strike="3100", + bid="0.10", + ask="0.05", + ) + # Normal control + _seed_chain_row( + conn, + asset="ETH", + ts=ts, + instrument="ETH-D", + strike="3150", + ) + report = audit_option_chain( + conn, asset="ETH", since=since, until=until + ) + finally: + conn.close() + assert report.ask_zero_count == 2 + assert report.bid_gt_ask_count == 1 + + def test_audit_chain_missing_snapshots(tmp_path: Path) -> None: conn = _make_conn(tmp_path) since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)