# 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 |