diff --git a/src/cerbero_bite/core/entry_validator.py b/src/cerbero_bite/core/entry_validator.py index 70e8866..a823a9a 100644 --- a/src/cerbero_bite/core/entry_validator.py +++ b/src/cerbero_bite/core/entry_validator.py @@ -182,6 +182,20 @@ def validate_entry(ctx: EntryContext, cfg: StrategyConfig) -> EntryDecision: f"(IV-RV={ctx.iv_minus_rv} < {entry_cfg.iv_minus_rv_min} vol pts)" ) + # §4-quater roadmap: vol-of-vol guard. Blocca entry quando il + # regime di volatilità sta cambiando bruscamente, anche se IV-RV + # è alto. Fail-open su gap dati 24h fa. + if ( + entry_cfg.vol_of_vol_guard_enabled + and ctx.dvol_24h_ago is not None + ): + delta = abs(ctx.dvol_now - ctx.dvol_24h_ago) + if delta >= entry_cfg.vol_of_vol_threshold_pt: + reasons.append( + f"DVOL shifted {delta} pt in {entry_cfg.vol_of_vol_lookback_hours}h " + f"(threshold {entry_cfg.vol_of_vol_threshold_pt})" + ) + return EntryDecision(accepted=not reasons, reasons=reasons) diff --git a/tests/unit/test_entry_validator.py b/tests/unit/test_entry_validator.py index 939e2e4..b2e3f6c 100644 --- a/tests/unit/test_entry_validator.py +++ b/tests/unit/test_entry_validator.py @@ -430,3 +430,54 @@ def test_iv_minus_rv_none_skips_gate_in_both_modes() -> None: cfg, ) assert decision.accepted is True + + +# --------------------------------------------------------------------------- +# Vol-of-Vol guard +# --------------------------------------------------------------------------- + + +def _vov_cfg(threshold: Decimal = Decimal("5")) -> StrategyConfig: + return golden_config(entry={ + "vol_of_vol_guard_enabled": True, + "vol_of_vol_threshold_pt": threshold, + "vol_of_vol_lookback_hours": 24, + }) + + +def test_vov_guard_blocks_on_large_dvol_shift() -> None: + cfg = _vov_cfg() + decision = validate_entry( + _good_ctx(dvol_now=Decimal("56"), dvol_24h_ago=Decimal("50")), cfg + ) + assert decision.accepted is False + assert any("DVOL shifted" in r for r in decision.reasons) + + +def test_vov_guard_passes_on_small_dvol_shift() -> None: + cfg = _vov_cfg() + decision = validate_entry( + _good_ctx(dvol_now=Decimal("52"), dvol_24h_ago=Decimal("50")), cfg + ) + assert decision.accepted is True + + +def test_vov_guard_passes_when_lookback_missing() -> None: + """fail-open su gap dati: se dvol_24h_ago=None il guard non scatta.""" + cfg = _vov_cfg() + decision = validate_entry( + _good_ctx(dvol_now=Decimal("99"), dvol_24h_ago=None), cfg + ) + # dvol_now=99 sarebbe oltre dvol_max=90; testiamo solo l'effetto VoV + # consultando le reasons (dvol_now potrebbe avere altre reason ma non + # quella VoV). + assert not any("DVOL shifted" in r for r in decision.reasons) + + +def test_vov_guard_disabled_does_nothing() -> None: + cfg = golden_config(entry={"vol_of_vol_guard_enabled": False}) + decision = validate_entry( + _good_ctx(dvol_now=Decimal("55"), dvol_24h_ago=Decimal("50")), cfg + ) + # Nessuna reason VoV (il delta=5 sarebbe oltre soglia se attivo) + assert not any("DVOL shifted" in r for r in decision.reasons)