8221aba10f
Risponde alla code review di 395191e:
- iv_rv_history accetta as_of (default now UTC) invece di
affidarsi al clock SQLite, rendendo i test time-stable.
- Valida max_days > 0 e raise se as_of/reference sono naive.
- Aggiunge 3 test sulle nuove guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
5.9 KiB
Python
191 lines
5.9 KiB
Python
"""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,
|
|
as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC),
|
|
)
|
|
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,
|
|
as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC),
|
|
)
|
|
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,
|
|
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,
|
|
asset="ETH",
|
|
max_days=60,
|
|
as_of=datetime(2026, 5, 4, 0, 0, tzinfo=UTC),
|
|
)
|
|
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
|
|
|
|
|
|
def test_iv_rv_history_rejects_non_positive_max_days(db_with_history) -> None:
|
|
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),
|
|
)
|
|
|
|
|
|
def test_iv_rv_history_rejects_naive_as_of(db_with_history) -> None:
|
|
repo = Repository()
|
|
with pytest.raises(ValueError, match="timezone-aware"):
|
|
repo.iv_rv_history(
|
|
db_with_history,
|
|
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:
|
|
repo = Repository()
|
|
with pytest.raises(ValueError, match="timezone-aware"):
|
|
repo.dvol_lookback(
|
|
db_with_history,
|
|
asset="ETH",
|
|
reference=datetime(2026, 5, 1, 12, 0), # naive
|
|
)
|