fix(analysis): distingui ask<=0 (missing ask) da bid>ask (cross reale)

Nuovo campo ChainAuditReport.ask_zero_count. La logica del loop
parsifica prima l'ask: se ask<=0 è "missing ask side" (su Deribit
significa nessun ordine in vendita), conta in ask_zero_count e non
applica il check bid>ask. Solo con ask>0 si confronta con bid.

Sul DB prod gli "bid>ask" del primo audit (27 ETH + 10 BTC negli
ultimi 7d) erano tutti falsi positivi con ask=0 — concentrati sulla
weekly 2026-05-29, OI alti ma volume basso. Dopo il fix: bid>ask=0,
ask<=0=83 ETH + 13 BTC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-05-16 19:59:16 +00:00
parent 374822a931
commit 16d73c309a
4 changed files with 76 additions and 3 deletions
@@ -76,6 +76,7 @@ class ChainAuditReport:
quotes_per_snap_p10: int quotes_per_snap_p10: int
quotes_per_snap_p90: int quotes_per_snap_p90: int
bid_gt_ask_count: int bid_gt_ask_count: int
ask_zero_count: int
iv_null_count: int iv_null_count: int
iv_null_pct: Decimal 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 | | 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 | 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 | 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 | | option_chain_snapshots | IV null/non-parseable | conteggio + % | IV è chiave per BS skew calibration |
> **Nota:** il check `book_depth_top3 = 0` originariamente previsto è > **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% snapshots: 672 expected: 672 coverage: 100.0%
quotes/snap: median 55 p10 50 p90 60 quotes/snap: median 55 p10 50 p90 60
bid > ask: 0 bid > ask: 0
ask <= 0: 37 (missing ask side)
IV null: 12 quotes (0.03%) IV null: 12 quotes (0.03%)
=== BTC — ... === BTC — ...
+15 -2
View File
@@ -91,6 +91,7 @@ class ChainAuditReport:
quotes_per_snap_p10: int = 0 quotes_per_snap_p10: int = 0
quotes_per_snap_p90: int = 0 quotes_per_snap_p90: int = 0
bid_gt_ask_count: int = 0 bid_gt_ask_count: int = 0
ask_zero_count: int = 0
iv_null_count: int = 0 iv_null_count: int = 0
iv_null_pct: Decimal = Decimal("0") iv_null_pct: Decimal = Decimal("0")
@@ -286,12 +287,23 @@ def audit_option_chain(
) )
bid_gt_ask = 0 bid_gt_ask = 0
ask_zero = 0
iv_null = 0 iv_null = 0
for r in quote_rows: for r in quote_rows:
bid_s, ask_s, iv_s = r["bid"], r["ask"], r["iv"] 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: 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 bid_gt_ask += 1
except (ValueError, ArithmeticError): except (ValueError, ArithmeticError):
pass pass
@@ -318,6 +330,7 @@ def audit_option_chain(
quotes_per_snap_p10=p10_q, quotes_per_snap_p10=p10_q,
quotes_per_snap_p90=p90_q, quotes_per_snap_p90=p90_q,
bid_gt_ask_count=bid_gt_ask, bid_gt_ask_count=bid_gt_ask,
ask_zero_count=ask_zero,
iv_null_count=iv_null, iv_null_count=iv_null,
iv_null_pct=iv_null_pct, iv_null_pct=iv_null_pct,
) )
+2
View File
@@ -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_p10": r.quotes_per_snap_p10,
"quotes_per_snap_p90": r.quotes_per_snap_p90, "quotes_per_snap_p90": r.quotes_per_snap_p90,
"bid_gt_ask_count": r.bid_gt_ask_count, "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_count": r.iv_null_count,
"iv_null_pct": str(r.iv_null_pct), "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}" 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" bid > ask: {r.bid_gt_ask_count}")
console.print(f" ask <= 0: {r.ask_zero_count} (missing ask side)")
console.print( console.print(
f" IV null: {r.iv_null_count} quotes ({r.iv_null_pct}%)" f" IV null: {r.iv_null_count} quotes ({r.iv_null_pct}%)"
) )
+55
View File
@@ -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_p10 == 10
assert report.quotes_per_snap_p90 == 10 assert report.quotes_per_snap_p90 == 10
assert report.bid_gt_ask_count == 0 assert report.bid_gt_ask_count == 0
assert report.ask_zero_count == 0
assert report.iv_null_count == 0 assert report.iv_null_count == 0
assert report.iv_null_pct == Decimal("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: finally:
conn.close() conn.close()
assert report.bid_gt_ask_count == 1 assert report.bid_gt_ask_count == 1
assert report.ask_zero_count == 0
assert report.iv_null_count == 1 assert report.iv_null_count == 1
# 1 of 3 quotes → 33.33% # 1 of 3 quotes → 33.33%
assert report.iv_null_pct == Decimal("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: def test_audit_chain_missing_snapshots(tmp_path: Path) -> None:
conn = _make_conn(tmp_path) conn = _make_conn(tmp_path)
since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC) since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC)