"""TDD per i nuovi helper repository del gate IV-RV adattivo. Spec: distinct-days policy — il caller (entry_cycle) interroga il numero di giorni coperti separatamente dai valori della finestra, così che cadenze miste (tick live 15min + backfill daily) restino statisticamente coerenti. Helpers: * ``count_iv_rv_distinct_days(asset, max_days, as_of) -> int`` * ``iv_rv_values_for_window(asset, window_days, as_of) -> list[Decimal]`` * ``dvol_lookback`` (invariato, riusato dal Vol-of-Vol guard) """ from __future__ import annotations import sqlite3 from datetime import UTC, datetime, timedelta from decimal import Decimal import pytest from cerbero_bite.state.db import connect, run_migrations from cerbero_bite.state.models import MarketSnapshotRecord from cerbero_bite.state.repository import Repository def _snap( *, ts: datetime, asset: str = "ETH", iv_minus_rv: Decimal | None = Decimal("2"), fetch_ok: bool = True, dvol: Decimal = Decimal("50"), ) -> MarketSnapshotRecord: return MarketSnapshotRecord( timestamp=ts, asset=asset, spot=Decimal("2000"), dvol=dvol, realized_vol_30d=Decimal("48"), iv_minus_rv=iv_minus_rv, funding_perp_annualized=Decimal("0"), funding_cross_annualized=Decimal("0"), dealer_net_gamma=Decimal("0"), gamma_flip_level=None, oi_delta_pct_4h=None, liquidation_long_risk="low", liquidation_short_risk="low", macro_days_to_event=None, fetch_ok=fetch_ok, fetch_errors_json=None, ) @pytest.fixture def db_one_day(tmp_path) -> sqlite3.Connection: """SQLite temp con 96 tick ETH a 15min (1 giorno) e fetch_ok=1.""" conn = connect(str(tmp_path / "test.sqlite")) run_migrations(conn) repo = Repository() base = datetime(2026, 5, 1, 0, 0, tzinfo=UTC) for i in range(96): repo.record_market_snapshot( conn, _snap( ts=base + timedelta(minutes=15 * i), iv_minus_rv=Decimal("2") + Decimal(i) / Decimal("100"), dvol=Decimal("50") + Decimal(i) / Decimal("10"), ), ) conn.commit() return conn @pytest.fixture def db_three_days_mixed(tmp_path) -> sqlite3.Connection: """SQLite temp con 3 giorni ETH: - day1 (2026-05-01): 96 tick @ 15min, valori 1..96 - day2 (2026-05-02): 1 record daily a 12:00, valore 100 (backfill style) - day3 (2026-05-03): 4 tick orari, valori 200, 201, 202, 203 Più 1 giorno BTC isolato (per cross-asset isolation). """ conn = connect(str(tmp_path / "test.sqlite")) run_migrations(conn) repo = Repository() day1 = datetime(2026, 5, 1, 0, 0, tzinfo=UTC) for i in range(96): repo.record_market_snapshot( conn, _snap( ts=day1 + timedelta(minutes=15 * i), iv_minus_rv=Decimal(i + 1), ), ) repo.record_market_snapshot( conn, _snap(ts=datetime(2026, 5, 2, 12, 0, tzinfo=UTC), iv_minus_rv=Decimal("100")), ) day3 = datetime(2026, 5, 3, 0, 0, tzinfo=UTC) for i in range(4): repo.record_market_snapshot( conn, _snap( ts=day3 + timedelta(hours=i), iv_minus_rv=Decimal(200 + i), ), ) repo.record_market_snapshot( conn, _snap( ts=datetime(2026, 4, 30, 0, 0, tzinfo=UTC), asset="BTC", iv_minus_rv=Decimal("999"), ), ) conn.commit() return conn # --------------------------------------------------------------------------- # count_iv_rv_distinct_days # --------------------------------------------------------------------------- def test_count_distinct_days_returns_one_for_single_day_history(db_one_day) -> None: repo = Repository() n = repo.count_iv_rv_distinct_days( db_one_day, asset="ETH", max_days=60, as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC), ) assert n == 1 def test_count_distinct_days_returns_zero_for_other_asset(db_one_day) -> None: repo = Repository() n = repo.count_iv_rv_distinct_days( db_one_day, asset="BTC", max_days=60, as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC), ) assert n == 0 def test_count_distinct_days_counts_unique_calendar_days( db_three_days_mixed, ) -> None: repo = Repository() n = repo.count_iv_rv_distinct_days( db_three_days_mixed, asset="ETH", max_days=60, as_of=datetime(2026, 5, 4, 0, 0, tzinfo=UTC), ) assert n == 3 def test_count_distinct_days_excludes_other_assets( db_three_days_mixed, ) -> None: repo = Repository() n_btc = repo.count_iv_rv_distinct_days( db_three_days_mixed, asset="BTC", max_days=60, as_of=datetime(2026, 5, 4, 0, 0, tzinfo=UTC), ) assert n_btc == 1 def test_count_distinct_days_respects_window_cutoff( db_three_days_mixed, ) -> None: """max_days=1 da as_of=2026-05-04 → cutoff=2026-05-03 → solo day3.""" repo = Repository() n = repo.count_iv_rv_distinct_days( db_three_days_mixed, asset="ETH", max_days=1, as_of=datetime(2026, 5, 4, 0, 0, tzinfo=UTC), ) assert n == 1 def test_count_distinct_days_excludes_null_iv_rv(tmp_path) -> None: conn = connect(str(tmp_path / "test.sqlite")) run_migrations(conn) repo = Repository() repo.record_market_snapshot( conn, _snap(ts=datetime(2026, 5, 1, 12, 0, tzinfo=UTC), iv_minus_rv=None), ) conn.commit() n = repo.count_iv_rv_distinct_days( conn, asset="ETH", max_days=60, as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC), ) assert n == 0 def test_count_distinct_days_excludes_fetch_failed(tmp_path) -> None: conn = connect(str(tmp_path / "test.sqlite")) run_migrations(conn) repo = Repository() repo.record_market_snapshot( conn, _snap( ts=datetime(2026, 5, 1, 12, 0, tzinfo=UTC), iv_minus_rv=Decimal("99"), fetch_ok=False, ), ) conn.commit() n = repo.count_iv_rv_distinct_days( conn, asset="ETH", max_days=60, as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC), ) assert n == 0 def test_count_distinct_days_rejects_naive_as_of(db_one_day) -> None: repo = Repository() with pytest.raises(ValueError, match="timezone-aware"): repo.count_iv_rv_distinct_days( db_one_day, asset="ETH", max_days=60, as_of=datetime(2026, 5, 2, 0, 0), # naive ) def test_count_distinct_days_rejects_non_positive_max_days(db_one_day) -> None: repo = Repository() with pytest.raises(ValueError, match="max_days must be positive"): repo.count_iv_rv_distinct_days( db_one_day, asset="ETH", max_days=0, as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC), ) # --------------------------------------------------------------------------- # iv_rv_values_for_window # --------------------------------------------------------------------------- def test_values_for_window_returns_ordered_asc(db_one_day) -> None: repo = Repository() values = repo.iv_rv_values_for_window( db_one_day, asset="ETH", window_days=60, as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC), ) assert len(values) == 96 assert values == sorted(values) assert values[0] == Decimal("2.00") def test_values_for_window_filters_other_asset(db_one_day) -> None: repo = Repository() values = repo.iv_rv_values_for_window( db_one_day, asset="BTC", window_days=60, as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC), ) assert values == [] def test_values_for_window_skips_null(db_one_day) -> None: repo = Repository() repo.record_market_snapshot( db_one_day, _snap(ts=datetime(2026, 5, 2, 0, 0, tzinfo=UTC), iv_minus_rv=None), ) db_one_day.commit() values = repo.iv_rv_values_for_window( db_one_day, asset="ETH", window_days=60, as_of=datetime(2026, 5, 3, 0, 0, tzinfo=UTC), ) assert len(values) == 96 def test_values_for_window_skips_fetch_failed(db_one_day) -> None: repo = Repository() repo.record_market_snapshot( db_one_day, _snap( ts=datetime(2026, 5, 3, 0, 0, tzinfo=UTC), iv_minus_rv=Decimal("99"), fetch_ok=False, ), ) db_one_day.commit() values = repo.iv_rv_values_for_window( db_one_day, asset="ETH", window_days=60, as_of=datetime(2026, 5, 4, 0, 0, tzinfo=UTC), ) assert Decimal("99") not in values def test_values_for_window_respects_window_cutoff( db_three_days_mixed, ) -> None: """window_days=1 da as_of=2026-05-04 → solo day3 (4 valori 200..203).""" repo = Repository() values = repo.iv_rv_values_for_window( db_three_days_mixed, asset="ETH", window_days=1, as_of=datetime(2026, 5, 4, 0, 0, tzinfo=UTC), ) assert values == [Decimal(200 + i) for i in range(4)] def test_values_for_window_full_window(db_three_days_mixed) -> None: """window_days=60: tutti i valori dei 3 giorni (96 + 1 + 4 = 101).""" repo = Repository() values = repo.iv_rv_values_for_window( db_three_days_mixed, asset="ETH", window_days=60, as_of=datetime(2026, 5, 4, 0, 0, tzinfo=UTC), ) assert len(values) == 101 def test_values_for_window_rejects_naive_as_of(db_one_day) -> None: repo = Repository() with pytest.raises(ValueError, match="timezone-aware"): repo.iv_rv_values_for_window( db_one_day, asset="ETH", window_days=60, as_of=datetime(2026, 5, 2, 0, 0), ) def test_values_for_window_rejects_non_positive_window(db_one_day) -> None: repo = Repository() with pytest.raises(ValueError, match="window_days must be positive"): repo.iv_rv_values_for_window( db_one_day, asset="ETH", window_days=0, as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC), ) # --------------------------------------------------------------------------- # dvol_lookback (regression — invariato dopo refactor) # --------------------------------------------------------------------------- def test_dvol_lookback_returns_closest_tick(db_one_day) -> None: repo = Repository() base = datetime(2026, 5, 1, 0, 0, tzinfo=UTC) target = base + timedelta(hours=12) out = repo.dvol_lookback( db_one_day, asset="ETH", reference=target, tolerance_minutes=15 ) # i=48 → dvol = 50 + 4.8 = 54.8 assert out == Decimal("54.8") def test_dvol_lookback_returns_none_when_gap(db_one_day) -> None: repo = Repository() target = datetime(2025, 1, 1, 0, 0, tzinfo=UTC) out = repo.dvol_lookback( db_one_day, asset="ETH", reference=target, tolerance_minutes=15 ) assert out is None def test_dvol_lookback_rejects_naive_reference(db_one_day) -> None: repo = Repository() with pytest.raises(ValueError, match="timezone-aware"): repo.dvol_lookback( db_one_day, asset="ETH", reference=datetime(2026, 5, 1, 12, 0), )