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:
2026-04-27 10:14:06 +02:00
parent 881bc8a1bf
commit fbb7753cc6
20 changed files with 3090 additions and 1 deletions
+158
View File
@@ -0,0 +1,158 @@
"""Exit-decision rule engine (``docs/03-algorithms.md §6``).
Pure function over a :class:`PositionSnapshot`. Triggers are evaluated
in the documented order; the first match wins. ``HOLD`` is returned
when no rule fires. The time-stop ``skip_if_close_to_profit`` exception
is interpreted as ``mark ≤ 70% × credit_received`` (re-using the same
units as the profit gate), which is the only interpretation that yields
non-trivial behaviour: rule 1 takes everything below 50% credit; the
exception covers the (50%, 70%] credit band where we wait for rule 1
to fire next cycle.
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict
from cerbero_bite.config import SpreadType, StrategyConfig
from cerbero_bite.core.types import OptionLeg
__all__ = ["ExitAction", "ExitDecisionResult", "PositionSnapshot", "evaluate"]
ExitAction = Literal[
"HOLD",
"CLOSE_PROFIT",
"CLOSE_STOP",
"CLOSE_VOL",
"CLOSE_TIME",
"CLOSE_DELTA",
"CLOSE_AVERSE",
]
class PositionSnapshot(BaseModel):
"""Inputs for :func:`evaluate`. All ETH amounts are *totals* for the position."""
model_config = ConfigDict(frozen=True, extra="forbid")
proposal_id: UUID
spread_type: SpreadType
legs: list[OptionLeg]
credit_received_eth: Decimal
credit_received_usd: Decimal
spot_at_entry: Decimal
dvol_at_entry: Decimal
expiry: datetime
opened_at: datetime
eth_price_usd_now: Decimal
spot_now: Decimal
dvol_now: Decimal
mark_combo_now_eth: Decimal
delta_short_now: Decimal
return_4h_now: Decimal
now: datetime
class ExitDecisionResult(BaseModel):
"""Action + human-readable reason + PnL estimate at the moment of decision."""
model_config = ConfigDict(frozen=True, extra="forbid")
action: ExitAction
reason: str
pnl_estimate_eth: Decimal
pnl_estimate_usd: Decimal
def _days_to_expiry(snapshot: PositionSnapshot) -> Decimal:
delta = snapshot.expiry - snapshot.now
return Decimal(str(delta.total_seconds())) / Decimal("86400")
def _adverse_move(spread_type: SpreadType, return_4h: Decimal, threshold: Decimal) -> bool:
if spread_type == "bull_put":
return return_4h <= -threshold
if spread_type == "bear_call":
return return_4h >= threshold
# iron_condor: adverse on either side
return return_4h.copy_abs() >= threshold
def evaluate(snapshot: PositionSnapshot, cfg: StrategyConfig) -> ExitDecisionResult:
"""Return the exit action for the given position snapshot."""
ec = cfg.exit
credit = snapshot.credit_received_eth
debit = snapshot.mark_combo_now_eth
pnl_eth = credit - debit
pnl_usd = pnl_eth * snapshot.eth_price_usd_now
profit_take_thresh = credit * ec.profit_take_pct_of_credit
stop_thresh = credit * ec.stop_loss_mark_x_credit
skip_time_thresh = credit * ec.time_stop_skip_if_close_to_profit_pct
days_left = _days_to_expiry(snapshot)
def _result(action: ExitAction, reason: str) -> ExitDecisionResult:
return ExitDecisionResult(
action=action,
reason=reason,
pnl_estimate_eth=pnl_eth,
pnl_estimate_usd=pnl_usd,
)
# 1. Profit take
if debit <= profit_take_thresh:
return _result(
"CLOSE_PROFIT",
f"mark {debit}{ec.profit_take_pct_of_credit:.0%} of credit {credit}",
)
# 2. Stop loss
if debit >= stop_thresh:
return _result(
"CLOSE_STOP",
f"mark {debit}{ec.stop_loss_mark_x_credit}× credit {credit}",
)
# 3. Vol stop
if snapshot.dvol_now >= snapshot.dvol_at_entry + ec.vol_stop_dvol_increase:
return _result(
"CLOSE_VOL",
f"DVOL {snapshot.dvol_now} ≥ entry {snapshot.dvol_at_entry} "
f"+ {ec.vol_stop_dvol_increase}",
)
# 4. Time stop with "close to profit" exception
if days_left <= ec.time_stop_dte_remaining and debit > skip_time_thresh:
return _result(
"CLOSE_TIME",
f"DTE {days_left:.2f}{ec.time_stop_dte_remaining} and mark "
f"{debit} above skip threshold {skip_time_thresh}",
)
# When DTE ≤ 7 but mark is in the (50%, 70%]-credit "close to profit"
# zone, we deliberately fall through; rule 1 will fire next cycle.
# 5. Strike tested
if snapshot.delta_short_now.copy_abs() >= ec.delta_breach_threshold:
return _result(
"CLOSE_DELTA",
f"|delta_short| {snapshot.delta_short_now.copy_abs()}"
f"{ec.delta_breach_threshold}",
)
# 6. Explosive adverse move
if _adverse_move(snapshot.spread_type, snapshot.return_4h_now, ec.adverse_move_4h_pct):
return _result(
"CLOSE_AVERSE",
f"4h return {snapshot.return_4h_now} adverse for {snapshot.spread_type}",
)
return _result("HOLD", "all triggers within tolerance")