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:
@@ -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"
|
||||
Reference in New Issue
Block a user