Merge feat/option-chain-snapshots
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user