Compare commits
6 Commits
589d003e5c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a80c17f2bc | |||
| 097e764f6a | |||
| 8a1fb823dc | |||
| 4d7b02b6cb | |||
| 1a46abd5cf | |||
| 1eeb53650e |
+26
-12
@@ -100,13 +100,20 @@ Risultato:
|
||||
Si seleziona lo strike short più vicino a delta target, in valore
|
||||
assoluto.
|
||||
|
||||
| Parametro | Valore |
|
||||
| Parametro | Valore (profilo B, v1.7.0) |
|
||||
|---|---|
|
||||
| Delta short target | 0.12 |
|
||||
| Tolleranza delta | [0.10, 0.15] |
|
||||
| Distanza minima OTM | 15% (anche se delta è dentro tolleranza) |
|
||||
| Delta short target | 0.18 |
|
||||
| Tolleranza delta | [0.12, 0.22] |
|
||||
| Distanza minima OTM | 10% (anche se delta è dentro tolleranza) |
|
||||
| Distanza massima OTM | 25% |
|
||||
|
||||
> **Perché delta 0.18 e non 0.12.** A delta 0.10-0.15 il miglior
|
||||
> `credit/width` fisicamente ottenibile su ETH (analisi 3.689 snapshot,
|
||||
> 1mag-8giu '26) è ~6%, sotto il minimo richiesto: 0 spread fattibili.
|
||||
> Per uno spread verticale `credit/width ≲ delta_short`, quindi l'unica
|
||||
> leva che produce credito vendibile è vendere più vicino. Trade-off:
|
||||
> la prob. di assegnazione sale da ~12% a ~18-22%.
|
||||
|
||||
**Variante dinamica per regime DVOL (Phase 5, opt-in).** Il campo
|
||||
`short_strike.delta_by_dvol` (lista step-function) sostituisce il
|
||||
delta target singolo con bande ordinate per `dvol_under`: a DVOL
|
||||
@@ -117,9 +124,9 @@ classico col delta target sopra. Esempio bande nel profilo
|
||||
|
||||
| dvol_under | delta_target | delta_min | delta_max |
|
||||
|---|---|---|---|
|
||||
| 50 | 0.15 | 0.13 | 0.17 |
|
||||
| 70 | 0.12 | 0.10 | 0.15 |
|
||||
| 90 | 0.10 | 0.08 | 0.12 |
|
||||
| 50 | 0.22 | 0.18 | 0.25 |
|
||||
| 70 | 0.18 | 0.12 | 0.22 |
|
||||
| 90 | 0.15 | 0.10 | 0.18 |
|
||||
|
||||
Se nessuno strike disponibile rientra in entrambe le tolleranze → no entry.
|
||||
|
||||
@@ -131,16 +138,23 @@ Si seleziona lo strike a distanza fissa dallo short:
|
||||
|---|---|
|
||||
| Larghezza spread (in % di spot) | 4% |
|
||||
| Larghezza minima accettabile | 3% |
|
||||
| Larghezza massima accettabile | 5% |
|
||||
| Larghezza massima accettabile | 6% |
|
||||
|
||||
Se non esiste lo strike esatto a 4%, si prende il più vicino entro
|
||||
[3%, 5%]; altrimenti no entry.
|
||||
[3%, 6%]; altrimenti no entry. La banda arriva a 6% perché la griglia
|
||||
strike ETH sotto i $1500 è a 100pt (~6% a spot <$2k): con cap 5%
|
||||
nessuna gamba long rientrava in banda.
|
||||
|
||||
### 3.4 Rapporto credito/larghezza
|
||||
|
||||
Il credito netto incassato (mid-price del combo) deve essere ≥ **30%**
|
||||
della larghezza dello spread. Sotto questa soglia il rapporto
|
||||
rischio/rendimento è inferiore al minimo accettabile. → no entry.
|
||||
Il credito netto incassato (mid-price del combo) deve essere ≥ **8%**
|
||||
della larghezza dello spread (profilo B). → altrimenti no entry.
|
||||
|
||||
> Era **30%**, ma è fisicamente irraggiungibile con delta short ≤0.22
|
||||
> in questo mercato: il miglior `credit/width` misurato su ETH è ~6%
|
||||
> (anche vendendo a delta 0.30 non si superava il 20%). La soglia 8% è
|
||||
> allineata al realizzabile a delta ~0.18 e mantiene un floor sul
|
||||
> rapporto rischio/rendimento.
|
||||
|
||||
## 4. Liquidità (filtro hard pre-entry)
|
||||
|
||||
|
||||
+35
-12
@@ -229,12 +229,23 @@ NULL = engine attivo.
|
||||
|
||||
### `option_chain_snapshots`
|
||||
|
||||
Snapshot della catena opzioni Deribit prelevata in continuo
|
||||
(cron `*/15 * * * *`, allineato a `market_snapshots`). Crypto è
|
||||
24/7: l'accumulo dataset deve essere continuo, non gateato sulla
|
||||
settimana. Ogni tick contiene un quote per strumento entro la
|
||||
finestra `[dte_min, dte_max]` di config; tutti i quote prelevati
|
||||
nello stesso tick condividono ``timestamp``. Migration `0005`.
|
||||
Snapshot della catena opzioni Deribit. La tabella è alimentata da
|
||||
**due collettori distinti**, separati dalla colonna `source`:
|
||||
|
||||
* `source='live'` — collettore operativo (cron `*/15 * * * *`,
|
||||
allineato a `market_snapshots`). Ogni tick contiene un quote per
|
||||
strumento entro la finestra `[dte_min, dte_max]` di config (1
|
||||
scadenza, ~13-15 strike); `book_depth_top3` NULL.
|
||||
* `source='research'` — collettore full-chain (§13-bis, cron orario,
|
||||
opt-in via `research_collector.enabled`). Cattura tutte le scadenze
|
||||
≤`expiry_max_days` ed entrambe le ali, con `book_depth_top3`
|
||||
popolato. Trasforma il dataset da "skew/premi medi" a backtest
|
||||
opzioni vero (per-trade e standing put).
|
||||
|
||||
Crypto è 24/7: l'accumulo dataset deve essere continuo, non gateato
|
||||
sulla settimana. Tutti i quote prelevati nello stesso tick
|
||||
condividono ``timestamp``. Migration `0005` (tabella), `0007`
|
||||
(colonna `source`).
|
||||
|
||||
```sql
|
||||
CREATE TABLE option_chain_snapshots (
|
||||
@@ -255,15 +266,19 @@ CREATE TABLE option_chain_snapshots (
|
||||
open_interest INTEGER,
|
||||
volume_24h INTEGER,
|
||||
book_depth_top3 INTEGER,
|
||||
source TEXT NOT NULL DEFAULT 'live',
|
||||
PRIMARY KEY (timestamp, instrument_name)
|
||||
) WITHOUT ROWID;
|
||||
```
|
||||
|
||||
Indici: `(asset, timestamp DESC)` per listing recenti, `(asset,
|
||||
expiry)` per query per scadenza specifica. ``book_depth_top3`` è
|
||||
NULL by design — il collector non chiama l'order book per ogni
|
||||
strike per non saturare l'API; lo legge il liquidity gate live solo
|
||||
sugli strike candidati al picker.
|
||||
expiry)` per query per scadenza specifica, `(asset, source,
|
||||
timestamp DESC)` per separare live/research nelle query di backtest.
|
||||
``book_depth_top3`` è NULL sulle righe `live` (il collector operativo
|
||||
non chiama l'order book per ogni strike, per non saturare l'API; lo
|
||||
legge il liquidity gate live solo sugli strike candidati al picker)
|
||||
ed è **popolato sulle righe `research`** (1 call orderbook/strumento,
|
||||
concorrenza limitata) per modellare lo slippage reale.
|
||||
|
||||
**Sblocca**: il backtest non-stilizzato (modulo `core/backtest.py`
|
||||
con prezzi reali invece di Black-Scholes), la calibrazione empirica
|
||||
@@ -363,12 +378,20 @@ idempotente, forward-only:
|
||||
| 3 | `0003_market_snapshots.sql` | tabella `market_snapshots` |
|
||||
| 4 | `0004_auto_pause.sql` | `system_state.auto_pause_until / _reason` |
|
||||
| 5 | `0005_option_chain_snapshots.sql` | tabella `option_chain_snapshots` |
|
||||
| 6 | `0006_dvol_history_multi_asset.sql` | `dvol_history` multi-asset |
|
||||
| 7 | `0007_option_chain_source.sql` | colonna `source` (live/research) + indice |
|
||||
|
||||
## Backup
|
||||
|
||||
Backup orari di `state.sqlite` in
|
||||
Backup **giornalieri** di `state.sqlite` in
|
||||
`data/backups/state-YYYYMMDD-HH.sqlite`, ritenuti per 30 giorni.
|
||||
Operazione idempotente con SQLite `VACUUM INTO`. Lo job è registrato
|
||||
nello scheduler dell'orchestrator (`backup_cron = "0 * * * *"`); è
|
||||
nello scheduler dell'orchestrator (`backup_cron = "0 0 * * *"`); è
|
||||
disponibile anche come CLI `python scripts/backup.py` per backup
|
||||
ad-hoc.
|
||||
|
||||
> Nota storica: il cron era orario (`0 * * * *`) e accumulava ~16 GB
|
||||
> di snapshot full per ~98 MB di dato unico (la stessa catena opzioni
|
||||
> ricopiata ~24×/giorno). Portato a giornaliero per ridurre il
|
||||
> footprint di ~24× a parità di retention — rilevante prima di
|
||||
> accendere il collettore research, che 2-4× la crescita del DB.
|
||||
|
||||
@@ -234,6 +234,8 @@ proposed
|
||||
| `0 12 1 * *` | Kelly recalibration | Mensile |
|
||||
| `*/5 * * * *` | Health check | 5 min |
|
||||
| `*/15 * * * *` | Market snapshot (calibrazione soglie) | 15 min |
|
||||
| `*/15 * * * *` | Option chain snapshot (live, finestra DTE strategia) | 15 min |
|
||||
| `0 * * * *` | Option chain research (full-chain + book_depth, opt-in) | Orario |
|
||||
| `0 0 * * *` | Backup SQLite + rotation log | Giornaliero |
|
||||
| `0 8 * * *` | Daily digest Telegram | Giornaliero |
|
||||
|
||||
|
||||
+30
-6
@@ -55,19 +55,43 @@ structure:
|
||||
dte_min: 14
|
||||
dte_max: 21
|
||||
|
||||
# Profilo B (v1.7.0). L'analisi su 3.689 snapshot (1mag-8giu '26) ha
|
||||
# mostrato che a delta 0.10-0.15 il miglior credit/width fisicamente
|
||||
# ottenibile è ~6%, quindi il vecchio credit_to_width_ratio_min=0.30
|
||||
# era irraggiungibile (0 spread fattibili). La sola leva efficace è
|
||||
# vendere più vicino → delta alzato a ~0.18.
|
||||
short_strike:
|
||||
delta_target: 0.12
|
||||
delta_min: 0.10
|
||||
delta_max: 0.15
|
||||
distance_otm_pct_min: 0.15
|
||||
delta_target: 0.18
|
||||
delta_min: 0.12
|
||||
delta_max: 0.22
|
||||
distance_otm_pct_min: 0.10
|
||||
distance_otm_pct_max: 0.25
|
||||
|
||||
spread_width:
|
||||
target_pct_of_spot: 0.04
|
||||
min_pct_of_spot: 0.03
|
||||
max_pct_of_spot: 0.05
|
||||
# 0.06: la griglia strike ETH sotto i $1500 è a 100pt (~6% a spot
|
||||
# <$2k); con cap 0.05 nessuna gamba long rientrava in banda.
|
||||
max_pct_of_spot: 0.06
|
||||
|
||||
credit_to_width_ratio_min: 0.30
|
||||
credit_to_width_ratio_min: 0.08 # era 0.30 (irraggiungibile a delta ≤0.22)
|
||||
|
||||
# === RESEARCH COLLECTOR (§13-bis) ===
|
||||
|
||||
# Collettore opzioni full-chain, indipendente dal live. Cattura tutte
|
||||
# le scadenze ≤ expiry_max_days ed entrambe le ali, popolando
|
||||
# book_depth_top3 (1 call orderbook/strumento) → backtest opzioni
|
||||
# per-trade e standing. Disabilitato di default: costo API non
|
||||
# trascurabile. Scrive righe con source='research'.
|
||||
research_collector:
|
||||
enabled: false
|
||||
cron: "0 * * * *" # orario, indipendente dal */15 del live
|
||||
expiry_max_days: 95 # 1g..3mesi
|
||||
moneyness_band_pct: null # null = catena completa (entrambe le ali)
|
||||
open_interest_min: 100
|
||||
fetch_book_depth: true
|
||||
book_depth_concurrency: 8 # concorrenza max delle call orderbook
|
||||
assets: ["ETH", "BTC"]
|
||||
|
||||
# === LIQUIDITY GATE ===
|
||||
|
||||
|
||||
@@ -1204,6 +1204,70 @@ def option_chain_trigger(
|
||||
)
|
||||
|
||||
|
||||
@option_chain.command(name="research-trigger")
|
||||
@click.option(
|
||||
"--strategy",
|
||||
"strategy_path",
|
||||
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
||||
default=_DEFAULT_STRATEGY_PATH,
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--db",
|
||||
"db_path",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
default=_DEFAULT_DB_PATH,
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--audit",
|
||||
"audit_path",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
default=_DEFAULT_AUDIT_PATH,
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--token",
|
||||
type=str,
|
||||
default=None,
|
||||
help="MCP bearer token (override su CERBERO_BITE_MCP_TOKEN).",
|
||||
)
|
||||
@click.option("--asset", default="ETH", show_default=True)
|
||||
def option_chain_research_trigger(
|
||||
strategy_path: Path,
|
||||
db_path: Path,
|
||||
audit_path: Path,
|
||||
token: str | None,
|
||||
asset: str,
|
||||
) -> None:
|
||||
"""Esegue UNA volta il collector research full-chain (source='research').
|
||||
|
||||
Cattura tutte le scadenze ed entrambe le ali con book_depth_top3
|
||||
popolato. ``force=True``: gira anche se ``research_collector.enabled``
|
||||
è false, utile per testare prima di accendere il cron orario.
|
||||
"""
|
||||
from cerbero_bite.runtime.dependencies import build_runtime # noqa: PLC0415
|
||||
from cerbero_bite.runtime.option_chain_research_cycle import ( # noqa: PLC0415
|
||||
collect_option_chain_research,
|
||||
)
|
||||
|
||||
cfg = load_strategy(strategy_path).config
|
||||
ctx = build_runtime(
|
||||
cfg=cfg,
|
||||
endpoints=load_endpoints(),
|
||||
token=load_token(value=token),
|
||||
db_path=db_path,
|
||||
audit_path=audit_path,
|
||||
bot_tag=load_bot_tag(),
|
||||
)
|
||||
n = asyncio.run(collect_option_chain_research(ctx, asset=asset, force=True))
|
||||
console.print(
|
||||
f"[green]Persisted {n} research quote(s) for {asset}[/green]"
|
||||
if n > 0
|
||||
else "[yellow]No quotes persisted (chain empty or fetch failed)[/yellow]"
|
||||
)
|
||||
|
||||
|
||||
@option_chain.command(name="analyze")
|
||||
@click.option(
|
||||
"--strategy",
|
||||
|
||||
@@ -359,6 +359,31 @@ class McpConfig(_LooseSection): ...
|
||||
class TelegramConfig(_LooseSection): ...
|
||||
|
||||
|
||||
class ResearchCollectorConfig(BaseModel):
|
||||
"""Collettore *research* full-chain (§13-bis).
|
||||
|
||||
Indipendente dal collettore operativo: cattura ogni ciclo tutte le
|
||||
scadenze liquide entro ``expiry_max_days`` ed entrambe le ali,
|
||||
popolando ``book_depth_top3`` (1 call orderbook/strumento). Trasforma
|
||||
il dataset da "skew/premi medi" a backtest opzioni per-trade e
|
||||
standing. Disabilitato di default: ha un costo API non trascurabile.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
enabled: bool = False
|
||||
cron: str = "0 * * * *" # orario, indipendente dal */15 del live
|
||||
expiry_max_days: int = 95 # 1g..3mesi
|
||||
# None = nessun filtro moneyness (catena completa, entrambe le ali).
|
||||
# Valorizzato (es. 0.30) = tiene solo gli strike entro ±band dallo spot.
|
||||
moneyness_band_pct: Decimal | None = None
|
||||
open_interest_min: int = 100
|
||||
fetch_book_depth: bool = True
|
||||
# Concorrenza max delle call orderbook depth (bound sul rate-limit).
|
||||
book_depth_concurrency: int = 8
|
||||
assets: list[str] = Field(default_factory=lambda: ["ETH", "BTC"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Root
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -383,6 +408,10 @@ class StrategyConfig(BaseModel):
|
||||
kelly_recalibration: KellyConfig = Field(default_factory=KellyConfig)
|
||||
auto_pause: AutoPauseConfig = Field(default_factory=AutoPauseConfig)
|
||||
|
||||
research_collector: ResearchCollectorConfig = Field(
|
||||
default_factory=ResearchCollectorConfig
|
||||
)
|
||||
|
||||
execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
|
||||
monitoring: MonitoringConfig = Field(default_factory=MonitoringConfig)
|
||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Full-chain *research* option collector (§13-bis).
|
||||
|
||||
Diverso dal collettore operativo (`option_chain_snapshot_cycle`):
|
||||
|
||||
* finestra scadenze ``[now, now + expiry_max_days]`` — cattura TUTTE le
|
||||
scadenze liquide (1g/1sett/2sett/1mese/3mesi), non solo quella nella
|
||||
finestra DTE della strategia;
|
||||
* entrambe le ali (nessun filtro moneyness di default), oppure entro
|
||||
``±moneyness_band_pct`` se configurato;
|
||||
* popola ``book_depth_top3`` chiamando l'orderbook per ogni strumento
|
||||
tenuto (1 call/strumento, concorrenza limitata da
|
||||
``book_depth_concurrency``) — così lo slippage reale è modellabile;
|
||||
* scrive con ``source='research'`` per non confondersi con le righe
|
||||
'live'.
|
||||
|
||||
Questo trasforma il dataset da "skew/premi medi" a backtest opzioni
|
||||
vero, per-trade e standing. Best-effort come l'altro collettore: un
|
||||
batch o un orderbook che falliscono non invalidano il resto.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from cerbero_bite.state import connect, transaction
|
||||
from cerbero_bite.state.models import OptionChainQuoteRecord
|
||||
|
||||
from cerbero_bite.runtime.option_chain_snapshot_cycle import (
|
||||
DEFAULT_BATCH_SIZE,
|
||||
_fetch_tickers_in_batches,
|
||||
_to_decimal_or_none,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cerbero_bite.runtime.dependencies import RuntimeContext
|
||||
|
||||
__all__ = ["collect_option_chain_research"]
|
||||
|
||||
_log = logging.getLogger("cerbero_bite.runtime.option_chain_research")
|
||||
|
||||
|
||||
def _underlying_price(ticker: dict[str, Any]) -> Decimal | None:
|
||||
"""Spot/index dell'underlying dal ticker, per il filtro moneyness."""
|
||||
for key in ("underlying_price", "index_price", "estimated_delivery_price"):
|
||||
val = _to_decimal_or_none(ticker.get(key))
|
||||
if val is not None and val > 0:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
async def _depth_for(
|
||||
ctx: RuntimeContext, name: str, sem: asyncio.Semaphore
|
||||
) -> int | None:
|
||||
"""Best-effort top-3 book depth per ``name`` (None se fallisce)."""
|
||||
async with sem:
|
||||
try:
|
||||
return await ctx.deribit.orderbook_depth_top3(name)
|
||||
except Exception as exc:
|
||||
_log.debug("orderbook_depth_top3 failed for %s: %s", name, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def collect_option_chain_research(
|
||||
ctx: RuntimeContext,
|
||||
*,
|
||||
asset: str = "ETH",
|
||||
now: datetime | None = None,
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
force: bool = False,
|
||||
) -> int:
|
||||
"""Collect + persist un singolo snapshot full-chain ``research`` per
|
||||
``asset``. Ritorna il numero di quote persistiti (0 su fallimento
|
||||
best-effort o se il collettore è disabilitato).
|
||||
|
||||
``force=True`` bypassa il gate ``enabled`` (usato dal trigger CLI per
|
||||
testare la raccolta prima di accendere il cron schedulato)."""
|
||||
rc = getattr(ctx.cfg, "research_collector", None)
|
||||
if rc is None or (not rc.enabled and not force):
|
||||
return 0
|
||||
|
||||
when = (now or datetime.now(UTC)).astimezone(UTC)
|
||||
|
||||
expiry_from = when
|
||||
expiry_to = when + timedelta(days=rc.expiry_max_days)
|
||||
|
||||
try:
|
||||
chain = await ctx.deribit.options_chain(
|
||||
currency=asset.upper(),
|
||||
expiry_from=expiry_from,
|
||||
expiry_to=expiry_to,
|
||||
min_open_interest=int(rc.open_interest_min),
|
||||
)
|
||||
except Exception:
|
||||
_log.exception("research option chain fetch failed")
|
||||
return 0
|
||||
|
||||
if not chain:
|
||||
_log.info("research option chain empty for %s in window", asset)
|
||||
return 0
|
||||
|
||||
names = [meta.name for meta in chain]
|
||||
tickers = await _fetch_tickers_in_batches(ctx, names, batch_size=batch_size)
|
||||
|
||||
band = rc.moneyness_band_pct # Decimal | None
|
||||
|
||||
# 1) costruisci i quote, applicando l'eventuale filtro moneyness.
|
||||
kept: list[tuple[OptionChainQuoteRecord, dict[str, Any] | None]] = []
|
||||
for meta in chain:
|
||||
ticker = tickers.get(meta.name)
|
||||
|
||||
if band is not None and ticker is not None:
|
||||
spot = _underlying_price(ticker)
|
||||
if spot is not None:
|
||||
moneyness = abs(meta.strike - spot) / spot
|
||||
if moneyness > band:
|
||||
continue # fuori dall'ala richiesta
|
||||
|
||||
if ticker is None:
|
||||
rec = OptionChainQuoteRecord(
|
||||
timestamp=when,
|
||||
asset=asset.upper(),
|
||||
instrument_name=meta.name,
|
||||
strike=meta.strike,
|
||||
expiry=meta.expiry,
|
||||
option_type=meta.option_type,
|
||||
open_interest=int(meta.open_interest)
|
||||
if meta.open_interest is not None
|
||||
else None,
|
||||
source="research",
|
||||
)
|
||||
kept.append((rec, None))
|
||||
continue
|
||||
|
||||
greeks = ticker.get("greeks") or {}
|
||||
rec = OptionChainQuoteRecord(
|
||||
timestamp=when,
|
||||
asset=asset.upper(),
|
||||
instrument_name=meta.name,
|
||||
strike=meta.strike,
|
||||
expiry=meta.expiry,
|
||||
option_type=meta.option_type,
|
||||
bid=_to_decimal_or_none(ticker.get("bid")),
|
||||
ask=_to_decimal_or_none(ticker.get("ask")),
|
||||
mid=_to_decimal_or_none(ticker.get("mark_price")),
|
||||
iv=_to_decimal_or_none(ticker.get("mark_iv")),
|
||||
delta=_to_decimal_or_none(greeks.get("delta")),
|
||||
gamma=_to_decimal_or_none(greeks.get("gamma")),
|
||||
theta=_to_decimal_or_none(greeks.get("theta")),
|
||||
vega=_to_decimal_or_none(greeks.get("vega")),
|
||||
open_interest=int(meta.open_interest)
|
||||
if meta.open_interest is not None
|
||||
else None,
|
||||
volume_24h=(
|
||||
int(ticker["volume_24h"])
|
||||
if ticker.get("volume_24h") is not None
|
||||
else None
|
||||
),
|
||||
source="research",
|
||||
)
|
||||
kept.append((rec, ticker))
|
||||
|
||||
# 2) popola book_depth_top3 (concorrenza limitata) sugli strumenti tenuti.
|
||||
if rc.fetch_book_depth and kept:
|
||||
sem = asyncio.Semaphore(max(1, int(rc.book_depth_concurrency)))
|
||||
depths = await asyncio.gather(
|
||||
*(_depth_for(ctx, rec.instrument_name, sem) for rec, _ in kept)
|
||||
)
|
||||
kept = [
|
||||
(rec.model_copy(update={"book_depth_top3": depth}), tk)
|
||||
for (rec, tk), depth in zip(kept, depths, strict=True)
|
||||
]
|
||||
|
||||
quotes = [rec for rec, _ in kept]
|
||||
|
||||
persisted = 0
|
||||
try:
|
||||
conn = connect(ctx.db_path)
|
||||
try:
|
||||
with transaction(conn):
|
||||
persisted = ctx.repository.record_option_chain_snapshot(conn, quotes)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
_log.exception("persist research option chain snapshot failed")
|
||||
return 0
|
||||
|
||||
_log.info(
|
||||
"option_chain_research persisted %d quote(s) for %s (%d expiries window<=%dd)",
|
||||
persisted,
|
||||
asset.upper(),
|
||||
len({q.expiry for q in quotes}),
|
||||
rc.expiry_max_days,
|
||||
)
|
||||
return persisted
|
||||
@@ -37,6 +37,9 @@ from cerbero_bite.runtime.market_snapshot_cycle import (
|
||||
from cerbero_bite.runtime.option_chain_snapshot_cycle import (
|
||||
collect_option_chain_snapshot,
|
||||
)
|
||||
from cerbero_bite.runtime.option_chain_research_cycle import (
|
||||
collect_option_chain_research,
|
||||
)
|
||||
from cerbero_bite.runtime.monitor_cycle import MonitorCycleResult, run_monitor_cycle
|
||||
from cerbero_bite.runtime.recovery import recover_state
|
||||
from cerbero_bite.runtime.scheduler import JobSpec, build_scheduler
|
||||
@@ -55,7 +58,7 @@ Environment = Literal["testnet", "mainnet"]
|
||||
_CRON_ENTRY = "0 */2 * * *" # crypto 24/7: valutazione ogni 2h; i gate decidono se entrare
|
||||
_CRON_MONITOR = "0 * * * *" # stop/take-profit check ogni ora
|
||||
_CRON_HEALTH = "*/5 * * * *"
|
||||
_CRON_BACKUP = "0 * * * *"
|
||||
_CRON_BACKUP = "0 0 * * *" # giornaliero (00:00 UTC): lo snapshot full orario gonfiava i backup (16GB per ~98MB di dato unico)
|
||||
_CRON_MANUAL_ACTIONS = "*/1 * * * *"
|
||||
_CRON_MARKET_SNAPSHOT = "*/15 * * * *"
|
||||
_CRON_OPTION_CHAIN_SNAPSHOT = "*/15 * * * *" # crypto è 24/7: cadenza continua allineata a market_snapshot
|
||||
@@ -320,6 +323,13 @@ class Orchestrator:
|
||||
|
||||
await _safe("option_chain_snapshot", _do)
|
||||
|
||||
async def _option_chain_research() -> None:
|
||||
async def _do() -> None:
|
||||
for asset in self._ctx.cfg.research_collector.assets:
|
||||
await collect_option_chain_research(self._ctx, asset=asset)
|
||||
|
||||
await _safe("option_chain_research", _do)
|
||||
|
||||
jobs: list[JobSpec] = [
|
||||
JobSpec(name="health", cron=health_cron, coro_factory=_health),
|
||||
JobSpec(name="backup", cron=backup_cron, coro_factory=_backup),
|
||||
@@ -354,6 +364,21 @@ class Orchestrator:
|
||||
coro_factory=_option_chain_snapshot,
|
||||
)
|
||||
)
|
||||
rc = self._ctx.cfg.research_collector
|
||||
if rc.enabled:
|
||||
jobs.append(
|
||||
JobSpec(
|
||||
name="option_chain_research",
|
||||
cron=rc.cron,
|
||||
coro_factory=_option_chain_research,
|
||||
)
|
||||
)
|
||||
_log.info(
|
||||
"research collector ENABLED (cron=%s, window<=%dd, depth=%s)",
|
||||
rc.cron,
|
||||
rc.expiry_max_days,
|
||||
rc.fetch_book_depth,
|
||||
)
|
||||
else:
|
||||
_log.warning(
|
||||
"data analysis disabled (CERBERO_BITE_ENABLE_DATA_ANALYSIS="
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 0007_option_chain_source.sql — distingue le righe del collettore
|
||||
--
|
||||
-- Due collettori scrivono ora su option_chain_snapshots:
|
||||
-- * 'live' — collettore operativo, finestra DTE della strategia
|
||||
-- (cfg.structure.dte_min..dte_max), 1 scadenza, no depth.
|
||||
-- * 'research' — collettore full-chain (tutte le scadenze <=95g,
|
||||
-- entrambe le ali, book_depth_top3 popolato) per il
|
||||
-- backtest opzioni vero (per-trade e standing put).
|
||||
--
|
||||
-- Le righe storiche pre-migrazione sono tutte 'live' (DEFAULT). Il
|
||||
-- backtest per-trade/standing filtra su source='research'.
|
||||
|
||||
ALTER TABLE option_chain_snapshots ADD COLUMN source TEXT NOT NULL DEFAULT 'live';
|
||||
|
||||
CREATE INDEX idx_option_chain_source
|
||||
ON option_chain_snapshots(asset, source, timestamp DESC);
|
||||
|
||||
PRAGMA user_version = 7;
|
||||
@@ -178,6 +178,9 @@ class OptionChainQuoteRecord(BaseModel):
|
||||
open_interest: int | None = None
|
||||
volume_24h: int | None = None
|
||||
book_depth_top3: int | None = None
|
||||
# 'live' = collettore operativo (finestra DTE strategia, no depth);
|
||||
# 'research' = collettore full-chain con book_depth popolato.
|
||||
source: str = "live"
|
||||
|
||||
|
||||
class ManualAction(BaseModel):
|
||||
|
||||
@@ -547,6 +547,7 @@ class Repository:
|
||||
q.open_interest,
|
||||
q.volume_24h,
|
||||
q.book_depth_top3,
|
||||
q.source,
|
||||
)
|
||||
for q in quotes
|
||||
]
|
||||
@@ -554,8 +555,8 @@ class Repository:
|
||||
"INSERT OR REPLACE INTO option_chain_snapshots("
|
||||
"timestamp, asset, instrument_name, strike, expiry, option_type, "
|
||||
"bid, ask, mid, iv, delta, gamma, theta, vega, "
|
||||
"open_interest, volume_24h, book_depth_top3) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
"open_interest, volume_24h, book_depth_top3, source) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
rows,
|
||||
)
|
||||
return len(rows)
|
||||
@@ -569,10 +570,14 @@ class Repository:
|
||||
end: datetime | None = None,
|
||||
expiry_from: datetime | None = None,
|
||||
expiry_to: datetime | None = None,
|
||||
source: str | None = None,
|
||||
limit: int = 50000,
|
||||
) -> list[OptionChainQuoteRecord]:
|
||||
clauses: list[str] = ["asset = ?"]
|
||||
params: list[Any] = [asset]
|
||||
if source is not None:
|
||||
clauses.append("source = ?")
|
||||
params.append(source)
|
||||
if start is not None:
|
||||
clauses.append("timestamp >= ?")
|
||||
params.append(_enc_dt(start))
|
||||
@@ -925,6 +930,11 @@ def _row_to_option_chain_quote(row: sqlite3.Row) -> OptionChainQuoteRecord:
|
||||
if row["book_depth_top3"] is not None
|
||||
else None
|
||||
),
|
||||
source=(
|
||||
row["source"]
|
||||
if "source" in row.keys() and row["source"] is not None
|
||||
else "live"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+33
-12
@@ -28,9 +28,9 @@
|
||||
# 2× via "ETH + BTC" indicato in `📚 Strategia` è una **stima ex-ante**
|
||||
# di cosa otterresti DOPO quel lavoro di codice.
|
||||
|
||||
config_version: "1.4.0-aggressiva"
|
||||
config_hash: "7fa9b0be5b56517293421bc19838b700da595725360fe018a1be13b802dea859"
|
||||
last_review: "2026-04-26"
|
||||
config_version: "1.5.0-aggressiva"
|
||||
config_hash: "a5e23c289315d738289f79e6b8c0e05e880e07c6ef878b013fc9849918e8b37a"
|
||||
last_review: "2026-06-09"
|
||||
last_reviewer: "Adriano"
|
||||
|
||||
asset:
|
||||
@@ -83,31 +83,52 @@ entry:
|
||||
vol_of_vol_lookback_hours: 24
|
||||
|
||||
|
||||
# §13-bis: collettore research full-chain (vedi strategy.yaml). Cattura
|
||||
# tutte le scadenze ed entrambe le ali con book_depth_top3 popolato per
|
||||
# il backtest opzioni per-trade/standing. Disabilitato di default.
|
||||
research_collector:
|
||||
enabled: false
|
||||
cron: "0 * * * *"
|
||||
expiry_max_days: 95
|
||||
moneyness_band_pct: null
|
||||
open_interest_min: 100
|
||||
fetch_book_depth: true
|
||||
book_depth_concurrency: 8
|
||||
assets: ["ETH", "BTC"]
|
||||
|
||||
|
||||
structure:
|
||||
dte_target: 18
|
||||
dte_min: 14
|
||||
dte_max: 21
|
||||
|
||||
# PROFILO B (tune 2026-06-09): stessa correzione del profilo
|
||||
# conservativo. La delta step-function aggressiva (max 0.17 a DVOL<50)
|
||||
# NON bastava: il credit/width satura a ~6% e 0.30 resta irraggiungibile
|
||||
# (0 spread fattibili su 3.689 snapshot). Si alza il delta band.
|
||||
short_strike:
|
||||
delta_target: "0.12"
|
||||
delta_min: "0.10"
|
||||
delta_max: "0.15"
|
||||
distance_otm_pct_min: "0.15"
|
||||
delta_target: "0.18"
|
||||
delta_min: "0.12"
|
||||
delta_max: "0.22"
|
||||
distance_otm_pct_min: "0.10"
|
||||
distance_otm_pct_max: "0.25"
|
||||
|
||||
# §3.2 (A): step-function delta-target per regime DVOL.
|
||||
# DVOL bassa (≤50) → più premio; alta (>70) → più safety.
|
||||
delta_by_dvol:
|
||||
- {dvol_under: "50", delta_target: "0.15", delta_min: "0.13", delta_max: "0.17"}
|
||||
- {dvol_under: "70", delta_target: "0.12", delta_min: "0.10", delta_max: "0.15"}
|
||||
- {dvol_under: "90", delta_target: "0.10", delta_min: "0.08", delta_max: "0.12"}
|
||||
- {dvol_under: "50", delta_target: "0.22", delta_min: "0.18", delta_max: "0.25"}
|
||||
- {dvol_under: "70", delta_target: "0.18", delta_min: "0.12", delta_max: "0.22"}
|
||||
- {dvol_under: "90", delta_target: "0.15", delta_min: "0.10", delta_max: "0.18"}
|
||||
|
||||
spread_width:
|
||||
target_pct_of_spot: "0.04"
|
||||
min_pct_of_spot: "0.03"
|
||||
max_pct_of_spot: "0.05"
|
||||
# 0.06: griglia strike ETH a 100pt (~6% a spot <$2k); cap 0.05
|
||||
# escludeva ogni gamba long.
|
||||
max_pct_of_spot: "0.06"
|
||||
|
||||
credit_to_width_ratio_min: "0.30"
|
||||
# Era 0.30 — fisicamente irraggiungibile. 0.08 allineato al realizzabile.
|
||||
credit_to_width_ratio_min: "0.08"
|
||||
|
||||
liquidity:
|
||||
open_interest_min: 100
|
||||
|
||||
+34
-9
@@ -6,9 +6,9 @@
|
||||
# config hash), and lands as a separate commit with the motivation in
|
||||
# the commit message.
|
||||
|
||||
config_version: "1.6.0"
|
||||
config_hash: "67df85f84ce6148b88f7eb9d96346145c1b9892a7250f5fc42619da3178dac86"
|
||||
last_review: "2026-05-29"
|
||||
config_version: "1.7.1"
|
||||
config_hash: "720a202e9dde4ccff7f79a8bc9521de62dc30115f11f486c55ad6f3986858dcb"
|
||||
last_review: "2026-06-09"
|
||||
last_reviewer: "Adriano"
|
||||
|
||||
asset:
|
||||
@@ -59,24 +59,49 @@ entry:
|
||||
iv_minus_rv_window_min_days: 30
|
||||
|
||||
|
||||
# §13-bis: collettore research full-chain. INDIPENDENTE dal collettore
|
||||
# live (che resta sulla finestra DTE della strategia, 1 scadenza, no
|
||||
# depth). Cattura tutte le scadenze <=expiry_max_days ed entrambe le
|
||||
# ali, popolando book_depth_top3 → backtest opzioni per-trade e
|
||||
# standing put + slippage reale. Disabilitato di default (costo API).
|
||||
research_collector:
|
||||
enabled: true
|
||||
cron: "0 * * * *" # orario
|
||||
expiry_max_days: 95 # 1g..3mesi
|
||||
moneyness_band_pct: null # null = catena completa (entrambe le ali)
|
||||
open_interest_min: 100
|
||||
fetch_book_depth: true
|
||||
book_depth_concurrency: 8
|
||||
assets: ["ETH", "BTC"]
|
||||
|
||||
|
||||
structure:
|
||||
dte_target: 18
|
||||
dte_min: 14
|
||||
dte_max: 21
|
||||
|
||||
# PROFILO B (tune 2026-06-09): vendere più vicino sblocca credito
|
||||
# realizzabile. Analisi su 3.689 snapshot (1mag-8giu): a delta
|
||||
# 0.10-0.15 il miglior credit/width ottenibile è ~6%, quindi 0.30 era
|
||||
# fisicamente irraggiungibile (0 spread fattibili). Alzando il delta a
|
||||
# ~0.18-0.22 il ratio sale a ~8-10% e l'eleggibilità a ~11%.
|
||||
short_strike:
|
||||
delta_target: "0.12"
|
||||
delta_min: "0.10"
|
||||
delta_max: "0.15"
|
||||
distance_otm_pct_min: "0.15"
|
||||
delta_target: "0.18"
|
||||
delta_min: "0.12"
|
||||
delta_max: "0.22"
|
||||
distance_otm_pct_min: "0.10"
|
||||
distance_otm_pct_max: "0.25"
|
||||
|
||||
spread_width:
|
||||
target_pct_of_spot: "0.04"
|
||||
min_pct_of_spot: "0.03"
|
||||
max_pct_of_spot: "0.05"
|
||||
# 0.06: la griglia strike ETH sotto i $1500 è a 100pt (~6% a spot
|
||||
# <$2k). Con cap 0.05 nessuna gamba long rientrava in banda.
|
||||
max_pct_of_spot: "0.06"
|
||||
|
||||
credit_to_width_ratio_min: "0.30"
|
||||
# Era 0.30 — irraggiungibile con delta <=0.30 in questo mercato.
|
||||
# 0.08 = allineato al credit/width fisicamente realizzabile a delta ~0.18.
|
||||
credit_to_width_ratio_min: "0.08"
|
||||
|
||||
liquidity:
|
||||
open_interest_min: 100
|
||||
|
||||
Reference in New Issue
Block a user