diff --git a/src/cerbero_bite/cli.py b/src/cerbero_bite/cli.py index 78635cd..9c9b267 100644 --- a/src/cerbero_bite/cli.py +++ b/src/cerbero_bite/cli.py @@ -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: