3a5cf2554b
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>
446 lines
17 KiB
Python
446 lines
17 KiB
Python
"""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)
|