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>
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
"""TDD for :mod:`cerbero_bite.core.exit_decision`.
|
||||
|
||||
Spec: ``docs/01-strategy-rules.md §7`` and ``docs/03-algorithms.md §6``.
|
||||
The eight mandatory cases listed in §6 are exercised here, with the
|
||||
``mark=30% credit`` case re-interpreted to satisfy the time-stop
|
||||
exception consistently with rule 1 (which always fires earlier when
|
||||
mark ≤ 50% credit).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from cerbero_bite.config import SpreadType, StrategyConfig, golden_config
|
||||
from cerbero_bite.core.exit_decision import PositionSnapshot, evaluate
|
||||
from cerbero_bite.core.types import OptionLeg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg() -> StrategyConfig:
|
||||
return golden_config()
|
||||
|
||||
|
||||
def _legs(spread_type: SpreadType = "bull_put", size: int = 1) -> list[OptionLeg]:
|
||||
expiry = datetime(2026, 5, 15, 8, 0, tzinfo=UTC)
|
||||
if spread_type == "bull_put":
|
||||
return [
|
||||
OptionLeg(
|
||||
instrument="ETH-X-2475-P",
|
||||
side="SELL",
|
||||
strike=Decimal("2475"),
|
||||
expiry=expiry,
|
||||
type="P",
|
||||
size=size,
|
||||
mid_price_eth=Decimal("0.020"),
|
||||
delta=Decimal("-0.12"),
|
||||
gamma=Decimal("0.001"),
|
||||
theta=Decimal("-0.0005"),
|
||||
vega=Decimal("0.10"),
|
||||
),
|
||||
OptionLeg(
|
||||
instrument="ETH-X-2350-P",
|
||||
side="BUY",
|
||||
strike=Decimal("2350"),
|
||||
expiry=expiry,
|
||||
type="P",
|
||||
size=size,
|
||||
mid_price_eth=Decimal("0.005"),
|
||||
delta=Decimal("-0.08"),
|
||||
gamma=Decimal("0.001"),
|
||||
theta=Decimal("-0.0003"),
|
||||
vega=Decimal("0.07"),
|
||||
),
|
||||
]
|
||||
return [
|
||||
OptionLeg(
|
||||
instrument="ETH-X-3525-C",
|
||||
side="SELL",
|
||||
strike=Decimal("3525"),
|
||||
expiry=expiry,
|
||||
type="C",
|
||||
size=size,
|
||||
mid_price_eth=Decimal("0.020"),
|
||||
delta=Decimal("0.12"),
|
||||
gamma=Decimal("0.001"),
|
||||
theta=Decimal("-0.0005"),
|
||||
vega=Decimal("0.10"),
|
||||
),
|
||||
OptionLeg(
|
||||
instrument="ETH-X-3645-C",
|
||||
side="BUY",
|
||||
strike=Decimal("3645"),
|
||||
expiry=expiry,
|
||||
type="C",
|
||||
size=size,
|
||||
mid_price_eth=Decimal("0.005"),
|
||||
delta=Decimal("0.08"),
|
||||
gamma=Decimal("0.001"),
|
||||
theta=Decimal("-0.0003"),
|
||||
vega=Decimal("0.07"),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _snapshot(
|
||||
*,
|
||||
spread_type: SpreadType = "bull_put",
|
||||
credit_received_eth: str = "0.030",
|
||||
mark_combo_now_eth: str = "0.020",
|
||||
dvol_at_entry: str = "50",
|
||||
dvol_now: str = "50",
|
||||
delta_short_now: str | None = None,
|
||||
return_4h_now: str = "0",
|
||||
days_to_expiry: int = 14,
|
||||
spot_at_entry: str = "3000",
|
||||
spot_now: str = "3000",
|
||||
eth_price_usd_now: str = "3000",
|
||||
) -> PositionSnapshot:
|
||||
now = datetime(2026, 4, 27, 14, 0, tzinfo=UTC)
|
||||
expiry = now + timedelta(days=days_to_expiry)
|
||||
if delta_short_now is None:
|
||||
delta_short_now = "-0.12" if spread_type == "bull_put" else "0.12"
|
||||
legs = _legs(spread_type=spread_type)
|
||||
return PositionSnapshot(
|
||||
proposal_id=uuid4(),
|
||||
spread_type=spread_type,
|
||||
legs=legs,
|
||||
credit_received_eth=Decimal(credit_received_eth),
|
||||
credit_received_usd=Decimal(credit_received_eth) * Decimal(eth_price_usd_now),
|
||||
spot_at_entry=Decimal(spot_at_entry),
|
||||
dvol_at_entry=Decimal(dvol_at_entry),
|
||||
expiry=expiry,
|
||||
opened_at=now - timedelta(days=4),
|
||||
eth_price_usd_now=Decimal(eth_price_usd_now),
|
||||
spot_now=Decimal(spot_now),
|
||||
dvol_now=Decimal(dvol_now),
|
||||
mark_combo_now_eth=Decimal(mark_combo_now_eth),
|
||||
delta_short_now=Decimal(delta_short_now),
|
||||
return_4h_now=Decimal(return_4h_now),
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mandatory cases from docs/03-algorithms.md §6
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mark_at_50pct_credit_triggers_close_profit(cfg: StrategyConfig) -> None:
|
||||
snap = _snapshot(credit_received_eth="0.030", mark_combo_now_eth="0.015")
|
||||
res = evaluate(snap, cfg)
|
||||
assert res.action == "CLOSE_PROFIT"
|
||||
|
||||
|
||||
def test_mark_at_250pct_credit_triggers_close_stop(cfg: StrategyConfig) -> None:
|
||||
snap = _snapshot(credit_received_eth="0.030", mark_combo_now_eth="0.075")
|
||||
res = evaluate(snap, cfg)
|
||||
assert res.action == "CLOSE_STOP"
|
||||
|
||||
|
||||
def test_dvol_increase_above_band_triggers_close_vol(cfg: StrategyConfig) -> None:
|
||||
snap = _snapshot(
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.020", # 67% credit, no profit/stop
|
||||
dvol_at_entry="50",
|
||||
dvol_now="62", # +12, above +10 trigger
|
||||
)
|
||||
res = evaluate(snap, cfg)
|
||||
assert res.action == "CLOSE_VOL"
|
||||
|
||||
|
||||
def test_six_days_left_mark_above_skip_threshold_triggers_close_time(
|
||||
cfg: StrategyConfig,
|
||||
) -> None:
|
||||
# mark = 80% credit > 70% credit (skip threshold) → CLOSE_TIME
|
||||
snap = _snapshot(
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.024",
|
||||
days_to_expiry=6,
|
||||
)
|
||||
res = evaluate(snap, cfg)
|
||||
assert res.action == "CLOSE_TIME"
|
||||
|
||||
|
||||
def test_six_days_left_mark_in_skip_zone_holds(cfg: StrategyConfig) -> None:
|
||||
# mark = 60% credit ∈ (50%, 70%] → close to profit, time stop is skipped.
|
||||
snap = _snapshot(
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.018",
|
||||
days_to_expiry=6,
|
||||
)
|
||||
res = evaluate(snap, cfg)
|
||||
assert res.action == "HOLD"
|
||||
|
||||
|
||||
def test_short_delta_breach_triggers_close_delta(cfg: StrategyConfig) -> None:
|
||||
snap = _snapshot(
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.020",
|
||||
delta_short_now="-0.32",
|
||||
)
|
||||
res = evaluate(snap, cfg)
|
||||
assert res.action == "CLOSE_DELTA"
|
||||
|
||||
|
||||
def test_4h_adverse_move_bull_put_triggers_close_averse(cfg: StrategyConfig) -> None:
|
||||
snap = _snapshot(
|
||||
spread_type="bull_put",
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.020",
|
||||
return_4h_now="-0.07",
|
||||
)
|
||||
res = evaluate(snap, cfg)
|
||||
assert res.action == "CLOSE_AVERSE"
|
||||
|
||||
|
||||
def test_4h_adverse_move_bear_call_triggers_close_averse(cfg: StrategyConfig) -> None:
|
||||
snap = _snapshot(
|
||||
spread_type="bear_call",
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.020",
|
||||
return_4h_now="0.07",
|
||||
)
|
||||
res = evaluate(snap, cfg)
|
||||
assert res.action == "CLOSE_AVERSE"
|
||||
|
||||
|
||||
def test_neutral_state_returns_hold(cfg: StrategyConfig) -> None:
|
||||
snap = _snapshot(
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.020",
|
||||
)
|
||||
res = evaluate(snap, cfg)
|
||||
assert res.action == "HOLD"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trigger ordering — first match wins
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_profit_wins_over_vol_stop(cfg: StrategyConfig) -> None:
|
||||
# mark = 30% credit AND DVOL +12 → profit takes precedence
|
||||
snap = _snapshot(
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.009",
|
||||
dvol_at_entry="50",
|
||||
dvol_now="62",
|
||||
)
|
||||
res = evaluate(snap, cfg)
|
||||
assert res.action == "CLOSE_PROFIT"
|
||||
|
||||
|
||||
def test_stop_wins_over_delta_breach(cfg: StrategyConfig) -> None:
|
||||
snap = _snapshot(
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.075", # 250% → stop
|
||||
delta_short_now="-0.40", # would also be CLOSE_DELTA
|
||||
)
|
||||
res = evaluate(snap, cfg)
|
||||
assert res.action == "CLOSE_STOP"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PnL reporting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pnl_in_eth_and_usd_reflect_credit_minus_debit(cfg: StrategyConfig) -> None:
|
||||
snap = _snapshot(
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.018",
|
||||
eth_price_usd_now="3100",
|
||||
)
|
||||
res = evaluate(snap, cfg)
|
||||
# pnl_eth = 0.030 - 0.018 = 0.012; pnl_usd = 0.012 × 3100 = 37.2
|
||||
assert res.pnl_estimate_eth == Decimal("0.012")
|
||||
assert res.pnl_estimate_usd == Decimal("37.2")
|
||||
|
||||
|
||||
def test_iron_condor_adverse_move_either_direction(cfg: StrategyConfig) -> None:
|
||||
snap = _snapshot(
|
||||
spread_type="iron_condor",
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.020",
|
||||
return_4h_now="-0.06",
|
||||
)
|
||||
res = evaluate(snap, cfg)
|
||||
assert res.action == "CLOSE_AVERSE"
|
||||
Reference in New Issue
Block a user