6ff021fbf4
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>
184 lines
6.5 KiB
Python
184 lines
6.5 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:
|
||
# 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
|