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>
260 lines
8.9 KiB
Python
260 lines
8.9 KiB
Python
"""TDD per :mod:`cerbero_bite.core.backtest`."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import UTC, datetime, timedelta
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
|
||
from cerbero_bite.config import StrategyConfig, golden_config
|
||
from cerbero_bite.core.backtest import (
|
||
bs_put_delta,
|
||
bs_put_price,
|
||
daily_picks,
|
||
estimate_credit_eth,
|
||
find_strike_for_delta,
|
||
normal_cdf,
|
||
run_backtest,
|
||
simulate_entry_filters,
|
||
)
|
||
from cerbero_bite.state.models import MarketSnapshotRecord
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Black-Scholes helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_normal_cdf_known_values() -> None:
|
||
assert normal_cdf(0.0) == pytest.approx(0.5, abs=1e-6)
|
||
assert normal_cdf(1.0) == pytest.approx(0.8413, abs=1e-3)
|
||
assert normal_cdf(-1.0) == pytest.approx(0.1587, abs=1e-3)
|
||
assert normal_cdf(2.0) == pytest.approx(0.9772, abs=1e-3)
|
||
|
||
|
||
def test_bs_put_price_atm_positive_and_less_than_strike() -> None:
|
||
p = bs_put_price(spot=3000, strike=3000, t_years=18 / 365, sigma=0.50)
|
||
assert p > 0
|
||
assert p < 3000 # cap
|
||
|
||
|
||
def test_bs_put_price_far_otm_close_to_zero() -> None:
|
||
p = bs_put_price(spot=3000, strike=1500, t_years=18 / 365, sigma=0.50)
|
||
assert 0 <= p < 5 # essentially zero
|
||
|
||
|
||
def test_bs_put_delta_atm_around_minus_half() -> None:
|
||
d = bs_put_delta(spot=3000, strike=3000, t_years=18 / 365, sigma=0.50)
|
||
assert d == pytest.approx(-0.475, abs=0.05)
|
||
|
||
|
||
def test_bs_put_delta_far_otm_close_to_zero() -> None:
|
||
d = bs_put_delta(spot=3000, strike=1500, t_years=18 / 365, sigma=0.50)
|
||
assert -0.05 < d <= 0
|
||
|
||
|
||
def test_find_strike_for_delta_monotone() -> None:
|
||
spot = 3000.0
|
||
dvol = 50.0
|
||
dte = 18
|
||
s_010 = find_strike_for_delta(
|
||
spot=spot, dvol_pct=dvol, dte_days=dte, target_delta_abs=0.10,
|
||
)
|
||
s_020 = find_strike_for_delta(
|
||
spot=spot, dvol_pct=dvol, dte_days=dte, target_delta_abs=0.20,
|
||
)
|
||
# |Δ|=0.20 (più ITM) ⇒ strike più alto di |Δ|=0.10 (più OTM).
|
||
assert s_020 > s_010
|
||
# Verifica che il delta corrisponda a target ± tolleranza.
|
||
achieved = abs(
|
||
bs_put_delta(
|
||
spot=spot, strike=s_020, t_years=dte / 365, sigma=dvol / 100,
|
||
)
|
||
)
|
||
assert achieved == pytest.approx(0.20, abs=0.02)
|
||
|
||
|
||
def test_estimate_credit_returns_positive_credit_in_normal_regime() -> None:
|
||
credit_eth, short_k, long_k = estimate_credit_eth(
|
||
spot=3000, dvol_pct=50, dte_days=18, width_pct=0.04, delta_target_abs=0.12,
|
||
)
|
||
# Sanity: credit > 0, short_k < spot, long_k = short_k - 4%×spot
|
||
assert credit_eth > 0
|
||
assert short_k < 3000
|
||
assert long_k < short_k
|
||
assert short_k - long_k == pytest.approx(0.04 * 3000, abs=1.0)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Daily picks + entry filter simulation
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _snap(
|
||
*, ts: datetime,
|
||
spot: float = 3000,
|
||
dvol: float = 50,
|
||
funding: float = 0.0,
|
||
macro_d: int | None = None,
|
||
asset: str = "ETH",
|
||
) -> MarketSnapshotRecord:
|
||
return MarketSnapshotRecord(
|
||
timestamp=ts,
|
||
asset=asset,
|
||
spot=Decimal(str(spot)),
|
||
dvol=Decimal(str(dvol)),
|
||
funding_perp_annualized=Decimal(str(funding)),
|
||
funding_cross_annualized=Decimal("0"),
|
||
dealer_net_gamma=Decimal("100"),
|
||
liquidation_long_risk="low",
|
||
liquidation_short_risk="low",
|
||
macro_days_to_event=macro_d,
|
||
fetch_ok=True,
|
||
)
|
||
|
||
|
||
def test_daily_picks_extracts_one_per_calendar_day() -> None:
|
||
day1 = datetime(2026, 5, 4, 14, 0, tzinfo=UTC)
|
||
day2 = datetime(2026, 5, 5, 14, 0, tzinfo=UTC) # Tuesday: PICKED ora (crypto 24/7)
|
||
snapshots = [
|
||
_snap(ts=day1),
|
||
_snap(ts=day1 + timedelta(minutes=15)), # stesso giorno, deduplicato
|
||
_snap(ts=day2),
|
||
]
|
||
picks = daily_picks(snapshots)
|
||
assert len(picks) == 2
|
||
assert picks[0].timestamp == day1
|
||
assert picks[1].timestamp == day2
|
||
|
||
|
||
def test_daily_picks_skips_other_hours() -> None:
|
||
snapshots = [
|
||
_snap(ts=datetime(2026, 5, 4, 13, 0, tzinfo=UTC)), # 13:00 → skipped
|
||
_snap(ts=datetime(2026, 5, 5, 15, 30, tzinfo=UTC)), # 15:30 → skipped
|
||
]
|
||
assert daily_picks(snapshots) == []
|
||
|
||
|
||
def test_daily_picks_filters_by_asset() -> None:
|
||
day = datetime(2026, 5, 4, 14, 0, tzinfo=UTC)
|
||
snapshots = [
|
||
_snap(ts=day, asset="BTC"),
|
||
_snap(ts=day, asset="ETH"),
|
||
]
|
||
picks = daily_picks(snapshots, asset="ETH")
|
||
assert len(picks) == 1
|
||
assert picks[0].snapshot.asset == "ETH"
|
||
|
||
|
||
def test_simulate_entry_filters_accepts_clean_snapshot(
|
||
) -> None:
|
||
cfg: StrategyConfig = golden_config()
|
||
monday = datetime(2026, 5, 4, 14, 0, tzinfo=UTC)
|
||
snap = _snap(ts=monday, dvol=50, funding=0.10)
|
||
picks = [
|
||
type("MP", (), {"timestamp": monday, "snapshot": snap})() # type: ignore[arg-type]
|
||
]
|
||
# Hack: build via real dataclass
|
||
from cerbero_bite.core.backtest import DailyPick
|
||
picks = [DailyPick(timestamp=monday, snapshot=snap)]
|
||
results = simulate_entry_filters(picks, cfg, capital_usd=Decimal("1500"))
|
||
assert len(results) == 1
|
||
assert results[0].accepted is True
|
||
|
||
|
||
def test_simulate_entry_filters_rejects_dvol_out_of_band() -> None:
|
||
cfg = golden_config()
|
||
monday = datetime(2026, 5, 4, 14, 0, tzinfo=UTC)
|
||
snap = _snap(ts=monday, dvol=20, funding=0.10) # below 35
|
||
from cerbero_bite.core.backtest import DailyPick
|
||
picks = [DailyPick(timestamp=monday, snapshot=snap)]
|
||
results = simulate_entry_filters(picks, cfg, capital_usd=Decimal("1500"))
|
||
assert results[0].accepted is False
|
||
assert any("dvol" in r.lower() for r in results[0].reasons)
|
||
|
||
|
||
def test_simulate_entry_filters_skips_incomplete_snapshot() -> None:
|
||
cfg = golden_config()
|
||
monday = datetime(2026, 5, 4, 14, 0, tzinfo=UTC)
|
||
incomplete = MarketSnapshotRecord(
|
||
timestamp=monday, asset="ETH", spot=Decimal("3000"),
|
||
# dvol=None ⇒ skipped
|
||
fetch_ok=False,
|
||
)
|
||
from cerbero_bite.core.backtest import DailyPick
|
||
picks = [DailyPick(timestamp=monday, snapshot=incomplete)]
|
||
results = simulate_entry_filters(picks, cfg, capital_usd=Decimal("1500"))
|
||
assert results[0].accepted is False
|
||
assert results[0].skipped_for_data is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Full pipeline (sintetico)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _synthetic_year_of_snapshots(
|
||
*,
|
||
n_weeks: int = 8,
|
||
spot: float = 3000,
|
||
dvol: float = 60, # con skew_premium 1.5 ⇒ credit/width ≈ 35% (sopra soglia 30%)
|
||
funding: float = 0.10,
|
||
) -> list[MarketSnapshotRecord]:
|
||
"""Genera N settimane di snapshot sintetici ETH a 4 tick/settimana."""
|
||
rows: list[MarketSnapshotRecord] = []
|
||
monday = datetime(2026, 5, 4, 14, 0, tzinfo=UTC)
|
||
for week in range(n_weeks):
|
||
base = monday + timedelta(weeks=week)
|
||
# Lunedì 14:00 è il pick
|
||
rows.append(_snap(ts=base, spot=spot, dvol=dvol, funding=funding))
|
||
# Tick intermedi che NON cadono alle 14:00:
|
||
# offset +1h (=15:00) così vengono ignorati da `daily_picks`.
|
||
for d in (2, 8, 14, 19):
|
||
rows.append(
|
||
_snap(
|
||
ts=base + timedelta(days=d, hours=1),
|
||
spot=spot * (1 + 0.005 * d), # +0.5% al giorno
|
||
dvol=dvol - 1.5 * d, # vol che scende lentamente
|
||
funding=funding,
|
||
)
|
||
)
|
||
return rows
|
||
|
||
|
||
def test_run_backtest_produces_report_with_trades() -> None:
|
||
# Per il test scaliamo il credit/width gate al 15%: il modello BS
|
||
# senza skew completo sottostima i premi OTM rispetto al reale.
|
||
# Vedi `estimate_credit_eth.skew_premium` docstring per dettagli.
|
||
from cerbero_bite.config.schema import StructureConfig
|
||
cfg = golden_config()
|
||
cfg = cfg.model_copy(
|
||
update={
|
||
"structure": StructureConfig(
|
||
**{
|
||
**cfg.structure.model_dump(),
|
||
"credit_to_width_ratio_min": Decimal("0.15"),
|
||
}
|
||
)
|
||
}
|
||
)
|
||
snapshots = _synthetic_year_of_snapshots(n_weeks=4)
|
||
report = run_backtest(snapshots, cfg, capital_usd=Decimal("1500"))
|
||
# Sanity: 4 picks, almeno 1 trade chiuso
|
||
assert report.n_picks == 4
|
||
assert report.n_completed >= 1
|
||
assert report.cumulative_pnl_usd != Decimal("0")
|
||
# Bull-put + ETH al rialzo + DVOL che scende ⇒ atteso win
|
||
assert report.n_winners >= 1
|
||
|
||
|
||
def test_run_backtest_handles_no_picks_gracefully() -> None:
|
||
cfg = golden_config()
|
||
# Solo tick infrasettimanali, niente Monday 14:00.
|
||
monday = datetime(2026, 5, 4, 14, 0, tzinfo=UTC)
|
||
snapshots = [_snap(ts=monday + timedelta(hours=1))]
|
||
report = run_backtest(snapshots, cfg, capital_usd=Decimal("1500"))
|
||
assert report.n_picks == 0
|
||
assert report.n_completed == 0
|
||
assert report.cumulative_pnl_usd == Decimal("0")
|