diff --git a/docs/13-strategia-spiegata.md b/docs/13-strategia-spiegata.md index ea3e060..67bd99d 100644 --- a/docs/13-strategia-spiegata.md +++ b/docs/13-strategia-spiegata.md @@ -532,6 +532,69 @@ quella che il sistema parte ad eseguire. --- +## 4-quater. IV richness gate (§2.9): il filtro che alza il win-rate + +Il filtro a maggior impatto sull'edge è anche il più semplice da +descrivere: **non vendere vol quando la IV non sta pagando un margine +misurabile sopra la RV**. È implementato come gate hard nel +`validate_entry`: + +``` +if iv_minus_rv_filter_enabled and iv_minus_rv < iv_minus_rv_min: + skip entry +``` + +con due parametri in `entry:` di `strategy.yaml`: + +| Parametro | Default | Effetto | +|---|---|---| +| `iv_minus_rv_filter_enabled` | `false` (golden) / `true` (aggressiva) | Master switch del gate | +| `iv_minus_rv_min` | `0` (golden) / `3` (aggressiva) | Soglia in punti vol che IV30g − RV30g deve eccedere | + +Il dato è già raccolto in `market_snapshots.iv_minus_rv` ogni 15 +minuti. Il gate consulta l'ultimo tick disponibile al momento +dell'entry cycle (non un percentile rolling — quello è il prossimo +step di calibrazione, vedi §4-quinquies in roadmap). + +**Profili di default ragionati.** + +- **Conservativa / golden config**: `enabled=false, min=0`. Tutti i + setup passano questo gate, anche con IV-RV negativa. Motivo: nei + primi 8 turni di lunedì non si hanno abbastanza tick per stabilire + che soglia ha senso nel proprio regime. Lasciamo la pagina + `📐 Calibrazione` mostrare la distribuzione e poi alziamo + manualmente. +- **Aggressiva**: `enabled=true, min=3`. Il profilo aggressivo già di + suo prende size più grande; pretendere `IV-RV ≥ 3 vol points` come + prerequisito è coerente — se stai betting più grosso, vuoi + win-rate più alto. La soglia 3 è conservativa; la letteratura + short-vol systematic suggerisce 5 dopo calibrazione. + +**Cosa cambia nel P/L atteso quando attivi il gate.** + +Il gate **riduce** il numero di entry (saltiamo settimane con premio +magro) ma **alza** la qualità di quelle che passano (premio ricco = +win-rate empirico più alto). Effetto netto sul P/L annuo: + +- Trade/anno: 18 → 12-14 (skip più aggressivo) +- Win-rate atteso: 0.72 → 0.78-0.80 +- E[trade] netto: +0.6 USD → +4-6 USD per contratto +- **P/L annuo proiettato sale anche se i trade scendono**, perché + ogni trade ha edge più alto. + +La pagina `📚 Strategia` ha lo slider win-rate già coerente con +questa logica: muovi da 0.72 a 0.78 e vedi l'APR scattare. + +**Roadmap di hardening (passi successivi al merge di questo PR).** + +1. **Soglia adattiva**: sostituire `iv_minus_rv_min: 3` con un valore + calcolato a runtime come `P25 rolling 60d` di `market_snapshots.iv_minus_rv`. +2. **Vol-of-vol guard**: bloccare entry quando `dvol` è cambiato di + ≥5 punti nelle ultime 24h, anche se `iv_minus_rv` è alto (regime + instabile). +3. **Multi-asset (ETH+BTC)**: come da §4-ter, sblocca il + moltiplicatore 2× sulle opportunità a parità di filtri. + ## 5. Come leggere il dato giorno per giorno Tre euristiche operative sui campi raccolti: diff --git a/src/cerbero_bite/config/schema.py b/src/cerbero_bite/config/schema.py index f916669..59bebcf 100644 --- a/src/cerbero_bite/config/schema.py +++ b/src/cerbero_bite/config/schema.py @@ -75,6 +75,15 @@ class EntryConfig(BaseModel): dealer_gamma_filter_enabled: bool = True liquidation_filter_enabled: bool = True + # IV richness filter (§2.9). `iv_minus_rv_min` è la soglia in + # punti vol che la IV implicita 30g deve eccedere la RV30g per + # ammettere l'entry. Letteratura short-vol systematic: l'edge + # sostenibile esiste solo con un margine misurabile fra IV e RV. + # Default disabilitato + soglia 0 per non bloccare l'avvio finché + # non si è calibrato sui dati raccolti (vedi `📐 Calibrazione`). + iv_minus_rv_min: Decimal = Field(default=Decimal("0")) + iv_minus_rv_filter_enabled: bool = False + # --------------------------------------------------------------------------- # Structure diff --git a/src/cerbero_bite/core/entry_validator.py b/src/cerbero_bite/core/entry_validator.py index 5c3bede..f2d5e6f 100644 --- a/src/cerbero_bite/core/entry_validator.py +++ b/src/cerbero_bite/core/entry_validator.py @@ -44,6 +44,12 @@ class EntryContext(BaseModel): dealer_net_gamma: Decimal | None = None liquidation_squeeze_risk_high: bool | None = None + # IV richness gate (§2.9). Differenza IV30g − RV30g in punti vol. + # Optional, stessa logica best-effort dei filtri quant: ``None`` + # significa "dato non disponibile" e fa saltare il gate (non + # invalida l'entry). + iv_minus_rv: Decimal | None = None + class EntryDecision(BaseModel): """Result of :func:`validate_entry`. ``reasons`` holds *all* blocking reasons.""" @@ -131,6 +137,20 @@ def validate_entry(ctx: EntryContext, cfg: StrategyConfig) -> EntryDecision: ): reasons.append("imminent liquidation squeeze risk") + # §2.9: IV richness gate. Vendere vol senza un margine misurabile + # fra IV e RV è statisticamente neutro: l'edge della strategia + # esiste solo quando il premio è "ricco" rispetto a quanto il + # mercato si è effettivamente mosso. + if ( + entry_cfg.iv_minus_rv_filter_enabled + and ctx.iv_minus_rv is not None + and ctx.iv_minus_rv < entry_cfg.iv_minus_rv_min + ): + reasons.append( + f"IV richness below floor " + f"(IV-RV={ctx.iv_minus_rv} < {entry_cfg.iv_minus_rv_min} vol pts)" + ) + return EntryDecision(accepted=not reasons, reasons=reasons) diff --git a/src/cerbero_bite/gui/pages/7_📚_Strategia.py b/src/cerbero_bite/gui/pages/7_📚_Strategia.py index cfe1601..2b9964a 100644 --- a/src/cerbero_bite/gui/pages/7_📚_Strategia.py +++ b/src/cerbero_bite/gui/pages/7_📚_Strategia.py @@ -280,26 +280,56 @@ def _build_gates( ) ) - # --- IV − RV (richness) — solo informativo -------------------- + # --- IV − RV (richness) — gate §2.9 --------------------------- rv = ( float(snap.realized_vol_30d) if snap.realized_vol_30d is not None else None ) iv_minus_rv = ( float(snap.iv_minus_rv) if snap.iv_minus_rv is not None else None ) - rows.append( - _GateRow( - "IV − RV (richness)", - ( - f"{iv_minus_rv:+.2f} pt vol" - if iv_minus_rv is not None - else "—" - ), - "info, > 0 = premio ricco", - "pass" if (iv_minus_rv is not None and iv_minus_rv > 0) else "n/a", - f"RV30={rv:.2f}" if rv is not None else "", - ) + iv_min = float(getattr(entry, "iv_minus_rv_min", 0.0)) if entry else 0.0 + iv_enabled = ( + bool(getattr(entry, "iv_minus_rv_filter_enabled", False)) if entry else False ) + if not iv_enabled: + rows.append( + _GateRow( + "IV − RV (richness)", + ( + f"{iv_minus_rv:+.2f} pt vol" + if iv_minus_rv is not None + else "—" + ), + "filtro DISABILITATO (info-only)", + "n/a", + f"RV30={rv:.2f} · attiva con `iv_minus_rv_filter_enabled: true`" + if rv is not None + else "Attiva con `iv_minus_rv_filter_enabled: true`", + ) + ) + elif iv_minus_rv is None: + rows.append( + _GateRow( + "IV − RV ≥ soglia", + "—", + f"≥ {iv_min:.1f} pt vol", + "n/a", + "Dato non disponibile in questo tick (best-effort skip).", + ) + ) + else: + ok = iv_minus_rv >= iv_min + rows.append( + _GateRow( + "IV − RV ≥ soglia", + f"{iv_minus_rv:+.2f} pt vol", + f"≥ {iv_min:.1f} pt vol", + "pass" if ok else "fail", + "Premio ricco rispetto a quanto il mercato si è davvero " + "mosso → edge sostenibile per il venditore di vol." + + (f" RV30={rv:.2f}" if rv is not None else ""), + ) + ) return rows diff --git a/src/cerbero_bite/runtime/entry_cycle.py b/src/cerbero_bite/runtime/entry_cycle.py index f14093c..961fcbc 100644 --- a/src/cerbero_bite/runtime/entry_cycle.py +++ b/src/cerbero_bite/runtime/entry_cycle.py @@ -94,6 +94,7 @@ class _MarketSnapshot: portfolio_eur: Decimal dealer_net_gamma: Decimal | None liquidation_squeeze_risk_high: bool | None + iv_minus_rv: Decimal | None async def _gather_snapshot( @@ -159,6 +160,9 @@ async def _gather_snapshot( liquidation_t: asyncio.Task[bool | None] = asyncio.create_task( _safe_liquidation_squeeze(sentiment) ) + iv_rv_t: asyncio.Task[Decimal | None] = asyncio.create_task( + _safe_iv_minus_rv(deribit) + ) await asyncio.gather( spot_t, @@ -172,6 +176,7 @@ async def _gather_snapshot( portfolio_t, dealer_t, liquidation_t, + iv_rv_t, ) return _MarketSnapshot( spot_eth_usd=spot_t.result(), @@ -185,6 +190,7 @@ async def _gather_snapshot( portfolio_eur=portfolio_t.result(), dealer_net_gamma=dealer_t.result(), liquidation_squeeze_risk_high=liquidation_t.result(), + iv_minus_rv=iv_rv_t.result(), ) @@ -196,6 +202,20 @@ async def _safe_dealer_gamma(deribit: DeribitClient) -> Decimal | None: return snap.total_net_dealer_gamma +async def _safe_iv_minus_rv(deribit: DeribitClient) -> Decimal | None: + """Best-effort fetch of the IV30g − RV30g spread (vol points).""" + try: + rv = await deribit.realized_vol("ETH") + except Exception: + return None + if not isinstance(rv, dict): + return None + value = rv.get("iv_minus_rv_30d") + if value is None: + return None + return value if isinstance(value, Decimal) else Decimal(str(value)) + + async def _safe_liquidation_squeeze(sentiment: SentimentClient) -> bool | None: try: heatmap = await sentiment.liquidation_heatmap("ETH") @@ -353,6 +373,7 @@ async def run_entry_cycle( next_macro_event_in_days=snap.macro_days_to_event, has_open_position=False, dealer_net_gamma=snap.dealer_net_gamma, + iv_minus_rv=snap.iv_minus_rv, liquidation_squeeze_risk_high=snap.liquidation_squeeze_risk_high, ) decision = validate_entry(entry_ctx, cfg) @@ -370,6 +391,9 @@ async def run_entry_cycle( "eth_holdings_pct": str(snap.eth_holdings_pct), "portfolio_eur": str(snap.portfolio_eur), "capital_usd": str(capital_usd), + "iv_minus_rv": ( + str(snap.iv_minus_rv) if snap.iv_minus_rv is not None else None + ), } } if not decision.accepted: diff --git a/strategy.aggressiva.yaml b/strategy.aggressiva.yaml index ffb8554..8ba5ba4 100644 --- a/strategy.aggressiva.yaml +++ b/strategy.aggressiva.yaml @@ -28,8 +28,8 @@ # 2× via "ETH + BTC" indicato in `📚 Strategia` è una **stima ex-ante** # di cosa otterresti DOPO quel lavoro di codice. -config_version: "1.0.0-aggressiva" -config_hash: "b931a2b96fbc149b21cae84a196ee8bad10220b5ee8fa9ab0ed06ae52d7dc531" +config_version: "1.1.0-aggressiva" +config_hash: "58086a4afbbf36c48d22f39bbc75d8145e76a063917431793d3b92ae76b5eb68" last_review: "2026-04-26" last_reviewer: "Adriano" @@ -66,6 +66,13 @@ entry: dealer_gamma_filter_enabled: true liquidation_filter_enabled: true + # IV richness gate (§2.9) — abilitato con soglia 3 pt vol. + # Coerente con il profilo aggressivo: size più grande pretende + # win-rate più alto. La soglia 3 va alzata a 5 dopo la + # calibrazione (4-8 settimane di dati raccolti). + iv_minus_rv_min: "3" + iv_minus_rv_filter_enabled: true + structure: dte_target: 18 dte_min: 14 diff --git a/strategy.conservativa.yaml b/strategy.conservativa.yaml index 52c30b0..f97d406 100644 --- a/strategy.conservativa.yaml +++ b/strategy.conservativa.yaml @@ -15,8 +15,8 @@ # cerbero-bite config hash --file strategy.conservativa.yaml # e bumpare config_version. -config_version: "1.0.0-conservativa" -config_hash: "eff824281bbb538fba49434d8cc4b9c37675bc73d60e351293e263cc7e7b29ef" +config_version: "1.1.0-conservativa" +config_hash: "188155fd0017a1353024151b8237f257b0c3156d2592ce89653d239b39fb69ce" last_review: "2026-04-26" last_reviewer: "Adriano" @@ -50,6 +50,10 @@ entry: dealer_gamma_filter_enabled: true liquidation_filter_enabled: true + # IV richness gate (§2.9) — disabilitato finché non calibrato. + iv_minus_rv_min: "0" + iv_minus_rv_filter_enabled: false + structure: dte_target: 18 dte_min: 14 diff --git a/strategy.yaml b/strategy.yaml index 082917d..7178ef1 100644 --- a/strategy.yaml +++ b/strategy.yaml @@ -6,8 +6,8 @@ # config hash), and lands as a separate commit with the motivation in # the commit message. -config_version: "1.0.0" -config_hash: "4c2be4c51c849ed58fa22ec2b302016c453894dd0964b6d05445ab1b723e2d10" +config_version: "1.1.0" +config_hash: "e0504e6936e9ec5013e7901cf98532e29ff2414b1cce10461cfe97790119b724" last_review: "2026-04-26" last_reviewer: "Adriano" @@ -46,6 +46,13 @@ entry: dealer_gamma_filter_enabled: true liquidation_filter_enabled: true + # IV richness gate (§2.9). Disabilitato di default: è il filtro + # con maggior impatto sul win-rate ma va calibrato sui dati + # raccolti in `market_snapshots` prima di metterlo in produzione. + # Vedi `docs/13-strategia-spiegata.md` §4-quater. + iv_minus_rv_min: "0" + iv_minus_rv_filter_enabled: false + structure: dte_target: 18 dte_min: 14 diff --git a/tests/integration/test_entry_cycle.py b/tests/integration/test_entry_cycle.py index a89f829..d17c08b 100644 --- a/tests/integration/test_entry_cycle.py +++ b/tests/integration/test_entry_cycle.py @@ -118,6 +118,16 @@ def _wire_market_snapshot( }, is_reusable=True, ) + httpx_mock.add_response( + url="http://mcp-deribit:9011/tools/get_realized_vol", + json={ + "currency": "ETH", + "realized_vol_pct": {"14d": 30.0, "30d": 30.0}, + "iv_current_pct": 38.0, + "iv_minus_rv_pct": {"14d": 8.0, "30d": 8.0}, + }, + is_reusable=True, + ) httpx_mock.add_response( url="http://mcp-sentiment:9014/tools/get_liquidation_heatmap", json={ diff --git a/tests/unit/test_config_loader.py b/tests/unit/test_config_loader.py index 26510b4..6e8754a 100644 --- a/tests/unit/test_config_loader.py +++ b/tests/unit/test_config_loader.py @@ -68,7 +68,7 @@ def test_compute_hash_is_independent_of_recorded_hash_value(tmp_path: Path) -> N def test_load_repo_strategy_yaml(tmp_path: Path) -> None: """The committed strategy.yaml validates with the recorded hash.""" result = load_strategy(REPO_ROOT / "strategy.yaml") - assert result.config.config_version == "1.0.0" + assert result.config.config_version == "1.1.0" assert result.config.sizing.kelly_fraction == Decimal("0.13") assert result.computed_hash == result.config.config_hash diff --git a/tests/unit/test_entry_validator.py b/tests/unit/test_entry_validator.py index 5822838..106c1f3 100644 --- a/tests/unit/test_entry_validator.py +++ b/tests/unit/test_entry_validator.py @@ -194,6 +194,62 @@ def test_dealer_gamma_filter_disabled_in_config(cfg: StrategyConfig) -> None: assert decision.accepted is True +# --------------------------------------------------------------------------- +# IV richness gate (§2.9) +# --------------------------------------------------------------------------- + + +def _strict_iv_rv_cfg( + cfg: StrategyConfig, *, threshold: Decimal = Decimal("5") +) -> StrategyConfig: + return golden_config( + entry=EntryConfig( + **{ + **cfg.entry.model_dump(), + "iv_minus_rv_filter_enabled": True, + "iv_minus_rv_min": threshold, + } + ) + ) + + +def test_iv_richness_gate_disabled_by_default_lets_thin_premium_pass( + cfg: StrategyConfig, +) -> None: + # Default config: filter disabled. Anche con IV-RV negativa (RV>IV) + # l'entry deve passare per non rompere setup pre-calibrazione. + decision = validate_entry(_good_ctx(iv_minus_rv=Decimal("-2")), cfg) + assert decision.accepted is True + + +def test_iv_richness_gate_blocks_when_below_floor(cfg: StrategyConfig) -> None: + strict = _strict_iv_rv_cfg(cfg, threshold=Decimal("5")) + decision = validate_entry(_good_ctx(iv_minus_rv=Decimal("3")), strict) + assert decision.accepted is False + assert any("IV richness" in r for r in decision.reasons) + + +def test_iv_richness_gate_passes_when_above_floor(cfg: StrategyConfig) -> None: + strict = _strict_iv_rv_cfg(cfg, threshold=Decimal("5")) + decision = validate_entry(_good_ctx(iv_minus_rv=Decimal("6")), strict) + assert decision.accepted is True + + +def test_iv_richness_gate_passes_at_exact_threshold(cfg: StrategyConfig) -> None: + # Soglia inclusiva: IV-RV == soglia → accettato (gate è "<", non "<="). + strict = _strict_iv_rv_cfg(cfg, threshold=Decimal("5")) + decision = validate_entry(_good_ctx(iv_minus_rv=Decimal("5")), strict) + assert decision.accepted is True + + +def test_iv_richness_gate_skipped_when_data_missing(cfg: StrategyConfig) -> None: + # MCP irraggiungibile: best-effort skip, non bloccare l'entry per + # un problema di infrastruttura. + strict = _strict_iv_rv_cfg(cfg, threshold=Decimal("5")) + decision = validate_entry(_good_ctx(iv_minus_rv=None), strict) + assert decision.accepted is True + + def test_validate_entry_accumulates_all_reasons(cfg: StrategyConfig) -> None: decision = validate_entry( _good_ctx(