Files
Cerbero-Bite/tests/unit/test_sizing_engine.py
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

183 lines
6.4 KiB
Python
Raw Permalink 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.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