Files
Cerbero-Bite/tests/integration/test_entry_cycle.py
T
Adriano 411b747e93 Phase 4 hardening: status CLI, lock file, backup job, hash enforce, pooling, real bias
Sei interventi mirati sui rischi operativi rilevati nell'audit
post-Fase 4. 317 test pass, mypy strict pulito, ruff clean.

1. status CLI: legge SQLite reale e mostra kill_switch, posizioni
   aperte, environment, config_version, last_health_check, started_at.
   Sostituisce il placeholder "phase 0 skeleton".

2. Lock file single-instance: runtime/lockfile.py acquisisce
   data/.lockfile via fcntl.flock al boot di run_forever; un secondo
   container fallisce subito con LockError.

3. Backup orario nello scheduler: nuovo job APScheduler 0 * * * *
   chiama scripts.backup.backup_database + prune_backups.

4. config_hash enforce su start: il CLI start verifica l'integrità
   del file (enforce_hash=True). Mismatch → exit 1 prima di toccare
   stato. dry-run resta enforce_hash=False per debug.

5. Connection pooling MCP: RuntimeContext espone un httpx.AsyncClient
   long-lived condiviso da tutti i wrapper (limits 20/10
   connections/keepalive). aclose() chiamato in run_forever finale.

6. Bias direzionale reale: deribit.historical_close +
   deribit.adx_14 popolano TrendContext con spot a 30 giorni e
   ADX(14) effettivi. Sblocca bull_put e bear_call. Quando i dati
   storici mancano l'engine emette alert MEDIUM e cade su no_entry
   in modo deterministico.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 00:15:28 +02:00

551 lines
17 KiB
Python
Raw 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,
) -> 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-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,
)
portfolio_eur_f = float(portfolio_eur)
httpx_mock.add_response(
url="http://mcp-portfolio:9018/tools/get_holdings",
json=[
{"ticker": "AAPL", "current_value_eur": portfolio_eur_f * (1 - eth_pct)},
{"ticker": "ETH-USD", "current_value_eur": portfolio_eur_f * eth_pct},
],
is_reusable=True,
)
httpx_mock.add_response(
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value",
json={"total_value_eur": portfolio_eur_f},
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:
httpx_mock.add_response(
url="http://mcp-telegram:9017/tools/notify_position_opened",
json={"ok": True},
is_reusable=True,
)
# ---------------------------------------------------------------------------
# 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:
httpx_mock.add_response(
url="http://mcp-telegram:9017/tools/notify",
json={"ok": True},
is_reusable=True,
)
# 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:
httpx_mock.add_response(
url="http://mcp-telegram:9017/tools/notify",
json={"ok": True},
is_reusable=True,
)
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:
httpx_mock.add_response(
url="http://mcp-telegram:9017/tools/notify",
json={"ok": True},
is_reusable=True,
)
# 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,
)
httpx_mock.add_response(
url="http://mcp-telegram:9017/tools/notify_alert",
json={"ok": True},
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_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"