"""TDD per :mod:`cerbero_bite.runtime.option_chain_snapshot_cycle`.""" from __future__ import annotations from datetime import UTC, datetime from decimal import Decimal from unittest.mock import AsyncMock, MagicMock import pytest from cerbero_bite.clients.deribit import InstrumentMeta from cerbero_bite.runtime.option_chain_snapshot_cycle import ( collect_option_chain_snapshot, ) from cerbero_bite.state.models import OptionChainQuoteRecord _NOW = datetime(2026, 5, 4, 13, 55, tzinfo=UTC) def _meta(name: str, strike: int, expiry_dte: int = 18) -> InstrumentMeta: expiry = _NOW.replace(hour=8, minute=0, second=0) expiry = expiry.replace(day=expiry.day) + ( # add days __import__("datetime").timedelta(days=expiry_dte) ) return InstrumentMeta( name=name, strike=Decimal(str(strike)), expiry=expiry, option_type="P", open_interest=Decimal("100"), tick_size=Decimal("0.0005"), min_trade_amount=Decimal("1"), ) def _ticker(name: str, *, mark: float = 0.020, bid: float = 0.018, ask: float = 0.022, delta: float = -0.12) -> dict: return { "instrument_name": name, "bid": bid, "ask": ask, "mark_price": mark, "mark_iv": 60.0, "volume_24h": 50, "greeks": { "delta": delta, "gamma": 0.001, "theta": -0.0005, "vega": 0.10, }, } @pytest.fixture def cfg() -> object: from cerbero_bite.config import golden_config return golden_config() @pytest.fixture def fake_ctx(cfg: object) -> MagicMock: """Mock minimal RuntimeContext.""" ctx = MagicMock() ctx.cfg = cfg ctx.db_path = ":memory:" return ctx @pytest.mark.asyncio async def test_collector_persists_one_quote_per_instrument( fake_ctx: MagicMock, ) -> None: metas = [_meta("ETH-21MAY26-2475-P", 2475), _meta("ETH-21MAY26-2400-P", 2400)] fake_ctx.deribit.options_chain = AsyncMock(return_value=metas) fake_ctx.deribit.get_tickers = AsyncMock( return_value=[_ticker(m.name) for m in metas] ) persisted: list[list[OptionChainQuoteRecord]] = [] def _record(_conn: object, qs: list[OptionChainQuoteRecord]) -> int: persisted.append(qs) return len(qs) fake_ctx.repository.record_option_chain_snapshot = _record n = await collect_option_chain_snapshot(fake_ctx, asset="ETH", now=_NOW) assert n == 2 assert len(persisted) == 1 assert {q.instrument_name for q in persisted[0]} == { "ETH-21MAY26-2475-P", "ETH-21MAY26-2400-P", } # Tutti i quote condividono il timestamp del cron tick. assert all(q.timestamp == _NOW for q in persisted[0]) @pytest.mark.asyncio async def test_collector_handles_missing_tickers_with_null_fields( fake_ctx: MagicMock, ) -> None: metas = [_meta("ETH-21MAY26-2475-P", 2475)] fake_ctx.deribit.options_chain = AsyncMock(return_value=metas) fake_ctx.deribit.get_tickers = AsyncMock(return_value=[]) # vuoto persisted: list[list[OptionChainQuoteRecord]] = [] def _record(_conn: object, qs: list[OptionChainQuoteRecord]) -> int: persisted.append(qs) return len(qs) fake_ctx.repository.record_option_chain_snapshot = _record n = await collect_option_chain_snapshot(fake_ctx, now=_NOW) assert n == 1 assert persisted[0][0].mid is None # ticker mancante ⇒ campi NULL assert persisted[0][0].instrument_name == "ETH-21MAY26-2475-P" @pytest.mark.asyncio async def test_collector_returns_zero_when_chain_empty( fake_ctx: MagicMock, ) -> None: fake_ctx.deribit.options_chain = AsyncMock(return_value=[]) n = await collect_option_chain_snapshot(fake_ctx, now=_NOW) assert n == 0 @pytest.mark.asyncio async def test_collector_swallows_chain_fetch_failure( fake_ctx: MagicMock, ) -> None: fake_ctx.deribit.options_chain = AsyncMock(side_effect=RuntimeError("boom")) n = await collect_option_chain_snapshot(fake_ctx, now=_NOW) assert n == 0 # best-effort: non rilancia