Files
Cerbero-Bite/src/cerbero_bite/config/schema.py
T
root 3a5cf2554b feat(core): IV-RV adaptive gate in validate_entry + tests
Quando iv_minus_rv_adaptive_enabled=True, la soglia diventa
max(P_q rolling, iv_minus_rv_min). Path legacy (statico) e
None-bypass restano invariati.

Aggiunge anche due model_validator a StrategyConfig per
fail-fast su config invalida (window_min_days < target_days,
percentile in (0,1)) — risponde alla code review T1.

Tests: pass/skip su rolling, warmup hard, floor binding,
backwards compat statico, None bypass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:29:48 +00:00

446 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)