Files
Cerbero-Bite/tests/unit/test_combo_builder.py
T
Adriano fbb7753cc6 Phase 1: core algorithms
Implementa i sette algoritmi puri di docs/03-algorithms.md con
disciplina TDD: 112 test, copertura statement+branch al 100% su
core/ e config/, mypy --strict pulito, ruff pulito.

Moduli:
- config/schema.py: StrategyConfig Pydantic v2 con validatori di
  consistenza (kelly, delta, OTM, spread width, profit/stop).
- core/types.py: OptionQuote e OptionLeg condivisi.
- core/entry_validator.py: validate_entry (accumula motivi) e
  compute_bias (bull_put/bear_call/iron_condor/None).
- core/liquidity_gate.py: check OI/volume/spread/depth + slippage
  stimato in % del credito.
- core/sizing_engine.py: Quarter Kelly con cap 200/1000 EUR e
  bande DVOL.
- core/combo_builder.py: select_strikes (DTE/OTM/delta/width/credit)
  e build (ComboProposal con credit/max_loss/breakeven).
- core/greeks_aggregator.py: somma firmata BUY/SELL, theta in USD.
- core/exit_decision.py: 6 trigger ordinati con eccezione skip-time
  vicino a profit (mark in (50%,70%] credito).
- core/kelly_recalibration.py: full/quarter Kelly, confidence per
  sample size, blend medio in fascia 30-99 trade.

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

332 lines
12 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.
"""TDD for :mod:`cerbero_bite.core.combo_builder`.
Spec: ``docs/03-algorithms.md §4`` and ``docs/01-strategy-rules.md §3``.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from uuid import UUID
import pytest
from cerbero_bite.config import StrategyConfig, golden_config
from cerbero_bite.core.combo_builder import build, select_strikes
from cerbero_bite.core.types import OptionQuote
@pytest.fixture
def cfg() -> StrategyConfig:
return golden_config()
@pytest.fixture
def now() -> datetime:
return datetime(2026, 4, 27, 14, 0, tzinfo=UTC)
def _quote(
*,
strike: str,
delta: str,
option_type: str = "P",
expiry_dte: int = 18,
mid: str | None = None,
bid: str | None = None,
ask: str | None = None,
open_interest: int = 500,
volume_24h: int = 100,
book_depth_top3: int = 30,
now_dt: datetime = datetime(2026, 4, 27, 14, 0, tzinfo=UTC),
) -> OptionQuote:
expiry = (now_dt + timedelta(days=expiry_dte)).replace(hour=8, minute=0, second=0)
if mid is None:
# crude pricing: deeper OTM → smaller premium; absolute delta proxy
mid = str((Decimal(delta).copy_abs() * Decimal("0.10")).quantize(Decimal("0.0001")))
mid_d = Decimal(mid)
if bid is None:
bid = str((mid_d * Decimal("0.98")).quantize(Decimal("0.000001")))
if ask is None:
ask = str((mid_d * Decimal("1.02")).quantize(Decimal("0.000001")))
return OptionQuote(
instrument=f"ETH-{expiry.strftime('%-d%b%y').upper()}-{strike}-{option_type}",
strike=Decimal(strike),
expiry=expiry,
option_type=option_type, # type: ignore[arg-type]
bid=Decimal(bid),
ask=Decimal(ask),
mid=mid_d,
delta=Decimal(delta),
gamma=Decimal("0.001"),
theta=Decimal("-0.0005"),
vega=Decimal("0.10"),
open_interest=open_interest,
volume_24h=volume_24h,
book_depth_top3=book_depth_top3,
)
# ---------------------------------------------------------------------------
# select_strikes
# ---------------------------------------------------------------------------
def _bull_put_chain(now_dt: datetime) -> list[OptionQuote]:
"""Realistic bull-put chain around spot 3000.
Mids are tuned so that the candidate (2475 short, 2350 long) yields
credit_usd / width_usd ≈ 36% — comfortably above the 30% gate.
"""
# Distance OTM in [15%, 25%] of 3000 → strikes in [2250, 2550].
return [
# Way too close (delta too high) — should be ignored.
_quote(strike="2700", delta="-0.30", mid="0.060", now_dt=now_dt),
# In OTM range, delta in [0.10, 0.15]
_quote(strike="2550", delta="-0.14", mid="0.022", now_dt=now_dt),
_quote(strike="2475", delta="-0.12", mid="0.020", now_dt=now_dt), # closest to 0.12
_quote(strike="2400", delta="-0.10", mid="0.014", now_dt=now_dt),
# Below OTM range (too far) — delta too small even if in range
_quote(strike="2250", delta="-0.06", mid="0.006", now_dt=now_dt),
# The long strike candidates (further OTM, ~4% width below short)
_quote(strike="2370", delta="-0.09", mid="0.008", now_dt=now_dt),
_quote(strike="2350", delta="-0.08", mid="0.005", now_dt=now_dt),
]
def test_select_strikes_picks_short_closest_to_delta_target(
cfg: StrategyConfig, now: datetime
) -> None:
chain = _bull_put_chain(now)
result = select_strikes(
chain=chain,
bias="bull_put",
spot=Decimal("3000"),
now=now,
cfg=cfg,
)
assert result is not None
short, long_ = result
assert short.strike == Decimal("2475") # |delta|=0.12 closest to 0.12
# width target = 4% × 3000 = 120 → long_strike target 2355; nearest at 2350
assert long_.strike == Decimal("2350")
def test_select_strikes_returns_none_when_no_strike_in_otm_range(
cfg: StrategyConfig, now: datetime
) -> None:
# All strikes too close (< 15% OTM).
chain = [
_quote(strike="2900", delta="-0.30", now_dt=now),
_quote(strike="2800", delta="-0.20", now_dt=now),
]
assert (
select_strikes(chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg)
is None
)
def test_select_strikes_returns_none_when_delta_out_of_band(
cfg: StrategyConfig, now: datetime
) -> None:
# OTM range OK but delta way off [0.10, 0.15].
chain = [
_quote(strike="2475", delta="-0.05", now_dt=now),
_quote(strike="2400", delta="-0.04", now_dt=now),
]
assert (
select_strikes(chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg)
is None
)
def test_select_strikes_returns_none_when_long_width_outside_range(
cfg: StrategyConfig, now: datetime
) -> None:
# Short OK; long candidates are all too close (< 3%) or too far (> 5%).
short = _quote(strike="2475", delta="-0.12", mid="0.020", now_dt=now)
too_close = _quote(strike="2470", delta="-0.115", mid="0.018", now_dt=now) # 0.2% width
too_far = _quote(strike="2200", delta="-0.05", mid="0.005", now_dt=now) # 9.17% width
chain = [short, too_close, too_far]
assert (
select_strikes(chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg)
is None
)
def test_select_strikes_returns_none_when_credit_to_width_below_min(
cfg: StrategyConfig, now: datetime
) -> None:
# Width 125 USD; credit (mid_short - mid_long) only 0.0001 ETH ≈ 0.3 USD → 0.24%.
short = _quote(strike="2475", delta="-0.12", mid="0.012", now_dt=now)
long_ = _quote(strike="2350", delta="-0.08", mid="0.0119", now_dt=now)
chain = [short, long_]
assert (
select_strikes(chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg)
is None
)
def test_select_strikes_filters_out_options_outside_dte_window(
cfg: StrategyConfig, now: datetime
) -> None:
chain = [
_quote(strike="2475", delta="-0.12", mid="0.012", expiry_dte=5, now_dt=now),
_quote(strike="2350", delta="-0.08", mid="0.007", expiry_dte=5, now_dt=now),
_quote(strike="2475", delta="-0.12", mid="0.012", expiry_dte=40, now_dt=now),
_quote(strike="2350", delta="-0.08", mid="0.007", expiry_dte=40, now_dt=now),
]
assert (
select_strikes(chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg)
is None
)
def test_select_strikes_picks_expiry_closest_to_dte_target(
cfg: StrategyConfig, now: datetime
) -> None:
# Two valid expiries: ~16 DTE and ~21 DTE. Target=18 → 16 closer than 21.
chain = [
_quote(strike="2475", delta="-0.12", mid="0.020", expiry_dte=17, now_dt=now),
_quote(strike="2350", delta="-0.08", mid="0.005", expiry_dte=17, now_dt=now),
_quote(strike="2475", delta="-0.12", mid="0.025", expiry_dte=22, now_dt=now),
_quote(strike="2350", delta="-0.08", mid="0.008", expiry_dte=22, now_dt=now),
]
result = select_strikes(
chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg
)
assert result is not None
short, _ = result
# 17 days requested, but expiry is set at 08:00 UTC and now at 14:00, so
# the calendar-day delta is 16 days for the closer expiry, 21 for the far.
picked_dte = (short.expiry - now).days
assert picked_dte == 16
def test_select_strikes_bear_call_picks_calls_above_spot(
cfg: StrategyConfig, now: datetime
) -> None:
spot = Decimal("3000")
chain = [
# OTM call range = [3450, 3750] (15% to 25% above).
# Mids tuned so credit ≈ 36% of width (≥ 30% gate).
_quote(strike="3525", delta="0.12", mid="0.020", option_type="C", now_dt=now),
_quote(strike="3645", delta="0.09", mid="0.005", option_type="C", now_dt=now),
_quote(strike="3450", delta="0.14", mid="0.024", option_type="C", now_dt=now),
# noise: puts that should be ignored
_quote(strike="2475", delta="-0.12", mid="0.020", option_type="P", now_dt=now),
]
result = select_strikes(chain=chain, bias="bear_call", spot=spot, now=now, cfg=cfg)
assert result is not None
short, long_ = result
assert short.option_type == "C"
assert long_.option_type == "C"
# short should be 3525 (delta 0.12, closest to target)
assert short.strike == Decimal("3525")
# width target = 120 above → 3645
assert long_.strike == Decimal("3645")
def test_select_strikes_iron_condor_not_supported(
cfg: StrategyConfig, now: datetime
) -> None:
# IC needs a different orchestration; for now select_strikes returns None for IC.
chain = _bull_put_chain(now)
assert (
select_strikes(chain=chain, bias="iron_condor", spot=Decimal("3000"), now=now, cfg=cfg)
is None
)
def test_select_strikes_returns_none_when_chain_has_no_matching_type(
cfg: StrategyConfig, now: datetime
) -> None:
# Bull put requested but the chain only has calls within the DTE window.
chain = [
_quote(strike="3525", delta="0.12", mid="0.020", option_type="C", now_dt=now),
_quote(strike="3645", delta="0.08", mid="0.005", option_type="C", now_dt=now),
]
assert (
select_strikes(chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg)
is None
)
def test_select_strikes_returns_none_when_no_long_candidate_exists(
cfg: StrategyConfig, now: datetime
) -> None:
# Only the short strike exists; nothing further OTM to pair as the long leg.
chain = [_quote(strike="2475", delta="-0.12", mid="0.020", now_dt=now)]
assert (
select_strikes(chain=chain, bias="bull_put", spot=Decimal("3000"), now=now, cfg=cfg)
is None
)
# ---------------------------------------------------------------------------
# build
# ---------------------------------------------------------------------------
def test_build_returns_proposal_with_correct_legs_and_metrics(
cfg: StrategyConfig, now: datetime
) -> None:
short = _quote(strike="2475", delta="-0.12", mid="0.012", now_dt=now)
long_ = _quote(strike="2350", delta="-0.08", mid="0.007", now_dt=now)
proposal = build(
short=short,
long_=long_,
n_contracts=2,
spot=Decimal("3000"),
dvol=Decimal("50"),
cfg=cfg,
now=now,
spread_type="bull_put",
)
# legs ordered short-first
assert len(proposal.legs) == 2
assert proposal.legs[0].side == "SELL"
assert proposal.legs[0].strike == Decimal("2475")
assert proposal.legs[1].side == "BUY"
assert proposal.legs[1].strike == Decimal("2350")
assert proposal.legs[0].size == 2
assert proposal.legs[1].size == 2
# credit per contract = 0.012 - 0.007 = 0.005 ETH; n=2 → 0.010 ETH
assert proposal.credit_target_eth == Decimal("0.010")
# credit USD = 0.010 × 3000 = 30
assert proposal.credit_target_usd == Decimal("30")
# width = 125 USD per contract; credit_per_contract_usd = 0.005×3000 = 15;
# max_loss per contract = 125 - 15 = 110 USD; n=2 → 220.
assert proposal.max_loss_usd == Decimal("220")
assert proposal.spot_at_proposal == Decimal("3000")
assert proposal.dvol_at_proposal == Decimal("50")
assert proposal.spread_type == "bull_put"
# breakeven (bull put) = short_strike - credit_per_contract_usd = 2475 - 15 = 2460
assert proposal.breakeven == Decimal("2460.000")
assert isinstance(proposal.proposal_id, UUID)
def test_build_bear_call_breakeven_above_short_strike(
cfg: StrategyConfig, now: datetime
) -> None:
short = _quote(strike="3525", delta="0.12", mid="0.012", option_type="C", now_dt=now)
long_ = _quote(strike="3645", delta="0.08", mid="0.007", option_type="C", now_dt=now)
proposal = build(
short=short,
long_=long_,
n_contracts=1,
spot=Decimal("3000"),
dvol=Decimal("55"),
cfg=cfg,
now=now,
spread_type="bear_call",
)
# credit per contract = 0.005 ETH × 3000 = 15 USD
# breakeven = 3525 + 15 = 3540
assert proposal.breakeven == Decimal("3540")
assert proposal.spread_type == "bear_call"