"""TDD per il backfill IV-RV (``scripts/backfill_iv_rv.py``). Testa solo la parte pura (compute RV + assemblaggio record). I/O HTTP e SQLite restano nel main del CLI: testati manualmente al deploy. """ from __future__ import annotations import importlib.util import sys from datetime import UTC, date, datetime, timedelta from decimal import Decimal from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parents[2] def _load_backfill_module() -> object: """Load scripts/backfill_iv_rv.py as a module without polluting sys.path.""" spec = importlib.util.spec_from_file_location( "_cerbero_bite_backfill_iv_rv", REPO_ROOT / "scripts" / "backfill_iv_rv.py" ) if spec is None or spec.loader is None: raise RuntimeError("cannot load backfill_iv_rv module") module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module @pytest.fixture(scope="module") def mod(): return _load_backfill_module() # --------------------------------------------------------------------------- # compute_rv30d_annualized # --------------------------------------------------------------------------- def test_constant_prices_yield_zero_rv(mod) -> None: closes = [Decimal("100")] * 31 # 30 returns of log(1)=0 rv = mod.compute_rv30d_annualized(closes) assert rv == Decimal("0") def test_too_few_closes_raises(mod) -> None: with pytest.raises(ValueError, match="need at least 31 closes"): mod.compute_rv30d_annualized([Decimal("100")] * 10) def test_monotonic_growth_yields_low_rv(mod) -> None: """Crescita +1% ogni giorno: log returns costanti → stdev = 0 → RV = 0.""" closes = [Decimal("100") * (Decimal("1.01") ** i) for i in range(31)] rv = mod.compute_rv30d_annualized(closes) # Tutti i log returns sono identici (log 1.01) → stdev zero assert rv == Decimal("0") def test_alternating_returns_yield_known_rv(mod) -> None: """Returns alternati ±2% ogni giorno: stdev nota.""" # closes: 100, 102, 100, 102, ... (ricorda: returns = log(c[i]/c[i-1])) closes = [Decimal("100")] + [ Decimal("102") if i % 2 == 0 else Decimal("100") for i in range(30) ] rv = mod.compute_rv30d_annualized(closes) # |log return| ~ 0.0198, stdev ≈ 0.0198 (alternano segno con media ≈ 0) # Annualized = 0.0198 * sqrt(365) * 100 ≈ 37.86 vol pts assert Decimal("36") <= rv <= Decimal("40") # --------------------------------------------------------------------------- # build_backfill_records # --------------------------------------------------------------------------- def test_build_records_skips_days_without_30d_history(mod) -> None: """Per i primi 30 giorni della serie spot, RV30d non è calcolabile.""" today = date(2026, 5, 10) days = [today - timedelta(days=i) for i in range(45)] spots = {d.isoformat(): Decimal("100") for d in days} dvols = {d.isoformat(): Decimal("50") for d in days} records = mod.build_backfill_records( asset="ETH", spots_by_day=spots, dvols_by_day=dvols, oldest_day=today - timedelta(days=40), ) # Per ogni record day, servono 30 giorni precedenti di spot. # Lo spot più vecchio è today-44; quindi il primo giorno computabile # è today-44+30 = today-14. Cap a oldest_day=today-40 → window day-14..day-0. assert len(records) == 15 # day-14..day-0 incluso for r in records: assert r.asset == "ETH" assert r.fetch_ok is True assert r.iv_minus_rv == Decimal("50") # rv=0 con prezzi costanti assert r.timestamp.tzinfo == UTC assert r.timestamp.hour == 12 def test_build_records_filters_to_requested_window(mod) -> None: """oldest_day applicato come cutoff inferiore inclusivo.""" today = date(2026, 5, 10) days = [today - timedelta(days=i) for i in range(45)] spots = {d.isoformat(): Decimal("100") for d in days} dvols = {d.isoformat(): Decimal("50") for d in days} records = mod.build_backfill_records( asset="BTC", spots_by_day=spots, dvols_by_day=dvols, oldest_day=today - timedelta(days=5), ) # day-5..day-0 → 6 record assert len(records) == 6 record_days = {r.timestamp.date() for r in records} assert record_days == {today - timedelta(days=i) for i in range(6)} def test_build_records_skips_days_missing_dvol(mod) -> None: """Se manca DVOL per un giorno della finestra, lo si salta (no record).""" today = date(2026, 5, 10) days = [today - timedelta(days=i) for i in range(45)] spots = {d.isoformat(): Decimal("100") for d in days} dvols = { d.isoformat(): Decimal("50") for d in days if d != today - timedelta(days=2) } records = mod.build_backfill_records( asset="ETH", spots_by_day=spots, dvols_by_day=dvols, oldest_day=today - timedelta(days=5), ) record_days = {r.timestamp.date() for r in records} assert today - timedelta(days=2) not in record_days assert len(records) == 5 def test_build_records_skips_days_missing_spot(mod) -> None: """Se manca lo spot del giorno target, no record per quel giorno.""" today = date(2026, 5, 10) days = [today - timedelta(days=i) for i in range(45)] spots = { d.isoformat(): Decimal("100") for d in days if d != today - timedelta(days=2) } dvols = {d.isoformat(): Decimal("50") for d in days} records = mod.build_backfill_records( asset="ETH", spots_by_day=spots, dvols_by_day=dvols, oldest_day=today - timedelta(days=5), ) record_days = {r.timestamp.date() for r in records} assert today - timedelta(days=2) not in record_days def test_build_records_uses_noon_utc_timestamp(mod) -> None: today = date(2026, 5, 10) days = [today - timedelta(days=i) for i in range(35)] spots = {d.isoformat(): Decimal("100") for d in days} dvols = {d.isoformat(): Decimal("50") for d in days} records = mod.build_backfill_records( asset="ETH", spots_by_day=spots, dvols_by_day=dvols, oldest_day=today, ) assert len(records) == 1 assert records[0].timestamp == datetime(2026, 5, 10, 12, 0, tzinfo=UTC)