feat(state): Repository.iv_rv_history + dvol_lookback per gate adaptive
Due nuovi metodi che leggono market_snapshots filtrando NULL e fetch_ok=0. iv_rv_history limita a max_days; dvol_lookback trova il tick più vicino a un istante con tolerance configurabile. Tests: ordered ASC, asset filter, NULL skip, fetch_ok=0 skip, lookback closest, gap returns None. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user