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
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -408,6 +408,64 @@ class Repository:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [_row_to_market_snapshot(r) for r in rows]
|
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
|
# 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