"""Pydantic schema for ``strategy.yaml``. The configuration is the single source of truth for every threshold used by the :mod:`cerbero_bite.core` algorithms. The schema is intentionally verbose: every numeric threshold from ``docs/10-config-spec.md`` is exposed as a typed field so that callers can refer to the same name they would write in YAML. All monetary and price-related thresholds use :class:`decimal.Decimal` to preserve the precision conventions documented in ``docs/03-algorithms.md §0.2`` (no ``float`` for money or option prices). Only the fields required by Phase 1 (core algorithms) are populated with defaults that match the golden config; fields belonging to runtime, storage, MCP, etc. are typed as ``dict[str, Any]`` or made optional so that the schema can absorb the full ``strategy.yaml`` without forcing non-core sections to be implemented before their owning module is written. """ from __future__ import annotations from decimal import Decimal from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator # --------------------------------------------------------------------------- # Asset # --------------------------------------------------------------------------- class AssetConfig(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") symbol: str = "ETH" exchange: str = "deribit" # --------------------------------------------------------------------------- # Entry # --------------------------------------------------------------------------- class EntryConfig(BaseModel): """Filters and bias thresholds documented in ``01-strategy-rules.md``.""" model_config = ConfigDict(frozen=True, extra="forbid") cron: str = "0 14 * * *" skip_holidays_country: str = "IT" # access filters (§2) capital_min_usd: Decimal = Field(default=Decimal("720")) dvol_min: Decimal = Field(default=Decimal("35")) dvol_max: Decimal = Field(default=Decimal("90")) funding_perp_abs_max_annualized: Decimal = Field(default=Decimal("0.80")) eth_holdings_pct_max: Decimal = Field(default=Decimal("0.30")) no_position_concurrent: bool = True exclude_macro_severity: list[str] = Field(default_factory=lambda: ["high"]) exclude_macro_countries: list[str] = Field(default_factory=lambda: ["US", "EU"]) # directional bias (§3.1) trend_window_days: int = 30 trend_bull_threshold_pct: Decimal = Field(default=Decimal("0.05")) trend_bear_threshold_pct: Decimal = Field(default=Decimal("-0.05")) funding_bull_threshold_annualized: Decimal = Field(default=Decimal("0.20")) funding_bear_threshold_annualized: Decimal = Field(default=Decimal("-0.20")) iron_condor_dvol_min: Decimal = Field(default=Decimal("55")) iron_condor_adx_max: Decimal = Field(default=Decimal("20")) iron_condor_trend_neutral_band_pct: Decimal = Field(default=Decimal("0.05")) # quant filters (§2.8 — added in Phase 4 hardening) dealer_gamma_min: Decimal = Field(default=Decimal("0")) dealer_gamma_filter_enabled: bool = True liquidation_filter_enabled: bool = True # IV richness filter (§2.9). `iv_minus_rv_min` è la soglia in # punti vol che la IV implicita 30g deve eccedere la RV30g per # ammettere l'entry. Letteratura short-vol systematic: l'edge # sostenibile esiste solo con un margine misurabile fra IV e RV. # Default disabilitato + soglia 0 per non bloccare l'avvio finché # non si è calibrato sui dati raccolti (vedi `📐 Calibrazione`). iv_minus_rv_min: Decimal = Field(default=Decimal("0")) iv_minus_rv_filter_enabled: bool = False # IV richness gate adattivo (Phase 5+). Quando # `iv_minus_rv_adaptive_enabled=True`, la soglia statica # `iv_minus_rv_min` diventa il floor assoluto e la soglia # effettiva è `max(P_q rolling, floor)` calcolata su # `market_snapshots`. Vedi # `docs/superpowers/specs/2026-05-08-iv-rv-adaptive-gate-design.md`. iv_minus_rv_adaptive_enabled: bool = False iv_minus_rv_percentile: Decimal = Field(default=Decimal("0.25")) iv_minus_rv_window_target_days: int = 60 iv_minus_rv_window_min_days: int = 30 # Vol-of-Vol guard (§4-quater roadmap punto 2): blocca entry se # |DVOL_now - DVOL_24h_ago| supera la soglia. Cattura regime # shift bruschi non riflessi nel percentile rolling. vol_of_vol_guard_enabled: bool = False vol_of_vol_threshold_pt: Decimal = Field(default=Decimal("5")) vol_of_vol_lookback_hours: int = 24 # --------------------------------------------------------------------------- # Structure # --------------------------------------------------------------------------- class DeltaByDvolBand(BaseModel): """Banda della step function delta-target per regime DVOL (§3.2 A).""" model_config = ConfigDict(frozen=True, extra="forbid") dvol_under: Decimal delta_target: Decimal delta_min: Decimal delta_max: Decimal class ShortStrikeSpec(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") delta_target: Decimal = Field(default=Decimal("0.12")) delta_min: Decimal = Field(default=Decimal("0.10")) delta_max: Decimal = Field(default=Decimal("0.15")) distance_otm_pct_min: Decimal = Field(default=Decimal("0.15")) distance_otm_pct_max: Decimal = Field(default=Decimal("0.25")) # §3.2 enhancement (A): step function delta-target by DVOL regime. # Empty list = behaviour invariato (delta_target sopra è il singolo # valore). Quando popolato, il combo_builder sceglie la prima # banda ordinata ascending su `dvol_under` con # `dvol_now ≤ dvol_under`. Esempio: # - dvol_under=50 → delta 0.15 (bassa vol → più premio) # - dvol_under=70 → delta 0.12 # - dvol_under=90 → delta 0.10 (alta vol → più safety) delta_by_dvol: list[DeltaByDvolBand] = Field(default_factory=list) class SpreadWidthSpec(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") target_pct_of_spot: Decimal = Field(default=Decimal("0.04")) min_pct_of_spot: Decimal = Field(default=Decimal("0.03")) max_pct_of_spot: Decimal = Field(default=Decimal("0.05")) class StructureConfig(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") dte_target: int = 18 dte_min: int = 14 dte_max: int = 21 short_strike: ShortStrikeSpec = Field(default_factory=ShortStrikeSpec) spread_width: SpreadWidthSpec = Field(default_factory=SpreadWidthSpec) credit_to_width_ratio_min: Decimal = Field(default=Decimal("0.30")) # --------------------------------------------------------------------------- # Liquidity # --------------------------------------------------------------------------- class LiquidityConfig(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") open_interest_min: int = 100 volume_24h_min: int = 20 bid_ask_spread_pct_max: Decimal = Field(default=Decimal("0.15")) book_depth_top3_min: int = 5 slippage_pct_of_credit_max: Decimal = Field(default=Decimal("0.08")) # --------------------------------------------------------------------------- # Sizing # --------------------------------------------------------------------------- class DvolAdjustmentBand(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") dvol_under: Decimal multiplier: Decimal def _default_dvol_bands() -> list[DvolAdjustmentBand]: return [ DvolAdjustmentBand(dvol_under=Decimal("45"), multiplier=Decimal("1.00")), DvolAdjustmentBand(dvol_under=Decimal("60"), multiplier=Decimal("0.85")), DvolAdjustmentBand(dvol_under=Decimal("80"), multiplier=Decimal("0.65")), ] class SizingConfig(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") kelly_fraction: Decimal = Field(default=Decimal("0.13")) cap_per_trade_eur: Decimal = Field(default=Decimal("200")) cap_aggregate_open_eur: Decimal = Field(default=Decimal("1000")) max_concurrent_positions: int = 5 max_contracts_per_trade: int = 4 dvol_adjustment: list[DvolAdjustmentBand] = Field(default_factory=_default_dvol_bands) dvol_no_entry_threshold: Decimal = Field(default=Decimal("80")) # --------------------------------------------------------------------------- # Exit # --------------------------------------------------------------------------- class PartialProfitLevel(BaseModel): """Livello della scala di profit-take graduale (§7.1bis C). `mark_at_pct_credit`: il livello è triggerato quando `mark_combo ≤ mark_at_pct_credit × credito_iniziale` (es. 0.25 = 25% del credito = 75% di profitto sulla porzione chiusa). `close_pct_of_initial_contracts`: frazione dei contratti aperti INIZIALMENTE da chiudere a questo livello (es. 0.50 = chiudi metà). Le frazioni sono cumulative; chiudere oltre i contratti residui è no-op. """ model_config = ConfigDict(frozen=True, extra="forbid") mark_at_pct_credit: Decimal close_pct_of_initial_contracts: Decimal class ExitConfig(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") profit_take_pct_of_credit: Decimal = Field(default=Decimal("0.50")) stop_loss_mark_x_credit: Decimal = Field(default=Decimal("2.50")) vol_stop_dvol_increase: Decimal = Field(default=Decimal("10")) time_stop_dte_remaining: int = 7 time_stop_skip_if_close_to_profit_pct: Decimal = Field(default=Decimal("0.70")) delta_breach_threshold: Decimal = Field(default=Decimal("0.30")) adverse_move_4h_pct: Decimal = Field(default=Decimal("0.05")) # §7.1ter (D): vol-collapse harvest. Esce in profit anche se il # profit-take non è ancora colpito quando DVOL è scesa di tot # punti rispetto all'entry (edge IV-RV catturato, vol attesa già # rientrata). 0 = filtro disabilitato. vol_harvest_dvol_decrease: Decimal = Field(default=Decimal("0")) # §7.1bis (C): scala graduata di profit-take. Lista vuota = # comportamento invariato (chiusura atomica al # `profit_take_pct_of_credit`). Quando popolata, l'engine # interpreta come "chiudi N% dei contratti iniziali al livello # di mark M%×credito". Le entry sono ordinate dal mark più alto # (più profit, livello triggerato prima) al più basso. Vedi # `core/exit_decision.py` per la semantica esatta. # # ATTENZIONE: questa funzione richiede il supporto di chiusure # parziali nel runtime (entry_cycle / repository / clients). # Fino al merge della partial-close pipeline, l'engine la mappa # a CLOSE_PROFIT atomico al primo livello triggerato (vedi # commento in `evaluate`). Default vuoto = no-op. profit_take_partial_levels: list[PartialProfitLevel] = Field( default_factory=list ) monitor_cron: str = "0 2,14 * * *" user_confirmation_timeout_min: int = 30 escalate_on_timeout: list[str] = Field( default_factory=lambda: ["CLOSE_STOP", "CLOSE_VOL", "CLOSE_DELTA"] ) # --------------------------------------------------------------------------- # Auto-pause (F): circuit breaker su drawdown rolling # --------------------------------------------------------------------------- class AutoPauseConfig(BaseModel): """Configurazione del circuit breaker su drawdown. Quando abilitato, il rule engine valuta — prima di ogni entry — il P/L cumulato delle ultime `lookback_trades` posizioni chiuse in proporzione al capitale attuale. Se la perdita supera la soglia, l'engine si auto-mette in pausa per `pause_days` giorni (skip-day). La pausa si annulla automaticamente alla scadenza, oppure manualmente via comando dalla GUI. Difende da regime change non rilevati dai filtri quant: se i filtri stanno fallendo sistematicamente, vale la pena fermarsi e attendere che le condizioni cambino, invece di continuare a sanguinare. È un'estensione conservativa del kill switch (che oggi reagisce solo a errori tecnici). """ model_config = ConfigDict(frozen=True, extra="forbid") enabled: bool = False lookback_trades: int = 5 max_drawdown_pct: Decimal = Field(default=Decimal("0.10")) pause_days: int = 14 # --------------------------------------------------------------------------- # Kelly recalibration # --------------------------------------------------------------------------- class KellyConfig(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") lookback_days: int = 365 min_sample_low_confidence: int = 30 min_sample_high_confidence: int = 100 weight_when_medium_confidence: Decimal = Field(default=Decimal("0.50")) # --------------------------------------------------------------------------- # Sections that core/ algorithms do not consume — kept loose for now # --------------------------------------------------------------------------- class _LooseSection(BaseModel): """Catch-all section used for parts of strategy.yaml not yet typed.""" model_config = ConfigDict(frozen=True, extra="allow") class ExecutionConfig(BaseModel): """Runtime execution settings consumed by the orchestrator. The remaining knobs (initial_limit, reprice_step_ticks, …) live as extra fields validated lazily — they will graduate to typed fields when the order-management layer needs them. """ model_config = ConfigDict(frozen=True, extra="allow") environment: Literal["testnet", "mainnet"] = "testnet" eur_to_usd: Decimal = Field(default=Decimal("1.075")) class MonitoringConfig(_LooseSection): ... class StorageConfig(_LooseSection): ... class McpConfig(_LooseSection): ... class TelegramConfig(_LooseSection): ... # --------------------------------------------------------------------------- # Root # --------------------------------------------------------------------------- class StrategyConfig(BaseModel): """Validated representation of ``strategy.yaml``.""" model_config = ConfigDict(frozen=True, extra="forbid") config_version: str config_hash: str last_review: str last_reviewer: str asset: AssetConfig = Field(default_factory=AssetConfig) entry: EntryConfig = Field(default_factory=EntryConfig) structure: StructureConfig = Field(default_factory=StructureConfig) liquidity: LiquidityConfig = Field(default_factory=LiquidityConfig) sizing: SizingConfig = Field(default_factory=SizingConfig) exit: ExitConfig = Field(default_factory=ExitConfig) kelly_recalibration: KellyConfig = Field(default_factory=KellyConfig) auto_pause: AutoPauseConfig = Field(default_factory=AutoPauseConfig) execution: ExecutionConfig = Field(default_factory=ExecutionConfig) monitoring: MonitoringConfig = Field(default_factory=MonitoringConfig) storage: StorageConfig = Field(default_factory=StorageConfig) mcp: McpConfig = Field(default_factory=McpConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) @model_validator(mode="after") def _validate_consistency(self) -> StrategyConfig: s = self.structure.short_strike if not (s.delta_min <= s.delta_target <= s.delta_max): raise ValueError("delta_target outside [delta_min, delta_max]") if s.distance_otm_pct_min >= s.distance_otm_pct_max: raise ValueError("OTM range invalid") sw = self.structure.spread_width if not (sw.min_pct_of_spot <= sw.target_pct_of_spot <= sw.max_pct_of_spot): raise ValueError("spread_width target outside min/max") if not (Decimal("0") < self.sizing.kelly_fraction < Decimal("0.5")): raise ValueError("kelly_fraction out of safe range (0, 0.5)") if self.exit.profit_take_pct_of_credit >= Decimal("1"): raise ValueError("profit_take >= 100% impossible") if self.exit.stop_loss_mark_x_credit <= Decimal("1"): raise ValueError("stop_loss multiple <= 1× makes no sense") if self.entry.dvol_min >= self.entry.dvol_max: raise ValueError("dvol_min must be < dvol_max") e = self.entry if e.iv_minus_rv_window_min_days >= e.iv_minus_rv_window_target_days: raise ValueError( "iv_minus_rv_window_min_days must be < iv_minus_rv_window_target_days" ) if not (Decimal("0") < e.iv_minus_rv_percentile < Decimal("1")): raise ValueError( "iv_minus_rv_percentile must be in (0, 1)" ) return self # --------------------------------------------------------------------------- # Convenience # --------------------------------------------------------------------------- SpreadType = Literal["bull_put", "bear_call", "iron_condor"] def golden_config(**overrides: Any) -> StrategyConfig: """Return a :class:`StrategyConfig` populated with the golden defaults. The caller may pass per-section overrides (``entry={...}``, ``sizing={...}``, …) to mutate specific values without rebuilding the whole tree. Useful in tests. """ base: dict[str, Any] = { "config_version": "1.0.0-test", "config_hash": "0" * 64, "last_review": "2026-04-26", "last_reviewer": "test", } base.update(overrides) return StrategyConfig(**base)