b1836d91c2
Sblocca il warmup hard del gate IV-RV adattivo (~21 giorni residui)
permettendo di mischiare cadenze diverse (tick live 15min + backfill
giornaliero) senza assumere il fattore costante 96 tick/giorno.
API change (no backwards-compat shims):
* compute_adaptive_threshold(history, *, n_days, percentile,
absolute_floor): rimossi `min_days`/`target_days`. La selezione
finestra (target_days/min_days/intera storia) si sposta al caller.
Warmup hard quando `n_days == 0`.
* repository: rimosso `iv_rv_history`; aggiunti
`count_iv_rv_distinct_days` (COUNT DISTINCT substr(ts,1,10)) e
`iv_rv_values_for_window`.
* EntryContext aggiunge `iv_rv_n_days: int = 0`. entry_cycle calcola
n_days, sceglie window_days e popola il context. Audit
`iv_rv_n_days` reale (non più len/96).
* GUI Calibrazione: counter giorni distinti tramite set di date.
* Spec aggiornata con errata 2026-05-10 e nuova warmup table.
Backfill (scripts/backfill_iv_rv.py, stdlib-only):
* Fetch DVOL daily + ETH/BTC-PERPETUAL closes da Deribit public REST.
* Calcolo RV30d annualizzato (stdev log-return × √365 × 100).
* INSERT OR REPLACE in market_snapshots con timestamp 12:00 UTC e
fetch_errors_json='{"backfill":true}' per distinzione audit.
* Compute layer testato (9 test): RV su prezzi costanti/monotoni/
alternati, build_records con cutoff e missing data.
Verifica live post-deploy (10 mag 2026 08:50 UTC):
* ETH: n_days=46, P25=2.21 vol pt, IV-RV=10.05 → gate PASS
* BTC: n_days=46, P25=5.69 vol pt, IV-RV=8.60 → gate PASS
509 test passati (500 esistenti + 9 backfill), ruff pulito.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
396 lines
11 KiB
Python
396 lines
11 KiB
Python
"""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),
|
|
)
|