Files
Cerbero-Bite/tests/unit/test_sizing_engine.py
T
root 6ff021fbf4 feat(strategy): abbandono gating settimanale — entry daily 24/7
Crypto opera 24/7: la cadenza settimanale lunedì-only era un retaggio
TradFi senza giustificazione. La nuova cadenza è giornaliera (cron
0 14 * * *), con i gate quantitativi a decidere se entrare o saltare.

Cambiamenti principali:

* runtime/orchestrator.py — _CRON_ENTRY 0 14 * * * (era MON)
* runtime/auto_pause.py — pause_until(days=) (era weeks=); minimo
  clamp 1 giorno (era 1 settimana)
* core/backtest.py — MondayPick→DailyPick, monday_picks→daily_picks
  (1 pick per calendar-day all'ora target); Sharpe annualization su
  ~120 trade/anno (era 52)
* config/schema.py — default cron daily; max_concurrent_positions 1→5;
  AutoPauseConfig.pause_weeks→pause_days, default 14
* runtime/option_chain_snapshot_cycle.py + orchestrator — cron */15
  per accumulo continuo dataset di backtest empirico

Strategy yamls (config_version 1.3.0 → 1.4.0, hash rigenerati):

* strategy.yaml — max_concurrent 1→5, cap_aggregate coerente
* strategy.aggressiva.yaml — max_concurrent 2→8, cap_aggregate
  3200→6400, max_contracts_per_trade invariato a 16
* strategy.conservativa.yaml — max_concurrent 1→3
* tutti — pause_weeks→pause_days: 14

GUI (pages/7_📚_Strategia.py):

* slider Trade/anno: range 20-200 (era 8-30), default 110, help
  riallineato sulla math 365 candidature × pass-rate 30-40%
* card profili: versione letta dinamicamente da config_version invece
  che hard-coded "v1.2.0"
* warning "entrambi perdono soldi" ora valuta i P/L effettivi
  (cons['annual_pl'], aggr['annual_pl']) invece del win_rate grezzo;
  aggiunto stato intermedio quando solo conservativo è in perdita

Tests (450/450 passati):

* test_auto_pause: pause_days, clamp ≥1 giorno
* test_backtest: rinomina + ridisegno daily picks (assert su
  calendar-day dedupe e hour filter)
* test_sizing_engine: other_open_positions=5 per cap default
* test_config_loader: version 1.4.0

Docs (README + 9 file in docs/) — tutti i riferimenti weekly/lunedì
allineati a daily/24-7, volume option_chain ricalcolato per cron
*/15 (~1.1 MB/giorno, ~400 MB/anno).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:21:16 +00:00

184 lines
6.5 KiB
Python
Raw 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:
# Default cap = 5 (entry daily). other_open_positions=5 ⇒ cap raggiunto.
res = compute_contracts(_ctx(capital_usd="1500", other_open_positions=5), 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