4ab7590745
Aggiunge il filtro a maggior impatto sul win-rate atteso: l'entry
salta se la IV implicita non sta pagando un margine misurabile sopra
la realized vol. La letteratura short-vol systematic indica che
l'edge sostenibile della strategia esiste solo quando IV30g − RV30g
supera una soglia di alcuni punti vol; senza questo gate il selling
vol nudo è strutturalmente neutro a win-rate 70-72%.
Implementazione end-to-end:
- `EntryConfig`: due nuovi campi `iv_minus_rv_min` e
`iv_minus_rv_filter_enabled`, con default `0` / `false` per non
rompere setup pre-calibrazione.
- `validate_entry`: §2.9 hard gate che blocca l'entry se
`iv_minus_rv < iv_minus_rv_min` (skip silenzioso quando il dato è
`None`, coerente con il pattern §2.8 dei filtri quant).
- `entry_cycle._gather_snapshot`: nuovo `_safe_iv_minus_rv` che
legge `deribit.realized_vol("ETH")["iv_minus_rv_30d"]` in
best-effort e lo propaga via `_MarketSnapshot.iv_minus_rv` →
`EntryContext.iv_minus_rv` → audit `inputs.snapshot.iv_minus_rv`.
- `tests/unit/test_entry_validator.py`: 5 nuovi casi (default
permissivo, gate sotto/sopra/uguale soglia, dato mancante).
- `tests/integration/test_entry_cycle.py`: stub `get_realized_vol`
nel mock helper così tutti gli scenari di happy/edge path
continuano a passare.
Configurazione di profili coerente con la disciplina:
- `strategy.yaml` (golden 1.1.0) e `strategy.conservativa.yaml`:
gate `enabled=false, min=0`. Manteniamo i lunedì pre-calibrazione
per accumulare dati sulla distribuzione di `iv_minus_rv`.
- `strategy.aggressiva.yaml` (1.1.0-aggressiva): gate
`enabled=true, min=3`. Coerente con la filosofia del profilo —
size più grande pretende win-rate più alto. La soglia 3 è
conservativa; la documentazione raccomanda 5 dopo 4-8 settimane di
calibrazione.
Doc + GUI:
- `docs/13-strategia-spiegata.md` §4-quater: spiega gate, parametri,
default per profilo, effetto atteso sul P/L (trade/anno scendono
ma E[trade] sale → APR cresce comunque), roadmap di hardening
(soglia adattiva, vol-of-vol guard, multi-asset).
- pagina `📚 Strategia`: la riga "IV − RV" passa da informativa a
pass/fail reale; mostra "filtro DISABILITATO (info-only)" quando
spento, ✅/❌ contro la soglia di config quando acceso.
Bump versioni e hash di tutti e tre i file YAML
(`config_version: 1.1.0`, hash ricalcolato). Test pinning aggiornato
(`test_load_repo_strategy_yaml`).
Suite: 410 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
354 lines
12 KiB
Python
354 lines
12 KiB
Python
"""TDD for :mod:`cerbero_bite.core.entry_validator`.
|
|
|
|
Spec: ``docs/01-strategy-rules.md §2,§3.1`` and ``docs/03-algorithms.md §1``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from cerbero_bite.config import EntryConfig, StrategyConfig, golden_config
|
|
from cerbero_bite.core.entry_validator import (
|
|
EntryContext,
|
|
TrendContext,
|
|
compute_bias,
|
|
validate_entry,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _good_ctx(**overrides: object) -> EntryContext:
|
|
base: dict[str, object] = {
|
|
"capital_usd": Decimal("1500"),
|
|
"dvol_now": Decimal("50"),
|
|
"funding_perp_annualized": Decimal("0.10"),
|
|
"eth_holdings_pct_of_portfolio": Decimal("0.10"),
|
|
"next_macro_event_in_days": None,
|
|
"has_open_position": False,
|
|
}
|
|
base.update(overrides)
|
|
return EntryContext(**base) # type: ignore[arg-type]
|
|
|
|
|
|
@pytest.fixture
|
|
def cfg() -> StrategyConfig:
|
|
return golden_config()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_entry — happy path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_entry_accepts_clean_context(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(_good_ctx(), cfg)
|
|
assert decision.accepted is True
|
|
assert decision.reasons == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_entry — single-reason rejections
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_open_position_blocks_entry(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(_good_ctx(has_open_position=True), cfg)
|
|
assert decision.accepted is False
|
|
assert any("position" in r for r in decision.reasons)
|
|
|
|
|
|
def test_capital_below_minimum_blocks_entry(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(_good_ctx(capital_usd=Decimal("719.99")), cfg)
|
|
assert decision.accepted is False
|
|
assert any("capital" in r for r in decision.reasons)
|
|
|
|
|
|
def test_capital_at_minimum_is_accepted(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(_good_ctx(capital_usd=Decimal("720")), cfg)
|
|
assert decision.accepted is True
|
|
|
|
|
|
def test_dvol_below_minimum_blocks_entry(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(_good_ctx(dvol_now=Decimal("34.99")), cfg)
|
|
assert decision.accepted is False
|
|
assert any("dvol" in r for r in decision.reasons)
|
|
|
|
|
|
def test_dvol_above_maximum_blocks_entry(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(_good_ctx(dvol_now=Decimal("90.01")), cfg)
|
|
assert decision.accepted is False
|
|
assert any("dvol" in r for r in decision.reasons)
|
|
|
|
|
|
def test_dvol_at_boundaries_is_accepted(cfg: StrategyConfig) -> None:
|
|
assert validate_entry(_good_ctx(dvol_now=Decimal("35")), cfg).accepted
|
|
assert validate_entry(_good_ctx(dvol_now=Decimal("90")), cfg).accepted
|
|
|
|
|
|
def test_macro_event_within_dte_blocks_entry(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(_good_ctx(next_macro_event_in_days=5), cfg)
|
|
assert decision.accepted is False
|
|
assert any("macro" in r for r in decision.reasons)
|
|
|
|
|
|
def test_macro_event_at_dte_boundary_blocks_entry(cfg: StrategyConfig) -> None:
|
|
# next_macro_event_in_days <= dte_target → block. dte_target = 18.
|
|
decision = validate_entry(_good_ctx(next_macro_event_in_days=18), cfg)
|
|
assert decision.accepted is False
|
|
|
|
|
|
def test_macro_event_beyond_dte_is_ok(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(_good_ctx(next_macro_event_in_days=19), cfg)
|
|
assert decision.accepted is True
|
|
|
|
|
|
def test_macro_none_is_ok(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(_good_ctx(next_macro_event_in_days=None), cfg)
|
|
assert decision.accepted is True
|
|
|
|
|
|
def test_funding_above_abs_cap_blocks(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(_good_ctx(funding_perp_annualized=Decimal("0.81")), cfg)
|
|
assert decision.accepted is False
|
|
assert any("funding" in r for r in decision.reasons)
|
|
|
|
|
|
def test_funding_negative_below_neg_cap_blocks(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(_good_ctx(funding_perp_annualized=Decimal("-0.81")), cfg)
|
|
assert decision.accepted is False
|
|
|
|
|
|
def test_funding_at_cap_is_accepted(cfg: StrategyConfig) -> None:
|
|
assert validate_entry(_good_ctx(funding_perp_annualized=Decimal("0.80")), cfg).accepted
|
|
|
|
|
|
def test_eth_holdings_above_cap_blocks(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(_good_ctx(eth_holdings_pct_of_portfolio=Decimal("0.31")), cfg)
|
|
assert decision.accepted is False
|
|
assert any("holdings" in r for r in decision.reasons)
|
|
|
|
|
|
def test_eth_holdings_at_cap_is_accepted(cfg: StrategyConfig) -> None:
|
|
assert validate_entry(
|
|
_good_ctx(eth_holdings_pct_of_portfolio=Decimal("0.30")), cfg
|
|
).accepted
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_entry — accumulates ALL failure reasons
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_dealer_short_gamma_blocks_entry(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(_good_ctx(dealer_net_gamma=Decimal("-5")), cfg)
|
|
assert decision.accepted is False
|
|
assert any("dealer short-gamma" in r for r in decision.reasons)
|
|
|
|
|
|
def test_dealer_long_gamma_passes(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(_good_ctx(dealer_net_gamma=Decimal("100")), cfg)
|
|
assert decision.accepted is True
|
|
|
|
|
|
def test_dealer_gamma_none_skips_filter(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(_good_ctx(dealer_net_gamma=None), cfg)
|
|
assert decision.accepted is True
|
|
|
|
|
|
def test_liquidation_squeeze_high_blocks_entry(cfg: StrategyConfig) -> None:
|
|
decision = validate_entry(
|
|
_good_ctx(liquidation_squeeze_risk_high=True), cfg
|
|
)
|
|
assert decision.accepted is False
|
|
assert any("liquidation squeeze" in r for r in decision.reasons)
|
|
|
|
|
|
def test_liquidation_squeeze_filter_disabled_in_config(
|
|
cfg: StrategyConfig,
|
|
) -> None:
|
|
permissive = golden_config(
|
|
entry=EntryConfig(
|
|
**{**cfg.entry.model_dump(), "liquidation_filter_enabled": False}
|
|
)
|
|
)
|
|
decision = validate_entry(
|
|
_good_ctx(liquidation_squeeze_risk_high=True), permissive
|
|
)
|
|
assert decision.accepted is True
|
|
|
|
|
|
def test_dealer_gamma_filter_disabled_in_config(cfg: StrategyConfig) -> None:
|
|
permissive = golden_config(
|
|
entry=EntryConfig(
|
|
**{**cfg.entry.model_dump(), "dealer_gamma_filter_enabled": False}
|
|
)
|
|
)
|
|
decision = validate_entry(
|
|
_good_ctx(dealer_net_gamma=Decimal("-1000")), permissive
|
|
)
|
|
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(
|
|
has_open_position=True,
|
|
capital_usd=Decimal("100"),
|
|
dvol_now=Decimal("10"),
|
|
funding_perp_annualized=Decimal("1.5"),
|
|
eth_holdings_pct_of_portfolio=Decimal("0.9"),
|
|
next_macro_event_in_days=2,
|
|
),
|
|
cfg,
|
|
)
|
|
assert decision.accepted is False
|
|
assert len(decision.reasons) >= 6
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# compute_bias
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _trend(
|
|
*,
|
|
eth_now: str = "3000",
|
|
eth_30d_ago: str = "3000",
|
|
funding_cross: str = "0",
|
|
dvol: str = "50",
|
|
adx: str = "25",
|
|
) -> TrendContext:
|
|
return TrendContext(
|
|
eth_now=Decimal(eth_now),
|
|
eth_30d_ago=Decimal(eth_30d_ago),
|
|
funding_cross_annualized=Decimal(funding_cross),
|
|
dvol_now=Decimal(dvol),
|
|
adx_14=Decimal(adx),
|
|
)
|
|
|
|
|
|
def test_bias_both_bull_returns_bull_put(cfg: StrategyConfig) -> None:
|
|
# +6% trend, +25% funding
|
|
ctx = _trend(eth_now="3180", eth_30d_ago="3000", funding_cross="0.25")
|
|
assert compute_bias(ctx, cfg) == "bull_put"
|
|
|
|
|
|
def test_bias_both_bear_returns_bear_call(cfg: StrategyConfig) -> None:
|
|
# -6% trend, -25% funding
|
|
ctx = _trend(eth_now="2820", eth_30d_ago="3000", funding_cross="-0.25")
|
|
assert compute_bias(ctx, cfg) == "bear_call"
|
|
|
|
|
|
def test_bias_discordant_returns_none(cfg: StrategyConfig) -> None:
|
|
# bull trend, bear funding
|
|
ctx = _trend(eth_now="3180", eth_30d_ago="3000", funding_cross="-0.25")
|
|
assert compute_bias(ctx, cfg) is None
|
|
|
|
|
|
def test_bias_neutral_with_high_dvol_low_adx_returns_iron_condor(
|
|
cfg: StrategyConfig,
|
|
) -> None:
|
|
# 0% trend, 0% funding, dvol 60, adx 15 → IC
|
|
ctx = _trend(
|
|
eth_now="3000", eth_30d_ago="3000", funding_cross="0", dvol="60", adx="15"
|
|
)
|
|
assert compute_bias(ctx, cfg) == "iron_condor"
|
|
|
|
|
|
def test_bias_neutral_with_low_dvol_returns_none(cfg: StrategyConfig) -> None:
|
|
ctx = _trend(
|
|
eth_now="3000", eth_30d_ago="3000", funding_cross="0", dvol="50", adx="15"
|
|
)
|
|
assert compute_bias(ctx, cfg) is None
|
|
|
|
|
|
def test_bias_neutral_with_high_adx_returns_none(cfg: StrategyConfig) -> None:
|
|
ctx = _trend(
|
|
eth_now="3000", eth_30d_ago="3000", funding_cross="0", dvol="60", adx="22"
|
|
)
|
|
assert compute_bias(ctx, cfg) is None
|
|
|
|
|
|
def test_bias_one_neutral_one_bull_returns_none(cfg: StrategyConfig) -> None:
|
|
# +6% trend, +10% funding (neutral)
|
|
ctx = _trend(eth_now="3180", eth_30d_ago="3000", funding_cross="0.10")
|
|
assert compute_bias(ctx, cfg) is None
|
|
|
|
|
|
def test_bias_at_bull_threshold_is_bull_put(cfg: StrategyConfig) -> None:
|
|
# exactly +5% trend, exactly +20% funding
|
|
ctx = _trend(eth_now="3150", eth_30d_ago="3000", funding_cross="0.20")
|
|
assert compute_bias(ctx, cfg) == "bull_put"
|
|
|
|
|
|
def test_bias_at_bear_threshold_is_bear_call(cfg: StrategyConfig) -> None:
|
|
ctx = _trend(eth_now="2850", eth_30d_ago="3000", funding_cross="-0.20")
|
|
assert compute_bias(ctx, cfg) == "bear_call"
|
|
|
|
|
|
def test_bias_zero_division_safe(cfg: StrategyConfig) -> None:
|
|
# eth_30d_ago == 0 must not crash; treat as no-bias (neutral)
|
|
ctx = _trend(eth_now="3000", eth_30d_ago="0", funding_cross="0", dvol="60", adx="15")
|
|
assert compute_bias(ctx, cfg) is None
|