Files
root 4ab7590745 feat(entry): IV richness gate (§2.9) + golden config bump 1.0.0 → 1.1.0
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>
2026-05-01 19:32:21 +00:00

641 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Integration tests for the weekly entry cycle.
Every external service is mocked via ``pytest-httpx``. The cycle
exercises the production code paths end-to-end: snapshot collection,
entry validation, bias, strike selection, liquidity, sizing, combo
order placement, and persistence.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from typing import Any
from uuid import uuid4
import pytest
from pytest_httpx import HTTPXMock
from cerbero_bite.config import StrategyConfig, golden_config
from cerbero_bite.config.mcp_endpoints import load_endpoints
from cerbero_bite.runtime import build_runtime
from cerbero_bite.runtime.entry_cycle import run_entry_cycle
from cerbero_bite.state import (
PositionRecord,
connect,
transaction,
)
from cerbero_bite.state import connect as connect_state
pytestmark = pytest.mark.httpx_mock(assert_all_responses_were_requested=False)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def now() -> datetime:
return datetime(2026, 4, 27, 14, 0, tzinfo=UTC)
@pytest.fixture
def cfg() -> StrategyConfig:
return golden_config()
@pytest.fixture
def runtime_paths(tmp_path: Path) -> tuple[Path, Path]:
return tmp_path / "state.sqlite", tmp_path / "audit.log"
def _ctx(
cfg: StrategyConfig,
runtime_paths: tuple[Path, Path],
now: datetime,
):
db, audit = runtime_paths
return build_runtime(
cfg=cfg,
endpoints=load_endpoints(env={}),
token="t",
db_path=db,
audit_path=audit,
retry_max=1,
clock=lambda: now,
)
def _option_name(strike: int, opt: str = "P", expiry: str = "15MAY26") -> str:
return f"ETH-{expiry}-{strike}-{opt}"
def _wire_market_snapshot(
httpx_mock: HTTPXMock,
*,
spot: float = 3000.0,
dvol: float = 50.0,
funding_perp_hourly: float = 0.0,
funding_cross_period: float = 0.0001,
macro_events: list[dict[str, Any]] | None = None,
eth_pct: float = 0.10,
portfolio_eur: float | Decimal = 5000.0,
dealer_total_net_gamma: float = 12345.6,
liquidation_long_risk: str = "low",
liquidation_short_risk: str = "low",
) -> None:
"""Stub every MCP endpoint queried during the snapshot stage."""
httpx_mock.add_response(
url="http://mcp-deribit:9011/tools/get_ticker",
json={"instrument_name": "ETH-PERPETUAL", "mark_price": spot},
is_reusable=True,
)
httpx_mock.add_response(
url="http://mcp-deribit:9011/tools/get_dvol",
json={"currency": "ETH", "latest": dvol, "candles": []},
is_reusable=True,
)
httpx_mock.add_response(
url="http://mcp-deribit:9011/tools/get_historical",
json={"candles": [{"close": spot * 0.95}]},
is_reusable=True,
)
httpx_mock.add_response(
url="http://mcp-deribit:9011/tools/get_technical_indicators",
json={"adx": [{"value": 22.0}]},
is_reusable=True,
)
httpx_mock.add_response(
url="http://mcp-deribit:9011/tools/get_dealer_gamma_profile",
json={
"spot_price": spot,
"total_net_dealer_gamma": dealer_total_net_gamma,
"gamma_flip_level": spot * 0.99,
"strikes_analyzed": 18,
"by_strike": [],
},
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={
"asset": "ETH",
"avg_funding_rate": funding_cross_period,
"oi_delta_pct_4h": 1.0,
"oi_delta_pct_24h": 1.0,
"long_squeeze_risk": liquidation_long_risk,
"short_squeeze_risk": liquidation_short_risk,
},
is_reusable=True,
)
httpx_mock.add_response(
url="http://mcp-hyperliquid:9012/tools/get_funding_rate",
json={"asset": "ETH", "current_funding_rate": funding_perp_hourly},
is_reusable=True,
)
httpx_mock.add_response(
url="http://mcp-sentiment:9014/tools/get_cross_exchange_funding",
json={
"snapshot": {
"ETH": {
"binance": funding_cross_period,
"bybit": funding_cross_period,
"okx": funding_cross_period,
"hyperliquid": None,
}
}
},
is_reusable=True,
)
httpx_mock.add_response(
url="http://mcp-macro:9013/tools/get_macro_calendar",
json={"events": macro_events or []},
is_reusable=True,
)
# In-process portfolio aggregator: wire the underlying exchange and
# macro endpoints so total_equity_eur and asset_pct_of_portfolio
# produce the requested ``portfolio_eur`` and ``eth_pct``.
# FX rate fixed at 1.0 → EUR amount equals USD amount in tests.
portfolio_eur_f = float(portfolio_eur)
httpx_mock.add_response(
url="http://mcp-macro:9013/tools/get_asset_price",
json={"ticker": "EURUSD", "price": 1.0},
is_reusable=True,
)
httpx_mock.add_response(
url="http://mcp-deribit:9011/tools/get_account_summary",
json={"equity": portfolio_eur_f, "currency": "USDC"},
is_reusable=True,
)
httpx_mock.add_response(
url="http://mcp-deribit:9011/tools/get_positions",
json=[
{
"instrument_name": "ETH-15MAY26-2475-P",
"notional_usd": portfolio_eur_f * eth_pct,
}
],
is_reusable=True,
)
httpx_mock.add_response(
url="http://mcp-hyperliquid:9012/tools/get_account_summary",
json={"equity": 0.0},
is_reusable=True,
)
httpx_mock.add_response(
url="http://mcp-hyperliquid:9012/tools/get_positions",
json=[],
is_reusable=True,
)
def _wire_chain_and_quotes(
httpx_mock: HTTPXMock,
*,
short_strike: int = 2475,
long_strike: int = 2350,
short_mid: float = 0.020,
long_mid: float = 0.005,
short_delta: float = -0.12,
long_delta: float = -0.08,
) -> None:
"""Stub the option chain → quotes → orderbook flow.
The two strikes returned satisfy the golden config gates by default:
OTM range, delta range, width 4% × 3000 = 120, credit 0.015 ETH × 3000
= 45 USD vs width 125 USD ≈ 36% (≥ 30% gate), liquidity OK.
"""
short_name = _option_name(short_strike)
long_name = _option_name(long_strike)
httpx_mock.add_response(
url="http://mcp-deribit:9011/tools/get_instruments",
json={
"instruments": [
{"name": short_name, "open_interest": 500, "tick_size": 0.0005},
{"name": long_name, "open_interest": 400, "tick_size": 0.0005},
]
},
is_reusable=True,
)
# Use tight 1% bid-ask spread relative to mid so the liquidity gate
# passes regardless of strike (otherwise the long leg's spread
# blows past the 15% cap on small premiums).
short_half = short_mid * 0.005
long_half = long_mid * 0.005
httpx_mock.add_response(
url="http://mcp-deribit:9011/tools/get_ticker_batch",
json={
"tickers": [
{
"instrument_name": short_name,
"bid": short_mid - short_half,
"ask": short_mid + short_half,
"mark_price": short_mid,
"volume_24h": 200,
"greeks": {
"delta": short_delta,
"gamma": 0.001,
"theta": -0.0005,
"vega": 0.10,
},
},
{
"instrument_name": long_name,
"bid": long_mid - long_half,
"ask": long_mid + long_half,
"mark_price": long_mid,
"volume_24h": 150,
"greeks": {
"delta": long_delta,
"gamma": 0.001,
"theta": -0.0003,
"vega": 0.07,
},
},
],
"errors": [],
},
is_reusable=True,
)
httpx_mock.add_response(
url="http://mcp-deribit:9011/tools/get_orderbook",
json={"bids": [[1, 50]], "asks": [[2, 50]]},
is_reusable=True,
)
def _wire_combo_order(
httpx_mock: HTTPXMock, *, state: str = "filled"
) -> None:
httpx_mock.add_response(
url="http://mcp-deribit:9011/tools/place_combo_order",
json={
"combo_instrument": "ETH-15MAY26-2475P_2350P",
"order_id": "ord-1",
"state": state,
"average_price": 0.005,
"filled_amount": 2,
},
is_reusable=True,
)
def _wire_telegram_notify_position_opened(httpx_mock: HTTPXMock) -> None:
"""No-op: Telegram is now an in-process client with disabled mode in tests.
Kept for call-site compatibility; the function used to register an MCP
notify mock but post-refactor there is no HTTP endpoint to mock when
the bot has no Telegram credentials configured.
"""
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_happy_path_places_combo_and_records_open_position(
cfg: StrategyConfig,
runtime_paths: tuple[Path, Path],
now: datetime,
httpx_mock: HTTPXMock,
) -> None:
# bull bias requires bull-trend AND bull-funding.
# Bull funding cross threshold = 0.20 annualised. Period rate × 1095
# → 0.20/1095 ≈ 0.000183 per period.
_wire_market_snapshot(
httpx_mock,
portfolio_eur=Decimal("3500"),
funding_cross_period=0.0002,
)
_wire_chain_and_quotes(httpx_mock)
_wire_combo_order(httpx_mock, state="filled")
_wire_telegram_notify_position_opened(httpx_mock)
# Bypass bias requirement: stub trend == bull by overriding the
# spot snapshot with a value > +5% vs entry. Since the entry cycle
# currently uses spot==spot (no historical data wired), it falls
# into the "neutral trend" branch. To make a directional bias fire
# we use iron_condor: trend neutral + funding neutral + DVOL ≥ 55
# + ADX < 20. But ADX is hard-coded 25 in the cycle for now, so
# instead we set funding to land in bull territory and accept the
# neutral-vs-bull mismatch which the cycle resolves to "no bias"
# — we bypass via configuration.
# In practice the orchestrator will provide eth_30d_ago; for this
# smoke test we widen bias acceptance with a config override.
bull_cfg = golden_config(
entry=type(cfg.entry)(
**{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")}
),
)
ctx = _ctx(bull_cfg, runtime_paths, now)
res = await run_entry_cycle(
ctx, eur_to_usd_rate=Decimal("1.075"), now=now
)
assert res.status == "entry_placed", res.reason
assert res.proposal is not None
assert res.order is not None
assert res.order.combo_instrument == "ETH-15MAY26-2475P_2350P"
assert res.proposal.spread_type == "bull_put"
db_path, _ = runtime_paths
conn = connect(db_path)
try:
positions = ctx.repository.list_positions(conn)
finally:
conn.close()
assert len(positions) == 1
assert positions[0].status == "open"
# ---------------------------------------------------------------------------
# Reject paths
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_kill_switch_short_circuits_cycle(
cfg: StrategyConfig,
runtime_paths: tuple[Path, Path],
now: datetime,
httpx_mock: HTTPXMock,
) -> None:
ctx = _ctx(cfg, runtime_paths, now)
ctx.kill_switch.arm(reason="test", source="manual")
res = await run_entry_cycle(ctx, eur_to_usd_rate=Decimal("1.075"), now=now)
assert res.status == "kill_switch_armed"
@pytest.mark.asyncio
async def test_below_capital_minimum_returns_no_entry(
cfg: StrategyConfig,
runtime_paths: tuple[Path, Path],
now: datetime,
httpx_mock: HTTPXMock,
) -> None:
# 500 EUR × 1.075 = 537 USD < 720 cfg minimum
_wire_market_snapshot(httpx_mock, portfolio_eur=500.0)
ctx = _ctx(cfg, runtime_paths, now)
res = await run_entry_cycle(
ctx, eur_to_usd_rate=Decimal("1.075"), now=now
)
assert res.status == "no_entry"
assert "capital" in (res.reason or "").lower()
@pytest.mark.asyncio
async def test_macro_event_within_dte_blocks_entry(
cfg: StrategyConfig,
runtime_paths: tuple[Path, Path],
now: datetime,
httpx_mock: HTTPXMock,
) -> None:
macro_events = [
{
"name": "FOMC",
"country_code": "US",
"importance": "high",
"datetime_utc": (now + timedelta(days=5)).isoformat(),
}
]
_wire_market_snapshot(httpx_mock, macro_events=macro_events, portfolio_eur=3500)
ctx = _ctx(cfg, runtime_paths, now)
res = await run_entry_cycle(
ctx, eur_to_usd_rate=Decimal("1.075"), now=now
)
assert res.status == "no_entry"
assert "macro" in (res.reason or "").lower()
@pytest.mark.asyncio
async def test_no_bias_returns_no_entry(
cfg: StrategyConfig,
runtime_paths: tuple[Path, Path],
now: datetime,
httpx_mock: HTTPXMock,
) -> None:
# Funding cross neutral (=0) and DVOL 40 → no IC, no directional;
# entry validates clean otherwise.
_wire_market_snapshot(
httpx_mock,
portfolio_eur=3500,
dvol=40.0,
funding_cross_period=0.0,
)
ctx = _ctx(cfg, runtime_paths, now)
res = await run_entry_cycle(
ctx, eur_to_usd_rate=Decimal("1.075"), now=now
)
assert res.status == "no_entry"
assert res.reason == "no_bias"
@pytest.mark.asyncio
async def test_undersize_returns_no_entry(
cfg: StrategyConfig,
runtime_paths: tuple[Path, Path],
now: datetime,
httpx_mock: HTTPXMock,
) -> None:
"""Capital that produces n_contracts < 1 yields no_entry/undersize."""
# Capital just above minimum (720 USD ≈ 670 EUR) but with high
# max_loss/contract → sizing returns 0.
_wire_market_snapshot(
httpx_mock,
portfolio_eur=670.0,
funding_cross_period=0.0002,
)
_wire_chain_and_quotes(httpx_mock, short_strike=2400, long_strike=2150)
bull_cfg = golden_config(
entry=type(cfg.entry)(
**{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")}
),
)
ctx = _ctx(bull_cfg, runtime_paths, now)
res = await run_entry_cycle(ctx, eur_to_usd_rate=Decimal("1.075"), now=now)
assert res.status == "no_entry"
assert res.reason in {"undersize", "no_strike", "illiquid"}
@pytest.mark.asyncio
async def test_no_strike_when_chain_is_empty(
cfg: StrategyConfig,
runtime_paths: tuple[Path, Path],
now: datetime,
httpx_mock: HTTPXMock,
) -> None:
_wire_market_snapshot(
httpx_mock, portfolio_eur=3500.0, funding_cross_period=0.0002
)
httpx_mock.add_response(
url="http://mcp-deribit:9011/tools/get_instruments",
json={"instruments": []},
is_reusable=True,
)
httpx_mock.add_response(
url="http://mcp-deribit:9011/tools/get_ticker_batch",
json={"tickers": [], "errors": []},
is_reusable=True,
)
bull_cfg = golden_config(
entry=type(cfg.entry)(
**{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")}
),
)
ctx = _ctx(bull_cfg, runtime_paths, now)
res = await run_entry_cycle(ctx, eur_to_usd_rate=Decimal("1.075"), now=now)
assert res.status == "no_entry"
assert res.reason == "no_strike"
@pytest.mark.asyncio
async def test_broker_reject_marks_position_cancelled(
cfg: StrategyConfig,
runtime_paths: tuple[Path, Path],
now: datetime,
httpx_mock: HTTPXMock,
) -> None:
_wire_market_snapshot(
httpx_mock, portfolio_eur=3500.0, funding_cross_period=0.0002
)
_wire_chain_and_quotes(httpx_mock)
httpx_mock.add_response(
url="http://mcp-deribit:9011/tools/place_combo_order",
json={
"combo_instrument": "ETH-15MAY26-2475P_2350P",
"order_id": None,
"state": "rejected",
"average_price": None,
"filled_amount": 0,
},
is_reusable=True,
)
bull_cfg = golden_config(
entry=type(cfg.entry)(
**{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")}
),
)
ctx = _ctx(bull_cfg, runtime_paths, now)
res = await run_entry_cycle(ctx, eur_to_usd_rate=Decimal("1.075"), now=now)
assert res.status == "broker_reject"
db_path, _ = runtime_paths
conn = connect(db_path)
try:
positions = ctx.repository.list_positions(conn)
finally:
conn.close()
assert positions[0].status == "cancelled"
assert ctx.kill_switch.is_armed() is True
@pytest.mark.asyncio
async def test_dealer_short_gamma_blocks_entry(
cfg: StrategyConfig,
runtime_paths: tuple[Path, Path],
now: datetime,
httpx_mock: HTTPXMock,
) -> None:
_wire_market_snapshot(
httpx_mock,
portfolio_eur=3500,
funding_cross_period=0.0002,
dealer_total_net_gamma=-42000.0,
)
bull_cfg = golden_config(
entry=type(cfg.entry)(
**{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")}
)
)
ctx = _ctx(bull_cfg, runtime_paths, now)
res = await run_entry_cycle(
ctx, eur_to_usd_rate=Decimal("1.075"), now=now
)
assert res.status == "no_entry"
assert "dealer short-gamma" in (res.reason or "")
@pytest.mark.asyncio
async def test_liquidation_high_risk_blocks_entry(
cfg: StrategyConfig,
runtime_paths: tuple[Path, Path],
now: datetime,
httpx_mock: HTTPXMock,
) -> None:
_wire_market_snapshot(
httpx_mock,
portfolio_eur=3500,
funding_cross_period=0.0002,
liquidation_long_risk="high",
)
bull_cfg = golden_config(
entry=type(cfg.entry)(
**{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")}
)
)
ctx = _ctx(bull_cfg, runtime_paths, now)
res = await run_entry_cycle(
ctx, eur_to_usd_rate=Decimal("1.075"), now=now
)
assert res.status == "no_entry"
assert "liquidation squeeze" in (res.reason or "")
@pytest.mark.asyncio
async def test_already_open_position_skips_cycle(
cfg: StrategyConfig,
runtime_paths: tuple[Path, Path],
now: datetime,
httpx_mock: HTTPXMock,
) -> None:
ctx = _ctx(cfg, runtime_paths, now)
# Pre-seed an open position
record = PositionRecord(
proposal_id=uuid4(),
spread_type="bull_put",
expiry=now + timedelta(days=18),
short_strike=Decimal("2475"),
long_strike=Decimal("2350"),
short_instrument="X",
long_instrument="Y",
n_contracts=1,
spread_width_usd=Decimal("125"),
spread_width_pct=Decimal("0.04"),
credit_eth=Decimal("0.015"),
credit_usd=Decimal("45"),
max_loss_usd=Decimal("80"),
spot_at_entry=Decimal("3000"),
dvol_at_entry=Decimal("50"),
delta_at_entry=Decimal("-0.12"),
eth_price_at_entry=Decimal("3000"),
proposed_at=now,
status="open",
created_at=now,
updated_at=now,
)
db_path, _ = runtime_paths
conn = connect_state(db_path)
try:
with transaction(conn):
ctx.repository.create_position(conn, record)
finally:
conn.close()
res = await run_entry_cycle(
ctx, eur_to_usd_rate=Decimal("1.075"), now=now
)
assert res.status == "has_open_position"