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:
@@ -2,8 +2,13 @@
|
||||
|
||||
Spec: ``docs/superpowers/specs/2026-05-08-iv-rv-adaptive-gate-design.md``.
|
||||
|
||||
Deterministic, no I/O. La query del repository è effettuata dal caller
|
||||
(``runtime/entry_cycle``) prima di chiamare questa funzione.
|
||||
Deterministic, no I/O. La selezione della finestra (target_days vs
|
||||
min_days vs intera storia disponibile) è responsabilità del caller, che
|
||||
interroga il repository con i parametri corretti e passa qui sia i
|
||||
valori (``history``) sia il numero di giorni distinti coperti
|
||||
(``n_days``). Questo permette di mischiare cadenze diverse — tick live a
|
||||
15 min e backfill daily — senza assumere un fattore costante
|
||||
``ticks_per_day``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,59 +18,43 @@ from decimal import Decimal
|
||||
|
||||
__all__ = ["compute_adaptive_threshold"]
|
||||
|
||||
_TICKS_PER_DAY = 96 # cron */15 → 4 tick/h × 24h
|
||||
|
||||
|
||||
def compute_adaptive_threshold(
|
||||
history: Sequence[Decimal],
|
||||
*,
|
||||
n_days: int,
|
||||
percentile: Decimal,
|
||||
absolute_floor: Decimal,
|
||||
min_days: int,
|
||||
target_days: int,
|
||||
) -> Decimal | None:
|
||||
"""Ritorna la soglia adattiva o ``None`` durante il warmup hard.
|
||||
|
||||
Args:
|
||||
history: Sequenza ordinata ASC dei valori IV-RV (un valore per
|
||||
ogni tick disponibile, max ``target_days * 96``). NULL e
|
||||
tick non riusciti devono essere già stati filtrati dal
|
||||
caller.
|
||||
history: Sequenza dei valori IV-RV nella finestra scelta dal
|
||||
caller. NULL e tick non riusciti devono essere già stati
|
||||
filtrati upstream. L'ordine non è significativo per il
|
||||
percentile.
|
||||
n_days: Numero di giorni distinti coperti dalla storia
|
||||
disponibile (calcolato dal caller, tipicamente con
|
||||
``COUNT(DISTINCT date(timestamp))``). ``0`` → warmup hard.
|
||||
percentile: Quantile target nella distribuzione (es. ``0.25``).
|
||||
absolute_floor: Floor minimo applicato dopo il calcolo del
|
||||
percentile. La soglia restituita è
|
||||
``max(P_q, absolute_floor)``.
|
||||
min_days: Sotto questa soglia di giorni di storia, la finestra
|
||||
usata è "tutta la storia disponibile". Sopra, la finestra è
|
||||
fissa a ``min_days`` finché non si raggiunge ``target_days``.
|
||||
target_days: Finestra finale stabile.
|
||||
|
||||
Returns:
|
||||
``None`` se la storia è < 1 giorno (warmup hard, gate
|
||||
disabilitato), altrimenti il percentile della finestra,
|
||||
``None`` se ``n_days == 0`` o ``history`` è vuota (warmup hard,
|
||||
gate disabilitato), altrimenti il percentile della finestra
|
||||
bounded dal floor.
|
||||
"""
|
||||
if not (Decimal(0) <= percentile <= Decimal(1)):
|
||||
raise ValueError(
|
||||
f"percentile must be in [0, 1], got {percentile}"
|
||||
)
|
||||
if min_days <= 0 or target_days <= 0 or min_days >= target_days:
|
||||
raise ValueError(
|
||||
f"require 0 < min_days < target_days, "
|
||||
f"got min_days={min_days}, target_days={target_days}"
|
||||
)
|
||||
if not history:
|
||||
if n_days < 0:
|
||||
raise ValueError(f"n_days must be >= 0, got {n_days}")
|
||||
if n_days == 0 or not history:
|
||||
return None
|
||||
n_ticks = len(history)
|
||||
if n_ticks < _TICKS_PER_DAY:
|
||||
return None
|
||||
if n_ticks >= target_days * _TICKS_PER_DAY:
|
||||
window = history[-target_days * _TICKS_PER_DAY:]
|
||||
elif n_ticks >= min_days * _TICKS_PER_DAY:
|
||||
window = history[-min_days * _TICKS_PER_DAY:]
|
||||
else:
|
||||
window = list(history)
|
||||
return max(_percentile(window, percentile), absolute_floor)
|
||||
return max(_percentile(history, percentile), absolute_floor)
|
||||
|
||||
|
||||
def _percentile(values: Sequence[Decimal], q: Decimal) -> Decimal:
|
||||
|
||||
@@ -51,13 +51,19 @@ class EntryContext(BaseModel):
|
||||
# invalida l'entry).
|
||||
iv_minus_rv: Decimal | None = None
|
||||
|
||||
# Storia recente di IV-RV (un valore per ogni tick di
|
||||
# market_snapshots, ASC, NULL e fetch_ok=0 esclusi). Caricata dal
|
||||
# repository in `entry_cycle` quando `iv_minus_rv_adaptive_enabled`
|
||||
# è True. Tuple per coerenza con frozen=True. Vuoto = warmup hard
|
||||
# (gate disabilitato).
|
||||
# Valori IV-RV nella finestra rolling già scelta dal caller
|
||||
# (entry_cycle): tutti i record validi su window_days, ASC, NULL e
|
||||
# fetch_ok=0 esclusi. Caricata dal repository quando
|
||||
# `iv_minus_rv_adaptive_enabled` è True. Tuple per coerenza con
|
||||
# frozen=True.
|
||||
iv_rv_history: tuple[Decimal, ...] = ()
|
||||
|
||||
# Numero di giorni di calendario distinti coperti dalla storia
|
||||
# IV-RV disponibile (non solo dalla finestra `iv_rv_history`).
|
||||
# ``0`` = warmup hard, gate disabilitato (fail-open). Calcolato dal
|
||||
# caller via `repository.count_iv_rv_distinct_days`.
|
||||
iv_rv_n_days: int = 0
|
||||
|
||||
# DVOL al tick più vicino a now - vol_of_vol_lookback_hours.
|
||||
# ``None`` = gap nel dato (es. cron mancante 24h fa) → VoV guard
|
||||
# skip. Caricato dal repository in `entry_cycle` quando
|
||||
@@ -162,10 +168,9 @@ def validate_entry(ctx: EntryContext, cfg: StrategyConfig) -> EntryDecision:
|
||||
if entry_cfg.iv_minus_rv_adaptive_enabled:
|
||||
threshold = compute_adaptive_threshold(
|
||||
history=ctx.iv_rv_history,
|
||||
n_days=ctx.iv_rv_n_days,
|
||||
percentile=entry_cfg.iv_minus_rv_percentile,
|
||||
absolute_floor=entry_cfg.iv_minus_rv_min,
|
||||
min_days=entry_cfg.iv_minus_rv_window_min_days,
|
||||
target_days=entry_cfg.iv_minus_rv_window_target_days,
|
||||
)
|
||||
if threshold is not None and ctx.iv_minus_rv < threshold:
|
||||
pct = int(entry_cfg.iv_minus_rv_percentile * 100)
|
||||
|
||||
@@ -267,24 +267,26 @@ def _render_adaptive_gate_panel(
|
||||
|
||||
# records DESC (newest first) → history ASC con NULL/fetch_ok=0 esclusi
|
||||
iv_rv_history: list[Decimal] = []
|
||||
distinct_days: set[str] = set()
|
||||
for r in reversed(records):
|
||||
if r.fetch_ok and r.iv_minus_rv is not None:
|
||||
iv_rv_history.append(r.iv_minus_rv)
|
||||
distinct_days.add(r.timestamp.date().isoformat())
|
||||
|
||||
n_ticks = len(iv_rv_history)
|
||||
n_days = len(distinct_days)
|
||||
target = int(getattr(entry, "iv_minus_rv_window_target_days", 60))
|
||||
min_days = int(getattr(entry, "iv_minus_rv_window_min_days", 30))
|
||||
n_days = n_ticks // 96
|
||||
|
||||
if n_days < 1:
|
||||
status = f"🟡 Warmup hard ({n_ticks}/96 tick)"
|
||||
status = "🟡 Warmup hard (nessun giorno coperto)"
|
||||
elif n_days < min_days:
|
||||
status = f"🟡 Warmup ({n_days}/{min_days}g — finestra crescente)"
|
||||
elif n_days < target:
|
||||
status = f"🟢 Attivo (finestra {min_days}g, target {target}g)"
|
||||
else:
|
||||
status = f"🟢 Attivo (finestra stabile {target}g)"
|
||||
st.markdown(f"**Status:** {status}")
|
||||
st.markdown(f"**Status:** {status} · {n_ticks} tick complessivi")
|
||||
|
||||
# Latest tick
|
||||
iv_rv_now: Decimal | None = None
|
||||
@@ -307,10 +309,9 @@ def _render_adaptive_gate_panel(
|
||||
try:
|
||||
threshold = compute_adaptive_threshold(
|
||||
history=iv_rv_history,
|
||||
n_days=n_days,
|
||||
percentile=percentile,
|
||||
absolute_floor=floor,
|
||||
min_days=min_days,
|
||||
target_days=target,
|
||||
)
|
||||
except ValueError as exc:
|
||||
st.warning(f"Configurazione gate non valida: {exc}")
|
||||
|
||||
@@ -316,33 +316,14 @@ async def _build_quotes(
|
||||
return out
|
||||
|
||||
|
||||
def _audit_threshold(
|
||||
entry_cfg: object,
|
||||
iv_rv_history: tuple[Decimal, ...],
|
||||
) -> str | None:
|
||||
"""Soglia P_q rolling effettivamente usata dal gate, per il decisions log."""
|
||||
if not getattr(entry_cfg, "iv_minus_rv_filter_enabled", False):
|
||||
return None
|
||||
if not getattr(entry_cfg, "iv_minus_rv_adaptive_enabled", False):
|
||||
return str(getattr(entry_cfg, "iv_minus_rv_min", Decimal("0")))
|
||||
threshold = compute_adaptive_threshold(
|
||||
history=iv_rv_history,
|
||||
percentile=entry_cfg.iv_minus_rv_percentile, # type: ignore[attr-defined]
|
||||
absolute_floor=entry_cfg.iv_minus_rv_min, # type: ignore[attr-defined]
|
||||
min_days=entry_cfg.iv_minus_rv_window_min_days, # type: ignore[attr-defined]
|
||||
target_days=entry_cfg.iv_minus_rv_window_target_days, # type: ignore[attr-defined]
|
||||
)
|
||||
return None if threshold is None else str(threshold)
|
||||
def _select_window_days(entry_cfg: object, n_days: int) -> int:
|
||||
"""Sceglie la finestra in giorni per il gate adattivo dato n_days
|
||||
disponibili.
|
||||
|
||||
|
||||
def _audit_window_days(
|
||||
entry_cfg: object,
|
||||
iv_rv_history: tuple[Decimal, ...],
|
||||
) -> int | None:
|
||||
"""Numero di giorni effettivamente usati dalla finestra rolling."""
|
||||
if not getattr(entry_cfg, "iv_minus_rv_adaptive_enabled", False):
|
||||
return None
|
||||
n_days = len(iv_rv_history) // 96
|
||||
Spec: warmup hard se ``n_days == 0`` → 0; finestra ``target_days``
|
||||
se ``n_days >= target_days``; ``min_days`` se ``n_days >= min_days``;
|
||||
altrimenti tutta la storia disponibile (capped a ``target_days``).
|
||||
"""
|
||||
target = int(getattr(entry_cfg, "iv_minus_rv_window_target_days", 60))
|
||||
min_days = int(getattr(entry_cfg, "iv_minus_rv_window_min_days", 30))
|
||||
if n_days < 1:
|
||||
@@ -351,7 +332,33 @@ def _audit_window_days(
|
||||
return target
|
||||
if n_days >= min_days:
|
||||
return min_days
|
||||
return n_days
|
||||
return target # storia parziale: query fino a target, repository ne ritorna n_days
|
||||
|
||||
|
||||
def _audit_threshold(
|
||||
entry_cfg: object,
|
||||
iv_rv_history: tuple[Decimal, ...],
|
||||
n_days: int,
|
||||
) -> str | None:
|
||||
"""Soglia P_q rolling effettivamente usata dal gate, per il decisions log."""
|
||||
if not getattr(entry_cfg, "iv_minus_rv_filter_enabled", False):
|
||||
return None
|
||||
if not getattr(entry_cfg, "iv_minus_rv_adaptive_enabled", False):
|
||||
return str(getattr(entry_cfg, "iv_minus_rv_min", Decimal("0")))
|
||||
threshold = compute_adaptive_threshold(
|
||||
history=iv_rv_history,
|
||||
n_days=n_days,
|
||||
percentile=entry_cfg.iv_minus_rv_percentile, # type: ignore[attr-defined]
|
||||
absolute_floor=entry_cfg.iv_minus_rv_min, # type: ignore[attr-defined]
|
||||
)
|
||||
return None if threshold is None else str(threshold)
|
||||
|
||||
|
||||
def _audit_window_days(entry_cfg: object, n_days: int) -> int | None:
|
||||
"""Numero di giorni effettivamente usati dalla finestra rolling."""
|
||||
if not getattr(entry_cfg, "iv_minus_rv_adaptive_enabled", False):
|
||||
return None
|
||||
return _select_window_days(entry_cfg, n_days)
|
||||
|
||||
|
||||
def _max_loss_per_contract_usd(short_strike: Decimal, long_strike: Decimal) -> Decimal:
|
||||
@@ -472,18 +479,27 @@ async def run_entry_cycle(
|
||||
asset = cfg.asset.symbol
|
||||
|
||||
iv_rv_history: tuple[Decimal, ...] = ()
|
||||
iv_rv_n_days: int = 0
|
||||
dvol_24h_ago: Decimal | None = None
|
||||
if entry_cfg.iv_minus_rv_filter_enabled and entry_cfg.iv_minus_rv_adaptive_enabled:
|
||||
conn = connect_state(ctx.db_path)
|
||||
try:
|
||||
iv_rv_history = tuple(
|
||||
ctx.repository.iv_rv_history(
|
||||
conn,
|
||||
asset=asset,
|
||||
max_days=entry_cfg.iv_minus_rv_window_target_days,
|
||||
as_of=when,
|
||||
)
|
||||
iv_rv_n_days = ctx.repository.count_iv_rv_distinct_days(
|
||||
conn,
|
||||
asset=asset,
|
||||
max_days=entry_cfg.iv_minus_rv_window_target_days,
|
||||
as_of=when,
|
||||
)
|
||||
window_days = _select_window_days(entry_cfg, iv_rv_n_days)
|
||||
if window_days > 0:
|
||||
iv_rv_history = tuple(
|
||||
ctx.repository.iv_rv_values_for_window(
|
||||
conn,
|
||||
asset=asset,
|
||||
window_days=window_days,
|
||||
as_of=when,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
if entry_cfg.vol_of_vol_guard_enabled:
|
||||
@@ -508,6 +524,7 @@ async def run_entry_cycle(
|
||||
iv_minus_rv=snap.iv_minus_rv,
|
||||
liquidation_squeeze_risk_high=snap.liquidation_squeeze_risk_high,
|
||||
iv_rv_history=iv_rv_history,
|
||||
iv_rv_n_days=iv_rv_n_days,
|
||||
dvol_24h_ago=dvol_24h_ago,
|
||||
)
|
||||
decision = validate_entry(entry_ctx, cfg)
|
||||
@@ -529,9 +546,12 @@ async def run_entry_cycle(
|
||||
str(snap.iv_minus_rv) if snap.iv_minus_rv is not None else None
|
||||
),
|
||||
"iv_rv_history_n": len(iv_rv_history),
|
||||
"iv_rv_threshold_used": _audit_threshold(entry_cfg, iv_rv_history),
|
||||
"iv_rv_n_days": iv_rv_n_days,
|
||||
"iv_rv_threshold_used": _audit_threshold(
|
||||
entry_cfg, iv_rv_history, iv_rv_n_days
|
||||
),
|
||||
"iv_rv_window_used_days": _audit_window_days(
|
||||
entry_cfg, iv_rv_history
|
||||
entry_cfg, iv_rv_n_days
|
||||
),
|
||||
"dvol_24h_ago": (
|
||||
str(dvol_24h_ago) if dvol_24h_ago is not None else None
|
||||
|
||||
@@ -408,22 +408,23 @@ class Repository:
|
||||
).fetchall()
|
||||
return [_row_to_market_snapshot(r) for r in rows]
|
||||
|
||||
def iv_rv_history(
|
||||
def count_iv_rv_distinct_days(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
asset: str,
|
||||
max_days: int,
|
||||
as_of: datetime | None = None,
|
||||
) -> list[Decimal]:
|
||||
"""Lista IV-RV ordinata ASC sull'intervallo `[as_of - max_days, as_of]`.
|
||||
) -> int:
|
||||
"""Numero di giorni di calendario distinti coperti da IV-RV validi.
|
||||
|
||||
Esclude righe con ``fetch_ok=0`` o ``iv_minus_rv IS NULL``.
|
||||
Usata dal validator quando il gate adattivo è abilitato.
|
||||
Usato dal caller del gate adattivo per decidere la finestra
|
||||
(warmup hard / min_days / target_days).
|
||||
|
||||
Args:
|
||||
as_of: Reference time for the rolling window. Defaults to
|
||||
``datetime.now(UTC)``. Tests can pin a fixed value.
|
||||
``datetime.now(UTC)``.
|
||||
"""
|
||||
if max_days <= 0:
|
||||
raise ValueError(f"max_days must be positive, got {max_days}")
|
||||
@@ -431,6 +432,39 @@ class Repository:
|
||||
if ref.tzinfo is None:
|
||||
raise ValueError("as_of must be timezone-aware")
|
||||
cutoff = ref - timedelta(days=max_days)
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(DISTINCT substr(timestamp, 1, 10)) AS n "
|
||||
"FROM market_snapshots "
|
||||
"WHERE asset = ? "
|
||||
" AND fetch_ok = 1 "
|
||||
" AND iv_minus_rv IS NOT NULL "
|
||||
" AND timestamp >= ?",
|
||||
(asset, _enc_dt(cutoff)),
|
||||
).fetchone()
|
||||
return int(row["n"]) if row is not None else 0
|
||||
|
||||
def iv_rv_values_for_window(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
asset: str,
|
||||
window_days: int,
|
||||
as_of: datetime | None = None,
|
||||
) -> list[Decimal]:
|
||||
"""Valori IV-RV ordinati ASC su ``[as_of - window_days, as_of]``.
|
||||
|
||||
Esclude righe con ``fetch_ok=0`` o ``iv_minus_rv IS NULL``.
|
||||
Tutti i record validi della finestra concorrono come singoli
|
||||
contributi alla statistica del percentile, indipendentemente
|
||||
dalla cadenza con cui sono stati raccolti (tick live vs backfill
|
||||
daily).
|
||||
"""
|
||||
if window_days <= 0:
|
||||
raise ValueError(f"window_days must be positive, got {window_days}")
|
||||
ref = as_of if as_of is not None else datetime.now(UTC)
|
||||
if ref.tzinfo is None:
|
||||
raise ValueError("as_of must be timezone-aware")
|
||||
cutoff = ref - timedelta(days=window_days)
|
||||
rows = conn.execute(
|
||||
"SELECT iv_minus_rv FROM market_snapshots "
|
||||
"WHERE asset = ? "
|
||||
|
||||
Reference in New Issue
Block a user