diff --git a/src/cerbero_bite/state/repository.py b/src/cerbero_bite/state/repository.py index 41c53fa..1751c8f 100644 --- a/src/cerbero_bite/state/repository.py +++ b/src/cerbero_bite/state/repository.py @@ -13,7 +13,7 @@ Decimals are stored as TEXT to preserve precision (see from __future__ import annotations import sqlite3 -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from decimal import Decimal from typing import Any from uuid import UUID @@ -408,6 +408,64 @@ class Repository: ).fetchall() return [_row_to_market_snapshot(r) for r in rows] + def iv_rv_history( + self, + conn: sqlite3.Connection, + *, + asset: str, + max_days: int, + ) -> list[Decimal]: + """Lista IV-RV ordinata ASC sull'intervallo `[now-max_days, now]`. + + Esclude righe con ``fetch_ok=0`` o ``iv_minus_rv IS NULL``. + Usata dal validator quando il gate adattivo è abilitato. + """ + rows = conn.execute( + "SELECT iv_minus_rv FROM market_snapshots " + "WHERE asset = ? " + " AND fetch_ok = 1 " + " AND iv_minus_rv IS NOT NULL " + " AND timestamp >= datetime('now', ?) " + "ORDER BY timestamp ASC", + (asset, f"-{int(max_days)} days"), + ).fetchall() + return [Decimal(str(r["iv_minus_rv"])) for r in rows] + + def dvol_lookback( + self, + conn: sqlite3.Connection, + *, + asset: str, + reference: datetime, + tolerance_minutes: int = 15, + ) -> Decimal | None: + """DVOL al tick più vicino a `reference`, entro ±tolerance_minutes. + + Ritorna ``None`` se non esiste un tick valido (``fetch_ok=1``, + ``dvol IS NOT NULL``) entro la tolerance. Usato dal Vol-of-Vol + guard per stimare DVOL N ore fa. + """ + ref_lo = (reference - timedelta(minutes=tolerance_minutes)).astimezone(UTC) + ref_hi = (reference + timedelta(minutes=tolerance_minutes)).astimezone(UTC) + row = conn.execute( + "SELECT dvol, timestamp FROM market_snapshots " + "WHERE asset = ? " + " AND fetch_ok = 1 " + " AND dvol IS NOT NULL " + " AND timestamp >= ? " + " AND timestamp <= ? " + "ORDER BY ABS(julianday(timestamp) - julianday(?)) ASC LIMIT 1", + ( + asset, + _enc_dt(ref_lo), + _enc_dt(ref_hi), + _enc_dt(reference), + ), + ).fetchone() + if row is None: + return None + return Decimal(str(row["dvol"])) + # ------------------------------------------------------------------ # option_chain_snapshots # ------------------------------------------------------------------ diff --git a/tests/unit/test_repository_iv_rv_helpers.py b/tests/unit/test_repository_iv_rv_helpers.py new file mode 100644 index 0000000..3606f11 --- /dev/null +++ b/tests/unit/test_repository_iv_rv_helpers.py @@ -0,0 +1,138 @@ +"""TDD per Repository.iv_rv_history e Repository.dvol_lookback.""" + +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 + + +@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) + + 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"), + 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, + ), + ) + conn.commit() + return conn + + +def test_iv_rv_history_returns_ordered_asc(db_with_history) -> None: + repo = Repository() + history = repo.iv_rv_history(db_with_history, asset="ETH", max_days=60) + assert len(history) == 96 + assert history == sorted(history) + assert history[0] == Decimal("2.00") + + +def test_iv_rv_history_filters_other_asset(db_with_history) -> None: + repo = Repository() + history = repo.iv_rv_history(db_with_history, asset="BTC", max_days=60) + assert history == [] + + +def test_iv_rv_history_skips_null_values(db_with_history) -> 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) + 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, asset="ETH", max_days=60) + assert Decimal("99") not in history + + +def test_dvol_lookback_returns_closest_tick(db_with_history) -> 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 + ) + # i=48 → dvol = 50 + 4.8 = 54.8 + assert out == Decimal("54.8") + + +def test_dvol_lookback_returns_none_when_gap(db_with_history) -> None: + 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 + ) + assert out is None