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

14 KiB
Raw Permalink Blame History

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=1iv_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:

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):

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):

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):

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):

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