fbb7753cc6
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>
183 lines
6.4 KiB
Python
183 lines
6.4 KiB
Python
"""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
|