Files
Cerbero-Bite/src/cerbero_bite/state/models.py
T
root c0a0ee416f feat(state+runtime): option_chain_snapshots — catena opzioni storica per backtest reale
Aggiunge la persistence della option chain Deribit con cron settimanale
``55 13 * * MON`` (5 minuti prima del trigger entry alle 14:00 UTC),
sbloccando il backtest non-stilizzato e la calibrazione empirica
dello skew premium.

**Schema (migrazione 0004)**

Nuova tabella ``option_chain_snapshots`` con primary key composta
``(timestamp, instrument_name)`` — tutti i quote prelevati nello
stesso tick condividono il timestamp, così le query "lo snapshot del
2026-05-04 alle 13:55" diventano una singola WHERE timestamp = X.
Indici su (asset, timestamp DESC) e (asset, expiry) per supportare
sia listing recenti sia query per scadenza specifica.

Campi: instrument_name, strike, expiry, option_type (C/P), bid, ask,
mid, iv, delta, gamma, theta, vega, open_interest, volume_24h,
book_depth_top3. Tutti i numerici sono nullable: il collector è
best-effort, un ticker mancante produce comunque una riga (utile
per sapere che lo strumento esisteva ma non era quotato).

**Modello + repository**

- ``OptionChainQuoteRecord`` (Pydantic, in ``state/models.py``).
- ``Repository.record_option_chain_snapshot`` (bulk insert
  idempotente).
- ``Repository.list_option_chain_snapshots`` (filtri su asset,
  timestamp window, expiry window, limit default 50000).
- ``Repository.latest_option_chain_timestamp`` (freshness check
  per dashboard GUI).

**Collector**

Nuovo ``runtime/option_chain_snapshot_cycle.py`` che:

1. Calcola la finestra scadenze ``[now+dte_min, now+dte_max]`` da
   ``cfg.structure``: niente richieste su scadenze che il rule
   engine non userebbe mai.
2. Chiama ``deribit.options_chain()`` con
   ``min_open_interest=cfg.liquidity.open_interest_min``.
3. Batch ``deribit.get_tickers()`` (max 20 per call, limite Deribit)
   con error-isolation per batch — un batch fallito non blocca
   gli altri.
4. NON chiama l'order book per ogni strike (rate-limit guard);
   ``book_depth_top3`` resta NULL e il liquidity gate live lo
   chiede on-the-fly per gli strike candidati al picker.

Best-effort end-to-end: chain assente, get_tickers giù, persist
fallito → ritorna 0 senza alzare eccezioni, logga sempre.

**Schedulazione**

Wired in ``Orchestrator.install_scheduler`` come job parallelo a
``market_snapshot``, attivo solo quando
``ENABLE_DATA_ANALYSIS=true``. Cron parametrizzabile via il nuovo
kwarg ``option_chain_cron`` (default ``55 13 * * MON``).

**Test**

- 4 unit test del collector (happy path, ticker mancante, chain
  vuota, fetch fail best-effort) con mock di RuntimeContext.
- Aggiornato ``test_install_scheduler_registers_canonical_jobs``
  per includere il nuovo job nel set canonico.

**Cosa sblocca**

- Backtest non-stilizzato: il PR ``feat/backtest-engine`` può
  dropparsi il modello BS+skew_premium e leggere prezzi reali
  ``mid`` dalla chain registrata.
- Calibrazione empirica dello skew premium (hardcoded a 1.5 nel
  backtest stilizzato): plot del rapporto fra quote reali Deribit
  e BS per delta/expiry, regressione → valore data-driven.
- Validazione ex-post: "il delta-0.12 era davvero a 25% OTM in
  quella settimana?" diventa una query SELECT.
- Dimensione attesa: ~50 strike × 3 scadenze × 1 snapshot/settimana
  × 17 colonne ≈ 12 KB/settimana, ~600 KB/anno. Trascurabile.

Suite: 409 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:44:49 +00:00

218 lines
5.8 KiB
Python

"""Pydantic record types mirroring the SQLite tables.
Every numeric column documented as ``NUMERIC`` in
``state/migrations/0001_init.sql`` is exposed as :class:`decimal.Decimal`
on the Python side. The repository layer is responsible for serialising
to ``TEXT`` (using ``str``) when writing and parsing back when reading,
so precision is never lost via ``float`` coercion.
"""
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, Field
__all__ = [
"DecisionRecord",
"DvolSnapshot",
"InstructionRecord",
"ManualAction",
"MarketSnapshotRecord",
"OptionChainQuoteRecord",
"PositionRecord",
"PositionStatus",
"SystemStateRecord",
]
PositionStatus = Literal[
"proposed",
"awaiting_fill",
"open",
"closing",
"closed",
"cancelled",
]
class PositionRecord(BaseModel):
"""Row of the ``positions`` table."""
model_config = ConfigDict(extra="forbid")
proposal_id: UUID
spread_type: str
asset: str = "ETH"
expiry: datetime
short_strike: Decimal
long_strike: Decimal
short_instrument: str
long_instrument: str
n_contracts: int
spread_width_usd: Decimal
spread_width_pct: Decimal
credit_eth: Decimal
credit_usd: Decimal
max_loss_usd: Decimal
spot_at_entry: Decimal
dvol_at_entry: Decimal
delta_at_entry: Decimal
eth_price_at_entry: Decimal
proposed_at: datetime
opened_at: datetime | None = None
closed_at: datetime | None = None
close_reason: str | None = None
debit_paid_eth: Decimal | None = None
pnl_eth: Decimal | None = None
pnl_usd: Decimal | None = None
status: PositionStatus
created_at: datetime
updated_at: datetime
class InstructionRecord(BaseModel):
"""Row of the ``instructions`` table."""
model_config = ConfigDict(extra="forbid")
instruction_id: UUID
proposal_id: UUID
kind: Literal["open_combo", "close_combo"]
payload_json: str
sent_at: datetime
acknowledged_at: datetime | None = None
filled_at: datetime | None = None
cancelled_at: datetime | None = None
actual_fill_eth: Decimal | None = None
actual_fees_eth: Decimal | None = None
class DecisionRecord(BaseModel):
"""Row of the ``decisions`` table.
``id`` is :class:`int` and may be ``None`` before the row has been
inserted; the repository sets it after the auto-increment fires.
"""
model_config = ConfigDict(extra="forbid")
id: int | None = None
decision_type: Literal["entry_check", "exit_check", "kelly_recalib"]
proposal_id: UUID | None = None
timestamp: datetime
inputs_json: str
outputs_json: str
action_taken: str | None = None
notes: str | None = None
class DvolSnapshot(BaseModel):
"""Row of the ``dvol_history`` table."""
model_config = ConfigDict(extra="forbid")
timestamp: datetime
dvol: Decimal
eth_spot: Decimal
class MarketSnapshotRecord(BaseModel):
"""Row of the ``market_snapshots`` table.
Single point in time, single asset. Every numeric field is
optional because the ``market_snapshot`` collector is best-effort:
a single MCP failure NULLs the affected metric without dropping
the row.
"""
model_config = ConfigDict(extra="forbid")
timestamp: datetime
asset: str # "ETH", "BTC"
spot: Decimal | None = None
dvol: Decimal | None = None
realized_vol_30d: Decimal | None = None
iv_minus_rv: Decimal | None = None
funding_perp_annualized: Decimal | None = None
funding_cross_annualized: Decimal | None = None
dealer_net_gamma: Decimal | None = None
gamma_flip_level: Decimal | None = None
oi_delta_pct_4h: Decimal | None = None
liquidation_long_risk: str | None = None
liquidation_short_risk: str | None = None
macro_days_to_event: int | None = None
fetch_ok: bool
fetch_errors_json: str | None = None
class OptionChainQuoteRecord(BaseModel):
"""Row of the ``option_chain_snapshots`` table.
One row per (snapshot_ts, instrument) — the same ``timestamp`` is
shared by every quote prelevato nello stesso tick del cron. Tutti
i campi numerici sono opzionali perché il collector è
best-effort: un ticker mancante non invalida il resto della chain.
"""
model_config = ConfigDict(extra="forbid")
timestamp: datetime
asset: str
instrument_name: str
strike: Decimal
expiry: datetime
option_type: Literal["C", "P"]
bid: Decimal | None = None
ask: Decimal | None = None
mid: Decimal | None = None
iv: Decimal | None = None
delta: Decimal | None = None
gamma: Decimal | None = None
theta: Decimal | None = None
vega: Decimal | None = None
open_interest: int | None = None
volume_24h: int | None = None
book_depth_top3: int | None = None
class ManualAction(BaseModel):
"""Row of the ``manual_actions`` table."""
model_config = ConfigDict(extra="forbid")
id: int | None = None
kind: Literal[
"approve_proposal",
"reject_proposal",
"force_close",
"arm_kill",
"disarm_kill",
"run_cycle",
]
proposal_id: UUID | None = None
payload_json: str | None = None
created_at: datetime
consumed_at: datetime | None = None
consumed_by: str | None = None
result: str | None = None
class SystemStateRecord(BaseModel):
"""Singleton row of the ``system_state`` table."""
model_config = ConfigDict(extra="forbid")
id: int = Field(default=1)
kill_switch: int = 0
kill_reason: str | None = None
kill_at: datetime | None = None
last_health_check: datetime
last_kelly_calib: datetime | None = None
config_version: str
started_at: datetime
last_audit_hash: str | None = None