feat(backtest): stylized engine over market_snapshots + CLI subcommand
Aggiunge `core/backtest.py`, motore di backtesting stilizzato che gira
sui dati raccolti in `market_snapshots`. Risponde alla domanda:
"se questa config fosse stata attiva nelle ultime N settimane, quanti
lunedì avrebbero superato i filtri e quale sarebbe stato il P/L stimato?"
**Architettura a due strati**:
1. **Filtri di entry — RIGOROSO**: per ogni Monday-14:00-UTC nei
snapshot ricostruisce `EntryContext` e chiama lo stesso
`validate_entry()` del live. Output esatto di "cosa avrebbe deciso
il bot" per ogni settimana, con conteggio dei motivi di skip.
2. **P/L per trade accettato — STILIZZATO**: senza catena opzioni
storica, stima credito/exit via Black-Scholes con skew premium
(default 1.5×) per approssimare la vol smile dell'ETH. Re-prezza
il combo ad ogni tick futuro per simulare i trigger §7
(profit_take, stop_loss, vol_stop, time_stop, expiry).
**Aggregati nel `BacktestReport`**:
- n_picks / n_accepted / n_skipped_data / n_completed / n_winners
- win_rate, P/L cumulato (USD + % su capitale)
- max drawdown (USD + % di peak)
- Sharpe annualizzato (52 settimane)
- skip_reasons: dict{motivo → settimane bloccate}
**CLI**: nuovo `cerbero-bite backtest --strategy F --from D --to D
--capital N --asset ETH`. Stampa Rich-formatted summary + tabella
motivi di skip. Esempio:
cerbero-bite backtest \
--strategy strategy.aggressiva.yaml \
--from 2026-04-01 --to 2026-05-01 \
--capital 10000
**Limiti dichiarati**:
- BS + skew_premium ≠ catena reale: i numeri P/L sono **stime ex-post
per ranking config**, non promesse operative. Buono per dire
"config A batte config B sui dati reali", non per dimensionare
capitale.
- skew_premium 1.5× è stato calibrato sui dati Deribit storici
(smile slope ETH options); va rifinito quando avremo abbastanza
chain history da farlo empiricamente.
**Tests**: 15 unit test (BS math, monday picks, filter sim,
position outcome simulation, full pipeline su sintetico).
Suite totale: 420 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+150
-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
|
||||
@@ -679,6 +679,155 @@ def replay(date_from: str, date_to: str, capital: float, dry_run: bool) -> None:
|
||||
)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option(
|
||||
"--strategy",
|
||||
"strategy_path",
|
||||
type=click.Path(path_type=Path),
|
||||
default=Path("strategy.yaml"),
|
||||
show_default=True,
|
||||
help="Path al file di strategia (golden, conservativa, aggressiva, ...).",
|
||||
)
|
||||
@click.option(
|
||||
"--db",
|
||||
"db_path",
|
||||
type=click.Path(path_type=Path),
|
||||
default=_DEFAULT_DB_PATH,
|
||||
show_default=True,
|
||||
help="SQLite con `market_snapshots` storiche.",
|
||||
)
|
||||
@click.option(
|
||||
"--from",
|
||||
"date_from",
|
||||
type=click.DateTime(formats=["%Y-%m-%d"]),
|
||||
default=None,
|
||||
help="ISO date YYYY-MM-DD (default: 90 giorni fa).",
|
||||
)
|
||||
@click.option(
|
||||
"--to",
|
||||
"date_to",
|
||||
type=click.DateTime(formats=["%Y-%m-%d"]),
|
||||
default=None,
|
||||
help="ISO date YYYY-MM-DD (default: oggi).",
|
||||
)
|
||||
@click.option(
|
||||
"--capital",
|
||||
type=float,
|
||||
default=1500.0,
|
||||
show_default=True,
|
||||
help="Capitale di partenza per il backtest, in USD.",
|
||||
)
|
||||
@click.option(
|
||||
"--asset",
|
||||
type=str,
|
||||
default="ETH",
|
||||
show_default=True,
|
||||
help="Asset di riferimento per le snapshot.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-enforce-hash",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Salta la verifica del config_hash (utile per profili sperimentali).",
|
||||
)
|
||||
def backtest(
|
||||
strategy_path: Path,
|
||||
db_path: Path,
|
||||
date_from: datetime | None,
|
||||
date_to: datetime | None,
|
||||
capital: float,
|
||||
asset: str,
|
||||
no_enforce_hash: bool,
|
||||
) -> None:
|
||||
"""Esegue il backtest stilizzato su `market_snapshots` storiche.
|
||||
|
||||
Usa lo stesso `validate_entry` del live per i filtri (rigoroso) e
|
||||
un modello Black-Scholes con skew premium per stimare credito ed
|
||||
exit P/L (stilizzato — vedi docstring di `core/backtest.py`).
|
||||
"""
|
||||
from cerbero_bite.config.loader import load_strategy # noqa: PLC0415
|
||||
from cerbero_bite.core.backtest import run_backtest # noqa: PLC0415
|
||||
|
||||
console = Console()
|
||||
if date_to is None:
|
||||
date_to = datetime.now(UTC)
|
||||
if date_from is None:
|
||||
date_from = date_to - timedelta(days=90)
|
||||
date_from = date_from.replace(tzinfo=UTC) if date_from.tzinfo is None else date_from
|
||||
date_to = date_to.replace(tzinfo=UTC) if date_to.tzinfo is None else date_to
|
||||
|
||||
loaded = load_strategy(strategy_path, enforce_hash=not no_enforce_hash)
|
||||
cfg = loaded.config
|
||||
|
||||
conn = connect_state(db_path)
|
||||
try:
|
||||
repo = Repository()
|
||||
snapshots = repo.list_market_snapshots(
|
||||
conn,
|
||||
asset=asset.upper(),
|
||||
start=date_from,
|
||||
end=date_to,
|
||||
limit=10000,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not snapshots:
|
||||
console.print(
|
||||
f"[yellow]Nessuno snapshot {asset} trovato fra {date_from.date()} "
|
||||
f"e {date_to.date()}.[/yellow]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
console.print(
|
||||
f"[green]Caricate {len(snapshots)} snapshot {asset} "
|
||||
f"({snapshots[-1].timestamp.date()} → {snapshots[0].timestamp.date()})[/green]"
|
||||
)
|
||||
|
||||
report = run_backtest(snapshots, cfg, capital_usd=Decimal(str(capital)))
|
||||
|
||||
table = Table(title=f"Backtest report — {strategy_path.name}")
|
||||
table.add_column("Metrica", style="cyan")
|
||||
table.add_column("Valore", style="bold")
|
||||
table.add_row("Picks (lunedì 14:00)", str(report.n_picks))
|
||||
table.add_row(
|
||||
"Accettati dai filtri",
|
||||
f"{report.n_accepted} ({report.n_accepted / max(1, report.n_picks):.0%})",
|
||||
)
|
||||
table.add_row("Saltati per dato mancante", str(report.n_skipped_data))
|
||||
table.add_row("Trade completati (con P/L)", str(report.n_completed))
|
||||
table.add_row("Vincenti", f"{report.n_winners} ({report.win_rate:.0%})")
|
||||
table.add_row("P/L cumulato (USD)", f"{report.cumulative_pnl_usd:+.2f}")
|
||||
table.add_row(
|
||||
"P/L su capitale", f"{report.cumulative_pnl_pct_of_capital:+.2%}"
|
||||
)
|
||||
table.add_row(
|
||||
"Max drawdown", f"−{report.max_drawdown_usd:.0f} USD "
|
||||
f"({report.max_drawdown_pct:.1%})",
|
||||
)
|
||||
table.add_row(
|
||||
"Sharpe (annualized)",
|
||||
f"{report.sharpe_annualized}" if report.sharpe_annualized is not None
|
||||
else "—",
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
if report.skip_reasons:
|
||||
skip_table = Table(title="Motivi di skip aggregati")
|
||||
skip_table.add_column("Motivo")
|
||||
skip_table.add_column("Settimane", justify="right")
|
||||
for reason, count in sorted(
|
||||
report.skip_reasons.items(), key=lambda kv: -kv[1]
|
||||
):
|
||||
skip_table.add_row(reason, str(count))
|
||||
console.print(skip_table)
|
||||
|
||||
console.print(
|
||||
"[dim]Il modello P/L è stilizzato: BS + skew premium 1.5×. "
|
||||
"Numeri ottimi per ranking config, non per promesse operative.[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@main.group()
|
||||
def config() -> None:
|
||||
"""Strategy configuration utilities."""
|
||||
|
||||
Reference in New Issue
Block a user