"""Integration tests for the monitor cycle (open positions → exit decisions).""" from __future__ import annotations from datetime import UTC, datetime, timedelta from decimal import Decimal from pathlib import Path from uuid import uuid4 import pytest from pytest_httpx import HTTPXMock from cerbero_bite.config import golden_config from cerbero_bite.config.mcp_endpoints import load_endpoints from cerbero_bite.runtime import build_runtime from cerbero_bite.runtime.monitor_cycle import run_monitor_cycle from cerbero_bite.state import ( DvolSnapshot, PositionRecord, transaction, ) from cerbero_bite.state import connect as connect_state pytestmark = pytest.mark.httpx_mock(assert_all_responses_were_requested=False) @pytest.fixture def now() -> datetime: return datetime(2026, 4, 27, 14, 0, tzinfo=UTC) @pytest.fixture def runtime_paths(tmp_path: Path) -> tuple[Path, Path]: return tmp_path / "state.sqlite", tmp_path / "audit.log" def _seed_position( ctx, *, proposal_id, short_mid_at_entry: Decimal, long_mid_at_entry: Decimal, n_contracts: int = 2, now: datetime, ): short_strike = Decimal("2475") long_strike = Decimal("2350") width = short_strike - long_strike credit_eth_per = short_mid_at_entry - long_mid_at_entry spot = Decimal("3000") record = PositionRecord( proposal_id=proposal_id, spread_type="bull_put", expiry=now + timedelta(days=18), short_strike=short_strike, long_strike=long_strike, short_instrument="ETH-15MAY26-2475-P", long_instrument="ETH-15MAY26-2350-P", n_contracts=n_contracts, spread_width_usd=width, spread_width_pct=width / spot, credit_eth=credit_eth_per * Decimal(n_contracts), credit_usd=credit_eth_per * Decimal(n_contracts) * spot, max_loss_usd=(width - credit_eth_per * spot) * Decimal(n_contracts), spot_at_entry=spot, dvol_at_entry=Decimal("50"), delta_at_entry=Decimal("-0.12"), eth_price_at_entry=spot, proposed_at=now - timedelta(days=4), opened_at=now - timedelta(days=4), status="open", created_at=now - timedelta(days=4), updated_at=now - timedelta(days=4), ) db_path = ctx.db_path conn = connect_state(db_path) try: with transaction(conn): ctx.repository.create_position(conn, record) finally: conn.close() return record def _seed_dvol_history(ctx, *, when: datetime, spot: Decimal, dvol: Decimal): conn = connect_state(ctx.db_path) try: with transaction(conn): ctx.repository.record_dvol_snapshot( conn, DvolSnapshot(timestamp=when, dvol=dvol, eth_spot=spot) ) finally: conn.close() def _wire_market_data( httpx_mock: HTTPXMock, *, spot: float = 3000.0, dvol: float = 50.0, historical_close: float | None = None, ) -> None: 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, ) # Bootstrap fallback for return_4h when dvol_history is empty. httpx_mock.add_response( url="http://mcp-deribit:9011/tools/get_historical", json={ "candles": ( [{"close": historical_close}] if historical_close is not None else [] ) }, is_reusable=True, ) def _wire_position_quotes( httpx_mock: HTTPXMock, *, short_mid: float, long_mid: float, short_delta: float = -0.12, long_delta: float = -0.08, ) -> None: httpx_mock.add_response( url="http://mcp-deribit:9011/tools/get_ticker_batch", json={ "tickers": [ { "instrument_name": "ETH-15MAY26-2475-P", "bid": short_mid * 0.995, "ask": short_mid * 1.005, "mark_price": short_mid, "greeks": { "delta": short_delta, "gamma": 0.001, "theta": -0.0005, "vega": 0.10, }, }, { "instrument_name": "ETH-15MAY26-2350-P", "bid": long_mid * 0.995, "ask": long_mid * 1.005, "mark_price": long_mid, "greeks": { "delta": long_delta, "gamma": 0.001, "theta": -0.0003, "vega": 0.07, }, }, ], "errors": [], }, is_reusable=True, ) def _ctx(runtime_paths, now: datetime): db, audit = runtime_paths return build_runtime( cfg=golden_config(), endpoints=load_endpoints(env={}), token="t", db_path=db, audit_path=audit, retry_max=1, clock=lambda: now, ) @pytest.mark.asyncio async def test_monitor_emits_hold_when_no_trigger_fires( runtime_paths: tuple[Path, Path], now: datetime, httpx_mock: HTTPXMock ) -> None: ctx = _ctx(runtime_paths, now) proposal_id = uuid4() _seed_position( ctx, proposal_id=proposal_id, short_mid_at_entry=Decimal("0.020"), long_mid_at_entry=Decimal("0.005"), now=now, ) _wire_market_data(httpx_mock) # Mark close to entry (mid still 60% of credit) → HOLD _wire_position_quotes(httpx_mock, short_mid=0.0143, long_mid=0.0050) res = await run_monitor_cycle(ctx, now=now) assert len(res.outcomes) == 1 assert res.outcomes[0].action == "HOLD" assert res.outcomes[0].closed is False @pytest.mark.asyncio async def test_monitor_closes_position_on_profit_take( runtime_paths: tuple[Path, Path], now: datetime, httpx_mock: HTTPXMock ) -> None: ctx = _ctx(runtime_paths, now) proposal_id = uuid4() _seed_position( ctx, proposal_id=proposal_id, short_mid_at_entry=Decimal("0.020"), long_mid_at_entry=Decimal("0.005"), now=now, ) _wire_market_data(httpx_mock) # mark = 30% of credit → profit take fires # credit per contract 0.015, so mark per contract 0.0045 → short-long _wire_position_quotes(httpx_mock, short_mid=0.0080, long_mid=0.0035) httpx_mock.add_response( url="http://mcp-deribit:9011/tools/place_combo_order", json={ "combo_instrument": "ETH-15MAY26-2475P_2350P", "order_id": "close-1", "state": "filled", "average_price": 0.0045, "filled_amount": 2, }, is_reusable=True, ) res = await run_monitor_cycle(ctx, now=now) assert len(res.outcomes) == 1 outcome = res.outcomes[0] assert outcome.action == "CLOSE_PROFIT" assert outcome.closed is True conn = connect_state(ctx.db_path) try: positions = ctx.repository.list_positions(conn) finally: conn.close() assert positions[0].status == "closed" assert positions[0].close_reason == "CLOSE_PROFIT" @pytest.mark.asyncio async def test_monitor_skips_when_kill_switch_armed( runtime_paths: tuple[Path, Path], now: datetime, httpx_mock: HTTPXMock ) -> None: ctx = _ctx(runtime_paths, now) ctx.kill_switch.arm(reason="test", source="manual") res = await run_monitor_cycle(ctx, now=now) assert res.outcomes == [] @pytest.mark.asyncio async def test_monitor_uses_dvol_history_for_return_4h( runtime_paths: tuple[Path, Path], now: datetime, httpx_mock: HTTPXMock ) -> None: ctx = _ctx(runtime_paths, now) proposal_id = uuid4() _seed_position( ctx, proposal_id=proposal_id, short_mid_at_entry=Decimal("0.020"), long_mid_at_entry=Decimal("0.005"), now=now, ) # Snapshot 4h ago: spot was 3300 → return = 3000/3300 - 1 ≈ -9% (adverse) _seed_dvol_history( ctx, when=now - timedelta(hours=4), spot=Decimal("3300"), dvol=Decimal("50"), ) _wire_market_data(httpx_mock, spot=3000.0) _wire_position_quotes(httpx_mock, short_mid=0.0140, long_mid=0.0050) httpx_mock.add_response( url="http://mcp-deribit:9011/tools/place_combo_order", json={ "combo_instrument": "ETH-15MAY26-2475P_2350P", "order_id": "close-1", "state": "filled", "average_price": 0.009, "filled_amount": 2, }, is_reusable=True, ) res = await run_monitor_cycle(ctx, now=now) assert res.outcomes[0].action == "CLOSE_AVERSE" assert res.outcomes[0].closed is True