Files
Cerbero-Bite/docs/superpowers/specs/2026-05-08-iv-rv-adaptive-gate-design.md
root b1836d91c2 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>
2026-05-10 08:52:05 +00:00

317 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# IV-RV adaptive entry gate — design
**Status**: drafted, awaiting implementation plan
**Date**: 2026-05-08
**Author**: brainstorming session (operator + Claude)
**Roadmap origin**: `docs/13-strategia-spiegata.md` §4-quater, hardening punti 1 e 2
## 1. Problema
Il gate IV-richness in `core/entry_validator.py:140-152` confronta `ctx.iv_minus_rv` con la soglia statica `entry.iv_minus_rv_min` (config). I dati raccolti in `market_snapshots` mostrano due problemi sul campione 2026-05-01 → 2026-05-08:
| metrica | ETH | BTC |
|---|---|---|
| IV-RV p25 | 1.87 | 5.48 |
| IV-RV p50 | 2.70 | 6.88 |
| IV-RV p90 | 8.52 | 8.26 |
| Pearson(\|drift%\|, IV-RV mean) | **0.02** | **+0.54** |
| Trend giornaliero IV-RV mean | 1.64 → 8.96 (+5.4×) | 4.78 → 8.11 (+1.7×) |
ETH mostra un IV richening monotono **decoupled dal drift realised** — la soglia statica `min=3` (profilo Aggressiva) avrebbe escluso il 50%+ dei tick nei primi 4 giorni e il 0% degli ultimi 3, sopra una distribuzione che non è stazionaria. Su BTC è meno drammatico ma il problema è strutturale: il regime di IV cambia, la soglia no.
## 2. Obiettivo
Sostituire la soglia statica con un meccanismo adattivo che si auto-calibra al regime corrente, senza richiedere intervento manuale dell'operatore. Il gate deve restare semanticamente "non vendere vol senza margine misurabile sopra la RV", solo che il "margine misurabile" è ora derivato dalla distribuzione storica recente invece che hardcoded.
## 3. Approccio scelto: Hybrid (P25 rolling + Vol-of-Vol guard)
Decisione presa nel brainstorming dopo aver scartato:
- **Solo percentile rolling**: insufficiente, non protegge da regime shift bruschi (DVOL salta di 5+ pt in 24h)
- **Solo regime detection (HMM/cluster)**: troppo opaco e ad alto rischio di overfit con 8 giorni di dati
L'hybrid bilancia due controlli additivi:
1. **Soglia adattiva** = P25 di IV-RV nella finestra rolling
2. **Vol-of-Vol guard** = blocco se |ΔDVOL_24h| ≥ 5 pt (regime shift detector)
## 4. Comportamento del gate
> **Errata 2026-05-10** — design originale assumeva `n_days = len(history) // 96` (cadenza fissa 96 tick/giorno). Refattorizzato a **distinct-days policy**: il caller interroga il repository per (a) il numero di giorni di calendario distinti coperti e (b) i valori della finestra scelta. Questo permette di mischiare cadenze (tick live 15 min + backfill daily) senza assumere un fattore costante. Sotto il pseudo-codice aggiornato.
```
def validate_iv_richness_adaptive(ctx, cfg, repo):
if not cfg.entry.iv_minus_rv_filter_enabled:
return PASS # gate off
# 1) Soglia adattiva — distinct-days policy
n_days = repo.count_iv_rv_distinct_days(
asset=ctx.asset, max_days=cfg.window_target_days,
)
if n_days < 1:
return PASS # warmup hard: nessun giorno coperto
if n_days >= cfg.window_target_days:
window_days = cfg.window_target_days # ≥60g → finestra fissa 60g
elif n_days >= cfg.window_min_days:
window_days = cfg.window_min_days # 30-60g → finestra fissa 30g
else:
window_days = cfg.window_target_days # 1-30g → query tutta la storia disp.
history = repo.iv_rv_values_for_window(
asset=ctx.asset, window_days=window_days,
)
threshold = max(percentile(history, cfg.percentile),
cfg.absolute_floor)
if ctx.iv_minus_rv < threshold:
return SKIP("IV richness below P25 rolling")
# 2) Vol-of-vol guard (additivo)
if cfg.vol_of_vol_guard_enabled:
dvol_24h_ago = repo.dvol_lookback(asset=ctx.asset, hours=24)
if dvol_24h_ago is not None and \
abs(ctx.dvol - dvol_24h_ago) >= cfg.vol_of_vol_threshold:
return SKIP("DVOL shifted ≥5pt in 24h")
return PASS
```
### 4.1 Warmup behavior
Tutte le soglie sono espresse in **giorni di calendario distinti** coperti da almeno un record valido (`fetch_ok=1``iv_minus_rv IS NOT NULL`).
| storia disponibile | finestra usata | comportamento |
|---|---|---|
| 0 giorni distinti | — | gate disabled (PASS), log `GATE_WARMUP_INSUFFICIENT` |
| 1 g ≤ giorni < 30 g | tutta la storia | percentile della finestra disponibile (decisione utente) |
| 30 g ≤ giorni < 60 g | ultimi 30 g | finestra fissa 30g |
| ≥ 60 g | ultimi 60 g | finestra fissa 60g (target) |
I valori della finestra contribuiscono uno-a-uno al percentile: un tick a 15 min e un record di backfill daily hanno lo stesso peso. Mix di cadenze diverse è statisticamente sbilanciato finché i tick live non saturano la finestra; questa è una scelta deliberata per non rinunciare allo storico backfill.
### 4.2 Soglia = `max(P25, floor)`
`floor` è il vecchio `iv_minus_rv_min` riutilizzato come *absolute floor*. Permette:
- backwards compat: se `adaptive_enabled=False`, comportamento identico ad oggi
- safety: anche se P25 storico fosse ≈0 (regime IV bassa persistente), l'operatore può tenere un floor minimo (es. 1 vol pt) per evitare di vendere vol mai
## 5. Schema config (`config/schema.py`)
Aggiunte alla classe `EntryConfig`:
```python
class EntryConfig(BaseModel):
# campi esistenti
iv_minus_rv_filter_enabled: bool = False
iv_minus_rv_min: Decimal = Decimal("0") # ora è absolute_floor
# nuovi — gate adattivo
iv_minus_rv_adaptive_enabled: bool = False
iv_minus_rv_percentile: Decimal = Decimal("0.25")
iv_minus_rv_window_target_days: int = 60
iv_minus_rv_window_min_days: int = 30
# nuovi — vol-of-vol guard
vol_of_vol_guard_enabled: bool = False
vol_of_vol_threshold_pt: Decimal = Decimal("5")
vol_of_vol_lookback_hours: int = 24
```
### 5.1 Profili predefiniti
**Conservativa / golden** (`config/golden.yaml`):
```yaml
entry:
iv_minus_rv_filter_enabled: false
iv_minus_rv_adaptive_enabled: false
vol_of_vol_guard_enabled: false
```
Comportamento invariato rispetto a oggi.
**Aggressiva** (`config/aggressive.yaml`):
```yaml
entry:
iv_minus_rv_filter_enabled: true
iv_minus_rv_adaptive_enabled: true
iv_minus_rv_min: 0 # floor 0, lascia decidere il P25 rolling
iv_minus_rv_percentile: 0.25
iv_minus_rv_window_target_days: 60
iv_minus_rv_window_min_days: 30
vol_of_vol_guard_enabled: true
vol_of_vol_threshold_pt: 5
```
### 5.2 Backwards compat
Se `iv_minus_rv_adaptive_enabled=False` e `iv_minus_rv_filter_enabled=True`, il validator usa il path legacy `iv_rv < iv_minus_rv_min` esattamente come oggi. Nessuna regressione comportamentale per chi non ha attivato l'adaptive.
## 6. Architettura
### 6.1 Modulo `core/adaptive_threshold.py`
Funzione pura, testabile senza I/O. La selezione della finestra è
delegata al caller (separation of concerns):
```python
def compute_adaptive_threshold(
history: Sequence[Decimal],
*,
n_days: int,
percentile: Decimal,
absolute_floor: Decimal,
) -> Decimal | None:
"""Ritorna None se warmup hard (n_days==0 o history vuota),
altrimenti max(P_q(history), absolute_floor)."""
```
### 6.2 Repository (`state/repository.py`)
Tre metodi su `Repository` (uno preesistente):
```python
def count_iv_rv_distinct_days(
self, *, asset: str, max_days: int, as_of: datetime | None = None,
) -> int:
"""Numero di giorni di calendario distinti con almeno un IV-RV
valido nell'intervallo [as_of - max_days, as_of]."""
def iv_rv_values_for_window(
self, *, asset: str, window_days: int, as_of: datetime | None = None,
) -> list[Decimal]:
"""Valori IV-RV ordinati ASC su [as_of - window_days, as_of]."""
def dvol_lookback(self, *, asset: str, hours: int) -> Decimal | None:
"""DVOL del tick più vicino a now-hours, ±15min tolerance. None se gap."""
```
Usa l'index esistente `idx_market_snapshots_asset_ts`. Nessuna nuova migration.
### 6.3 Inline nel validator
`core/entry_validator.py` chiama `compute_adaptive_threshold` con i dati dal repo. Nessun caching, stateless. La query per finestra 60g (5760 righe per asset) costa ms-level con index — non vale la pena introdurre cache da invalidare.
### 6.4 Audit / logging
Ogni entry cycle scrive in `decisions`:
- `inputs_json`: `{iv_rv_now, threshold_used, dvol_now, dvol_24h_ago, n_history, window_used_days}`
- `outputs_json`: `{gate: "iv_richness_adaptive", verdict: PASS|SKIP, reason}`
Permette ricostruzione ex-post: perché un trade è stato saltato e con quali numeri.
## 7. GUI Calibrazione (`pages/6_📐_Calibrazione.py`)
Aggiunta sezione "Gate adattivo" sopra ai percentili statici esistenti — questi ultimi NON vengono modificati (restano per analisi).
```
┌─ 🎯 Gate IV-RV adattivo ──────────────────────────────────┐
│ Status: 🟢 Attivo (Aggressiva) | 🟡 Warmup (n=8/30g) │
│ │
│ Soglia P25 rolling (corrente) 2.74 vol pts │
│ IV-RV ultimo tick 8.96 vol pts ✅ │
│ Floor assoluto 0.00 vol pts │
│ │
│ Evoluzione 7g (sparkline) ▁▂▂▃▄▆▇ │
│ │
│ ── VoV guard ── │
│ ΔDVOL ultime 24h 0.43 pt ✅ │
│ Soglia VoV 5.00 pt │
│ │
│ Decisione hypothetical: PASS │
└──────────────────────────────────────────────────────────┘
```
La GUI usa la stessa funzione `compute_adaptive_threshold` del validator → unica fonte di verità. Refresh manuale al page load (coerente con resto GUI).
## 8. Error handling
Principio: **fail-open** in tutti i casi di dato mancante. Il gate adattivo è additivo sopra ai gate hard esistenti (delta band, credit, ecc.); il dato mancante non deve trasformare un trade non voluto in trade fatto, ma neppure deve bloccare entry valide se è il dato del gate stesso a mancare.
| Scenario | Comportamento | Loggato come |
|---|---|---|
| `iv_rv_history` ritorna 0 righe | gate = PASS | `GATE_WARMUP_NO_DATA` |
| Storia < 96 tick (1g) | gate = PASS, threshold None | `GATE_WARMUP_INSUFFICIENT` |
| `dvol_lookback` = None (gap dati 24h fa) | VoV guard = PASS | `VOV_GUARD_NO_LOOKBACK` |
| `ctx.iv_minus_rv` = None | gate bypassato (riga 146 esistente) | invariato |
| `ctx.dvol` = None | VoV guard bypassato, gate adattivo prosegue | invariato |
## 9. Testing strategy
### 9.1 Unit — `core/adaptive_threshold.py`
- Warmup: `n_days=0` → None
- Warmup difensivo: `n_days=0` ma history non vuota → None
- Difensivo: history vuota con `n_days>0` → None
- `n_days=1`, 96 tick → P25 sui 96
- Mix di cadenze (30 daily + 96 live) → percentile uno-a-uno
- Floor binding: P25=0.5, floor=3 → 3
- Floor non binding: P25=5, floor=0 → 5
- Percentile diverso: percentile=0.5 → mediana
- Validation: percentile ∉ [0,1] o `n_days<0` → ValueError
### 9.1bis Unit — `state/repository.py`
- `count_iv_rv_distinct_days`: 1 giorno → 1; 3 giorni misti → 3
- esclusione asset diversi, NULL e fetch_ok=0
- rispetto del cutoff `max_days`
- ValueError su `as_of` naive o `max_days≤0`
- `iv_rv_values_for_window`: ordine ASC, filtri equivalenti, ValueError input
### 9.2 Unit — `core/entry_validator.py`
Mock repo, focus sul flusso decisionale:
- Adaptive disabled, statico passa → PASS legacy
- Adaptive disabled, statico fail → SKIP legacy
- Adaptive enabled, IV-RV sopra P25 → PASS
- Adaptive enabled, IV-RV sotto P25 sopra floor → SKIP("rolling")
- VoV guard ON, ΔDVOL=6 pt → SKIP("vov")
- VoV guard ON, ΔDVOL=4 pt, gate principale pass → PASS
- VoV guard ON, dvol_lookback=None → guard bypass
- ctx.iv_minus_rv=None → bypass
- decisions log popolato con threshold, n_history, dvol_lookback
### 9.3 Integration — `tests/integration/test_entry_cycle_adaptive.py`
SQLite temp + fixture market_snapshots con 5760 tick (60g):
- Aggressiva con flag adattivo → entry passa solo nei tick sopra P25 della fixture
- Golden → invariato
- Warmup: DB con 50 tick → tutti pass, log `GATE_WARMUP_INSUFFICIENT`
- Regime shift fixture: DVOL salta da 50 a 56 in 24h → VoV guard scatta
### 9.4 Backtest sanity
Aggiunta nel report del CLI `backtest`: count distinto skip-reasons (`iv_rv_static`, `iv_rv_rolling`, `vov_guard`) per analisi ex-post.
### 9.5 GUI smoke
Manuale al deploy:
- Calibrazione carica con `enabled=False` (fallback grafico)
- Calibrazione mostra warmup status quando DB < 30g
- Refresh ricalcola coerente
### 9.6 Cosa NON testiamo
- Performance query (5760 righe con index = trascurabile)
- Concorrenza entry cycle / GUI (WAL abilitato)
- Migrazione (nessuna tabella nuova)
## 10. Out of scope
- Regime detection avanzato (HMM, cluster) — esplicitamente scartato per opacità
- Soglie per-asset diverse — il P25 si calibra naturalmente per asset (history filtrata per asset)
- Auto-attivazione adaptive su Conservativa quando warmup è completo — l'operatore decide manualmente quando passare al profilo aggressivo
- Multi-asset (ETH+BTC simultanei) — già scope §4-ter, indipendente da questo design
- Override manuale soglia da GUI — explicit no, l'obiettivo è autocalibrante
## 11. Decisioni prese durante brainstorming
| # | Domanda | Scelta |
|---|---|---|
| 1 | Approccio | Hybrid (percentile + VoV guard) |
| 2 | Warmup | Percentile della finestra disponibile (anche se <30g) |
| 3 | Percentile | P25 (allineato roadmap) |
| 4 | Window | Target 60g, attivazione a 30g, sotto usa quel che c'è |
| 5 | VoV soglia | 5 pt vol in 24h |
| 6 | Architettura | Inline nel validator, stateless, no cache DB |
| 7 | GUI | Pannello informativo aggiunto, slider esistenti invariati |