Files
Cerbero-Bite/tests/integration/test_entry_cycle.py
T
Adriano abf5a140e2 refactor: telegram + portfolio in-process (drop shared MCP)
Each bot now manages its own notification + portfolio aggregation:

* TelegramClient calls the public Bot API directly via httpx, reading
  CERBERO_BITE_TELEGRAM_BOT_TOKEN / CERBERO_BITE_TELEGRAM_CHAT_ID from
  env. No credentials → silent disabled mode.
* PortfolioClient composes DeribitClient + HyperliquidClient + the new
  MacroClient.get_asset_price/eur_usd_rate to expose equity (EUR) and
  per-asset exposure as the bot's own slice (no cross-bot view).
* mcp-telegram and mcp-portfolio removed from MCP_SERVICES / McpEndpoints
  and the cerbero-bite ping CLI; health_check no longer probes portfolio.

Docs (02/04/06/07) and docker-compose updated to reflect the new
architecture.

353/353 tests pass; ruff clean; mypy src clean.

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

631 lines
20 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,
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-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"