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>
This commit is contained in:
2026-04-27 10:14:06 +02:00
parent 881bc8a1bf
commit fbb7753cc6
20 changed files with 3090 additions and 1 deletions
+331
View File
@@ -0,0 +1,331 @@
"""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"