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,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")
|
||||
Reference in New Issue
Block a user