feat(cli): comando option-chain (trigger + analyze) per la catena opzioni
Espone direttamente da CLI le due operazioni più utili sui dati di
``option_chain_snapshots`` raccolti dal cron settimanale:
- ``cerbero-bite option-chain trigger`` — esegue UNA volta il
collector della catena. Riusa la stessa pipeline schedulata (cron
``55 13 * * MON``) ma on-demand. Utile per popolare il DB senza
aspettare lunedì.
- ``cerbero-bite option-chain analyze [--bias bull_put|bear_call]`` —
legge l'ultimo snapshot, simula il selector di strike
(``select_strikes``) con la strategy passata e stampa una tabella
con: short/long strike, delta, width, credito reale, ratio
credit/width, e PASS/FAIL del gate ``credit_to_width_ratio_min``.
Il comando ``analyze`` rende immediatamente actionable la catena
appena raccolta: invece di stime ex-ante via Black-Scholes (modulo
``core/backtest.py``), legge i mid REALI di Deribit e dice "il rule
engine aprirebbe questo trade qui? credit/width ratio passa o no?".
Esempio di output sui primi snapshot raccolti (regime ETH ~2200,
DTE ~14g):
Snapshot del 2026-05-01T20:53:49 — 21 quote totali
Il rule engine NON aprirebbe trade con questa catena
(no strike compatibile coi gate delta/distance/width/credit-ratio).
Conferma empirica del messaggio del documento ``13-strategia-spiegata``:
con delta target 0.12 + width 4% + credit/width ≥ 30%, il regime
attuale di ETH options non è abbastanza ricco per produrre trade —
serve calibrare soglie o aspettare un regime IV più alto.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+236
-1
@@ -13,7 +13,7 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -812,6 +812,241 @@ def state_inspect(db: Path) -> None:
|
||||
console.print(table)
|
||||
|
||||
|
||||
@main.group(name="option-chain")
|
||||
def option_chain() -> None:
|
||||
"""Strumenti per la catena opzioni storica (`option_chain_snapshots`)."""
|
||||
|
||||
|
||||
@option_chain.command(name="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_trigger(
|
||||
strategy_path: Path,
|
||||
db_path: Path,
|
||||
audit_path: Path,
|
||||
token: str | None,
|
||||
asset: str,
|
||||
) -> None:
|
||||
"""Esegue UNA volta il collector della catena opzioni e persiste in DB.
|
||||
|
||||
Utile per popolare i dati senza aspettare il cron settimanale del
|
||||
job ``option_chain_snapshot``. Riusa esattamente la stessa pipeline
|
||||
schedulata.
|
||||
"""
|
||||
from cerbero_bite.runtime.dependencies import build_runtime # noqa: PLC0415
|
||||
from cerbero_bite.runtime.option_chain_snapshot_cycle import ( # noqa: PLC0415
|
||||
collect_option_chain_snapshot,
|
||||
)
|
||||
|
||||
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_snapshot(ctx, asset=asset))
|
||||
console.print(
|
||||
f"[green]Persisted {n} option chain quote(s) for {asset}[/green]"
|
||||
if n > 0
|
||||
else f"[yellow]No quotes persisted (chain empty or fetch failed)[/yellow]"
|
||||
)
|
||||
|
||||
|
||||
@option_chain.command(name="analyze")
|
||||
@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("--asset", default="ETH", show_default=True)
|
||||
@click.option(
|
||||
"--bias",
|
||||
type=click.Choice(["bull_put", "bear_call"], case_sensitive=False),
|
||||
default="bull_put",
|
||||
show_default=True,
|
||||
help="Direzione da simulare (il rule engine lo deciderebbe da trend×funding).",
|
||||
)
|
||||
def option_chain_analyze(
|
||||
strategy_path: Path,
|
||||
db_path: Path,
|
||||
asset: str,
|
||||
bias: str,
|
||||
) -> None:
|
||||
"""Analizza l'ultimo snapshot di catena salvato.
|
||||
|
||||
Per la strategia indicata, simula la selezione strike (delta
|
||||
target, OTM range, width 4%, credit/width ratio min) e mostra:
|
||||
* lo strike che il rule engine sceglierebbe come short e long,
|
||||
* credito atteso, larghezza, rapporto credit/width,
|
||||
* pass/fail del gate `credit_to_width_ratio_min`.
|
||||
"""
|
||||
from cerbero_bite.core.combo_builder import select_strikes # noqa: PLC0415
|
||||
from cerbero_bite.core.types import OptionQuote # noqa: PLC0415
|
||||
|
||||
cfg = load_strategy(strategy_path).config
|
||||
|
||||
conn = connect_state(db_path)
|
||||
try:
|
||||
repo = Repository()
|
||||
latest_ts = repo.latest_option_chain_timestamp(conn, asset=asset.upper())
|
||||
if latest_ts is None:
|
||||
console.print(
|
||||
"[red]Nessuno snapshot di catena trovato. Lancia prima "
|
||||
"`cerbero-bite option-chain trigger`.[/red]"
|
||||
)
|
||||
sys.exit(1)
|
||||
quotes_records = repo.list_option_chain_snapshots(
|
||||
conn, asset=asset.upper(), start=latest_ts, end=latest_ts,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
console.print(
|
||||
f"[cyan]Snapshot del {latest_ts.isoformat()} — {len(quotes_records)} "
|
||||
f"quote totali[/cyan]"
|
||||
)
|
||||
|
||||
# Costruzione OptionQuote da OptionChainQuoteRecord per riusare select_strikes.
|
||||
quotes: list[OptionQuote] = []
|
||||
for q in quotes_records:
|
||||
if q.bid is None or q.ask is None or q.mid is None or q.delta is None:
|
||||
continue
|
||||
quotes.append(
|
||||
OptionQuote(
|
||||
instrument=q.instrument_name,
|
||||
strike=q.strike,
|
||||
expiry=q.expiry,
|
||||
option_type=q.option_type,
|
||||
bid=q.bid,
|
||||
ask=q.ask,
|
||||
mid=q.mid,
|
||||
delta=q.delta,
|
||||
gamma=q.gamma or Decimal("0"),
|
||||
theta=q.theta or Decimal("0"),
|
||||
vega=q.vega or Decimal("0"),
|
||||
open_interest=q.open_interest or 0,
|
||||
volume_24h=q.volume_24h or 0,
|
||||
book_depth_top3=q.book_depth_top3 or 0,
|
||||
)
|
||||
)
|
||||
|
||||
if not quotes:
|
||||
console.print("[red]Nessun quote completo per la simulazione.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
# Lo spot al momento dello snapshot: estraiamo dall'ultimo
|
||||
# `market_snapshot` ETH a quel timestamp (tolleranza ±15 min).
|
||||
spot = _resolve_spot_at(db_path, asset=asset.upper(), at=latest_ts)
|
||||
if spot is None:
|
||||
console.print(
|
||||
"[yellow]Spot non recuperabile dai market_snapshots; "
|
||||
"stimato dal mid ATM.[/yellow]"
|
||||
)
|
||||
spot = _atm_spot_proxy(quotes)
|
||||
|
||||
selection = select_strikes(
|
||||
chain=quotes,
|
||||
bias=bias, # type: ignore[arg-type]
|
||||
spot=spot,
|
||||
now=latest_ts,
|
||||
cfg=cfg,
|
||||
)
|
||||
if selection is None:
|
||||
console.print(
|
||||
"[red]Il rule engine NON aprirebbe trade con questa catena[/red] "
|
||||
"(no strike compatibile coi gate delta/distance/width/credit-ratio)."
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
short, long_ = selection
|
||||
width_usd = (short.strike - long_.strike).copy_abs()
|
||||
credit_eth = short.mid - long_.mid
|
||||
credit_usd = credit_eth * spot
|
||||
ratio = credit_usd / width_usd if width_usd > 0 else Decimal("0")
|
||||
ratio_target = cfg.structure.credit_to_width_ratio_min
|
||||
|
||||
table = Table(title=f"Simulazione picker — bias={bias}, spot={spot:.0f}")
|
||||
table.add_column("Campo", style="cyan")
|
||||
table.add_column("Valore", style="bold")
|
||||
table.add_row("Short strike", f"{short.strike} ({short.delta:+.3f}δ)")
|
||||
table.add_row("Long strike", f"{long_.strike} ({long_.delta:+.3f}δ)")
|
||||
table.add_row("Width", f"{width_usd:.0f} USD")
|
||||
table.add_row("Credit", f"{credit_eth:.4f} ETH ≈ {credit_usd:.2f} USD")
|
||||
table.add_row(
|
||||
"Credit/width ratio",
|
||||
f"{ratio:.2%} (gate ≥ {float(ratio_target):.0%})",
|
||||
)
|
||||
pass_str = (
|
||||
"[green]PASS — entry possibile[/green]"
|
||||
if ratio >= ratio_target
|
||||
else "[red]FAIL — premio troppo magro[/red]"
|
||||
)
|
||||
table.add_row("Verdetto gate ratio", pass_str)
|
||||
console.print(table)
|
||||
|
||||
|
||||
def _resolve_spot_at(db_path: Path, *, asset: str, at: datetime) -> Decimal | None:
|
||||
"""Best-effort lookup dello spot al timestamp ``at`` ± 15 min."""
|
||||
conn = connect_state(db_path)
|
||||
try:
|
||||
rows = Repository().list_market_snapshots(
|
||||
conn,
|
||||
asset=asset,
|
||||
start=at - timedelta(minutes=15),
|
||||
end=at + timedelta(minutes=15),
|
||||
limit=1,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
if not rows:
|
||||
return None
|
||||
return rows[0].spot
|
||||
|
||||
|
||||
def _atm_spot_proxy(quotes: list[Any]) -> Decimal:
|
||||
"""Stima dello spot prendendo lo strike il cui delta è più vicino a 0.5."""
|
||||
quote = min(quotes, key=lambda q: abs(abs(q.delta) - Decimal("0.5")))
|
||||
return quote.strike
|
||||
|
||||
|
||||
def _entrypoint() -> None:
|
||||
"""Wrapper used by ``cerbero-bite`` console script."""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user