"""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_PROFIT_PARTIAL", "CLOSE_STOP", "CLOSE_VOL", "CLOSE_VOL_HARVEST", "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}", ) # 1bis. Vol-collapse harvest (D): siamo IN profit (debit < credit) e # la DVOL è scesa di tot punti rispetto all'entry. Edge IV-RV già # catturato, non c'è motivo di tenere fino a profit_take. Esce # opportunisticamente quando il regime di vol che giustificava # l'entry non c'è più. if ( ec.vol_harvest_dvol_decrease > 0 and debit < credit and snapshot.dvol_now <= snapshot.dvol_at_entry - ec.vol_harvest_dvol_decrease ): return _result( "CLOSE_VOL_HARVEST", f"DVOL {snapshot.dvol_now} ≤ entry {snapshot.dvol_at_entry} − " f"{ec.vol_harvest_dvol_decrease}, harvest while in profit", ) # 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")