"""Unit tests for analysis.data_audit.""" from __future__ import annotations import sqlite3 from datetime import UTC, datetime from decimal import Decimal from pathlib import Path from cerbero_bite.analysis.data_audit import ( ChainAuditReport, MarketAuditReport, audit_market_snapshots, audit_option_chain, ) from cerbero_bite.analysis.data_audit import ( # noqa: PLC2701 _detect_gaps, _expected_ticks, _max_zero_streak, _pct, ) from cerbero_bite.state import connect, run_migrations, transaction def test_expected_ticks_basic() -> None: since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC) until = datetime(2026, 5, 12, 13, 0, tzinfo=UTC) # 12:00, 12:15, 12:30, 12:45 → 4 ticks before 13:00 assert _expected_ticks(since, until) == 4 def test_expected_ticks_inclusive_left_exclusive_right() -> None: since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC) until = datetime(2026, 5, 12, 12, 15, tzinfo=UTC) # Only 12:00 assert _expected_ticks(since, until) == 1 def test_expected_ticks_unaligned_since_rounds_up() -> None: since = datetime(2026, 5, 12, 12, 7, tzinfo=UTC) until = datetime(2026, 5, 12, 12, 30, tzinfo=UTC) # First aligned tick after 12:07 is 12:15, then 12:30 is excluded assert _expected_ticks(since, until) == 1 def test_expected_ticks_non_multiple_span_uses_ceiling() -> None: # Span = 16 min (12:00 → 12:16): ticks 12:00 AND 12:15 are < 12:16. # floor(16/15) = 1 would under-count; the correct answer is 2. since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC) until = datetime(2026, 5, 12, 12, 16, tzinfo=UTC) assert _expected_ticks(since, until) == 2 def test_expected_ticks_exact_quarter_boundary_is_excluded() -> None: # Until landing exactly on a */15 tick: that tick is NOT counted # because the window is half-open on the right. since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC) until = datetime(2026, 5, 12, 12, 45, tzinfo=UTC) # Ticks at 12:00, 12:15, 12:30 → 3 (12:45 excluded) assert _expected_ticks(since, until) == 3 def test_detect_gaps_returns_empty_when_no_gap() -> None: ts = [ datetime(2026, 5, 12, 12, 0, tzinfo=UTC), datetime(2026, 5, 12, 12, 15, tzinfo=UTC), datetime(2026, 5, 12, 12, 30, tzinfo=UTC), ] assert _detect_gaps(ts) == () def test_detect_gaps_flags_above_threshold() -> None: ts = [ datetime(2026, 5, 12, 12, 0, tzinfo=UTC), datetime(2026, 5, 12, 12, 45, tzinfo=UTC), # 45-min gap datetime(2026, 5, 12, 13, 0, tzinfo=UTC), ] gaps = _detect_gaps(ts) assert len(gaps) == 1 assert gaps[0].gap_minutes == 45 assert gaps[0].prev_timestamp == ts[0] assert gaps[0].next_timestamp == ts[1] def test_detect_gaps_ignores_threshold_boundary() -> None: # 20-min gap is exactly the threshold → NOT flagged (strict >) ts = [ datetime(2026, 5, 12, 12, 0, tzinfo=UTC), datetime(2026, 5, 12, 12, 20, tzinfo=UTC), ] assert _detect_gaps(ts) == () def test_max_zero_streak_empty() -> None: assert _max_zero_streak([]) == 0 def test_max_zero_streak_no_zeros() -> None: assert _max_zero_streak([1, 1, 1, 1]) == 0 def test_max_zero_streak_single_zero_block() -> None: assert _max_zero_streak([1, 0, 0, 0, 1, 0, 1]) == 3 def test_max_zero_streak_all_zeros() -> None: assert _max_zero_streak([0, 0, 0]) == 3 def _make_conn(tmp_path: Path) -> sqlite3.Connection: conn = connect(tmp_path / "state.sqlite") run_migrations(conn) return conn def _seed_market( conn: sqlite3.Connection, *, asset: str, ts: datetime, spot: Decimal | None = Decimal("3000"), dvol: Decimal | None = Decimal("55"), dealer_net_gamma: Decimal | None = Decimal("-50000000"), fetch_ok: int = 1, ) -> None: conn.execute( "INSERT INTO market_snapshots(timestamp, asset, spot, dvol, " "dealer_net_gamma, fetch_ok) VALUES (?,?,?,?,?,?)", ( ts.isoformat(), asset, str(spot) if spot is not None else None, str(dvol) if dvol is not None else None, str(dealer_net_gamma) if dealer_net_gamma is not None else None, fetch_ok, ), ) def test_audit_market_full_coverage(tmp_path: Path) -> None: conn = _make_conn(tmp_path) since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC) until = datetime(2026, 5, 12, 13, 0, tzinfo=UTC) try: with transaction(conn): for minute in (0, 15, 30, 45): _seed_market( conn, asset="ETH", ts=since.replace(minute=minute), ) report = audit_market_snapshots( conn, asset="ETH", since=since, until=until ) finally: conn.close() assert report.asset == "ETH" assert report.expected_ticks == 4 assert report.actual_ticks == 4 assert report.coverage_pct == Decimal("100") assert report.gaps == () assert report.fetch_ok_zero_count == 0 assert report.max_fetch_ok_zero_streak == 0 def test_audit_market_detects_gap_and_streak(tmp_path: Path) -> None: conn = _make_conn(tmp_path) since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC) until = datetime(2026, 5, 12, 13, 30, tzinfo=UTC) try: with transaction(conn): # 12:00 OK, 12:15 OK, gap (12:30, 12:45 missing), 13:00 fail, # 13:15 fail, 13:30 outside window. _seed_market(conn, asset="ETH", ts=since.replace(minute=0)) _seed_market(conn, asset="ETH", ts=since.replace(minute=15)) _seed_market( conn, asset="ETH", ts=since.replace(hour=13, minute=0), fetch_ok=0, ) _seed_market( conn, asset="ETH", ts=since.replace(hour=13, minute=15), fetch_ok=0, ) report = audit_market_snapshots( conn, asset="ETH", since=since, until=until ) finally: conn.close() assert report.expected_ticks == 6 assert report.actual_ticks == 4 # 12:15 → 13:00 is a 45-min gap → flagged assert len(report.gaps) == 1 assert report.gaps[0].gap_minutes == 45 assert report.fetch_ok_zero_count == 2 assert report.max_fetch_ok_zero_streak == 2 def test_audit_market_null_rate_per_column(tmp_path: Path) -> None: conn = _make_conn(tmp_path) since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC) until = datetime(2026, 5, 12, 13, 0, tzinfo=UTC) try: with transaction(conn): # 4 ticks, 1 with dealer_net_gamma=NULL → 25% null for minute in (0, 15, 30, 45): _seed_market( conn, asset="ETH", ts=since.replace(minute=minute), dealer_net_gamma=None if minute == 30 else Decimal("-50000000"), ) report = audit_market_snapshots( conn, asset="ETH", since=since, until=until ) finally: conn.close() assert report.null_rate_by_column["dealer_net_gamma"] == Decimal("0.25") assert report.null_rate_by_column["spot"] == Decimal("0") def test_pct_empty_returns_zero() -> None: assert _pct([], 50) == 0 def test_pct_single_value_returns_that_value() -> None: assert _pct([42], 50) == 42 assert _pct([42], 10) == 42 assert _pct([42], 90) == 42 def test_pct_median_odd_count() -> None: assert _pct([10, 20, 30, 40, 50], 50) == 30 def test_pct_p10_p90_on_100_values() -> None: # inclusive method on [1..100]: # p10 interpolates to 10.9 → 11; p90 interpolates to 90.1 → 90. values = list(range(1, 101)) assert _pct(values, 10) == 11 assert _pct(values, 90) == 90 def _seed_chain_row( conn: sqlite3.Connection, *, asset: str, ts: datetime, instrument: str, strike: str = "3000", expiry: datetime | None = None, option_type: str = "C", bid: str | None = "0.01", ask: str | None = "0.02", iv: str | None = "0.7", ) -> None: if expiry is None: expiry = datetime(2026, 6, 12, 8, 0, tzinfo=UTC) conn.execute( "INSERT INTO option_chain_snapshots(timestamp, asset, instrument_name, " "strike, expiry, option_type, bid, ask, iv) " "VALUES (?,?,?,?,?,?,?,?,?)", ( ts.isoformat(), asset, instrument, strike, expiry.isoformat(), option_type, bid, ask, iv, ), ) def test_audit_chain_full_coverage(tmp_path: Path) -> None: conn = _make_conn(tmp_path) since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC) until = datetime(2026, 5, 12, 13, 0, tzinfo=UTC) try: with transaction(conn): for minute in (0, 15, 30, 45): ts = since.replace(minute=minute) for i in range(10): _seed_chain_row( conn, asset="ETH", ts=ts, instrument=f"ETH-EXP-{i}-C", strike=str(3000 + i * 50), ) report = audit_option_chain( conn, asset="ETH", since=since, until=until ) finally: conn.close() assert isinstance(report, ChainAuditReport) assert report.asset == "ETH" assert report.expected_snapshots == 4 assert report.actual_snapshots == 4 assert report.coverage_pct == Decimal("100") assert report.quotes_per_snap_median == 10 assert report.quotes_per_snap_p10 == 10 assert report.quotes_per_snap_p90 == 10 assert report.bid_gt_ask_count == 0 assert report.iv_null_count == 0 assert report.iv_null_pct == Decimal("0") def test_audit_chain_detects_bid_gt_ask_and_iv_null(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) # Crossed BBO: bid > ask _seed_chain_row( conn, asset="ETH", ts=ts, instrument="ETH-A", bid="0.10", ask="0.05", ) # Missing IV _seed_chain_row( conn, asset="ETH", ts=ts, instrument="ETH-B", strike="3050", iv=None, ) # Normal row (control) _seed_chain_row( conn, asset="ETH", ts=ts, instrument="ETH-C", strike="3100", ) report = audit_option_chain( conn, asset="ETH", since=since, until=until ) finally: conn.close() assert report.bid_gt_ask_count == 1 assert report.iv_null_count == 1 # 1 of 3 quotes → 33.33% assert report.iv_null_pct == Decimal("33.33") def test_audit_chain_missing_snapshots(tmp_path: Path) -> None: conn = _make_conn(tmp_path) since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC) until = datetime(2026, 5, 12, 13, 0, tzinfo=UTC) try: with transaction(conn): # Only 2 of the expected 4 ticks have data for minute in (0, 15): _seed_chain_row( conn, asset="ETH", ts=since.replace(minute=minute), instrument=f"ETH-{minute}", ) report = audit_option_chain( conn, asset="ETH", since=since, until=until ) finally: conn.close() assert report.expected_snapshots == 4 assert report.actual_snapshots == 2 assert report.coverage_pct == Decimal("50") def test_audit_chain_empty_window_returns_zero_coverage(tmp_path: Path) -> None: conn = _make_conn(tmp_path) since = datetime(2026, 5, 12, 12, 0, tzinfo=UTC) until = datetime(2026, 5, 12, 13, 0, tzinfo=UTC) try: report = audit_option_chain( conn, asset="ETH", since=since, until=until ) finally: conn.close() assert report.expected_snapshots == 4 assert report.actual_snapshots == 0 assert report.coverage_pct == Decimal("0") assert report.quotes_per_snap_median == 0 assert report.bid_gt_ask_count == 0 assert report.iv_null_count == 0