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,8 @@
|
||||
"""End-to-end test del gate IV-RV adattivo + Vol-of-Vol guard via Repository."""
|
||||
"""End-to-end test del gate IV-RV adattivo + Vol-of-Vol guard via Repository.
|
||||
|
||||
Verifica che la nuova API distinct-days componga correttamente repository
|
||||
helpers + ``compute_adaptive_threshold``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,6 +11,7 @@ from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from cerbero_bite.core.adaptive_threshold import compute_adaptive_threshold
|
||||
from cerbero_bite.state.db import connect, run_migrations
|
||||
from cerbero_bite.state.models import MarketSnapshotRecord
|
||||
from cerbero_bite.state.repository import Repository
|
||||
@@ -67,26 +72,49 @@ def db_30d(tmp_path):
|
||||
return conn, repo
|
||||
|
||||
|
||||
def test_iv_rv_history_p25_picks_up_recent_regime(db_30d) -> None:
|
||||
"""Sanity: con bimodale 1.0/5.0 e finestra 30g, P25 di tutta la
|
||||
storia è 1.0 (il 25° centile è ancora nella metà bassa)."""
|
||||
def test_distinct_days_count_matches_calendar_days(db_30d) -> None:
|
||||
"""30 giorni di calendario seedati → COUNT DISTINCT = 30."""
|
||||
conn, repo = db_30d
|
||||
history = repo.iv_rv_history(
|
||||
n = repo.count_iv_rv_distinct_days(
|
||||
conn,
|
||||
asset="ETH",
|
||||
max_days=60,
|
||||
as_of=datetime(2026, 5, 1, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert len(history) == 2880
|
||||
from cerbero_bite.core.adaptive_threshold import compute_adaptive_threshold
|
||||
assert n == 30
|
||||
|
||||
|
||||
def test_window_values_returned_for_full_history(db_30d) -> None:
|
||||
conn, repo = db_30d
|
||||
values = repo.iv_rv_values_for_window(
|
||||
conn,
|
||||
asset="ETH",
|
||||
window_days=60,
|
||||
as_of=datetime(2026, 5, 1, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert len(values) == 2880
|
||||
# Bimodale: 1440 valori 1.0 e 1440 valori 5.0
|
||||
assert sum(1 for v in values if v == Decimal("1.0")) == 1440
|
||||
assert sum(1 for v in values if v == Decimal("5.0")) == 1440
|
||||
|
||||
|
||||
def test_p25_of_bimodal_history_picks_low_regime(db_30d) -> None:
|
||||
"""Comporre repository + adaptive_threshold come fa entry_cycle."""
|
||||
conn, repo = db_30d
|
||||
as_of = datetime(2026, 5, 1, 0, 0, tzinfo=UTC)
|
||||
n_days = repo.count_iv_rv_distinct_days(
|
||||
conn, asset="ETH", max_days=60, as_of=as_of
|
||||
)
|
||||
values = repo.iv_rv_values_for_window(
|
||||
conn, asset="ETH", window_days=60, as_of=as_of
|
||||
)
|
||||
threshold = compute_adaptive_threshold(
|
||||
history=history,
|
||||
history=values,
|
||||
n_days=n_days,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
min_days=30,
|
||||
target_days=60,
|
||||
)
|
||||
# P25 di 2880 valori bimodali: 1440 ×1.0, 1440 ×5.0 → soglia = 1.0
|
||||
assert threshold == Decimal("1.0")
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"""TDD per :mod:`cerbero_bite.core.adaptive_threshold`.
|
||||
|
||||
Spec: ``docs/superpowers/specs/2026-05-08-iv-rv-adaptive-gate-design.md``.
|
||||
|
||||
La funzione è una pura statistica: riceve già la finestra di valori scelta
|
||||
dal caller e il numero di giorni distinti coperti dalla storia disponibile
|
||||
(``n_days``), e restituisce ``max(percentile, floor)`` o ``None`` durante
|
||||
il warmup hard. La selezione della finestra (target_days vs min_days vs
|
||||
intera storia) è responsabilità del caller (repository + entry_cycle).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,107 +17,109 @@ import pytest
|
||||
|
||||
from cerbero_bite.core.adaptive_threshold import compute_adaptive_threshold
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Warmup
|
||||
# Warmup hard: nessun giorno disponibile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_empty_history_returns_none() -> None:
|
||||
def test_n_days_zero_returns_none() -> None:
|
||||
"""Storia vuota o nessun giorno coperto → warmup hard."""
|
||||
out = compute_adaptive_threshold(
|
||||
history=[],
|
||||
n_days=0,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
min_days=30,
|
||||
target_days=60,
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
def test_history_under_one_day_returns_none() -> None:
|
||||
def test_n_days_zero_with_values_still_returns_none() -> None:
|
||||
"""Difensivo: se il caller passa n_days=0 ma valori non vuoti, warmup
|
||||
hard vince comunque (gate disabilitato)."""
|
||||
out = compute_adaptive_threshold(
|
||||
history=[Decimal("3")] * 50, # 50 tick < 96
|
||||
history=[Decimal("3")] * 10,
|
||||
n_days=0,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
min_days=30,
|
||||
target_days=60,
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
def test_history_exactly_one_day_returns_percentile() -> None:
|
||||
def test_empty_history_with_positive_n_days_returns_none() -> None:
|
||||
"""Difensivo: history vuota anche con n_days>0 → None."""
|
||||
out = compute_adaptive_threshold(
|
||||
history=[],
|
||||
n_days=5,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Calcolo percentile sulla finestra ricevuta
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_n_days_one_returns_percentile_of_history() -> None:
|
||||
"""Singolo giorno con tick a 15 min (96 valori): P25 standard."""
|
||||
history = [Decimal(i) / Decimal("10") for i in range(96)] # 0.0..9.5
|
||||
out = compute_adaptive_threshold(
|
||||
history=history,
|
||||
n_days=1,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
min_days=30,
|
||||
target_days=60,
|
||||
)
|
||||
# P25 di [0.0..9.5] passo 0.1 con method='linear': k=23.75, val ≈ 2.375
|
||||
assert out is not None
|
||||
assert Decimal("2.3") < out < Decimal("2.5")
|
||||
|
||||
|
||||
def _ramp(n: int, base: Decimal = Decimal("1")) -> list[Decimal]:
|
||||
"""Ramp lineare 1, 2, 3, ... per testare in modo predicibile il P25."""
|
||||
return [base * Decimal(i + 1) for i in range(n)]
|
||||
|
||||
|
||||
def test_below_min_days_uses_full_history() -> None:
|
||||
# 5 giorni di storia (5*96=480 tick), min_days=30, target=60.
|
||||
# Window = full history.
|
||||
history = _ramp(480)
|
||||
def test_window_chosen_by_caller_is_used_verbatim() -> None:
|
||||
"""La funzione NON fa slicing: usa esattamente la history ricevuta."""
|
||||
history = [Decimal(i) for i in range(1, 201)] # 1..200
|
||||
out = compute_adaptive_threshold(
|
||||
history=history,
|
||||
percentile=Decimal("0.25"),
|
||||
n_days=30,
|
||||
percentile=Decimal("0.5"),
|
||||
absolute_floor=Decimal("0"),
|
||||
min_days=30,
|
||||
target_days=60,
|
||||
)
|
||||
# P25 di ramp [1..480] = ~120.75 (k=479*0.25=119.75, ramp[119]=120, ramp[120]=121)
|
||||
# P50 di [1..200] = (200+1)/2 = 100.5
|
||||
assert out is not None
|
||||
assert Decimal("120") <= out <= Decimal("121")
|
||||
assert Decimal("100") <= out <= Decimal("101")
|
||||
|
||||
|
||||
def test_between_min_and_target_uses_min_window() -> None:
|
||||
# 50 giorni di storia (4800 tick), min_days=30, target=60. Window = ultimi 30g.
|
||||
history = _ramp(4800) # values 1..4800
|
||||
def test_mixed_cadence_window_no_special_treatment() -> None:
|
||||
"""Mix di valori (es. backfill daily + tick live) trattato come una
|
||||
distribuzione qualunque: il caller ha già scelto la finestra; la
|
||||
funzione calcola il percentile sui valori ricevuti uno-a-uno."""
|
||||
# 30 valori "daily backfill" (uno per giorno) + 96 tick "live"
|
||||
history = [Decimal("5")] * 30 + [Decimal("8")] * 96
|
||||
out = compute_adaptive_threshold(
|
||||
history=history,
|
||||
n_days=31,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
min_days=30,
|
||||
target_days=60,
|
||||
)
|
||||
# Window = ultimi 30*96=2880, valori 1921..4800, P25 ≈ 2640
|
||||
# Sorted: 30 ×5, 96 ×8. P25 a indice 0.25*125 = 31.25 → tra 5 e 8.
|
||||
# NumPy linear: sorted_v[31]=8, sorted_v[32]=8 → 8.
|
||||
# Verifica solo l'estremo superiore della famiglia di valori sorted.
|
||||
assert out is not None
|
||||
assert Decimal("2630") <= out <= Decimal("2650")
|
||||
assert out in (Decimal("5"), Decimal("8"))
|
||||
|
||||
|
||||
def test_above_target_uses_target_window() -> None:
|
||||
# 100 giorni (9600 tick), target=60. Window = ultimi 60g.
|
||||
history = _ramp(9600) # values 1..9600
|
||||
out = compute_adaptive_threshold(
|
||||
history=history,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
min_days=30,
|
||||
target_days=60,
|
||||
)
|
||||
# Window = ultimi 5760, valori 3841..9600, P25 ≈ 5280
|
||||
assert out is not None
|
||||
assert Decimal("5270") <= out <= Decimal("5290")
|
||||
# ---------------------------------------------------------------------------
|
||||
# Floor binding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_floor_binding_overrides_low_percentile() -> None:
|
||||
history = [Decimal("0.5")] * 200
|
||||
out = compute_adaptive_threshold(
|
||||
history=history,
|
||||
n_days=30,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("3"),
|
||||
min_days=30,
|
||||
target_days=60,
|
||||
)
|
||||
assert out == Decimal("3")
|
||||
|
||||
@@ -120,58 +128,13 @@ def test_floor_not_binding_returns_percentile() -> None:
|
||||
history = [Decimal("5")] * 200
|
||||
out = compute_adaptive_threshold(
|
||||
history=history,
|
||||
n_days=30,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
min_days=30,
|
||||
target_days=60,
|
||||
)
|
||||
assert out == Decimal("5")
|
||||
|
||||
|
||||
def test_median_percentile_returns_p50() -> None:
|
||||
history = _ramp(200)
|
||||
out = compute_adaptive_threshold(
|
||||
history=history,
|
||||
percentile=Decimal("0.5"),
|
||||
absolute_floor=Decimal("0"),
|
||||
min_days=30,
|
||||
target_days=60,
|
||||
)
|
||||
# P50 di [1..200] = (200+1)/2 = 100.5
|
||||
assert out is not None
|
||||
assert Decimal("100") <= out <= Decimal("101")
|
||||
|
||||
|
||||
def test_exactly_min_days_uses_min_window() -> None:
|
||||
"""Boundary: history == min_days*96 → window is min_days (per spec 9.1 item 9)."""
|
||||
history = _ramp(30 * 96) # exactly 2880 ticks
|
||||
out = compute_adaptive_threshold(
|
||||
history=history,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
min_days=30,
|
||||
target_days=60,
|
||||
)
|
||||
# Window = last 2880 = all history; P25 of ramp [1..2880] ≈ 720.75
|
||||
assert out is not None
|
||||
assert Decimal("720") <= out <= Decimal("721")
|
||||
|
||||
|
||||
def test_exactly_target_days_uses_target_window() -> None:
|
||||
"""Boundary: history == target_days*96 → window is target_days."""
|
||||
history = _ramp(60 * 96) # exactly 5760 ticks
|
||||
out = compute_adaptive_threshold(
|
||||
history=history,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
min_days=30,
|
||||
target_days=60,
|
||||
)
|
||||
# Window = last 5760 = all history; P25 of ramp [1..5760] ≈ 1440.75
|
||||
assert out is not None
|
||||
assert Decimal("1440") <= out <= Decimal("1441")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input validation
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -181,10 +144,9 @@ def test_invalid_percentile_above_one_raises() -> None:
|
||||
with pytest.raises(ValueError, match="percentile must be in"):
|
||||
compute_adaptive_threshold(
|
||||
history=[Decimal("1")] * 200,
|
||||
n_days=10,
|
||||
percentile=Decimal("1.5"),
|
||||
absolute_floor=Decimal("0"),
|
||||
min_days=30,
|
||||
target_days=60,
|
||||
)
|
||||
|
||||
|
||||
@@ -192,30 +154,17 @@ def test_invalid_percentile_negative_raises() -> None:
|
||||
with pytest.raises(ValueError, match="percentile must be in"):
|
||||
compute_adaptive_threshold(
|
||||
history=[Decimal("1")] * 200,
|
||||
n_days=10,
|
||||
percentile=Decimal("-0.1"),
|
||||
absolute_floor=Decimal("0"),
|
||||
min_days=30,
|
||||
target_days=60,
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_window_inverted_raises() -> None:
|
||||
with pytest.raises(ValueError, match="min_days < target_days"):
|
||||
def test_invalid_negative_n_days_raises() -> None:
|
||||
with pytest.raises(ValueError, match="n_days must be >= 0"):
|
||||
compute_adaptive_threshold(
|
||||
history=[Decimal("1")] * 200,
|
||||
history=[Decimal("1")] * 10,
|
||||
n_days=-1,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
min_days=60,
|
||||
target_days=30,
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_window_zero_raises() -> None:
|
||||
with pytest.raises(ValueError, match="min_days < target_days"):
|
||||
compute_adaptive_threshold(
|
||||
history=[Decimal("1")] * 200,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
min_days=0,
|
||||
target_days=60,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""TDD per il backfill IV-RV (``scripts/backfill_iv_rv.py``).
|
||||
|
||||
Testa solo la parte pura (compute RV + assemblaggio record). I/O HTTP
|
||||
e SQLite restano nel main del CLI: testati manualmente al deploy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _load_backfill_module() -> object:
|
||||
"""Load scripts/backfill_iv_rv.py as a module without polluting sys.path."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_cerbero_bite_backfill_iv_rv", REPO_ROOT / "scripts" / "backfill_iv_rv.py"
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("cannot load backfill_iv_rv module")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def mod():
|
||||
return _load_backfill_module()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_rv30d_annualized
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_constant_prices_yield_zero_rv(mod) -> None:
|
||||
closes = [Decimal("100")] * 31 # 30 returns of log(1)=0
|
||||
rv = mod.compute_rv30d_annualized(closes)
|
||||
assert rv == Decimal("0")
|
||||
|
||||
|
||||
def test_too_few_closes_raises(mod) -> None:
|
||||
with pytest.raises(ValueError, match="need at least 31 closes"):
|
||||
mod.compute_rv30d_annualized([Decimal("100")] * 10)
|
||||
|
||||
|
||||
def test_monotonic_growth_yields_low_rv(mod) -> None:
|
||||
"""Crescita +1% ogni giorno: log returns costanti → stdev = 0 → RV = 0."""
|
||||
closes = [Decimal("100") * (Decimal("1.01") ** i) for i in range(31)]
|
||||
rv = mod.compute_rv30d_annualized(closes)
|
||||
# Tutti i log returns sono identici (log 1.01) → stdev zero
|
||||
assert rv == Decimal("0")
|
||||
|
||||
|
||||
def test_alternating_returns_yield_known_rv(mod) -> None:
|
||||
"""Returns alternati ±2% ogni giorno: stdev nota."""
|
||||
# closes: 100, 102, 100, 102, ... (ricorda: returns = log(c[i]/c[i-1]))
|
||||
closes = [Decimal("100")] + [
|
||||
Decimal("102") if i % 2 == 0 else Decimal("100") for i in range(30)
|
||||
]
|
||||
rv = mod.compute_rv30d_annualized(closes)
|
||||
# |log return| ~ 0.0198, stdev ≈ 0.0198 (alternano segno con media ≈ 0)
|
||||
# Annualized = 0.0198 * sqrt(365) * 100 ≈ 37.86 vol pts
|
||||
assert Decimal("36") <= rv <= Decimal("40")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_backfill_records
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_records_skips_days_without_30d_history(mod) -> None:
|
||||
"""Per i primi 30 giorni della serie spot, RV30d non è calcolabile."""
|
||||
today = date(2026, 5, 10)
|
||||
days = [today - timedelta(days=i) for i in range(45)]
|
||||
spots = {d.isoformat(): Decimal("100") for d in days}
|
||||
dvols = {d.isoformat(): Decimal("50") for d in days}
|
||||
|
||||
records = mod.build_backfill_records(
|
||||
asset="ETH",
|
||||
spots_by_day=spots,
|
||||
dvols_by_day=dvols,
|
||||
oldest_day=today - timedelta(days=40),
|
||||
)
|
||||
# Per ogni record day, servono 30 giorni precedenti di spot.
|
||||
# Lo spot più vecchio è today-44; quindi il primo giorno computabile
|
||||
# è today-44+30 = today-14. Cap a oldest_day=today-40 → window day-14..day-0.
|
||||
assert len(records) == 15 # day-14..day-0 incluso
|
||||
for r in records:
|
||||
assert r.asset == "ETH"
|
||||
assert r.fetch_ok is True
|
||||
assert r.iv_minus_rv == Decimal("50") # rv=0 con prezzi costanti
|
||||
assert r.timestamp.tzinfo == UTC
|
||||
assert r.timestamp.hour == 12
|
||||
|
||||
|
||||
def test_build_records_filters_to_requested_window(mod) -> None:
|
||||
"""oldest_day applicato come cutoff inferiore inclusivo."""
|
||||
today = date(2026, 5, 10)
|
||||
days = [today - timedelta(days=i) for i in range(45)]
|
||||
spots = {d.isoformat(): Decimal("100") for d in days}
|
||||
dvols = {d.isoformat(): Decimal("50") for d in days}
|
||||
|
||||
records = mod.build_backfill_records(
|
||||
asset="BTC",
|
||||
spots_by_day=spots,
|
||||
dvols_by_day=dvols,
|
||||
oldest_day=today - timedelta(days=5),
|
||||
)
|
||||
# day-5..day-0 → 6 record
|
||||
assert len(records) == 6
|
||||
record_days = {r.timestamp.date() for r in records}
|
||||
assert record_days == {today - timedelta(days=i) for i in range(6)}
|
||||
|
||||
|
||||
def test_build_records_skips_days_missing_dvol(mod) -> None:
|
||||
"""Se manca DVOL per un giorno della finestra, lo si salta (no record)."""
|
||||
today = date(2026, 5, 10)
|
||||
days = [today - timedelta(days=i) for i in range(45)]
|
||||
spots = {d.isoformat(): Decimal("100") for d in days}
|
||||
dvols = {
|
||||
d.isoformat(): Decimal("50")
|
||||
for d in days
|
||||
if d != today - timedelta(days=2)
|
||||
}
|
||||
records = mod.build_backfill_records(
|
||||
asset="ETH",
|
||||
spots_by_day=spots,
|
||||
dvols_by_day=dvols,
|
||||
oldest_day=today - timedelta(days=5),
|
||||
)
|
||||
record_days = {r.timestamp.date() for r in records}
|
||||
assert today - timedelta(days=2) not in record_days
|
||||
assert len(records) == 5
|
||||
|
||||
|
||||
def test_build_records_skips_days_missing_spot(mod) -> None:
|
||||
"""Se manca lo spot del giorno target, no record per quel giorno."""
|
||||
today = date(2026, 5, 10)
|
||||
days = [today - timedelta(days=i) for i in range(45)]
|
||||
spots = {
|
||||
d.isoformat(): Decimal("100")
|
||||
for d in days
|
||||
if d != today - timedelta(days=2)
|
||||
}
|
||||
dvols = {d.isoformat(): Decimal("50") for d in days}
|
||||
records = mod.build_backfill_records(
|
||||
asset="ETH",
|
||||
spots_by_day=spots,
|
||||
dvols_by_day=dvols,
|
||||
oldest_day=today - timedelta(days=5),
|
||||
)
|
||||
record_days = {r.timestamp.date() for r in records}
|
||||
assert today - timedelta(days=2) not in record_days
|
||||
|
||||
|
||||
def test_build_records_uses_noon_utc_timestamp(mod) -> None:
|
||||
today = date(2026, 5, 10)
|
||||
days = [today - timedelta(days=i) for i in range(35)]
|
||||
spots = {d.isoformat(): Decimal("100") for d in days}
|
||||
dvols = {d.isoformat(): Decimal("50") for d in days}
|
||||
records = mod.build_backfill_records(
|
||||
asset="ETH",
|
||||
spots_by_day=spots,
|
||||
dvols_by_day=dvols,
|
||||
oldest_day=today,
|
||||
)
|
||||
assert len(records) == 1
|
||||
assert records[0].timestamp == datetime(2026, 5, 10, 12, 0, tzinfo=UTC)
|
||||
@@ -376,7 +376,12 @@ def test_adaptive_pass_when_iv_rv_above_p25() -> None:
|
||||
cfg = _adaptive_cfg()
|
||||
history = tuple(Decimal(i) for i in range(1, 201))
|
||||
decision = validate_entry(
|
||||
_good_ctx(iv_minus_rv=Decimal("80"), iv_rv_history=history), cfg
|
||||
_good_ctx(
|
||||
iv_minus_rv=Decimal("80"),
|
||||
iv_rv_history=history,
|
||||
iv_rv_n_days=30,
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
assert decision.accepted is True
|
||||
assert not any("IV richness" in r for r in decision.reasons)
|
||||
@@ -386,16 +391,27 @@ def test_adaptive_blocks_when_iv_rv_below_p25() -> None:
|
||||
cfg = _adaptive_cfg()
|
||||
history = tuple(Decimal(i) for i in range(1, 201))
|
||||
decision = validate_entry(
|
||||
_good_ctx(iv_minus_rv=Decimal("20"), iv_rv_history=history), cfg
|
||||
_good_ctx(
|
||||
iv_minus_rv=Decimal("20"),
|
||||
iv_rv_history=history,
|
||||
iv_rv_n_days=30,
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
assert decision.accepted is False
|
||||
assert any("IV richness" in r and "rolling" in r for r in decision.reasons)
|
||||
|
||||
|
||||
def test_adaptive_with_empty_history_passes_warmup() -> None:
|
||||
def test_adaptive_with_n_days_zero_passes_warmup() -> None:
|
||||
"""Warmup hard: nessun giorno coperto → gate skip (fail-open)."""
|
||||
cfg = _adaptive_cfg()
|
||||
decision = validate_entry(
|
||||
_good_ctx(iv_minus_rv=Decimal("0.1"), iv_rv_history=()), cfg
|
||||
_good_ctx(
|
||||
iv_minus_rv=Decimal("0.1"),
|
||||
iv_rv_history=(),
|
||||
iv_rv_n_days=0,
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
assert decision.accepted is True
|
||||
|
||||
@@ -404,7 +420,12 @@ def test_adaptive_with_floor_floor_binds_when_p25_low() -> None:
|
||||
cfg = _adaptive_cfg(iv_minus_rv_min=Decimal("3"))
|
||||
history = tuple(Decimal("0.5") for _ in range(200))
|
||||
decision = validate_entry(
|
||||
_good_ctx(iv_minus_rv=Decimal("1"), iv_rv_history=history), cfg
|
||||
_good_ctx(
|
||||
iv_minus_rv=Decimal("1"),
|
||||
iv_rv_history=history,
|
||||
iv_rv_n_days=30,
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
assert decision.accepted is False
|
||||
assert any("IV richness" in r for r in decision.reasons)
|
||||
@@ -417,7 +438,8 @@ def test_legacy_static_gate_still_works_when_adaptive_disabled() -> None:
|
||||
"iv_minus_rv_min": Decimal("3"),
|
||||
})
|
||||
decision = validate_entry(
|
||||
_good_ctx(iv_minus_rv=Decimal("2"), iv_rv_history=()), cfg
|
||||
_good_ctx(iv_minus_rv=Decimal("2"), iv_rv_history=(), iv_rv_n_days=0),
|
||||
cfg,
|
||||
)
|
||||
assert decision.accepted is False
|
||||
assert any("IV richness below floor" in r for r in decision.reasons)
|
||||
@@ -426,12 +448,45 @@ def test_legacy_static_gate_still_works_when_adaptive_disabled() -> None:
|
||||
def test_iv_minus_rv_none_skips_gate_in_both_modes() -> None:
|
||||
cfg = _adaptive_cfg()
|
||||
decision = validate_entry(
|
||||
_good_ctx(iv_minus_rv=None, iv_rv_history=tuple(Decimal(i) for i in range(1, 201))),
|
||||
_good_ctx(
|
||||
iv_minus_rv=None,
|
||||
iv_rv_history=tuple(Decimal(i) for i in range(1, 201)),
|
||||
iv_rv_n_days=30,
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
assert decision.accepted is True
|
||||
|
||||
|
||||
def test_adaptive_with_n_days_one_uses_history_for_percentile() -> None:
|
||||
"""Singolo giorno disponibile (cadenza qualunque): gate attivo,
|
||||
soglia = P25 della finestra ricevuta. Dimostra che il warmup hard
|
||||
finisce a n_days=1 (non 30 come nella vecchia implementazione)."""
|
||||
cfg = _adaptive_cfg()
|
||||
history = tuple(Decimal(i) for i in range(1, 101)) # 1..100, P25 = 25.75
|
||||
# IV-RV sopra P25 → pass
|
||||
pass_decision = validate_entry(
|
||||
_good_ctx(
|
||||
iv_minus_rv=Decimal("30"),
|
||||
iv_rv_history=history,
|
||||
iv_rv_n_days=1,
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
assert pass_decision.accepted is True
|
||||
# IV-RV sotto P25 → block
|
||||
block_decision = validate_entry(
|
||||
_good_ctx(
|
||||
iv_minus_rv=Decimal("10"),
|
||||
iv_rv_history=history,
|
||||
iv_rv_n_days=1,
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
assert block_decision.accepted is False
|
||||
assert any("IV richness" in r and "rolling" in r for r in block_decision.reasons)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vol-of-Vol guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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