refactor(core): IV-RV adattivo distinct-days policy + backfill Deribit
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>
This commit is contained in:
@@ -1,4 +1,15 @@
|
||||
"""TDD per Repository.iv_rv_history e Repository.dvol_lookback."""
|
||||
"""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
|
||||
|
||||
@@ -13,178 +24,372 @@ from cerbero_bite.state.models import MarketSnapshotRecord
|
||||
from cerbero_bite.state.repository import Repository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_with_history(tmp_path) -> sqlite3.Connection:
|
||||
"""SQLite temp con 96 tick ETH a 15min ciascuno (1 giorno) e fetch_ok=1."""
|
||||
db_path = tmp_path / "test.sqlite"
|
||||
conn = connect(str(db_path))
|
||||
run_migrations(conn)
|
||||
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,
|
||||
MarketSnapshotRecord(
|
||||
timestamp=base + timedelta(minutes=15 * i),
|
||||
asset="ETH",
|
||||
spot=Decimal("2000"),
|
||||
dvol=Decimal("50") + Decimal(i) / Decimal("10"),
|
||||
realized_vol_30d=Decimal("48"),
|
||||
_snap(
|
||||
ts=base + timedelta(minutes=15 * i),
|
||||
iv_minus_rv=Decimal("2") + Decimal(i) / Decimal("100"),
|
||||
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=True,
|
||||
fetch_errors_json=None,
|
||||
dvol=Decimal("50") + Decimal(i) / Decimal("10"),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def test_iv_rv_history_returns_ordered_asc(db_with_history) -> None:
|
||||
@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()
|
||||
history = repo.iv_rv_history(
|
||||
db_with_history,
|
||||
|
||||
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 len(history) == 96
|
||||
assert history == sorted(history)
|
||||
assert history[0] == Decimal("2.00")
|
||||
assert n == 1
|
||||
|
||||
|
||||
def test_iv_rv_history_filters_other_asset(db_with_history) -> None:
|
||||
def test_count_distinct_days_returns_zero_for_other_asset(db_one_day) -> None:
|
||||
repo = Repository()
|
||||
history = repo.iv_rv_history(
|
||||
db_with_history,
|
||||
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 history == []
|
||||
assert n == 0
|
||||
|
||||
|
||||
def test_iv_rv_history_skips_null_values(db_with_history) -> None:
|
||||
def test_count_distinct_days_counts_unique_calendar_days(
|
||||
db_three_days_mixed,
|
||||
) -> None:
|
||||
repo = Repository()
|
||||
repo.record_market_snapshot(
|
||||
db_with_history,
|
||||
MarketSnapshotRecord(
|
||||
timestamp=datetime(2026, 5, 2, 0, 0, tzinfo=UTC),
|
||||
asset="ETH",
|
||||
spot=Decimal("2000"),
|
||||
dvol=Decimal("50"),
|
||||
realized_vol_30d=None,
|
||||
iv_minus_rv=None,
|
||||
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=True,
|
||||
fetch_errors_json=None,
|
||||
),
|
||||
)
|
||||
db_with_history.commit()
|
||||
history = repo.iv_rv_history(
|
||||
db_with_history,
|
||||
asset="ETH",
|
||||
max_days=60,
|
||||
as_of=datetime(2026, 5, 3, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert len(history) == 96
|
||||
|
||||
|
||||
def test_iv_rv_history_skips_fetch_failed(db_with_history) -> None:
|
||||
repo = Repository()
|
||||
repo.record_market_snapshot(
|
||||
db_with_history,
|
||||
MarketSnapshotRecord(
|
||||
timestamp=datetime(2026, 5, 3, 0, 0, tzinfo=UTC),
|
||||
asset="ETH",
|
||||
spot=Decimal("2000"),
|
||||
dvol=Decimal("50"),
|
||||
realized_vol_30d=None,
|
||||
iv_minus_rv=Decimal("99"),
|
||||
funding_perp_annualized=Decimal("0"),
|
||||
funding_cross_annualized=Decimal("0"),
|
||||
dealer_net_gamma=None,
|
||||
gamma_flip_level=None,
|
||||
oi_delta_pct_4h=None,
|
||||
liquidation_long_risk=None,
|
||||
liquidation_short_risk=None,
|
||||
macro_days_to_event=None,
|
||||
fetch_ok=False,
|
||||
fetch_errors_json='{"x":"y"}',
|
||||
),
|
||||
)
|
||||
db_with_history.commit()
|
||||
history = repo.iv_rv_history(
|
||||
db_with_history,
|
||||
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 Decimal("99") not in history
|
||||
assert n == 3
|
||||
|
||||
|
||||
def test_dvol_lookback_returns_closest_tick(db_with_history) -> None:
|
||||
def test_count_distinct_days_excludes_other_assets(
|
||||
db_three_days_mixed,
|
||||
) -> None:
|
||||
repo = Repository()
|
||||
base = datetime(2026, 5, 1, 0, 0, tzinfo=UTC)
|
||||
target = base + timedelta(hours=12)
|
||||
out = repo.dvol_lookback(
|
||||
db_with_history, asset="ETH", reference=target, tolerance_minutes=15
|
||||
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),
|
||||
)
|
||||
# i=48 → dvol = 50 + 4.8 = 54.8
|
||||
assert out == Decimal("54.8")
|
||||
assert n_btc == 1
|
||||
|
||||
|
||||
def test_dvol_lookback_returns_none_when_gap(db_with_history) -> None:
|
||||
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()
|
||||
target = datetime(2025, 1, 1, 0, 0, tzinfo=UTC)
|
||||
out = repo.dvol_lookback(
|
||||
db_with_history, asset="ETH", reference=target, tolerance_minutes=15
|
||||
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 out is None
|
||||
assert n == 1
|
||||
|
||||
|
||||
def test_iv_rv_history_rejects_non_positive_max_days(db_with_history) -> None:
|
||||
def test_count_distinct_days_excludes_null_iv_rv(tmp_path) -> None:
|
||||
conn = connect(str(tmp_path / "test.sqlite"))
|
||||
run_migrations(conn)
|
||||
repo = Repository()
|
||||
with pytest.raises(ValueError, match="max_days must be positive"):
|
||||
repo.iv_rv_history(
|
||||
db_with_history,
|
||||
asset="ETH",
|
||||
max_days=0,
|
||||
as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
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_iv_rv_history_rejects_naive_as_of(db_with_history) -> None:
|
||||
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.iv_rv_history(
|
||||
db_with_history,
|
||||
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_dvol_lookback_rejects_naive_reference(db_with_history) -> None:
|
||||
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_with_history,
|
||||
db_one_day,
|
||||
asset="ETH",
|
||||
reference=datetime(2026, 5, 1, 12, 0), # naive
|
||||
reference=datetime(2026, 5, 1, 12, 0),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user