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"
+104
View File
@@ -0,0 +1,104 @@
"""Unit tests for :mod:`cerbero_bite.config.schema`."""
from __future__ import annotations
from decimal import Decimal
import pytest
from cerbero_bite.config import (
EntryConfig,
ExitConfig,
ShortStrikeSpec,
SizingConfig,
SpreadWidthSpec,
StrategyConfig,
StructureConfig,
golden_config,
)
def test_golden_config_builds_with_defaults() -> None:
cfg = golden_config()
assert cfg.config_version == "1.0.0-test"
assert cfg.sizing.kelly_fraction == Decimal("0.13")
assert cfg.exit.profit_take_pct_of_credit == Decimal("0.50")
assert cfg.entry.dvol_min == Decimal("35")
assert cfg.entry.dvol_max == Decimal("90")
def test_dvol_adjustment_default_bands_ordered() -> None:
cfg = golden_config()
bands = cfg.sizing.dvol_adjustment
assert [b.dvol_under for b in bands] == [Decimal("45"), Decimal("60"), Decimal("80")]
assert [b.multiplier for b in bands] == [
Decimal("1.00"),
Decimal("0.85"),
Decimal("0.65"),
]
def test_validator_rejects_kelly_out_of_safe_range() -> None:
with pytest.raises(ValueError, match="kelly_fraction"):
golden_config(sizing=SizingConfig(kelly_fraction=Decimal("0.6")))
def test_validator_rejects_delta_target_outside_band() -> None:
bad = ShortStrikeSpec(
delta_target=Decimal("0.20"),
delta_min=Decimal("0.10"),
delta_max=Decimal("0.15"),
)
with pytest.raises(ValueError, match="delta_target"):
golden_config(structure=StructureConfig(short_strike=bad))
def test_validator_rejects_inverted_otm_range() -> None:
bad = ShortStrikeSpec(
distance_otm_pct_min=Decimal("0.30"),
distance_otm_pct_max=Decimal("0.20"),
)
with pytest.raises(ValueError, match="OTM range"):
golden_config(structure=StructureConfig(short_strike=bad))
def test_validator_rejects_inverted_spread_width() -> None:
bad = SpreadWidthSpec(
target_pct_of_spot=Decimal("0.06"),
min_pct_of_spot=Decimal("0.03"),
max_pct_of_spot=Decimal("0.05"),
)
with pytest.raises(ValueError, match="spread_width"):
golden_config(structure=StructureConfig(spread_width=bad))
def test_validator_rejects_profit_take_ge_100pct() -> None:
with pytest.raises(ValueError, match="profit_take"):
golden_config(exit=ExitConfig(profit_take_pct_of_credit=Decimal("1.0")))
def test_validator_rejects_stop_loss_le_1x() -> None:
with pytest.raises(ValueError, match="stop_loss"):
golden_config(exit=ExitConfig(stop_loss_mark_x_credit=Decimal("1.0")))
def test_validator_rejects_inverted_dvol_range() -> None:
with pytest.raises(ValueError, match="dvol_min"):
golden_config(entry=EntryConfig(dvol_min=Decimal("90"), dvol_max=Decimal("35")))
def test_strategy_config_is_immutable() -> None:
cfg = golden_config()
with pytest.raises(Exception): # pydantic raises ValidationError on frozen
cfg.entry = EntryConfig() # type: ignore[misc]
def test_extra_fields_rejected_on_root() -> None:
with pytest.raises(ValueError):
StrategyConfig(
config_version="x",
config_hash="0" * 64,
last_review="2026-04-26",
last_reviewer="test",
unknown_field=42, # type: ignore[call-arg]
)
+247
View File
@@ -0,0 +1,247 @@
"""TDD for :mod:`cerbero_bite.core.entry_validator`.
Spec: ``docs/01-strategy-rules.md §2,§3.1`` and ``docs/03-algorithms.md §1``.
"""
from __future__ import annotations
from decimal import Decimal
import pytest
from cerbero_bite.config import StrategyConfig, golden_config
from cerbero_bite.core.entry_validator import (
EntryContext,
TrendContext,
compute_bias,
validate_entry,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _good_ctx(**overrides: object) -> EntryContext:
base: dict[str, object] = {
"capital_usd": Decimal("1500"),
"dvol_now": Decimal("50"),
"funding_perp_annualized": Decimal("0.10"),
"eth_holdings_pct_of_portfolio": Decimal("0.10"),
"next_macro_event_in_days": None,
"has_open_position": False,
}
base.update(overrides)
return EntryContext(**base) # type: ignore[arg-type]
@pytest.fixture
def cfg() -> StrategyConfig:
return golden_config()
# ---------------------------------------------------------------------------
# validate_entry — happy path
# ---------------------------------------------------------------------------
def test_validate_entry_accepts_clean_context(cfg: StrategyConfig) -> None:
decision = validate_entry(_good_ctx(), cfg)
assert decision.accepted is True
assert decision.reasons == []
# ---------------------------------------------------------------------------
# validate_entry — single-reason rejections
# ---------------------------------------------------------------------------
def test_open_position_blocks_entry(cfg: StrategyConfig) -> None:
decision = validate_entry(_good_ctx(has_open_position=True), cfg)
assert decision.accepted is False
assert any("position" in r for r in decision.reasons)
def test_capital_below_minimum_blocks_entry(cfg: StrategyConfig) -> None:
decision = validate_entry(_good_ctx(capital_usd=Decimal("719.99")), cfg)
assert decision.accepted is False
assert any("capital" in r for r in decision.reasons)
def test_capital_at_minimum_is_accepted(cfg: StrategyConfig) -> None:
decision = validate_entry(_good_ctx(capital_usd=Decimal("720")), cfg)
assert decision.accepted is True
def test_dvol_below_minimum_blocks_entry(cfg: StrategyConfig) -> None:
decision = validate_entry(_good_ctx(dvol_now=Decimal("34.99")), cfg)
assert decision.accepted is False
assert any("dvol" in r for r in decision.reasons)
def test_dvol_above_maximum_blocks_entry(cfg: StrategyConfig) -> None:
decision = validate_entry(_good_ctx(dvol_now=Decimal("90.01")), cfg)
assert decision.accepted is False
assert any("dvol" in r for r in decision.reasons)
def test_dvol_at_boundaries_is_accepted(cfg: StrategyConfig) -> None:
assert validate_entry(_good_ctx(dvol_now=Decimal("35")), cfg).accepted
assert validate_entry(_good_ctx(dvol_now=Decimal("90")), cfg).accepted
def test_macro_event_within_dte_blocks_entry(cfg: StrategyConfig) -> None:
decision = validate_entry(_good_ctx(next_macro_event_in_days=5), cfg)
assert decision.accepted is False
assert any("macro" in r for r in decision.reasons)
def test_macro_event_at_dte_boundary_blocks_entry(cfg: StrategyConfig) -> None:
# next_macro_event_in_days <= dte_target → block. dte_target = 18.
decision = validate_entry(_good_ctx(next_macro_event_in_days=18), cfg)
assert decision.accepted is False
def test_macro_event_beyond_dte_is_ok(cfg: StrategyConfig) -> None:
decision = validate_entry(_good_ctx(next_macro_event_in_days=19), cfg)
assert decision.accepted is True
def test_macro_none_is_ok(cfg: StrategyConfig) -> None:
decision = validate_entry(_good_ctx(next_macro_event_in_days=None), cfg)
assert decision.accepted is True
def test_funding_above_abs_cap_blocks(cfg: StrategyConfig) -> None:
decision = validate_entry(_good_ctx(funding_perp_annualized=Decimal("0.81")), cfg)
assert decision.accepted is False
assert any("funding" in r for r in decision.reasons)
def test_funding_negative_below_neg_cap_blocks(cfg: StrategyConfig) -> None:
decision = validate_entry(_good_ctx(funding_perp_annualized=Decimal("-0.81")), cfg)
assert decision.accepted is False
def test_funding_at_cap_is_accepted(cfg: StrategyConfig) -> None:
assert validate_entry(_good_ctx(funding_perp_annualized=Decimal("0.80")), cfg).accepted
def test_eth_holdings_above_cap_blocks(cfg: StrategyConfig) -> None:
decision = validate_entry(_good_ctx(eth_holdings_pct_of_portfolio=Decimal("0.31")), cfg)
assert decision.accepted is False
assert any("holdings" in r for r in decision.reasons)
def test_eth_holdings_at_cap_is_accepted(cfg: StrategyConfig) -> None:
assert validate_entry(
_good_ctx(eth_holdings_pct_of_portfolio=Decimal("0.30")), cfg
).accepted
# ---------------------------------------------------------------------------
# validate_entry — accumulates ALL failure reasons
# ---------------------------------------------------------------------------
def test_validate_entry_accumulates_all_reasons(cfg: StrategyConfig) -> None:
decision = validate_entry(
_good_ctx(
has_open_position=True,
capital_usd=Decimal("100"),
dvol_now=Decimal("10"),
funding_perp_annualized=Decimal("1.5"),
eth_holdings_pct_of_portfolio=Decimal("0.9"),
next_macro_event_in_days=2,
),
cfg,
)
assert decision.accepted is False
assert len(decision.reasons) >= 6
# ---------------------------------------------------------------------------
# compute_bias
# ---------------------------------------------------------------------------
def _trend(
*,
eth_now: str = "3000",
eth_30d_ago: str = "3000",
funding_cross: str = "0",
dvol: str = "50",
adx: str = "25",
) -> TrendContext:
return TrendContext(
eth_now=Decimal(eth_now),
eth_30d_ago=Decimal(eth_30d_ago),
funding_cross_annualized=Decimal(funding_cross),
dvol_now=Decimal(dvol),
adx_14=Decimal(adx),
)
def test_bias_both_bull_returns_bull_put(cfg: StrategyConfig) -> None:
# +6% trend, +25% funding
ctx = _trend(eth_now="3180", eth_30d_ago="3000", funding_cross="0.25")
assert compute_bias(ctx, cfg) == "bull_put"
def test_bias_both_bear_returns_bear_call(cfg: StrategyConfig) -> None:
# -6% trend, -25% funding
ctx = _trend(eth_now="2820", eth_30d_ago="3000", funding_cross="-0.25")
assert compute_bias(ctx, cfg) == "bear_call"
def test_bias_discordant_returns_none(cfg: StrategyConfig) -> None:
# bull trend, bear funding
ctx = _trend(eth_now="3180", eth_30d_ago="3000", funding_cross="-0.25")
assert compute_bias(ctx, cfg) is None
def test_bias_neutral_with_high_dvol_low_adx_returns_iron_condor(
cfg: StrategyConfig,
) -> None:
# 0% trend, 0% funding, dvol 60, adx 15 → IC
ctx = _trend(
eth_now="3000", eth_30d_ago="3000", funding_cross="0", dvol="60", adx="15"
)
assert compute_bias(ctx, cfg) == "iron_condor"
def test_bias_neutral_with_low_dvol_returns_none(cfg: StrategyConfig) -> None:
ctx = _trend(
eth_now="3000", eth_30d_ago="3000", funding_cross="0", dvol="50", adx="15"
)
assert compute_bias(ctx, cfg) is None
def test_bias_neutral_with_high_adx_returns_none(cfg: StrategyConfig) -> None:
ctx = _trend(
eth_now="3000", eth_30d_ago="3000", funding_cross="0", dvol="60", adx="22"
)
assert compute_bias(ctx, cfg) is None
def test_bias_one_neutral_one_bull_returns_none(cfg: StrategyConfig) -> None:
# +6% trend, +10% funding (neutral)
ctx = _trend(eth_now="3180", eth_30d_ago="3000", funding_cross="0.10")
assert compute_bias(ctx, cfg) is None
def test_bias_at_bull_threshold_is_bull_put(cfg: StrategyConfig) -> None:
# exactly +5% trend, exactly +20% funding
ctx = _trend(eth_now="3150", eth_30d_ago="3000", funding_cross="0.20")
assert compute_bias(ctx, cfg) == "bull_put"
def test_bias_at_bear_threshold_is_bear_call(cfg: StrategyConfig) -> None:
ctx = _trend(eth_now="2850", eth_30d_ago="3000", funding_cross="-0.20")
assert compute_bias(ctx, cfg) == "bear_call"
def test_bias_zero_division_safe(cfg: StrategyConfig) -> None:
# eth_30d_ago == 0 must not crash; treat as no-bias (neutral)
ctx = _trend(eth_now="3000", eth_30d_ago="0", funding_cross="0", dvol="60", adx="15")
assert compute_bias(ctx, cfg) is None
+273
View File
@@ -0,0 +1,273 @@
"""TDD for :mod:`cerbero_bite.core.exit_decision`.
Spec: ``docs/01-strategy-rules.md §7`` and ``docs/03-algorithms.md §6``.
The eight mandatory cases listed in §6 are exercised here, with the
``mark=30% credit`` case re-interpreted to satisfy the time-stop
exception consistently with rule 1 (which always fires earlier when
mark ≤ 50% credit).
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from uuid import uuid4
import pytest
from cerbero_bite.config import SpreadType, StrategyConfig, golden_config
from cerbero_bite.core.exit_decision import PositionSnapshot, evaluate
from cerbero_bite.core.types import OptionLeg
@pytest.fixture
def cfg() -> StrategyConfig:
return golden_config()
def _legs(spread_type: SpreadType = "bull_put", size: int = 1) -> list[OptionLeg]:
expiry = datetime(2026, 5, 15, 8, 0, tzinfo=UTC)
if spread_type == "bull_put":
return [
OptionLeg(
instrument="ETH-X-2475-P",
side="SELL",
strike=Decimal("2475"),
expiry=expiry,
type="P",
size=size,
mid_price_eth=Decimal("0.020"),
delta=Decimal("-0.12"),
gamma=Decimal("0.001"),
theta=Decimal("-0.0005"),
vega=Decimal("0.10"),
),
OptionLeg(
instrument="ETH-X-2350-P",
side="BUY",
strike=Decimal("2350"),
expiry=expiry,
type="P",
size=size,
mid_price_eth=Decimal("0.005"),
delta=Decimal("-0.08"),
gamma=Decimal("0.001"),
theta=Decimal("-0.0003"),
vega=Decimal("0.07"),
),
]
return [
OptionLeg(
instrument="ETH-X-3525-C",
side="SELL",
strike=Decimal("3525"),
expiry=expiry,
type="C",
size=size,
mid_price_eth=Decimal("0.020"),
delta=Decimal("0.12"),
gamma=Decimal("0.001"),
theta=Decimal("-0.0005"),
vega=Decimal("0.10"),
),
OptionLeg(
instrument="ETH-X-3645-C",
side="BUY",
strike=Decimal("3645"),
expiry=expiry,
type="C",
size=size,
mid_price_eth=Decimal("0.005"),
delta=Decimal("0.08"),
gamma=Decimal("0.001"),
theta=Decimal("-0.0003"),
vega=Decimal("0.07"),
),
]
def _snapshot(
*,
spread_type: SpreadType = "bull_put",
credit_received_eth: str = "0.030",
mark_combo_now_eth: str = "0.020",
dvol_at_entry: str = "50",
dvol_now: str = "50",
delta_short_now: str | None = None,
return_4h_now: str = "0",
days_to_expiry: int = 14,
spot_at_entry: str = "3000",
spot_now: str = "3000",
eth_price_usd_now: str = "3000",
) -> PositionSnapshot:
now = datetime(2026, 4, 27, 14, 0, tzinfo=UTC)
expiry = now + timedelta(days=days_to_expiry)
if delta_short_now is None:
delta_short_now = "-0.12" if spread_type == "bull_put" else "0.12"
legs = _legs(spread_type=spread_type)
return PositionSnapshot(
proposal_id=uuid4(),
spread_type=spread_type,
legs=legs,
credit_received_eth=Decimal(credit_received_eth),
credit_received_usd=Decimal(credit_received_eth) * Decimal(eth_price_usd_now),
spot_at_entry=Decimal(spot_at_entry),
dvol_at_entry=Decimal(dvol_at_entry),
expiry=expiry,
opened_at=now - timedelta(days=4),
eth_price_usd_now=Decimal(eth_price_usd_now),
spot_now=Decimal(spot_now),
dvol_now=Decimal(dvol_now),
mark_combo_now_eth=Decimal(mark_combo_now_eth),
delta_short_now=Decimal(delta_short_now),
return_4h_now=Decimal(return_4h_now),
now=now,
)
# ---------------------------------------------------------------------------
# Mandatory cases from docs/03-algorithms.md §6
# ---------------------------------------------------------------------------
def test_mark_at_50pct_credit_triggers_close_profit(cfg: StrategyConfig) -> None:
snap = _snapshot(credit_received_eth="0.030", mark_combo_now_eth="0.015")
res = evaluate(snap, cfg)
assert res.action == "CLOSE_PROFIT"
def test_mark_at_250pct_credit_triggers_close_stop(cfg: StrategyConfig) -> None:
snap = _snapshot(credit_received_eth="0.030", mark_combo_now_eth="0.075")
res = evaluate(snap, cfg)
assert res.action == "CLOSE_STOP"
def test_dvol_increase_above_band_triggers_close_vol(cfg: StrategyConfig) -> None:
snap = _snapshot(
credit_received_eth="0.030",
mark_combo_now_eth="0.020", # 67% credit, no profit/stop
dvol_at_entry="50",
dvol_now="62", # +12, above +10 trigger
)
res = evaluate(snap, cfg)
assert res.action == "CLOSE_VOL"
def test_six_days_left_mark_above_skip_threshold_triggers_close_time(
cfg: StrategyConfig,
) -> None:
# mark = 80% credit > 70% credit (skip threshold) → CLOSE_TIME
snap = _snapshot(
credit_received_eth="0.030",
mark_combo_now_eth="0.024",
days_to_expiry=6,
)
res = evaluate(snap, cfg)
assert res.action == "CLOSE_TIME"
def test_six_days_left_mark_in_skip_zone_holds(cfg: StrategyConfig) -> None:
# mark = 60% credit ∈ (50%, 70%] → close to profit, time stop is skipped.
snap = _snapshot(
credit_received_eth="0.030",
mark_combo_now_eth="0.018",
days_to_expiry=6,
)
res = evaluate(snap, cfg)
assert res.action == "HOLD"
def test_short_delta_breach_triggers_close_delta(cfg: StrategyConfig) -> None:
snap = _snapshot(
credit_received_eth="0.030",
mark_combo_now_eth="0.020",
delta_short_now="-0.32",
)
res = evaluate(snap, cfg)
assert res.action == "CLOSE_DELTA"
def test_4h_adverse_move_bull_put_triggers_close_averse(cfg: StrategyConfig) -> None:
snap = _snapshot(
spread_type="bull_put",
credit_received_eth="0.030",
mark_combo_now_eth="0.020",
return_4h_now="-0.07",
)
res = evaluate(snap, cfg)
assert res.action == "CLOSE_AVERSE"
def test_4h_adverse_move_bear_call_triggers_close_averse(cfg: StrategyConfig) -> None:
snap = _snapshot(
spread_type="bear_call",
credit_received_eth="0.030",
mark_combo_now_eth="0.020",
return_4h_now="0.07",
)
res = evaluate(snap, cfg)
assert res.action == "CLOSE_AVERSE"
def test_neutral_state_returns_hold(cfg: StrategyConfig) -> None:
snap = _snapshot(
credit_received_eth="0.030",
mark_combo_now_eth="0.020",
)
res = evaluate(snap, cfg)
assert res.action == "HOLD"
# ---------------------------------------------------------------------------
# Trigger ordering — first match wins
# ---------------------------------------------------------------------------
def test_profit_wins_over_vol_stop(cfg: StrategyConfig) -> None:
# mark = 30% credit AND DVOL +12 → profit takes precedence
snap = _snapshot(
credit_received_eth="0.030",
mark_combo_now_eth="0.009",
dvol_at_entry="50",
dvol_now="62",
)
res = evaluate(snap, cfg)
assert res.action == "CLOSE_PROFIT"
def test_stop_wins_over_delta_breach(cfg: StrategyConfig) -> None:
snap = _snapshot(
credit_received_eth="0.030",
mark_combo_now_eth="0.075", # 250% → stop
delta_short_now="-0.40", # would also be CLOSE_DELTA
)
res = evaluate(snap, cfg)
assert res.action == "CLOSE_STOP"
# ---------------------------------------------------------------------------
# PnL reporting
# ---------------------------------------------------------------------------
def test_pnl_in_eth_and_usd_reflect_credit_minus_debit(cfg: StrategyConfig) -> None:
snap = _snapshot(
credit_received_eth="0.030",
mark_combo_now_eth="0.018",
eth_price_usd_now="3100",
)
res = evaluate(snap, cfg)
# pnl_eth = 0.030 - 0.018 = 0.012; pnl_usd = 0.012 × 3100 = 37.2
assert res.pnl_estimate_eth == Decimal("0.012")
assert res.pnl_estimate_usd == Decimal("37.2")
def test_iron_condor_adverse_move_either_direction(cfg: StrategyConfig) -> None:
snap = _snapshot(
spread_type="iron_condor",
credit_received_eth="0.030",
mark_combo_now_eth="0.020",
return_4h_now="-0.06",
)
res = evaluate(snap, cfg)
assert res.action == "CLOSE_AVERSE"
+100
View File
@@ -0,0 +1,100 @@
"""TDD for :mod:`cerbero_bite.core.greeks_aggregator`.
Spec: ``docs/03-algorithms.md §5``.
"""
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from cerbero_bite.core.greeks_aggregator import aggregate
from cerbero_bite.core.types import OptionLeg
def _leg(
*,
side: str,
size: int = 1,
delta: str,
gamma: str = "0.001",
theta: str = "-0.0005",
vega: str = "0.10",
strike: str = "2400",
option_type: str = "P",
instrument: str = "ETH-X-2400-P",
) -> OptionLeg:
return OptionLeg(
instrument=instrument,
side=side, # type: ignore[arg-type]
strike=Decimal(strike),
expiry=datetime(2026, 5, 15, 8, 0, tzinfo=UTC),
type=option_type, # type: ignore[arg-type]
size=size,
mid_price_eth=Decimal("0.012"),
delta=Decimal(delta),
gamma=Decimal(gamma),
theta=Decimal(theta),
vega=Decimal(vega),
)
def test_bull_put_spread_net_delta_long_positive() -> None:
"""SELL put (negative delta) + BUY further-OTM put → net delta positive."""
legs = [
_leg(side="SELL", delta="-0.12"),
_leg(side="BUY", delta="-0.08", strike="2300"),
]
res = aggregate(legs=legs, eth_price_usd=Decimal("3000"))
# delta_net = -1*-0.12 + +1*-0.08 = 0.12 - 0.08 = +0.04
assert res.delta_net == Decimal("0.04")
def test_bear_call_spread_net_delta_short_negative() -> None:
legs = [
_leg(side="SELL", delta="0.12", option_type="C"),
_leg(side="BUY", delta="0.08", option_type="C", strike="3650"),
]
res = aggregate(legs=legs, eth_price_usd=Decimal("3000"))
# delta_net = -1*0.12 + +1*0.08 = -0.04
assert res.delta_net == Decimal("-0.04")
def test_size_scales_each_leg() -> None:
legs = [
_leg(side="SELL", size=3, delta="-0.12"),
_leg(side="BUY", size=3, delta="-0.08", strike="2300"),
]
res = aggregate(legs=legs, eth_price_usd=Decimal("3000"))
assert res.delta_net == Decimal("0.12") # 3 × 0.04
def test_theta_converted_to_usd_per_day() -> None:
legs = [
_leg(side="SELL", delta="-0.12", theta="-0.0005"), # selling → +0.0005 ETH theta
_leg(side="BUY", delta="-0.08", theta="-0.0003", strike="2300"), # +0.0003 cost
]
res = aggregate(legs=legs, eth_price_usd=Decimal("3000"))
# theta_eth = -1*(-0.0005) + +1*(-0.0003) = 0.0005 - 0.0003 = 0.0002
# theta_usd = 0.0002 × 3000 = 0.60
assert res.theta_net == Decimal("0.60")
def test_gamma_and_vega_summed_with_sign() -> None:
legs = [
_leg(side="SELL", delta="-0.12", gamma="0.0020", vega="0.30"),
_leg(side="BUY", delta="-0.08", gamma="0.0015", vega="0.20", strike="2300"),
]
res = aggregate(legs=legs, eth_price_usd=Decimal("3000"))
# gamma: -1*0.0020 + 1*0.0015 = -0.0005
# vega: -1*0.30 + 1*0.20 = -0.10
assert res.gamma_net == Decimal("-0.0005")
assert res.vega_net == Decimal("-0.10")
def test_empty_legs_returns_zero_greeks() -> None:
res = aggregate(legs=[], eth_price_usd=Decimal("3000"))
assert res.delta_net == Decimal("0")
assert res.gamma_net == Decimal("0")
assert res.theta_net == Decimal("0")
assert res.vega_net == Decimal("0")
+155
View File
@@ -0,0 +1,155 @@
"""TDD for :mod:`cerbero_bite.core.kelly_recalibration`.
Spec: ``docs/03-algorithms.md §7``.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from uuid import uuid4
import pytest
from cerbero_bite.config import StrategyConfig, golden_config
from cerbero_bite.core.kelly_recalibration import TradeRecord, recalibrate
@pytest.fixture
def cfg() -> StrategyConfig:
return golden_config()
@pytest.fixture
def now() -> datetime:
return datetime(2026, 4, 27, 14, 0, tzinfo=UTC)
def _trade(
*,
pnl_usd: str,
risk_usd: str = "100",
days_ago: int = 30,
outcome: str = "CLOSE_PROFIT",
now_dt: datetime = datetime(2026, 4, 27, 14, 0, tzinfo=UTC),
) -> TradeRecord:
return TradeRecord(
proposal_id=uuid4(),
pnl_usd=Decimal(pnl_usd),
risk_usd=Decimal(risk_usd),
closed_at=now_dt - timedelta(days=days_ago),
outcome=outcome,
)
# ---------------------------------------------------------------------------
# Confidence bands and recommended fraction
# ---------------------------------------------------------------------------
def test_empty_history_returns_low_confidence_with_cfg_kelly(
cfg: StrategyConfig, now: datetime
) -> None:
res = recalibrate(trades=[], now=now, cfg=cfg)
assert res.confidence == "low"
assert res.sample_size == 0
assert res.recommended_fraction == cfg.sizing.kelly_fraction
assert res.win_rate == Decimal("0")
assert res.full_kelly_pct == Decimal("0")
assert res.quarter_kelly_pct == Decimal("0")
def test_low_sample_keeps_cfg_kelly_fraction(cfg: StrategyConfig, now: datetime) -> None:
trades = [_trade(pnl_usd="10", now_dt=now) for _ in range(20)]
trades += [_trade(pnl_usd="-15", now_dt=now) for _ in range(5)]
res = recalibrate(trades=trades, now=now, cfg=cfg)
assert res.sample_size == 25
assert res.confidence == "low"
assert res.recommended_fraction == cfg.sizing.kelly_fraction
def test_medium_sample_blends_quarter_kelly_with_cfg(
cfg: StrategyConfig, now: datetime
) -> None:
# 60 trades, 70% wins (avg_win=10/100=0.10), 30% losses (avg_loss=15/100=0.15)
# b = 0.10 / 0.15 = 0.667
# full_kelly = (0.7 × 0.667 - 0.3) / 0.667 = (0.467 - 0.3) / 0.667 = 0.25
# quarter_kelly = 0.0625
# blended = 0.5 × 0.0625 + 0.5 × 0.13 = 0.09625
wins = [_trade(pnl_usd="10", now_dt=now) for _ in range(42)]
losses = [_trade(pnl_usd="-15", now_dt=now) for _ in range(18)]
res = recalibrate(trades=wins + losses, now=now, cfg=cfg)
assert res.sample_size == 60
assert res.confidence == "medium"
assert res.win_rate == Decimal("0.7")
expected = Decimal("0.5") * res.quarter_kelly_pct + Decimal("0.5") * cfg.sizing.kelly_fraction
assert res.recommended_fraction == expected
def test_high_sample_adopts_quarter_kelly(cfg: StrategyConfig, now: datetime) -> None:
wins = [_trade(pnl_usd="10", now_dt=now) for _ in range(120)]
losses = [_trade(pnl_usd="-15", now_dt=now) for _ in range(40)]
res = recalibrate(trades=wins + losses, now=now, cfg=cfg)
assert res.sample_size == 160
assert res.confidence == "high"
assert res.recommended_fraction == res.quarter_kelly_pct
# ---------------------------------------------------------------------------
# Lookback filter
# ---------------------------------------------------------------------------
def test_trades_older_than_lookback_are_ignored(
cfg: StrategyConfig, now: datetime
) -> None:
fresh = [_trade(pnl_usd="10", days_ago=30, now_dt=now) for _ in range(40)]
stale = [_trade(pnl_usd="-50", days_ago=400, now_dt=now) for _ in range(60)]
res = recalibrate(trades=fresh + stale, now=now, cfg=cfg)
# Only 40 trades in window; all wins → win_rate = 1.0
assert res.sample_size == 40
assert res.win_rate == Decimal("1")
# ---------------------------------------------------------------------------
# Edge cases for full_kelly arithmetic
# ---------------------------------------------------------------------------
def test_all_losses_clip_full_kelly_to_zero(cfg: StrategyConfig, now: datetime) -> None:
trades = [_trade(pnl_usd="-20", now_dt=now) for _ in range(40)]
res = recalibrate(trades=trades, now=now, cfg=cfg)
assert res.win_rate == Decimal("0")
assert res.full_kelly_pct == Decimal("0")
assert res.quarter_kelly_pct == Decimal("0")
def test_all_wins_yield_full_kelly_equal_to_win_rate(
cfg: StrategyConfig, now: datetime
) -> None:
# No losses → division by zero. Convention: full_kelly = win_rate (= 1.0).
trades = [_trade(pnl_usd="20", now_dt=now) for _ in range(40)]
res = recalibrate(trades=trades, now=now, cfg=cfg)
assert res.win_rate == Decimal("1")
assert res.avg_loss_pct_risk == Decimal("0")
assert res.full_kelly_pct == Decimal("1")
assert res.quarter_kelly_pct == Decimal("0.25")
def test_avg_loss_uses_absolute_value(cfg: StrategyConfig, now: datetime) -> None:
wins = [_trade(pnl_usd="10", now_dt=now) for _ in range(30)]
losses = [_trade(pnl_usd="-20", now_dt=now) for _ in range(30)]
res = recalibrate(trades=wins + losses, now=now, cfg=cfg)
# avg_win_pct_risk = 10/100 = 0.10; avg_loss_pct_risk = 20/100 = 0.20 (positive)
assert res.avg_win_pct_risk == Decimal("0.1")
assert res.avg_loss_pct_risk == Decimal("0.2")
def test_zero_pnl_counts_as_loss_for_winrate(cfg: StrategyConfig, now: datetime) -> None:
# Spec: "trade con pnl > 0" → wins. 0 is not a win.
trades = [
*[_trade(pnl_usd="10", now_dt=now) for _ in range(20)],
*[_trade(pnl_usd="0", now_dt=now) for _ in range(20)],
]
res = recalibrate(trades=trades, now=now, cfg=cfg)
assert res.win_rate == Decimal("0.5")
+265
View File
@@ -0,0 +1,265 @@
"""TDD for :mod:`cerbero_bite.core.liquidity_gate`.
Spec: ``docs/01-strategy-rules.md §4`` and ``docs/03-algorithms.md §2``.
"""
from __future__ import annotations
from decimal import Decimal
import pytest
from cerbero_bite.config import StrategyConfig, golden_config
from cerbero_bite.core.liquidity_gate import InstrumentSnapshot, check
def _snap(
*,
instrument: str = "ETH-13MAY26-2400-P",
bid: str = "0.090",
ask: str = "0.100",
mid: str = "0.095",
open_interest: int = 500,
volume_24h: int = 80,
book_depth_top3: int = 20,
) -> InstrumentSnapshot:
return InstrumentSnapshot(
instrument=instrument,
bid=Decimal(bid),
ask=Decimal(ask),
mid=Decimal(mid),
open_interest=open_interest,
volume_24h=volume_24h,
book_depth_top3=book_depth_top3,
)
@pytest.fixture
def cfg() -> StrategyConfig:
return golden_config()
# Two-leg vertical: short at 0.095 mid, long at 0.060 mid → credit 0.035 ETH.
# Tight book: per-contract slippage 0.001 ETH → ~2.86% of credit at n=1.
def _good_pair() -> tuple[InstrumentSnapshot, InstrumentSnapshot]:
short = _snap(bid="0.0945", ask="0.0955", mid="0.0950")
long_ = _snap(
instrument="ETH-13MAY26-2300-P",
bid="0.0595",
ask="0.0605",
mid="0.0600",
)
return short, long_
def test_clean_pair_passes(cfg: StrategyConfig) -> None:
# tight book: per-contract slippage 0.001 ETH → 2.86% of 0.035 credit.
short, long_ = _good_pair()
res = check(
short_leg=short,
long_leg=long_,
credit=Decimal("0.035"),
n_contracts=1,
cfg=cfg,
)
assert res.accepted is True
assert res.reasons == []
def test_short_leg_oi_below_threshold_fails(cfg: StrategyConfig) -> None:
short, long_ = _good_pair()
short = _snap(
instrument=short.instrument,
bid=str(short.bid),
ask=str(short.ask),
mid=str(short.mid),
open_interest=99,
)
res = check(
short_leg=short,
long_leg=long_,
credit=Decimal("0.035"),
n_contracts=1,
cfg=cfg,
)
assert res.accepted is False
assert any("open interest" in r for r in res.reasons)
def test_long_leg_volume_below_threshold_fails(cfg: StrategyConfig) -> None:
short, long_ = _good_pair()
long_ = _snap(
instrument=long_.instrument,
bid=str(long_.bid),
ask=str(long_.ask),
mid=str(long_.mid),
volume_24h=10,
)
res = check(
short_leg=short,
long_leg=long_,
credit=Decimal("0.035"),
n_contracts=1,
cfg=cfg,
)
assert res.accepted is False
assert any("volume" in r for r in res.reasons)
def test_spread_pct_above_cap_fails(cfg: StrategyConfig) -> None:
short, long_ = _good_pair()
# Make bid-ask huge: bid 0.05, ask 0.20 → spread/mid > 0.15
short = _snap(
instrument=short.instrument,
bid="0.050",
ask="0.200",
mid="0.125",
)
res = check(
short_leg=short,
long_leg=long_,
credit=Decimal("0.060"),
n_contracts=1,
cfg=cfg,
)
assert res.accepted is False
assert any("bid-ask" in r for r in res.reasons)
def test_book_depth_below_threshold_fails(cfg: StrategyConfig) -> None:
short, long_ = _good_pair()
short = _snap(
instrument=short.instrument,
bid=str(short.bid),
ask=str(short.ask),
mid=str(short.mid),
book_depth_top3=4,
)
res = check(
short_leg=short,
long_leg=long_,
credit=Decimal("0.035"),
n_contracts=1,
cfg=cfg,
)
assert res.accepted is False
assert any("depth" in r for r in res.reasons)
def test_slippage_above_8pct_fails(cfg: StrategyConfig) -> None:
# tight book * n=10 → 0.010 / 0.035 ≈ 28% slippage
short, long_ = _good_pair()
res = check(
short_leg=short,
long_leg=long_,
credit=Decimal("0.035"),
n_contracts=10,
cfg=cfg,
)
assert res.accepted is False
assert any("slippage" in r for r in res.reasons)
assert res.estimated_slippage_pct_of_credit > Decimal("0.08")
def test_slippage_well_below_cap_passes(cfg: StrategyConfig) -> None:
# tight book: short ask-mid = 0.0005, long mid-bid = 0.0005 → slip = 0.001 per
# contract, credit 0.035 → 2.85% per contract.
short = _snap(bid="0.0945", ask="0.0955", mid="0.0950")
long_ = _snap(
instrument="ETH-13MAY26-2300-P",
bid="0.0595",
ask="0.0605",
mid="0.0600",
)
res = check(
short_leg=short,
long_leg=long_,
credit=Decimal("0.035"),
n_contracts=1,
cfg=cfg,
)
assert res.accepted is True
assert res.estimated_slippage_pct_of_credit < Decimal("0.08")
def test_zero_credit_is_rejected(cfg: StrategyConfig) -> None:
short, long_ = _good_pair()
res = check(
short_leg=short,
long_leg=long_,
credit=Decimal("0"),
n_contracts=1,
cfg=cfg,
)
assert res.accepted is False
assert any("credit" in r for r in res.reasons)
def test_negative_credit_is_rejected(cfg: StrategyConfig) -> None:
short, long_ = _good_pair()
res = check(
short_leg=short,
long_leg=long_,
credit=Decimal("-0.001"),
n_contracts=1,
cfg=cfg,
)
assert res.accepted is False
def test_zero_or_negative_n_contracts_is_rejected(cfg: StrategyConfig) -> None:
short, long_ = _good_pair()
for n in (0, -1):
res = check(
short_leg=short,
long_leg=long_,
credit=Decimal("0.035"),
n_contracts=n,
cfg=cfg,
)
assert res.accepted is False
assert any("contracts" in r for r in res.reasons)
def test_non_positive_mid_is_flagged(cfg: StrategyConfig) -> None:
short, long_ = _good_pair()
bad_short = _snap(
instrument=short.instrument,
bid="0",
ask="0",
mid="0",
open_interest=short.open_interest,
volume_24h=short.volume_24h,
book_depth_top3=short.book_depth_top3,
)
res = check(
short_leg=bad_short,
long_leg=long_,
credit=Decimal("0.035"),
n_contracts=1,
cfg=cfg,
)
assert res.accepted is False
assert any("mid price not positive" in r for r in res.reasons)
def test_failures_accumulate(cfg: StrategyConfig) -> None:
short = _snap(open_interest=10, volume_24h=2, book_depth_top3=1, bid="0.001", ask="0.500")
long_ = _snap(
instrument="ETH-13MAY26-2300-P",
open_interest=5,
volume_24h=1,
book_depth_top3=1,
bid="0.001",
ask="0.500",
)
res = check(
short_leg=short,
long_leg=long_,
credit=Decimal("0.001"),
n_contracts=10,
cfg=cfg,
)
assert res.accepted is False
# at least 4 categories per leg + slippage = many reasons
assert len(res.reasons) >= 5
+182
View File
@@ -0,0 +1,182 @@
"""TDD for :mod:`cerbero_bite.core.sizing_engine`.
Spec: ``docs/01-strategy-rules.md §5`` and ``docs/03-algorithms.md §3``.
The five mandatory test cases listed in §3 are reproduced here verbatim.
"""
from __future__ import annotations
from decimal import Decimal
import pytest
from cerbero_bite.config import StrategyConfig, golden_config
from cerbero_bite.core.sizing_engine import SizingContext, compute_contracts
# Standard fixture: EUR/USD = 1.075 (cap 200 EUR ≈ 215 USD, 1000 EUR ≈ 1075 USD)
EUR_USD = Decimal("1.075")
def _ctx(
*,
capital_usd: str = "1500",
max_loss_per_contract_usd: str = "93",
dvol_now: str = "40",
open_engagement_usd: str = "0",
eur_to_usd: Decimal = EUR_USD,
other_open_positions: int = 0,
) -> SizingContext:
return SizingContext(
capital_usd=Decimal(capital_usd),
max_loss_per_contract_usd=Decimal(max_loss_per_contract_usd),
dvol_now=Decimal(dvol_now),
open_engagement_usd=Decimal(open_engagement_usd),
eur_to_usd=eur_to_usd,
other_open_positions=other_open_positions,
)
@pytest.fixture
def cfg() -> StrategyConfig:
return golden_config()
# ---------------------------------------------------------------------------
# Mandatory examples from docs/03-algorithms.md §3
# ---------------------------------------------------------------------------
def test_minimum_capital_dvol_40_yields_one_contract(cfg: StrategyConfig) -> None:
res = compute_contracts(_ctx(capital_usd="720", dvol_now="40"), cfg)
assert res.n_contracts == 1
assert res.reason_if_zero is None
def test_capital_1500_dvol_50_yields_one_contract(cfg: StrategyConfig) -> None:
# 13% × 1500 = 195; adj 0.85 → 165.75; floor(165.75/93) = 1
res = compute_contracts(_ctx(capital_usd="1500", dvol_now="50"), cfg)
assert res.n_contracts == 1
def test_capital_1500_dvol_40_yields_two_contracts(cfg: StrategyConfig) -> None:
# 13% × 1500 = 195; cap 215 USD; adj 1.0 → 195; floor(195/93) = 2
res = compute_contracts(_ctx(capital_usd="1500", dvol_now="40"), cfg)
assert res.n_contracts == 2
def test_capital_5000_dvol_40_capped_at_two_contracts(cfg: StrategyConfig) -> None:
# 13% × 5000 = 650; cap 215 USD wins; floor(215/93) = 2
res = compute_contracts(_ctx(capital_usd="5000", dvol_now="40"), cfg)
assert res.n_contracts == 2
def test_capital_100000_dvol_40_capped_at_two_contracts(cfg: StrategyConfig) -> None:
res = compute_contracts(_ctx(capital_usd="100000", dvol_now="40"), cfg)
assert res.n_contracts == 2
# ---------------------------------------------------------------------------
# DVOL adjustment bands and no-entry threshold
# ---------------------------------------------------------------------------
def test_dvol_in_60_to_80_band_uses_0_65_multiplier(cfg: StrategyConfig) -> None:
# 13% × 5000 = 650; cap 215; adj 0.65 → 139.75; floor(139.75/93) = 1
res = compute_contracts(_ctx(capital_usd="5000", dvol_now="65"), cfg)
assert res.n_contracts == 1
def test_dvol_at_80_returns_zero(cfg: StrategyConfig) -> None:
res = compute_contracts(_ctx(capital_usd="5000", dvol_now="80"), cfg)
assert res.n_contracts == 0
assert res.reason_if_zero is not None
assert "dvol" in res.reason_if_zero.lower()
def test_dvol_above_no_entry_threshold_returns_zero(cfg: StrategyConfig) -> None:
res = compute_contracts(_ctx(capital_usd="5000", dvol_now="85"), cfg)
assert res.n_contracts == 0
# ---------------------------------------------------------------------------
# Aggregate engagement constraint
# ---------------------------------------------------------------------------
def test_aggregate_cap_reduces_contracts(cfg: StrategyConfig) -> None:
# cap_aggregate ≈ 1075 USD. With 1000 USD already engaged and ML=93,
# only floor((1075-1000)/93) = 0 new contracts should fit.
res = compute_contracts(
_ctx(
capital_usd="100000",
dvol_now="40",
open_engagement_usd="1000",
),
cfg,
)
assert res.n_contracts == 0
assert res.reason_if_zero is not None
def test_aggregate_cap_partially_reduces(cfg: StrategyConfig) -> None:
# 800 already engaged → free 275; floor(275/93)=2 but also kelly cap may bind.
# 13% × 100000 = 13000; cap 215 → 215; floor(215/93)=2 contracts.
# 2*93 = 186; 186+800=986 ≤ 1075 ✓ → 2.
res = compute_contracts(
_ctx(capital_usd="100000", dvol_now="40", open_engagement_usd="800"),
cfg,
)
assert res.n_contracts == 2
def test_aggregate_cap_at_975_reduces_to_one(cfg: StrategyConfig) -> None:
# 975 engaged + 2*93 = 1161 > 1075 → drop to 1: 975 + 93 = 1068 ≤ 1075 ✓
res = compute_contracts(
_ctx(capital_usd="100000", dvol_now="40", open_engagement_usd="975"),
cfg,
)
assert res.n_contracts == 1
# ---------------------------------------------------------------------------
# Concurrent positions guard
# ---------------------------------------------------------------------------
def test_concurrent_positions_at_cap_returns_zero(cfg: StrategyConfig) -> None:
res = compute_contracts(_ctx(capital_usd="1500", other_open_positions=1), cfg)
assert res.n_contracts == 0
assert res.reason_if_zero is not None
assert "position" in res.reason_if_zero.lower()
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
def test_below_one_contract_returns_zero(cfg: StrategyConfig) -> None:
# capital 600 → 13% * 600 = 78; floor(78/93)=0 → undersize
res = compute_contracts(_ctx(capital_usd="600", dvol_now="40"), cfg)
assert res.n_contracts == 0
assert "undersize" in (res.reason_if_zero or "").lower()
def test_max_contracts_per_trade_cap_binds(cfg: StrategyConfig) -> None:
# very high capital + tiny ML → would compute > 4 → cap at 4.
res = compute_contracts(
_ctx(
capital_usd="100000",
max_loss_per_contract_usd="1",
dvol_now="40",
eur_to_usd=Decimal("100"), # cap 20000 USD per trade → no cap
),
cfg,
)
assert res.n_contracts == cfg.sizing.max_contracts_per_trade
def test_zero_max_loss_per_contract_returns_zero(cfg: StrategyConfig) -> None:
res = compute_contracts(_ctx(max_loss_per_contract_usd="0"), cfg)
assert res.n_contracts == 0
assert res.reason_if_zero is not None